agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
958 lines • 43.6 kB
JavaScript
;
/**
* FoundryLocalProvider — on-device models over Foundry Local's
* OpenAI-compatible `/v1/chat/completions`.
*
* Pattern: Adapter (GoF) + Ports-and-Adapters (Cockburn 2005).
* Role: Outer ring — translates `LLMRequest`/`LLMResponse` to/from the
* wire Foundry Local serves on localhost. Knows nothing about
* agents, recorders, or compositions.
* Emits: N/A.
*
* ─── Why this exists ─────────────────────────────────────────────────
*
* The adapter ladder is `mock()` → a local model → a paid API, and
* `ollama()` is the proof that the middle rung is worth owning: zero
* dependencies, honest refusals, real token counts. Foundry Local is
* Microsoft's runtime for the same rung — ONNX under the hood, models
* pulled with `foundry model run <alias>`, no key, no account — and a
* Windows or macOS machine that has it installed deserves the same
* one-import experience. So this file owns that wire the way
* `OllamaProvider.ts` owns Ollama's:
*
* • ZERO dependencies — one `fetch` POST and SSE. The official
* `foundry-local-sdk` is NOT imported; its manager is accepted
* duck-typed (see {@link FoundryLocalProviderOptions.manager}) so a
* consumer who already uses it can hand over the discovered URL
* without this package gaining a dependency.
* • HONEST REFUSALS — a typed {@link FoundryLocalUnavailableError}
* that names the endpoint it tried and the command to run. The
* service's port is DYNAMIC per start, which makes "nothing is
* answering" the most likely first failure — so that message
* carries the discovery command, not just the start command. A
* failure the service reports IN BAND — an `error` frame on an
* already-200 stream, the out-of-memory a laptop runtime really does
* hit — is RAISED the same way, never handed over as a shorter
* answer that reads like a clean stop.
* • REAL TOKEN COUNTS while streaming — `stream_options:
* { include_usage: true }` is always sent. This is OUR wire, a
* documented Foundry Local surface, not an arbitrary
* OpenAI-compatible server — so the caution that made
* `openai({ baseURL })` withhold the field (and silently zero every
* local token count until 9.73.0) does not apply here.
*
* ─── Wire realities this file owns ───────────────────────────────────
*
* • THE PORT IS DYNAMIC. Every `foundry server start` may pick a new
* port; the docs' own REST example shows `http://localhost:5272` and
* that is the default here, but the truthful discovery is
* `foundry server status` (or the SDK manager's `.urls`). Note the
* CLI group was RENAMED from `foundry service` to `foundry server` —
* every message in this file uses the NEW spelling.
* • ALIASES vs VARIANT IDS. The catalog speaks in aliases
* (`qwen2.5-0.5b`) that fan out to hardware variants
* (`qwen2.5-0.5b-instruct-generic-cpu:1`), but REST chat calls take
* the FULL variant id. This adapter resolves an alias through
* `GET /foundry/list` — first matching variant wins, because the
* list's order IS the service's priority order — and caches the
* answer per provider instance, HIT OR MISS: exactly one catalog
* attempt per name, so an alias the catalog never answers for cannot
* re-ask before every call. A fresh provider is the retry. A name that
* already carries a variant's execution-provider suffix
* (`-cpu`/`-gpu`/`-npu`, optional `:version`) is used as-is with no
* catalog round-trip.
* • NO API KEY EXISTS. The docs' own samples pass placeholders. This
* adapter sends no `Authorization` header at all — there is nothing
* to put in one, and an invented value would only end up in somebody's
* proxy log.
*
* ─── Ceilings (stated, not worked around) ────────────────────────────
*
* • NO FORCED TOOL CHOICE. `tool_choice` support is UNDOCUMENTED on
* this wire, so `carriesForcedToolChoice` is `false` and an agent
* using `.outputSchema(parser, { strategy: 'tool-forced' })` refuses
* at run start, naming this provider. Claiming an undocumented field
* works would turn a guarantee into a suggestion.
* • TOOL CALLING IS MODEL-DEPENDENT. `/foundry/list` reports
* `supportsToolCalling` per variant, but this adapter does not
* preflight-refuse on it — a wrong refusal is worse than a weak
* answer, the same stance `ollama()` takes on `/api/show`. Pick a
* tool-capable variant.
* • NO MULTI-MODAL. `LLMMessage.content` is a string. Same ceiling as
* every other adapter here.
* • NO PROMPT CACHING — resolves to the NoOp cache strategy.
* • NO STRUCTURED THINKING. The wire has no thinking field; a reasoning
* model's `<think>` tags ride the answer text untouched.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.FoundryLocalProvider = exports.foundryLocal = exports.FoundryLocalUnavailableError = void 0;
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 an on-device 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 — and because Foundry Local's
* port changes per start, the unreachable message teaches the discovery
* command (`foundry server status`) alongside the start command.
*/
class FoundryLocalUnavailableError extends Error {
name = 'FoundryLocalUnavailableError';
/** Which of the two situations this is. */
reason;
/** The endpoint that was tried — the thing to check or change. */
endpoint;
/** The model asked for. Absent when the service never answered at all. */
model;
/** Models this machine DOES have cached, when the service could tell us. */
availableModels;
constructor(init) {
super(buildUnavailableMessage(init));
this.reason = init.reason;
this.endpoint = init.endpoint;
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.FoundryLocalUnavailableError = FoundryLocalUnavailableError;
function buildUnavailableMessage(init) {
if (init.reason === 'service-unreachable') {
return (`foundryLocal: nothing is answering at ${init.endpoint}. ` +
'Start it with `foundry server start` (Foundry Local, install: ' +
'https://learn.microsoft.com/azure/foundry-local). ' +
'The port is dynamic — `foundry server status` prints the live URL; ' +
'pin one with `foundry server start --port <p>`. ' +
"Running elsewhere? Pass foundryLocal('<model>', { endpoint }) or set FOUNDRY_LOCAL_ENDPOINT.");
}
const model = init.model ?? '(unnamed)';
const have = init.availableModels && init.availableModels.length > 0
? ` Models on this machine: ${init.availableModels.join(', ')}.`
: '';
// A 404 that did not speak the dialect is as likely a wrong route as a
// missing model. Say both rather than one confidently.
const route = init.routeUnconfirmed
? ` That 404 named no model error, so ${init.endpoint} may not be Foundry Local's chat route at all — \`foundry server status\` prints the live URL.`
: '';
return (`foundryLocal: model '${model}' is not available on the service at ${init.endpoint}. ` +
`Run: foundry model run ${model} — it downloads the model if needed (aliases work too).${have}${route}`);
}
/**
* The docs' own REST example port. Only a default, never a promise —
* see {@link FoundryLocalProviderOptions.endpoint} for the truth about
* dynamic ports.
*/
const DEFAULT_ENDPOINT = 'http://localhost:5272';
/** The alias the Foundry Local docs use in their own REST walkthrough. */
const DEFAULT_MODEL = 'qwen2.5-0.5b';
const DEFAULT_TIMEOUT_MS = 10_000;
/** Request `model` values that mean "whatever this provider was configured with". */
const MODEL_SHORTHANDS = new Set(['foundry-local']);
/**
* Which roles this wire carries inside `messages`.
*
* The OpenAI dialect takes the system prompt as a message like any other
* (no separate top-level `system` field), so all three roles survive the
* trip.
*/
const CARRIES_IN_MESSAGES = Object.freeze(['system', 'user', 'assistant']);
function foundryLocal(modelOrOptions, maybeOptions) {
const options = typeof modelOrOptions === 'string' ? maybeOptions ?? {} : modelOrOptions ?? {};
const positionalModel = typeof modelOrOptions === 'string' ? modelOrOptions : undefined;
const endpoint = resolveEndpoint(options);
const defaultModel = positionalModel ?? options.defaultModel ?? DEFAULT_MODEL;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const fetchImpl = options._fetch ?? ((...args) => fetch(...args));
const chatUrl = `${endpoint}/v1/chat/completions`;
const cfg = {
defaultModel,
...(options.defaultMaxTokens !== undefined && { defaultMaxTokens: options.defaultMaxTokens }),
};
// Tool-call ids are synthesized per provider instance — the dialect says
// every call carries one, small local models sometimes disagree, and the
// whole tool round-trip in this library is keyed by id.
let toolCallSeq = 0;
const nextToolCallId = () => `foundry-call-${++toolCallSeq}`;
// Alias → variant-id resolutions, cached per provider instance — HIT OR
// MISS. The catalog does not change under a running service often enough
// to be worth re-asking on every call, and the cache is what keeps an
// alias-configured agent at one catalog fetch per process. Caching the
// FALLBACK too is what keeps that promise true when the catalog has no
// answer for the alias.
const resolutionCache = new Map();
/**
* REST chat calls take the FULL variant id, so an alias must be
* resolved first:
*
* • A name already shaped like a variant id (it ends in the
* execution-provider suffix `-cpu`/`-gpu`/`-npu`, optionally
* `:version`) is used AS-IS — no catalog round-trip. That suffix is
* how Foundry Local itself distinguishes a variant from an alias.
* • Anything else is treated as an alias: `GET /foundry/list`, first
* variant whose alias matches wins (the list's order is the
* service's priority order), and the answer is cached.
* • A silent or alias-less catalog resolves to the name UNCHANGED —
* the chat call's own 404 then reports honestly, with the model
* name the caller actually wrote. A failed lookup must never
* replace the error that was coming anyway. That fallback is CACHED
* exactly like a success: ONE catalog attempt per name per provider,
* so an alias the service has no answer for cannot put a
* `/foundry/list` round-trip — and, against a host that accepts but
* never answers, a whole `timeoutMs` — in front of every single
* call. A model pulled with `foundry model run` after the fact is
* picked up by a FRESH provider; that is the retry.
*/
const resolveModel = async (requested) => {
const named = MODEL_SHORTHANDS.has(requested) ? cfg.defaultModel : requested;
if (looksLikeVariantId(named))
return named;
const cached = resolutionCache.get(named);
if (cached !== undefined)
return cached;
const catalog = await fetchJsonBounded(fetchImpl, `${endpoint}/foundry/list`, timeoutMs);
// The fallback is cached like an answer, so the catalog is asked once
// per name whatever it says. A fresh provider re-asks.
const answer = firstVariantForAlias(catalog, named) ?? named;
resolutionCache.set(named, answer);
return answer;
};
/**
* 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) => {
// No Authorization header: no key exists on this wire, and nothing is
// sent where a secret could be invented, logged, or leaked.
const response = await fetchUntilHeaders(fetchImpl, chatUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}, { timeoutMs, endpoint, call, ...(req.signal && { signal: req.signal }) });
if (!response.ok) {
throw await (0, abort_js_1.untilAborted)(describeFailure(response, body.model, endpoint, fetchImpl, timeoutMs), req.signal);
}
return response;
};
const provider = {
name: 'foundry-local',
carriesInMessages: CARRIES_IN_MESSAGES,
// `tool_choice` support is undocumented on this wire. Absence would mean
// the same thing; saying it out loud documents that this was checked
// rather than forgotten. See the header's ceilings.
carriesForcedToolChoice: false,
async complete(req) {
// A signal that fired BEFORE the call stops it here, before any socket
// opens — the catalog lookup 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 model = await (0, abort_js_1.untilAborted)(resolveModel(req.model), req.signal);
const body = buildBody(req, cfg, model, 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 fromFoundryResponse(json, nextToolCallId);
}
finally {
call.unlink();
}
},
async *stream(req) {
(0, abort_js_1.throwIfAborted)(req.signal);
const model = await (0, abort_js_1.untilAborted)(resolveModel(req.model), req.signal);
const call = (0, abort_js_1.linkedController)(req.signal);
try {
yield* streamUnder(req, model, call.controller);
}
finally {
call.unlink();
}
},
};
async function* streamUnder(req, model, call) {
const body = buildBody(req, cfg, model, true);
const response = await post(body, req, call);
if (!response.body)
throw new Error('[foundry-local] response has no body');
const textParts = [];
// The dialect streams tool calls as DELTAS — id and name on the first
// fragment, argument JSON split across the rest — assembled by index.
const toolCallsByIndex = new Map();
let finishReason = null;
let usage;
let lastId = '';
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 parsed of parseSse(response.body, req.signal)) {
const frame = parsed.data;
// An already-200 stream can still FAIL mid-generation, and this
// dialect says so IN BAND. Such a frame carries no `choices`, so the
// guard below would drop 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(parsed);
if (failure) {
throw providerError(`[foundry-local] the stream failed mid-generation — ${failure}`);
}
// Usage FIRST, and OUTSIDE the choice guard. With
// `stream_options.include_usage` (set in buildBody) the token counts
// ride a FINAL chunk whose `choices` array is EMPTY — the exact bug
// class 9.73.0 fixed in the OpenAI adapter: a `continue` on a missing
// choice threw away the only usage the stream ever reports, and every
// streamed local call read zero tokens everywhere usage is consumed.
if (frame.id)
lastId = frame.id;
if (frame.usage)
usage = frame.usage;
const choice = frame.choices?.[0];
if (!choice)
continue;
if (choice.finish_reason)
finishReason = choice.finish_reason;
const delta = choice.delta;
if (!delta)
continue;
if (delta.content) {
textParts.push(delta.content);
yield { tokenIndex, content: delta.content, done: false };
tokenIndex++;
}
if (delta.tool_calls) {
for (const tcDelta of delta.tool_calls) {
const idx = tcDelta.index ?? 0;
const existing = toolCallsByIndex.get(idx) ?? { id: '', name: '', argsJson: '' };
if (tcDelta.id)
existing.id = tcDelta.id;
if (tcDelta.function?.name)
existing.name = tcDelta.function.name;
if (tcDelta.function?.arguments)
existing.argsJson += tcDelta.function.arguments;
toolCallsByIndex.set(idx, existing);
}
}
}
// Sorted by INDEX, not by first-seen order: a wire is free to open
// index 1 before index 0, and a consumer reading `toolCalls[0]` as
// "the first tool the model asked for" would then name the wrong one.
const toolCalls = Array.from(toolCallsByIndex.entries())
.sort(([a], [b]) => a - b)
.map(([, tc]) => ({
id: tc.id.length > 0 ? tc.id : nextToolCallId(),
name: tc.name,
args: coerceArgs(tc.argsJson),
}));
const authoritative = {
content: textParts.join(''),
toolCalls,
usage: {
input: usage?.prompt_tokens ?? 0,
output: usage?.completion_tokens ?? 0,
},
stopReason: normalizeStopReason(finishReason ?? 'stop', toolCalls.length > 0),
...(lastId && { providerRef: lastId }),
};
(0, abort_js_1.throwIfAborted)(req.signal);
yield { tokenIndex, content: '', done: true, response: authoritative };
}
return provider;
}
exports.foundryLocal = foundryLocal;
/**
* Class form for consumers who prefer `new FoundryLocalProvider(...)`.
*/
class FoundryLocalProvider {
name = 'foundry-local';
carriesInMessages = CARRIES_IN_MESSAGES;
carriesForcedToolChoice = false;
inner;
constructor(model, options) {
this.inner =
typeof model === 'string'
? foundryLocal(model, options)
: foundryLocal(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.FoundryLocalProvider = FoundryLocalProvider;
// ─── Internals ──────────────────────────────────────────────────────
/**
* Resolve where the service lives. Most specific wins: an explicit
* `endpoint`, then a duck-typed SDK manager's discovered URL, then the
* two env spellings, then the docs' example port.
*
* A `/v1` suffix is trimmed rather than rejected — someone copying the
* chat URL out of `foundry server status` output means the same
* machine — and a bare `host:port` gets `http://`.
*/
function resolveEndpoint(options) {
const env = typeof process !== 'undefined' ? process.env : undefined;
const raw = firstConfigured(options.endpoint, options.manager?.urls?.[0], env?.FOUNDRY_LOCAL_ENDPOINT, env?.FOUNDRY_LOCAL_BASE_URL) ?? DEFAULT_ENDPOINT;
const withScheme = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
return (0, linearText_js_1.trimTrailing)(withScheme, linearText_js_1.isSlash).replace(/\/v1$/i, '');
}
/**
* First candidate that actually says something.
*
* A blank value is treated as ABSENT rather than as an endpoint. `??` would
* accept `''`, and `'' → 'http://' → strip trailing slashes` leaves the URL
* `http:` — a refusal naming nothing the reader can check or fix. An env key
* present with an empty value is a config that forgot to fill it in, so the
* next candidate (and ultimately the default) gets its turn.
*/
function firstConfigured(...candidates) {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0)
return candidate.trim();
}
return undefined;
}
/**
* Does this name already carry a variant's shape?
*
* Full Foundry Local variant ids name their execution provider as the
* terminal segment — `-cpu`, `-gpu` or `-npu`, optionally followed by a
* `:version` — e.g. `qwen2.5-0.5b-instruct-generic-cpu:1`. Aliases never
* do. This structural fact is what lets a fully-qualified id skip the
* catalog round-trip entirely.
*/
function looksLikeVariantId(model) {
const bare = model.replace(/:\d+$/, '');
return /-(cpu|gpu|npu)$/i.test(bare);
}
/**
* First catalog variant whose alias matches — `/foundry/list` order is
* the service's priority order, so first wins. Tolerates both a bare
* array and a `{ models: [...] }` wrapper, and both id spellings.
*/
function firstVariantForAlias(catalog, alias) {
const entries = Array.isArray(catalog)
? catalog
: catalog?.models;
if (!Array.isArray(entries))
return undefined;
for (const raw of entries) {
if (typeof raw !== 'object' || raw === null)
continue;
const entry = raw;
if (entry.alias !== alias)
continue;
const id = entry.name ?? entry.id;
if (typeof id === 'string' && id.length > 0)
return id;
}
return undefined;
}
function buildBody(req, cfg, model, stream) {
const body = {
model,
messages: toFoundryMessages(req.messages, req.systemPrompt),
stream,
};
// Always ask for usage on a stream. This wire is a documented Foundry
// Local surface, not an arbitrary OpenAI-compatible server, so the
// reject-unknown-field caution that cost `openai({ baseURL })` its token
// counts (fixed in 9.73.0) has no purchase here — and a streamed call
// that reports zero tokens silently disarms `.compaction()` and budgets.
if (stream)
body.stream_options = { include_usage: true };
if (req.tools && req.tools.length > 0)
body.tools = req.tools.map(toFoundryTool);
const maxTokens = req.maxTokens ?? cfg.defaultMaxTokens;
if (maxTokens !== undefined)
body.max_tokens = maxTokens;
if (req.temperature !== undefined)
body.temperature = req.temperature;
if (req.stop && req.stop.length > 0)
body.stop = [...req.stop];
// `req.toolChoice` is intentionally NOT translated: support for it is
// undocumented on this wire, `carriesForcedToolChoice` says so, and the
// agent refuses before it ever reaches here.
return body;
}
/**
* messages → wire messages.
*
* Roles map 1:1. The system prompt is prepended as an ordinary `system`
* message (this dialect has no separate system field). Assistant turns
* carry their `tool_calls` back — arguments re-serialized to the JSON
* STRING the dialect expects — and tool results carry `tool_call_id`,
* which is how this wire correlates a result with the call that asked
* for it. A role the port does not define is dropped, not forwarded
* blindly.
*/
function toFoundryMessages(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) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
}));
}
result.push(msg);
continue;
}
if (m.role === 'tool') {
result.push({
role: 'tool',
content: m.content,
...(m.toolCallId && { tool_call_id: m.toolCallId }),
});
continue;
}
}
return result;
}
function toFoundryTool(schema) {
return {
type: 'function',
function: {
name: schema.name,
description: schema.description,
parameters: { ...schema.inputSchema },
},
};
}
/**
* Wire response → the port's shape.
*
* @throws a `FoundryLocalProviderError` 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 fromFoundryResponse(response, nextToolCallId) {
if (response.error !== undefined && response.error !== null) {
const detail = extractErrorPayload(response.error) || 'the service named no reason';
throw providerError(`[foundry-local] the service answered 200 with an error — ${detail}`);
}
const choice = response.choices?.[0];
const message = choice?.message;
const wireToolCalls = message?.tool_calls ?? [];
const toolCalls = wireToolCalls.map((tc) => toLLMToolCall(tc, nextToolCallId));
return {
content: message?.content ?? '',
toolCalls,
usage: {
input: response.usage?.prompt_tokens ?? 0,
output: response.usage?.completion_tokens ?? 0,
},
stopReason: normalizeStopReason(choice?.finish_reason ?? 'stop', toolCalls.length > 0),
...(response.id && { providerRef: response.id }),
};
}
/**
* One wire tool call → the port's shape.
*
* Two wire facts handled here:
* • `id` should always be present on this dialect, but small local
* models have been seen to omit it — and the whole tool round-trip
* in this library is keyed by id. So an id is SYNTHESIZED when
* missing, unique per provider instance.
* • `arguments` is a JSON STRING per the dialect (Ollama sends an
* object); the object form 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 };
}
/**
* `finish_reason` → the port's stop vocabulary.
*
* `tool_calls` is the dialect's own word for a tool-ending turn, but a
* local model has been seen to report plain `stop` with tool calls
* attached — so the presence of tool calls also decides, the same
* correction the Ollama adapter makes.
*/
function normalizeStopReason(raw, hasToolCalls) {
if (hasToolCalls && (raw === 'stop' || raw === ''))
return 'tool_use';
switch (raw) {
case 'stop':
return 'stop';
case 'tool_calls':
return 'tool_use';
case 'length':
return 'max_tokens';
case 'content_filter':
return 'content_filter';
default:
return raw;
}
}
/**
* Parse an SSE body — `data: {...}` lines, one JSON payload each, each
* carrying its frame's `event:` name when it had one.
*
* The same discipline as the Ollama adapter's NDJSON parser, adapted to
* SSE: a cross-read buffer so a frame split anywhere (even mid-byte
* sequence — TextDecoder streams) reassembles; a malformed line is
* SKIPPED, never fatal; comments and every other field are ignored; and
* the `[DONE]` sentinel ends the stream, so anything a broken server
* writes after it never reaches a consumer.
*
* Two things here that a plain SSE reader would not do, both because the
* other end is a model on THIS machine: the `event:` name survives (it is
* one of the two ways this dialect spells a mid-generation failure), and
* the caller's `signal` both interrupts the read and CANCELS the body —
* a generation nobody is reading still occupies the GPU.
*/
async function* parseSse(body, signal) {
const decoder = new TextDecoder();
let buf = '';
let eventName;
// `bodyChunks` races every read against the caller's signal — `fetch`
// resolved on headers, so this loop is the whole rest of the call — and
// CANCELS the body on every way out: `[DONE]`, a consumer that breaks out of
// the loop, an error frame, a caller abort. An open body means the on-device
// 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(); // trim eats the \r of a \r\n wire
buf = buf.slice(idx + 1);
if (line.length === 0) {
eventName = undefined; // the blank line ends a frame
continue;
}
if (line.startsWith('event:')) {
eventName = line.slice('event:'.length).trim();
continue;
}
if (!line.startsWith('data:'))
continue;
const payload = line.slice('data:'.length).trim();
if (payload === '[DONE]')
return;
let data;
try {
data = JSON.parse(payload);
}
catch {
/* skip malformed line */
eventName = undefined;
continue;
}
yield { ...(eventName !== undefined && { event: eventName }), data };
eventName = undefined;
}
}
// A final data line with no trailing newline still counts.
const tail = buf.trim();
if (tail.startsWith('data:')) {
(0, abort_js_1.throwIfAborted)(signal);
const payload = tail.slice('data:'.length).trim();
if (payload !== '[DONE]') {
let data;
try {
data = JSON.parse(payload);
}
catch {
return; // skip a malformed tail — there is nothing after it anyway
}
yield { ...(eventName !== undefined && { event: eventName }), data };
}
}
}
/**
* POST, but never hang waiting for a service 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 service —
* 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 service 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 FoundryLocalUnavailableError({
reason: 'service-unreachable',
endpoint: opts.endpoint,
}));
}, 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 FoundryLocalUnavailableError)
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 endpoint. The original is preserved as
// `cause` for anyone who wants it — just never in the message.
throw new FoundryLocalUnavailableError({
reason: 'service-unreachable',
endpoint: opts.endpoint,
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);
}
}
/**
* Best-effort GET-and-parse, bounded the same way the main POST is.
*
* Used for the two side lookups (`/foundry/list`, `/openai/models`),
* where the answer improves an outcome but its absence must never worsen
* one. Any failure — non-2xx, bad JSON, a fetch that hangs past the
* deadline — resolves to `undefined`; the race (not just the abort)
* keeps the "never hangs" promise even against a fetch implementation
* that ignores its signal.
*/
async function fetchJsonBounded(fetchImpl, url, timeoutMs) {
const controller = new AbortController();
let timer;
const deadline = new Promise((resolve) => {
timer = setTimeout(() => {
controller.abort();
resolve(undefined);
}, timeoutMs);
});
const attempt = (async () => {
const response = await fetchImpl(url, { signal: controller.signal });
if (!response.ok)
return undefined;
return (await response.json());
})();
try {
return await Promise.race([attempt, deadline]);
}
catch {
// Best-effort lookup. Its failure must never replace the error the
// caller is on the way to reporting.
return undefined;
}
finally {
if (timer !== undefined)
clearTimeout(timer);
// If the deadline won, the losing attempt may still reject later with
// nobody listening — swallow that deliberately.
attempt.catch(() => undefined);
}
}
/**
* Turn a non-2xx into the most actionable error available.
*
* A 404 from chat means the model is not on this service. Before saying
* so we ask `/openai/models` — a cheap local call — so the message can
* also name what IS cached here, which is usually enough to spot a typo.
* If that lookup fails too, the `foundry model run` instruction stands
* on its own.
*
* A 404 whose body does not speak this dialect's `{"error": ...}` may not
* be a model 404 at all — the endpoint may point at something that is not
* Foundry Local's chat route. The refusal still names the model command
* (guessing the other way would be just as confident and just as wrong),
* but it also names the endpoint as a suspect instead of pretending to know.
*/
async function describeFailure(response, model, endpoint, fetchImpl, timeoutMs) {
const bodyText = await safeText(response);
if (response.status === 404) {
const availableModels = await listCachedModels(fetchImpl, endpoint, timeoutMs);
return new FoundryLocalUnavailableError({
reason: 'model-not-available',
endpoint,
model,
...(availableModels && { availableModels }),
...(speaksDialectError(bodyText) ? {} : { routeUnconfirmed: true }),
});
}
const detail = extractErrorText(bodyText);
const said = detail ? ` — ${detail}` : '';
return providerError(`[foundry-local] ${response.status} ${response.statusText}${said}`.trim(), response.status);
}
/** `GET /openai/models` — a bare JSON array of cached model names. */
async function listCachedModels(fetchImpl, endpoint, timeoutMs) {
const json = await fetchJsonBounded(fetchImpl, `${endpoint}/openai/models`, timeoutMs);
if (!Array.isArray(json))
return undefined;
const names = json.filter((n) => typeof n === 'string' && n.length > 0);
return names.length > 0 ? names : undefined;
}
async function safeText(response) {
try {
return await response.text();
}
catch {
return '';
}
}
/**
* Errors are `{"error": {"message": "..."}}` per the OpenAI dialect; a
* bare `{"error": "..."}` string and raw text are tolerated. Every path
* is capped at 200 chars — the wire's words help diagnose, but a server
* 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);
}
/**
* Did this body speak the dialect's `{"error": ...}`?
*
* The one fact {@link describeFailure} needs to tell a model 404 from a 404
* that is really "this route is not ours".
*/
function speaksDialectError(bodyText) {
if (!bodyText)
return false;
try {
const parsed = JSON.parse(bodyText);
return parsed.error !== undefined && parsed.error !== null;
}
catch {
return false;
}
}
/**
* The words out of an `error` payload — `{"message": "..."}` per the
* dialect, a bare string 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.
*
* Two spellings on this dialect: a `data:` frame carrying `error`, and an
* `event: error` frame whose payload names the failure at the top level.
* Both mean the generation failed; neither carries `choices`, which is
* exactly why an unchecked one looks like an ordinary skippable frame.
*/
function frameFailureText(frame) {
const data = typeof frame.data === 'object' && frame.data !== null
? frame.data
: undefined;
const carriesError = data?.error !== undefined && data?.error !== null;
const isErrorEvent = frame.event === 'error';
if (!carriesError && !isErrorEvent)
return '';
const detail = (carriesError ? extractErrorPayload(data?.error) : '') ||
(typeof data?.message === 'string' ? data.message.slice(0, ERROR_TEXT_CAP) : '');
return detail || 'the service reported an error but named no reason';
}
/**
* The provider's own labelled error — one shape for every failure that is
* not one of the two typed, actionable ones.
*/
function providerError(message, status) {
return Object.assign(new Error(message), {
name: 'FoundryLocalProviderError',
...(status !== undefined && { status }),
});
}
//# sourceMappingURL=FoundryLocalProvider.js.map