UNPKG

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
"use strict"; /** * 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