UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

521 lines • 22.2 kB
"use strict"; /** * BrowserOpenAIProvider — fetch-based OpenAI adapter for browsers. * * Pattern: Adapter (GoF). Zero peer dependencies — uses global `fetch`. * Role: Outer ring. Same `LLMProvider` contract as `OpenAIProvider` * but skips the `openai` SDK. For prototypes / playgrounds. * Production apps should proxy through a backend. * Emits: N/A. * * Also works with OpenAI-compatible endpoints (Ollama, Together, vLLM) * via `apiUrl`. * * ─── Limitations ──────────────────────────────────────────────────── * * • Multi-modal NOT supported. * • CORS depends on the endpoint — OpenAI requires the user-supplied * key in the Authorization header, which they'll do explicitly. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.BrowserAzureOpenAIProvider = exports.browserAzureOpenai = exports.BrowserOpenAIProvider = exports.browserOpenai = void 0; const contextWindow_js_1 = require("../../provider-contract/contextWindow.js"); const abort_js_1 = require("../../lib/abort.js"); const azureUrl_js_1 = require("./azureUrl.js"); const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'; /** * Which roles this wire carries inside `messages`. * * The OpenAI chat-completions shape takes the system prompt as a message like * any other (as `developer` on reasoning models), so all three roles survive * — the one place where a `slot: 'messages'` injection with `role: 'system'` * genuinely reaches the model. */ const CARRIES_IN_MESSAGES = Object.freeze(['system', 'user', 'assistant']); /** * What the OpenAI wire promises about prompt caching: OpenAI (and Azure * OpenAI) cache a repeated prefix on their own, so a breakpoint means nothing * here, and this adapter does not lift `prompt_tokens_details.cached_tokens` * onto the port usage — so the meter reports *not applicable*, never a zero. * Declared for the real endpoints only (the `carriesForcedToolChoice` signal): * what an OpenAI-COMPATIBLE server behind a custom URL caches is its own * business. */ const OPENAI_PROMPT_CACHING = Object.freeze({ mode: 'automatic', reportsUsage: false, }); function browserOpenai(options) { if (!options.apiKey) { throw new Error('BrowserOpenAIProvider requires `apiKey`. Browser providers do not read environment variables.'); } const apiUrl = options.apiUrl ?? OPENAI_API_URL; const defaultModel = options.defaultModel ?? 'gpt-4o-mini'; const defaultMaxTokens = options.defaultMaxTokens; const fetchImpl = options._fetch ?? fetch; // OpenAI (default URL) and Azure (`api-key` auth) get the modern params; a custom // bearer endpoint (Ollama/vLLM/…) keeps legacy `max_tokens` and no `stream_options`. const legacyEndpoint = apiUrl !== OPENAI_API_URL && options.authScheme !== 'api-key'; const reasoningOpt = options.reasoning ?? false; const cfg = { defaultModel, defaultMaxTokens, legacyEndpoint, reasoning: reasoningOpt }; const headers = { 'content-type': 'application/json' }; if (options.authScheme === 'api-key') { headers['api-key'] = options.apiKey; // Azure OpenAI } else { headers['authorization'] = `Bearer ${options.apiKey}`; } if (options.organization) headers['openai-organization'] = options.organization; const provider = { name: 'browser-openai', carriesInMessages: CARRIES_IN_MESSAGES, // Not behind a custom baseURL — see the Node provider for why the library // does not promise an OpenAI-compatible server's behaviour. carriesForcedToolChoice: !legacyEndpoint, ...(!legacyEndpoint && { promptCaching: OPENAI_PROMPT_CACHING }), async complete(req) { // A cancelled call sends nothing (lib/abort.ts, law 1). (0, abort_js_1.throwIfAborted)(req.signal); const body = buildBody(req, { ...cfg, stream: false }); let response; try { // The signal rides the fetch, so an abort cancels the request itself; // every wait after it (headers, body) is raced against it as well, so // an answer that lands after the abort is refused (lib/abort.ts). response = await (0, abort_js_1.untilAborted)(fetchImpl(apiUrl, { method: 'POST', headers, body: JSON.stringify(body), ...(req.signal && { signal: req.signal }), }), req.signal); } catch (err) { throw wrapError(err, req.signal); } if (!response.ok) throw await (0, abort_js_1.untilAborted)(wrapStatus(response), req.signal); const json = (await (0, abort_js_1.untilAborted)(response.json(), req.signal)); return fromOpenAIResponse(json); }, async *stream(req) { (0, abort_js_1.throwIfAborted)(req.signal); const body = buildBody(req, { ...cfg, stream: true }); let response; try { response = await (0, abort_js_1.untilAborted)(fetchImpl(apiUrl, { method: 'POST', headers, body: JSON.stringify(body), ...(req.signal && { signal: req.signal }), }), req.signal); } catch (err) { throw wrapError(err, req.signal); } if (!response.ok) throw await (0, abort_js_1.untilAborted)(wrapStatus(response), req.signal); if (!response.body) throw new Error('[browser-openai] response has no body'); const textParts = []; const toolCallsByIndex = new Map(); let lastFinishReason = 'stop'; let lastUsage; let lastId = ''; let tokenIndex = 0; // The signal rides INTO the parser: each read is raced against it, and // the body is cancelled on the way out (lib/abort.ts · bodyChunks). for await (const data of parseSSE(response.body, req.signal)) { if (data === '[DONE]') break; const chunk = data; // Usage FIRST, outside the choice guard — the `include_usage` chunk // carries an EMPTY `choices` array, so guarding on a choice discarded // the only token counts the stream reports. See OpenAIProvider for the // same fix and its blast radius (costBudget, `.compaction()`). if (chunk.id) lastId = chunk.id; if (chunk.usage) lastUsage = chunk.usage; const choice = chunk.choices[0]; if (!choice) continue; if (choice.finish_reason) lastFinishReason = choice.finish_reason; const delta = choice.delta; 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; 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); } } } const toolCalls = Array.from(toolCallsByIndex.values()).map((tc) => ({ id: tc.id, name: tc.name, args: parseArgs(tc.argsJson), })); const finalResponse = { content: textParts.join(''), toolCalls, usage: { input: lastUsage?.prompt_tokens ?? 0, output: lastUsage?.completion_tokens ?? 0, }, stopReason: normalizeStopReason(lastFinishReason), providerRef: lastId, }; yield { tokenIndex, content: '', done: true, response: finalResponse }; }, }; return provider; } exports.browserOpenai = browserOpenai; class BrowserOpenAIProvider { name = 'browser-openai'; carriesInMessages = CARRIES_IN_MESSAGES; /** Read off `inner` — it depends on the options, not on the class. */ carriesForcedToolChoice; /** Read off `inner` — it depends on the options, not on the class. */ promptCaching; thinkingHandler; inner; constructor(options) { this.inner = browserOpenai(options); this.carriesForcedToolChoice = this.inner.carriesForcedToolChoice ?? false; if (this.inner.promptCaching !== undefined) this.promptCaching = this.inner.promptCaching; if (this.inner.thinkingHandler !== undefined) this.thinkingHandler = this.inner.thinkingHandler; } // `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.BrowserOpenAIProvider = BrowserOpenAIProvider; const AZURE_BROWSER_SHORTHANDS = new Set([ 'azure', 'browser-azure-openai', 'azure-openai', 'openai', ]); /** * Fetch-based **Azure OpenAI** provider for the browser/edge — no SDK, no Node. * * The browser can't use the Node `azureOpenai()` (it needs the `openai` SDK), so * use this in a browser "bring your own (company) key" flow. Builds the * deployment-scoped Azure URL + `api-key` header + `api-version`, and reuses all * of `browserOpenai()`'s body/streaming/tool logic. The request `model` is the * deployment; `'azure'` resolves to the configured `deployment`. * * **CORS:** an `*.openai.azure.com` resource may not allow direct browser calls; * if blocked, point `endpoint` at a same-origin proxy (e.g. a Vite `/azure` * proxy) or a backend. Same trade-off as `browserOpenai`. * * @example * import { browserAzureOpenai } from 'agentfootprint/providers'; * const provider = browserAzureOpenai({ * endpoint: 'https://my-co.openai.azure.com', * apiKey: userKey, apiVersion: '2024-12-01-preview', deployment: 'gpt-4o-128k', * }); * // Agent.create({ provider, model: 'azure' }) */ function browserAzureOpenai(options) { if (!options.apiKey) throw new Error('browserAzureOpenai requires `apiKey`.'); if (!options.endpoint) { throw new Error('browserAzureOpenai requires `endpoint` (https://<resource>.openai.azure.com).'); } if (!options.apiVersion) { throw new Error('browserAzureOpenai requires `apiVersion` (e.g. 2024-12-01-preview).'); } if (!options.deployment) { throw new Error('browserAzureOpenai requires `deployment` (the Azure deployment name).'); } // Shared with the Node `azureOpenai()` door, so the same endpoint value — // trailing slash, or with `/openai` already written on it — reaches the same // URL through either. See ./azureUrl.ts. const apiUrl = (0, azureUrl_js_1.azureChatCompletionsUrl)(options.endpoint, options.deployment, options.apiVersion); const inner = browserOpenai({ apiKey: options.apiKey, apiUrl, authScheme: 'api-key', defaultModel: options.deployment, ...(options.reasoning !== undefined && { reasoning: options.reasoning }), ...(options.defaultMaxTokens !== undefined && { defaultMaxTokens: options.defaultMaxTokens }), ...(options._fetch && { _fetch: options._fetch }), }); const withDeployment = (req) => AZURE_BROWSER_SHORTHANDS.has(req.model) ? { ...req, model: options.deployment } : req; return { name: 'browser-azure-openai', carriesInMessages: CARRIES_IN_MESSAGES, ...(inner.carriesForcedToolChoice !== undefined && { carriesForcedToolChoice: inner.carriesForcedToolChoice, }), ...(inner.supportsForcedToolChoice !== undefined && { supportsForcedToolChoice: inner.supportsForcedToolChoice, }), ...(inner.promptCaching !== undefined && { promptCaching: inner.promptCaching }), ...(inner.thinkingHandler !== undefined && { thinkingHandler: inner.thinkingHandler }), // `hooks` is FORWARDED, not dropped — see LLMCallHooks in provider-contract/types.ts. complete: (req, hooks) => inner.complete(withDeployment(req), hooks), ...(inner.stream && { stream: (req, hooks) => inner.stream(withDeployment(req), hooks), }), }; } exports.browserAzureOpenai = browserAzureOpenai; class BrowserAzureOpenAIProvider { name = 'browser-azure-openai'; carriesInMessages = CARRIES_IN_MESSAGES; /** Read off `inner` — it depends on the options, not on the class. */ carriesForcedToolChoice; /** Read off `inner` — it depends on the options, not on the class. */ promptCaching; thinkingHandler; inner; constructor(options) { this.inner = browserAzureOpenai(options); this.carriesForcedToolChoice = this.inner.carriesForcedToolChoice ?? false; if (this.inner.promptCaching !== undefined) this.promptCaching = this.inner.promptCaching; if (this.inner.thinkingHandler !== undefined) this.thinkingHandler = this.inner.thinkingHandler; } // `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.BrowserAzureOpenAIProvider = BrowserAzureOpenAIProvider; // ─── Internals ────────────────────────────────────────────────────── /** o-series reasoning ids (o1, o3, o4-mini, …). `gpt-4o` starts with `g` — not matched. */ function isReasoningModel(model) { return /^o\d/i.test(model); } function buildBody(req, cfg) { const model = req.model === 'openai' || req.model === 'browser-openai' ? cfg.defaultModel : req.model; const reasoning = cfg.reasoning || isReasoningModel(model); const body = { model, messages: toOpenAIMessages(req.messages, req.systemPrompt, reasoning), }; if (cfg.stream) { body.stream = true; // Without this OpenAI/Azure send no usage while streaming → 0-token accounting. if (!cfg.legacyEndpoint) body.stream_options = { include_usage: true }; } if (req.tools && req.tools.length > 0) body.tools = req.tools.map(toOpenAITool); const max = req.maxTokens ?? cfg.defaultMaxTokens; if (max !== undefined) { // `max_tokens` is deprecated and rejected by o-series; `max_completion_tokens` is // current. Keep `max_tokens` only for custom compatible endpoints. if (cfg.legacyEndpoint) body.max_tokens = max; else body.max_completion_tokens = max; } // Reasoning models reject an explicit temperature. if (req.temperature !== undefined && !reasoning) body.temperature = req.temperature; if (req.stop && req.stop.length > 0) body.stop = [...req.stop]; // v7.26 — forced choice of one named tool. Guarded on tools being present // for the same reason the Anthropic adapter guards: a tool choice naming a // tool the request does not carry is a request that cannot be served. if (req.toolChoice && body.tools !== undefined && body.tools.length > 0) { body.tool_choice = { type: 'function', function: { name: req.toolChoice.name } }; } return body; } function toOpenAIMessages(messages, systemPrompt, reasoning) { const systemRole = reasoning ? 'developer' : 'system'; const result = []; if (systemPrompt) result.push({ role: systemRole, content: systemPrompt }); for (const m of messages) { if (m.role === 'system') { result.push({ role: systemRole, content: m.content }); continue; } if (m.role === 'user') { result.push({ role: 'user', content: m.content }); continue; } if (m.role === 'assistant') { const msg = { role: 'assistant', content: m.content || null }; 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, tool_call_id: m.toolCallId ?? '', }); continue; } } return result; } function toOpenAITool(schema) { return { type: 'function', function: { name: schema.name, description: schema.description, parameters: { ...schema.inputSchema }, }, }; } function fromOpenAIResponse(response) { const choice = response.choices[0]; if (!choice) throw new Error('[browser-openai] response missing choices[0]'); const message = choice.message; const toolCalls = (message.tool_calls ?? []).map((tc) => ({ id: tc.id, name: tc.function.name, args: parseArgs(tc.function.arguments), })); return { content: message.content ?? '', toolCalls, usage: { input: response.usage?.prompt_tokens ?? 0, output: response.usage?.completion_tokens ?? 0, }, stopReason: normalizeStopReason(choice.finish_reason), providerRef: response.id, }; } function parseArgs(json) { if (!json) return {}; try { const parsed = JSON.parse(json); return typeof parsed === 'object' && parsed !== null ? parsed : {}; } catch { return {}; } } function normalizeStopReason(raw) { 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 OpenAI's SSE stream — `data: ...\n\n` lines, `[DONE]` terminator. * Read under `signal` through `bodyChunks`, which races every read against it * and CANCELS the body on every way out — `[DONE]`, an early stop, an error, * an abort — so a stream nobody reads never holds its socket open. */ async function* parseSSE(body, signal) { const decoder = new TextDecoder(); let buf = ''; 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\n')) >= 0) { const raw = buf.slice(0, idx); buf = buf.slice(idx + 2); for (const line of raw.split('\n')) { (0, abort_js_1.throwIfAborted)(signal); if (!line.startsWith('data:')) continue; const data = line.slice(5).trim(); if (data === '[DONE]') { yield '[DONE]'; return; } if (data) { try { yield JSON.parse(data); } catch { /* skip malformed */ } } } } } } async function wrapStatus(response) { let bodyText = ''; try { bodyText = await response.text(); } catch { /* ignore */ } // The refusal body is where a context overflow says so, and this adapter // reads the response itself — so the translation happens HERE as well as in // `wrapError`. An HTTP error never passes through `wrapError`. const tooBig = (0, contextWindow_js_1.asContextWindowExceeded)(new Error(bodyText), { provider: 'browser-openai', bodyText, status: response.status, }); if (tooBig) return tooBig; return Object.assign(new Error(`[browser-openai] ${response.status} ${response.statusText} — ${bodyText.slice(0, 200)}`), { name: 'BrowserOpenAIProviderError', status: response.status }); } function wrapError(err, signal) { // A cancelled call fails AS the cancellation, never as a provider error that // a retry would re-send (lib/abort.ts). if (signal?.aborted) return (0, abort_js_1.abortErrorOf)(signal); const tooBig = (0, contextWindow_js_1.asContextWindowExceeded)(err, { provider: 'browser-openai' }); if (tooBig) return tooBig; if (err instanceof Error) { return Object.assign(new Error(`[browser-openai] ${err.message}`), { name: 'BrowserOpenAIProviderError', cause: err, }); } return new Error(`[browser-openai] ${String(err)}`); } //# sourceMappingURL=BrowserOpenAIProvider.js.map