agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
762 lines • 33.6 kB
JavaScript
;
/**
* OllamaProvider — local models over Ollama's native `/api/chat`.
*
* Pattern: Adapter (GoF) + Ports-and-Adapters (Cockburn 2005).
* Role: Outer ring — translates `LLMRequest`/`LLMResponse` to/from
* Ollama's native chat wire. Knows nothing about agents,
* recorders, or compositions.
* Emits: N/A.
*
* ─── Why this exists, and why it talks the native wire ───────────────
*
* The adapter ladder is `mock()` → a local model → a paid API, and the
* strongest version of "the test run and the production run are the same
* code path" is one where the middle step costs nothing and needs no API
* key. Through 8.0.0 the middle step needed BOTH a competitor's SDK
* (`npm install openai`, because `ollama()` was a thin wrapper over
* `openai({ baseURL })`) and a tolerance for failures labelled `[openai]`.
* That is not a free rung.
*
* So this file owns the wire:
*
* • ZERO dependencies — one `fetch` POST and NDJSON. Nothing to install
* beyond Ollama itself. (Same choice, same reasons, as the two
* `Browser*Provider` adapters.)
* • HONEST REFUSALS — a typed {@link OllamaUnavailableError} that names
* the address it tried and the command to run. Owning the fetch and
* the status code is what makes that possible; through an SDK you get
* whatever the SDK decided to throw.
* • REAL TOKEN COUNTS — `/api/chat` returns `prompt_eval_count` /
* `eval_count` on every response, streaming or not, with no opt-in
* flag. Through the OpenAI-compatible endpoint a streamed local call
* reported ZERO tokens, which silently disarmed `.compaction()` and
* `costBudget` (see `CompactionUnmeasurableError`, whose message names
* this exact case).
* • STRUCTURED THINKING — `message.thinking` is a first-class field on
* this wire; the OpenAI-compatible layer renames it to a non-standard
* `reasoning` key that no OpenAI adapter reads. See
* `OllamaThinkingHandler`.
*
* Want the SDK path instead? It is still there and still supported:
* `openai({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' })`.
*
* ─── Ceilings (stated, not worked around) ────────────────────────────
*
* • TOOL CALLING IS MODEL-DEPENDENT. Ollama forwards a `tools` array to
* any model; a model that was not trained for tools simply answers in
* prose and no tool call ever arrives. This adapter does not preflight
* `/api/show` capabilities to refuse first — a wrong refusal is worse
* than a weak answer, and the metadata is less reliable than the
* ceiling being written down. Pick a tool-capable model.
* • NO FORCED TOOL CHOICE. Ollama does not support `tool_choice` on
* either wire, so `carriesForcedToolChoice` is `false` and an agent
* using `.outputSchema(parser, { strategy: 'tool-forced' })` refuses at
* run start, naming this provider.
* • NO MULTI-MODAL. The wire carries `images`; `LLMMessage.content` is a
* string. Same ceiling as every other adapter here.
* • NO PROMPT CACHING — resolves to the NoOp cache strategy.
* • NO `providerRef`. The native wire returns no response id, and a
* fabricated one would point at nothing.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.OllamaProvider = exports.ollama = exports.OllamaUnavailableError = void 0;
// Type-only: erased at build, so this adds no runtime edge from adapters/
// into thinking/ (the same relationship `provider-contract/types.ts` already has
// with `ThinkingBlock`). ALL tag parsing lives in the handler; this file
// only notices the shape and labels it.
const OllamaThinkingHandler_js_1 = require("../../thinking/OllamaThinkingHandler.js");
const linearText_js_1 = require("../../lib/linearText.js");
// The one owner of how a cancellation is honoured (lib/abort.ts) — this file
// kept private copies of the first three until the other adapters needed them.
const abort_js_1 = require("../../lib/abort.js");
/** Cap for any text the wire supplies before it becomes one of our messages. */
const ERROR_TEXT_CAP = 200;
// ─── Errors ─────────────────────────────────────────────────────────
/**
* The two failures a local runtime actually has, told in words that
* contain the fix.
*
* Both are things the person at the keyboard can resolve in one command,
* which is exactly why they get a type instead of a wrapped
* `ECONNREFUSED` or a bare `404`. `reason` is the discriminator; the
* message already reads as instructions.
*/
class OllamaUnavailableError extends Error {
name = 'OllamaUnavailableError';
/** Which of the two situations this is. */
reason;
/** The address that was tried — the thing to check or change. */
baseUrl;
/** The model asked for. Absent when the daemon never answered at all. */
model;
/** Models this machine DOES have, when the daemon could tell us. */
availableModels;
constructor(init) {
super(buildUnavailableMessage(init));
this.reason = init.reason;
this.baseUrl = init.baseUrl;
if (init.model !== undefined)
this.model = init.model;
if (init.availableModels !== undefined)
this.availableModels = init.availableModels;
if (init.cause !== undefined)
this.cause = init.cause;
}
}
exports.OllamaUnavailableError = OllamaUnavailableError;
function buildUnavailableMessage(init) {
if (init.reason === 'daemon-unreachable') {
return (`ollama: nothing is answering at ${init.baseUrl}. ` +
`Start it with \`ollama serve\` (or open the Ollama app); install it from ` +
`https://ollama.com/download. ` +
`Running somewhere else? Pass ollama('<model>', { baseUrl: '...' }) or set OLLAMA_HOST.`);
}
const model = init.model ?? '(unnamed)';
const have = init.availableModels && init.availableModels.length > 0
? ` Models on this machine: ${init.availableModels.join(', ')}.`
: '';
return (`ollama: model '${model}' is not pulled on the machine at ${init.baseUrl}. ` +
`Run: ollama pull ${model}.${have}`);
}
const DEFAULT_BASE_URL = 'http://localhost:11434';
const DEFAULT_MODEL = 'llama3.2';
const DEFAULT_TIMEOUT_MS = 10_000;
/** Request `model` values that mean "whatever this provider was configured with". */
const MODEL_SHORTHANDS = new Set(['ollama']);
/**
* Which roles this wire carries inside `messages`.
*
* Ollama's native chat takes the system prompt as a message like any
* other (there is no separate top-level `system` field), so all three
* roles survive the trip.
*/
const CARRIES_IN_MESSAGES = Object.freeze(['system', 'user', 'assistant']);
function ollama(modelOrOptions, maybeOptions) {
const options = typeof modelOrOptions === 'string' ? maybeOptions ?? {} : modelOrOptions ?? {};
const positionalModel = typeof modelOrOptions === 'string' ? modelOrOptions : undefined;
const baseUrl = resolveBaseUrl(options);
const defaultModel = positionalModel ?? options.defaultModel ?? DEFAULT_MODEL;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const fetchImpl = options._fetch ?? ((...args) => fetch(...args));
const chatUrl = `${baseUrl}/api/chat`;
const cfg = {
defaultModel,
...(options.defaultMaxTokens !== undefined && { defaultMaxTokens: options.defaultMaxTokens }),
...(options.think !== undefined && { think: options.think }),
...(options.keepAlive !== undefined && { keepAlive: options.keepAlive }),
};
// Tool-call ids are synthesized per provider instance — see toLLMToolCall
// for why this wire needs that at all.
let toolCallSeq = 0;
const nextToolCallId = () => `ollama-call-${++toolCallSeq}`;
/**
* POST under `call` — the call's own controller, linked to the caller's
* signal for the WHOLE call (body included) and aborted by the header
* deadline; `complete`/`stream` unlink it when the call ends, so no listener
* is left on the caller's signal (lib/abort.ts · linkedController).
*/
const post = async (body, req, call) => {
const response = await fetchUntilHeaders(fetchImpl, chatUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}, { timeoutMs, baseUrl, call, ...(req.signal && { signal: req.signal }) });
if (!response.ok) {
throw await (0, abort_js_1.untilAborted)(describeFailure(response, body.model, baseUrl, fetchImpl, timeoutMs), req.signal);
}
return response;
};
const provider = {
name: 'ollama',
carriesInMessages: CARRIES_IN_MESSAGES,
// Ollama supports no forced tool choice on either of its wires. Absence
// would mean the same thing; saying it out loud documents that this was
// checked rather than forgotten.
carriesForcedToolChoice: false,
thinkingHandler: OllamaThinkingHandler_js_1.ollamaThinkingHandler,
async complete(req) {
// A signal that fired BEFORE the call stops it here, before any socket
// opens — the `/api/tags` enrichment included. An already-aborted signal
// never dispatches another 'abort' event, so a listener alone cannot
// see it.
(0, abort_js_1.throwIfAborted)(req.signal);
const body = buildBody(req, cfg, false);
const call = (0, abort_js_1.linkedController)(req.signal);
try {
const response = await post(body, req, call.controller);
// `fetch` resolves on HEADERS, so reading the body is the stretch where
// a caller abort would otherwise be inert — honor it for that too.
const json = (await (0, abort_js_1.untilAborted)(response.json(), req.signal));
return fromOllamaResponse(json, nextToolCallId);
}
finally {
call.unlink();
}
},
async *stream(req) {
(0, abort_js_1.throwIfAborted)(req.signal);
const call = (0, abort_js_1.linkedController)(req.signal);
try {
yield* streamUnder(req, call.controller);
}
finally {
call.unlink();
}
},
};
async function* streamUnder(req, call) {
const body = buildBody(req, cfg, true);
const response = await post(body, req, call);
if (!response.body)
throw new Error('[ollama] response has no body');
const textParts = [];
const thinkingParts = [];
const toolCalls = [];
let doneReason = 'stop';
let promptEvalCount = 0;
let evalCount = 0;
let tokenIndex = 0;
// The signal rides INTO the parser: it is what stops a generation the
// caller no longer wants, and what cancels the body when it does.
for await (const chunk of parseNdjson(response.body, req.signal)) {
const frame = chunk;
// An already-200 stream can still FAIL mid-generation, and this wire
// says so IN BAND with an `{"error": "..."}` frame. Such a frame
// carries no `message` and no `done`, so the guards below would skip
// it and the terminal chunk would report a truncated answer as
// `stopReason: 'stop'` with nobody told. Raise instead: a failed
// generation is a failure, never a shorter answer.
const failure = frameFailureText(frame);
if (failure) {
throw providerError(`[ollama] the stream failed mid-generation — ${failure}`);
}
// Counts ride the terminal frame, whose `message.content` is empty —
// read them before any content guard, or the only token counts the
// stream reports are thrown away (the failure that made streamed
// local calls report zero and disarmed `.compaction()`).
if (typeof frame.prompt_eval_count === 'number')
promptEvalCount = frame.prompt_eval_count;
if (typeof frame.eval_count === 'number')
evalCount = frame.eval_count;
if (frame.done_reason)
doneReason = frame.done_reason;
const message = frame.message;
if (message) {
if (message.tool_calls && message.tool_calls.length > 0) {
// Ollama emits each tool call whole, not as an assembled delta.
toolCalls.push(...message.tool_calls);
}
if (message.thinking) {
thinkingParts.push(message.thinking);
(0, abort_js_1.throwIfAborted)(req.signal);
yield { tokenIndex, content: '', done: false, thinkingDelta: message.thinking };
tokenIndex++;
}
if (message.content) {
textParts.push(message.content);
(0, abort_js_1.throwIfAborted)(req.signal);
yield { tokenIndex, content: message.content, done: false };
tokenIndex++;
}
}
if (frame.done)
break;
}
const content = textParts.join('');
const rawThinking = describeThinking(content, thinkingParts.join(''));
const authoritative = {
content,
toolCalls: toolCalls.map((tc) => toLLMToolCall(tc, nextToolCallId)),
usage: { input: promptEvalCount, output: evalCount },
stopReason: normalizeStopReason(doneReason, toolCalls.length > 0),
...(rawThinking && { rawThinking }),
};
(0, abort_js_1.throwIfAborted)(req.signal);
yield { tokenIndex, content: '', done: true, response: authoritative };
}
return provider;
}
exports.ollama = ollama;
/**
* Class form for consumers who prefer `new OllamaProvider(...)`.
*/
class OllamaProvider {
name = 'ollama';
carriesInMessages = CARRIES_IN_MESSAGES;
carriesForcedToolChoice = false;
thinkingHandler = OllamaThinkingHandler_js_1.ollamaThinkingHandler;
inner;
constructor(model, options) {
this.inner =
typeof model === 'string' ? ollama(model, options) : ollama(model);
}
// `hooks` is FORWARDED, not dropped — see LLMCallHooks in provider-contract/types.ts.
complete(req, hooks) {
return this.inner.complete(req, hooks);
}
stream(req, hooks) {
if (!this.inner.stream)
throw new Error('stream() unavailable');
return this.inner.stream(req, hooks);
}
}
exports.OllamaProvider = OllamaProvider;
// ─── Internals ──────────────────────────────────────────────────────
/**
* Resolve where Ollama lives, accepting every spelling that has ever been
* valid here plus Ollama's own `OLLAMA_HOST` convention.
*
* A `/v1` suffix is trimmed rather than rejected: through 8.0.0 the
* factory built `${host}/v1` for the OpenAI-compatible endpoint, and a
* config carrying that URL means the same machine.
*/
function resolveBaseUrl(options) {
const raw = options.baseUrl ??
options.host ??
options.baseURL ??
(typeof process !== 'undefined' ? process.env?.OLLAMA_HOST : undefined) ??
DEFAULT_BASE_URL;
const withScheme = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
return (0, linearText_js_1.trimTrailing)(withScheme, linearText_js_1.isSlash).replace(/\/v1$/i, '');
}
function buildBody(req, cfg, stream) {
const model = MODEL_SHORTHANDS.has(req.model) ? cfg.defaultModel : req.model;
const body = {
model,
messages: toOllamaMessages(req.messages, req.systemPrompt),
stream,
};
if (req.tools && req.tools.length > 0)
body.tools = req.tools.map(toOllamaTool);
// Two ways to ask for thinking: the provider-wide option (which can name a
// level) and the per-request field the agent's `.thinking({ budget })` sets.
// The request cannot express a level, so it turns thinking on and leaves the
// configured level in place. `budget` has no equivalent on this wire —
// Ollama does not take a reasoning-token cap — so it is deliberately dropped
// rather than mistranslated into `num_predict`, which caps the ANSWER.
if (cfg.think !== undefined)
body.think = cfg.think;
else if (req.thinking !== undefined)
body.think = true;
if (cfg.keepAlive !== undefined)
body.keep_alive = cfg.keepAlive;
const maxTokens = req.maxTokens ?? cfg.defaultMaxTokens;
const options = {};
if (maxTokens !== undefined)
options.num_predict = maxTokens;
if (req.temperature !== undefined)
options.temperature = req.temperature;
if (req.stop && req.stop.length > 0)
options.stop = [...req.stop];
if (Object.keys(options).length > 0)
body.options = options;
// `req.toolChoice` is intentionally NOT translated: Ollama supports no
// forced tool choice, `carriesForcedToolChoice` says so, and the agent
// refuses before it ever reaches here.
return body;
}
/**
* messages → Ollama messages.
*
* Roles map 1:1. The system prompt is prepended as an ordinary `system`
* message (this wire has no separate system field). Assistant turns carry
* their `tool_calls` back so the model can see its own requests; tool
* results carry `tool_name`, which is how this wire correlates a result
* with the call that asked for it.
*/
function toOllamaMessages(messages, systemPrompt) {
const result = [];
if (systemPrompt)
result.push({ role: 'system', content: systemPrompt });
for (const m of messages) {
if (m.role === 'system' || m.role === 'user') {
result.push({ role: m.role, content: m.content });
continue;
}
if (m.role === 'assistant') {
const msg = { role: 'assistant', content: m.content };
if (m.toolCalls && m.toolCalls.length > 0) {
msg.tool_calls = m.toolCalls.map((tc) => ({
...(tc.id && { id: tc.id }),
function: { name: tc.name, arguments: { ...tc.args } },
}));
}
result.push(msg);
continue;
}
if (m.role === 'tool') {
// `tool_name` is what this wire matches on. The agent always sets
// `toolName`; a hand-built history might not, so fall back to looking
// the id up in the assistant turn that requested it.
const toolName = m.toolName ?? nameForToolCallId(messages, m.toolCallId);
result.push({
role: 'tool',
content: m.content,
...(toolName && { tool_name: toolName }),
...(m.toolCallId && { tool_call_id: m.toolCallId }),
});
continue;
}
}
return result;
}
function nameForToolCallId(messages, toolCallId) {
if (!toolCallId)
return undefined;
for (const m of messages) {
if (m.role !== 'assistant' || !m.toolCalls)
continue;
for (const tc of m.toolCalls) {
if (tc.id === toolCallId)
return tc.name;
}
}
return undefined;
}
function toOllamaTool(schema) {
return {
type: 'function',
function: {
name: schema.name,
description: schema.description,
parameters: { ...schema.inputSchema },
},
};
}
/**
* Wire response → the port's shape.
*
* @throws an `OllamaProviderError` when the 200 body is really a failure
* (`{"error": "..."}`). Reading it as a response would yield empty content
* with `stopReason: 'stop'` and zero tokens — a failed call dressed as a
* successful one.
*/
function fromOllamaResponse(response, nextToolCallId) {
if (response.error !== undefined && response.error !== null) {
const detail = extractErrorPayload(response.error) || 'the daemon named no reason';
throw providerError(`[ollama] the daemon answered 200 with an error — ${detail}`);
}
const message = response.message;
const wireToolCalls = message?.tool_calls ?? [];
const toolCalls = wireToolCalls.map((tc) => toLLMToolCall(tc, nextToolCallId));
const content = message?.content ?? '';
const rawThinking = describeThinking(content, message?.thinking);
return {
content,
toolCalls,
usage: {
input: response.prompt_eval_count ?? 0,
output: response.eval_count ?? 0,
},
stopReason: normalizeStopReason(response.done_reason ?? 'stop', toolCalls.length > 0),
// No `providerRef` — this wire returns no response id, and inventing
// one would hand consumers a reference that resolves to nothing.
...(rawThinking && { rawThinking }),
};
}
/**
* Where the model's reasoning ended up, labelled for the handler.
*
* `message.thinking` is the good case — Ollama lifted the reasoning out of
* the answer because we asked it to. Failing that, a reasoning model that
* was NOT asked leaves `<think>…</think>` sitting in the answer text; we
* notice and say so, and we pass the answer through UNCHANGED. Editing a
* model's answer behind its back is a meaning change and belongs to the
* application, not to an adapter. See `OllamaThinkingHandler`.
*/
function describeThinking(content, thinkingField) {
if (thinkingField && thinkingField.length > 0) {
return { kind: 'field', thinking: thinkingField };
}
if (content.includes('<think'))
return { kind: 'inline', content };
return undefined;
}
/**
* One wire tool call → the port's shape.
*
* Two wire facts handled here:
* • `id` is optional and most models omit it, but the whole tool
* round-trip in this library is keyed by id. So an id is SYNTHESIZED
* when missing — unique per provider instance, which is all the
* correlation needs, and the name still rides `tool_name` on the way
* back regardless.
* • `arguments` is already a JSON OBJECT here (OpenAI sends a string),
* so there is nothing to parse. A string is still tolerated in case a
* proxy in the middle reshaped it.
*/
function toLLMToolCall(tc, nextToolCallId) {
return {
id: tc.id && tc.id.length > 0 ? tc.id : nextToolCallId(),
name: tc.function?.name ?? '',
args: coerceArgs(tc.function?.arguments),
};
}
function coerceArgs(args) {
if (args === undefined || args === null)
return {};
if (typeof args === 'string') {
if (args.length === 0)
return {};
try {
const parsed = JSON.parse(args);
return typeof parsed === 'object' && parsed !== null
? parsed
: {};
}
catch {
// Malformed args are rare but observed on small local models. Surface
// empty rather than crash — the tool-call event still fires, so the
// problem is visible in the trace.
return {};
}
}
return { ...args };
}
/**
* `done_reason` → the port's stop vocabulary.
*
* Ollama reports `stop` even when the turn ended in tool calls, so the
* presence of tool calls is what distinguishes `tool_use` — the same
* correction Ollama's own OpenAI-compat layer makes.
*/
function normalizeStopReason(raw, hasToolCalls) {
if (hasToolCalls && (raw === 'stop' || raw === ''))
return 'tool_use';
switch (raw) {
case 'stop':
return 'stop';
case 'length':
return 'max_tokens';
default:
return raw;
}
}
/**
* Parse an NDJSON body — one JSON object per line.
*
* Two things here a plain line reader would not do, both because the other
* end is a model on THIS machine: the caller's `signal` interrupts the read
* (`fetch` resolved on headers, so this loop is the whole rest of the call),
* and the body is CANCELLED on the way out — a generation nobody is reading
* still occupies the machine.
*/
async function* parseNdjson(body, signal) {
const decoder = new TextDecoder();
let buf = '';
// `bodyChunks` races every read against the caller's signal — a signal that
// only reached the headers would be a cancellation that cancels nothing —
// and CANCELS the body on every way out: the terminal `done` frame (the
// stream loop breaks on it), a consumer that breaks out, an error frame, a
// caller abort. An open body means the local model keeps generating for
// nobody and the socket stays up.
for await (const value of (0, abort_js_1.bodyChunks)(body, signal)) {
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
(0, abort_js_1.throwIfAborted)(signal);
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line)
continue;
try {
yield JSON.parse(line);
}
catch {
/* skip malformed line */
}
}
}
// A final line with no trailing newline still counts.
const tail = buf.trim();
if (tail) {
(0, abort_js_1.throwIfAborted)(signal);
try {
yield JSON.parse(tail);
}
catch {
/* skip malformed tail */
}
}
}
/**
* POST, but never hang waiting for a daemon that is not there.
*
* The timer bounds the wait for RESPONSE HEADERS and is cleared the moment
* they arrive, so a model that takes four minutes to write a long answer is
* unaffected — `fetch` resolves on headers, and the body streams afterwards.
*
* The deadline is a RACE, not just an `AbortSignal`. Aborting is the polite
* request — it releases the socket and is what a real `fetch` acts on — but a
* promise that never settles is exactly the failure this guards against, and
* "never hangs" cannot be a promise the caller's `fetch` implementation gets
* to break on our behalf. So the timeout wins on its own.
*
* A caller's own `AbortSignal` is forwarded and, if IT is what fired, the
* abort is re-thrown as an abort rather than blamed on the daemon —
* including a signal that was ALREADY aborted when the call was made, which
* never dispatches an event for a listener to hear.
*/
async function fetchUntilHeaders(fetchImpl, url, init, opts) {
// An already-aborted signal never dispatches another 'abort' event, so the
// bridge below could not fire and the request would go out on a turn the
// caller has cancelled. Refuse before the socket opens, and refuse with the
// caller's own reason: an abort is their call, never a daemon fault.
(0, abort_js_1.throwIfAborted)(opts.signal);
// The call's controller: it follows the caller's signal for the WHOLE call —
// the body included, long after this timer is cleared — and the deadline
// aborts it here. Its link is the call's to end (`post`).
const controller = opts.call;
let timedOut = false;
let timer;
const deadline = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
timedOut = true;
controller.abort();
reject(new OllamaUnavailableError({ reason: 'daemon-unreachable', baseUrl: opts.baseUrl }));
}, opts.timeoutMs);
});
try {
// The caller's abort wins the race too: a `fetch` that ignores its signal
// must not hold a cancelled call open until the deadline (lib/abort.ts).
return await (0, abort_js_1.untilAborted)(Promise.race([fetchImpl(url, { ...init, signal: controller.signal }), deadline]), opts.signal);
}
catch (err) {
if (err instanceof OllamaUnavailableError)
throw err; // the deadline fired
// The caller's call, not ours — and it fails with the caller's reason.
if (opts.signal?.aborted && !timedOut)
throw (0, abort_js_1.abortErrorOf)(opts.signal);
// Timed out, or connection refused / DNS failure / TLS failure — either
// way, nothing answered at that address.
throw new OllamaUnavailableError({
reason: 'daemon-unreachable',
baseUrl: opts.baseUrl,
cause: err,
});
}
finally {
if (timer !== undefined)
clearTimeout(timer);
// When the fetch wins the race, the deadline promise may still reject with
// nobody listening. Swallow it deliberately: the answer already arrived, so
// an unhandled-rejection warning here would be noise about a non-event.
deadline.catch(() => undefined);
}
}
/**
* Turn a non-2xx into the most actionable error available.
*
* A 404 from `/api/chat` means the model is not on this machine. Before
* saying so we ask `/api/tags` — a cheap local call — so the message can
* also name what IS here, which is usually enough to spot a typo. If that
* lookup fails too, the `ollama pull` instruction stands on its own.
*/
async function describeFailure(response, model, baseUrl, fetchImpl, timeoutMs) {
const bodyText = await safeText(response);
if (response.status === 404) {
const availableModels = await listPulledModels(fetchImpl, baseUrl, timeoutMs);
return new OllamaUnavailableError({
reason: 'model-not-pulled',
baseUrl,
model,
...(availableModels && { availableModels }),
});
}
const detail = extractErrorText(bodyText);
return providerError(`[ollama] ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}`.trim(), response.status);
}
async function listPulledModels(fetchImpl, baseUrl, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(`${baseUrl}/api/tags`, { signal: controller.signal });
if (!response.ok)
return undefined;
const json = (await response.json());
const names = (json.models ?? [])
.map((m) => m.name ?? m.model)
.filter((n) => typeof n === 'string' && n.length > 0);
return names.length > 0 ? names : undefined;
}
catch {
// Best-effort enrichment. Its failure must never replace the error we
// are already in the middle of reporting.
return undefined;
}
finally {
clearTimeout(timer);
}
}
async function safeText(response) {
try {
return await response.text();
}
catch {
return '';
}
}
/**
* Native errors are `{"error": "..."}`; the object spelling and raw text are
* tolerated. Every path is capped — the wire's words help diagnose, but a
* daemon echoing something enormous (or poisoned) must not become the message.
*/
function extractErrorText(bodyText) {
if (!bodyText)
return '';
try {
const parsed = JSON.parse(bodyText);
const detail = extractErrorPayload(parsed.error);
if (detail)
return detail;
}
catch {
/* not JSON — fall through */
}
return bodyText.slice(0, ERROR_TEXT_CAP);
}
/**
* The words out of an `error` payload — a bare string on this wire, the
* `{"message": "..."}` spelling tolerated — capped like every other piece of
* wire text this file repeats. Empty when there is nothing usable to say, so
* a caller can tell "no error" from "an error that named no reason".
*/
function extractErrorPayload(err) {
if (typeof err === 'string')
return err.slice(0, ERROR_TEXT_CAP);
if (typeof err === 'object' && err !== null) {
const message = err.message;
if (typeof message === 'string' && message.length > 0) {
return message.slice(0, ERROR_TEXT_CAP);
}
}
return '';
}
/**
* What a mid-stream frame says went wrong — '' when nothing did.
*
* One spelling on this wire: an NDJSON frame carrying `error`. It means the
* generation failed, and it carries neither `message` nor `done`, which is
* exactly why an unchecked one looks like an ordinary skippable frame. An
* ordinary frame — the terminal `done` one included, whose content is empty
* by design — has no `error` key at all, so this guard stays narrow.
*/
function frameFailureText(frame) {
const data = typeof frame === 'object' && frame !== null ? frame : undefined;
const err = data?.error;
if (err === undefined || err === null)
return '';
return extractErrorPayload(err) || 'the daemon reported an error but named no reason';
}
/**
* The provider's own labelled error — one shape for every failure that is not
* the typed, actionable {@link OllamaUnavailableError}.
*/
function providerError(message, status) {
return Object.assign(new Error(message), {
name: 'OllamaProviderError',
...(status !== undefined && { status }),
});
}
//# sourceMappingURL=OllamaProvider.js.map