UNPKG

agentfootprint

Version:

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

787 lines • 38.6 kB
/** * OpenAIProvider — wraps the `openai` SDK as an `LLMProvider`. * * Pattern: Adapter (GoF) + Ports-and-Adapters (Cockburn 2005). * Role: Outer ring — translates `LLMRequest`/`LLMResponse` to/from * OpenAI's Chat Completions API. Knows nothing about agents, * recorders, or compositions. * Emits: N/A. * * ─── Limitations ──────────────────────────────────────────────────── * * • Multi-modal NOT supported (`LLMMessage.content` is * `string`). May extend in a future release. * • `responseFormat` (JSON-mode) NOT exposed — pass schema * instructions via `systemPrompt` for now. * * The `baseURL` option enables OpenAI-compatible APIs (Ollama, Together, * Groq, vLLM, LM Studio) without a separate adapter — see the `ollama()` * convenience factory below. */ import { openAIThinkingHandler } from '../../thinking/OpenAIThinkingHandler.js'; import { lazyRequire } from '../../lib/lazyRequire.js'; import { abortableIterable, abortErrorOf, throwIfAborted, untilAborted } from '../../lib/abort.js'; import { asContextWindowExceeded } from '../../provider-contract/contextWindow.js'; import { retryAfterMsFromError } from './retryAfter.js'; import { azureBaseUrl } from './azureUrl.js'; import { AZURE_COGNITIVE_SERVICES_SCOPE } from '../identity/azure.js'; /** `create(params)`, with the request's signal in the SDK's options when it has one. */ function createUnder(client, params, signal) { return signal ? client.chat.completions.create(params, { signal }) : client.chat.completions.create(params); } /** The SDK stream's own close, where it has one (`Stream.controller`). */ function closeOpenAIStream(stream) { stream.controller?.abort?.(); } /** * 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 `baseURL` caches is its own * business. */ const OPENAI_PROMPT_CACHING = Object.freeze({ mode: 'automatic', reportsUsage: false, }); /** * Build an `LLMProvider` backed by OpenAI's Chat Completions API. * * @example * import { Agent } from 'agentfootprint'; * import { openai } from 'agentfootprint/providers'; * * const agent = Agent.create({ * provider: openai({ defaultModel: 'gpt-4o' }), * model: 'openai', * }) * .tool(searchTool) * .build(); */ export function openai(options = {}) { const connect = createClientResolver(options); const defaultModel = options.defaultModel ?? 'gpt-4o-mini'; const defaultMaxTokens = options.defaultMaxTokens; // A custom baseURL means an OpenAI-COMPATIBLE endpoint (Ollama/vLLM/Together/Groq), // which may only accept the legacy `max_tokens` and may not support `stream_options`. // Real OpenAI (no baseURL) and Azure (via injected _client, also no baseURL) get the // modern params. Reasoning detection is per-request (model id) OR the explicit flag. // Since 9.74.0 the implication is a DEFAULT the consumer can override: an explicit // `legacyEndpoint` wins, and `false` is how a modern-dialect baseURL (Azure's v1 // route, which foundry() rides) gets the current params. Unset keeps `!!baseURL`. const legacyEndpoint = options.legacyEndpoint ?? !!options.baseURL; const reasoning = options.reasoning ?? false; const cfg = { defaultModel, defaultMaxTokens, legacyEndpoint, reasoning, streamUsage: options.streamUsage === true, }; const provider = { name: 'openai', carriesInMessages: CARRIES_IN_MESSAGES, // Declared for real OpenAI and Azure, and NOT behind a custom baseURL. // What an OpenAI-COMPATIBLE server (Ollama, vLLM, Together, …) does with // `tool_choice` is that server's business; promising it here on their // behalf would be this library guaranteeing someone else's endpoint. Same // signal the file already trusts to pick `max_tokens` vs // `max_completion_tokens`. carriesForcedToolChoice: !legacyEndpoint, ...(!legacyEndpoint && { promptCaching: OPENAI_PROMPT_CACHING }), thinkingHandler: openAIThinkingHandler, async complete(req) { // A cancelled call sends nothing — checked again once the credential is // in hand, because asking for it can take a while (lib/abort.ts, law 1). throwIfAborted(req.signal); const params = buildParams(req, { ...cfg, stream: false }); // The credential is asked for HERE, per request, so a token that expired // since the last call is a new token and not a 401. const client = await untilAborted(connect(), req.signal); throwIfAborted(req.signal); try { // The signal rides the SDK's request options, so an abort cancels the // HTTP request itself; the race refuses an answer that lands after it. const response = (await untilAborted(createUnder(client, params, req.signal), req.signal)); return fromOpenAIResponse(response); } catch (err) { throw wrapError(err, req.signal); } }, async *stream(req) { throwIfAborted(req.signal); const params = buildParams(req, { ...cfg, stream: true }); const client = await untilAborted(connect(), req.signal); throwIfAborted(req.signal); let stream; try { // AWAIT, then check. The SDK's `create()` returns an `APIPromise` — a // Promise subclass that RESOLVES to the async-iterable stream. Iterating // it unawaited died as `stream is not async iterable`, a TypeError that // named a local variable and no cause; the first real streamed turn // through this adapter is where one production consumer met it. Test // doubles hid it for a year by returning an async generator directly // from `create()`, which is why the guard accepts BOTH shapes: `await` // on a non-thenable is the identity. stream = await untilAborted(asChunkStream(createUnder(client, params, req.signal)), req.signal); } catch (err) { throw wrapError(err, req.signal); } const opened = stream; // Accumulate the streamed pieces so we can synthesize the // authoritative LLMResponse on the terminal chunk. OpenAI streams // tool_calls in chunks too — assemble id/name/args by index. const textParts = []; const toolCallsByIndex = new Map(); let lastFinishReason = null; let lastUsage; let lastId = ''; let tokenIndex = 0; try { // An abort closes the SDK stream and ends this loop at once, even // when a read is still pending (lib/abort.ts, laws 2–3). for await (const chunk of abortableIterable(opened, req.signal, () => closeOpenAIStream(opened))) { // Usage FIRST, and outside the choice guard. With // `stream_options.include_usage` (set in buildParams) OpenAI sends the // token counts on a final chunk whose `choices` array is EMPTY — so a // `continue` on a missing choice threw away the only usage the stream // ever reports. Every consumer of `response.usage` went to zero: // `costBudget` silently stopped being enforceable, and any budget // counted from adapter-reported tokens (7.16 `.compaction()`) could // never trip. Read it before deciding whether the chunk has content. 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 response = { content: textParts.join(''), toolCalls, usage: { input: lastUsage?.prompt_tokens ?? 0, output: lastUsage?.completion_tokens ?? 0, }, stopReason: normalizeStopReason(lastFinishReason ?? 'stop'), providerRef: lastId, }; yield { tokenIndex, content: '', done: true, response }; } catch (err) { throw wrapError(err, req.signal); } }, }; return provider; } /** * Class form for consumers who prefer `new OpenAIProvider(...)`. */ export class OpenAIProvider { name = 'openai'; carriesInMessages = CARRIES_IN_MESSAGES; /** Read off `inner` rather than fixed here: the answer depends on whether a * custom `baseURL` was given, and this class is only the thing its options * made it. */ carriesForcedToolChoice; /** Read off `inner` for the same reason. */ promptCaching; thinkingHandler; inner; constructor(options = {}) { this.inner = openai(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); } } /** Shorthand model ids that resolve to the configured deployment. */ const AZURE_MODEL_SHORTHANDS = new Set(['azure', 'azure-openai', 'openai']); /** * Build an `LLMProvider` for **Azure OpenAI**. * * Azure is NOT a drop-in OpenAI-compatible URL — it uses a deployment-scoped * path, `api-key` header auth, and an `api-version` query param. This wraps the * `openai` SDK's `AzureOpenAI` client (which handles all that) and reuses the * exact same completion/streaming/tool-call logic as `openai()`. * * The request's `model` is the Azure **deployment** name. Pass a deployment id * to target it; the shorthands `'azure'` / `'azure-openai'` resolve to the * configured default `deployment`. * * `endpoint` is the resource ROOT (`https://my-co.openai.azure.com`), and * `AZURE_OPENAI_ENDPOINT` and `OPENAI_BASE_URL` are two names for it that * resolve to the identical final URL. Setting `OPENAI_BASE_URL` no longer * collides with the SDK's own reading of that variable — this factory hands the * SDK a `baseURL` it computed rather than an `endpoint` the SDK would have to * reconcile with the environment. * * @example * import { azureOpenai } from 'agentfootprint/providers'; * * const agent = Agent.create({ * provider: azureOpenai({ * endpoint: process.env.OPENAI_BASE_URL, // *.openai.azure.com * apiKey: process.env.AZURE_OPENAI_API_KEY, * apiVersion: process.env.AZURE_OPENAI_API_VERSION, // 2024-12-01-preview * deployment: process.env.MODEL_NAME, // gpt-4o-128k * }), * model: 'azure', * }).build(); */ export function azureOpenai(options = {}) { const client = resolveAzureClient(options); const deployment = options.deployment ?? process.env.AZURE_OPENAI_DEPLOYMENT ?? process.env.MODEL_NAME; if (!deployment) { throw new Error('azureOpenai: a `deployment` is required (or set AZURE_OPENAI_DEPLOYMENT / MODEL_NAME).'); } // Reuse ALL of openai()'s logic via the injected client; defaultModel is the // deployment so shorthand model ids resolve to it. const inner = openai({ _client: client, defaultModel: deployment, ...(options.reasoning !== undefined && { reasoning: options.reasoning }), ...(options.defaultMaxTokens !== undefined && { defaultMaxTokens: options.defaultMaxTokens }), }); // Azure's "model" IS the deployment — rewrite shorthand ids to it; a concrete // deployment id passes through (so you can target multiple deployments). const withDeployment = (req) => AZURE_MODEL_SHORTHANDS.has(req.model) ? { ...req, model: deployment } : req; return { name: 'azure-openai', // The wire is `inner`'s, so the capability is `inner`'s. Re-stating it // rather than inheriting by accident: this factory builds a fresh object, // and a dropped capability silently becomes the user/assistant floor. 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), }), }; } /** * Build the SDK's `AzureOpenAI` client — always via `baseURL`, never `endpoint`. * * The SDK's constructor defaults `baseURL` to `process.env.OPENAI_BASE_URL` * and then refuses a `baseURL` and an `endpoint` together * ("baseURL and endpoint are mutually exclusive"). We document * `OPENAI_BASE_URL` as an alias for the Azure endpoint, so the path our own * docs advertise handed the SDK the value twice — once explicitly as * `endpoint`, once invisibly out of the environment — and could not boot. * One production consumer met that on their first server-side Azure run with * the same gateway and key their browser app had used for months. * * Passing `baseURL` ourselves settles it for good: the ambient read is * overridden rather than fought with, `endpoint` is never passed so the * collision cannot occur, and both spellings survive. The `/openai` suffix the * SDK would have appended is appended by `azureBaseUrl`, so the final URL is * byte-identical either way — pinned by test/adapters/integration/azure-openai-wire.test.ts. */ function resolveAzureClient(options) { // Two credentials is not "extra secure" — whichever one this factory // silently preferred would be the one the consumer did not think was in // use. Refused by NAME, and checked BEFORE the `_client` short-circuit so a // test can pin the wording without the SDK installed. if (options.credential && options.apiKey) { throw new Error('azureOpenai: both `credential` and `apiKey` were given, and they are two answers to the ' + 'same question — which identity signs the request.\n' + ' Fix: pass exactly one — `credential` (keyless Entra ID) or `apiKey` (static key).'); } if (options._client) return options._client; let AzureOpenAI; try { const mod = lazyRequire('openai'); AzureOpenAI = (mod.AzureOpenAI ?? mod.default?.AzureOpenAI); } catch { throw new Error('azureOpenai requires the `openai` package.\n' + ' Install: npm install openai\n' + ' Or pass `_client` for test injection.'); } if (!AzureOpenAI) { throw new Error('azureOpenai needs `openai` >= 4.x (no `AzureOpenAI` export found).'); } const endpoint = options.endpoint ?? process.env.AZURE_OPENAI_ENDPOINT ?? process.env.OPENAI_BASE_URL; const apiKey = options.apiKey ?? process.env.AZURE_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY; const apiVersion = options.apiVersion ?? process.env.AZURE_OPENAI_API_VERSION; const deployment = options.deployment ?? process.env.AZURE_OPENAI_DEPLOYMENT ?? process.env.MODEL_NAME; if (!endpoint) { throw new Error('azureOpenai: `endpoint` is required (or set AZURE_OPENAI_ENDPOINT / OPENAI_BASE_URL), ' + 'e.g. https://my-co.openai.azure.com'); } if (!apiVersion) { throw new Error('azureOpenai: `apiVersion` is required (or set AZURE_OPENAI_API_VERSION), e.g. 2024-12-01-preview.'); } if (options.credential) { const credential = options.credential; const scope = options.scope ?? AZURE_COGNITIVE_SERVICES_SCOPE; return new AzureOpenAI({ // NOT `endpoint` — see the note on this function. baseURL: azureBaseUrl(endpoint), // The SDK insists on exactly ONE of `apiKey` / `azureADTokenProvider` — // so no key is sent. `apiKey: ''` is deliberate, not decorative: the // SDK's constructor defaults a MISSING `apiKey` from AZURE_OPENAI_API_KEY, // and an ambient env key beside the token provider trips its "mutually // exclusive" refusal. An explicit `credential` must beat the environment // (the same stance this function already takes on `OPENAI_BASE_URL`), // and an empty string is falsy to every SDK check while blocking the // env read. apiKey: '', azureADTokenProvider: async () => entraBearerToken('azureOpenai', credential, scope), apiVersion, ...(deployment && { deployment }), }); } return new AzureOpenAI({ // NOT `endpoint` — see the note on this function. `baseURL` is passed // explicitly so the SDK's own `OPENAI_BASE_URL` default is overridden // instead of colliding with an `endpoint` we also passed. baseURL: azureBaseUrl(endpoint), ...(apiKey && { apiKey }), apiVersion, ...(deployment && { deployment }), }); } /** * `TokenCredentialLike.getToken(scope)` → the bearer string an OpenAI-shaped * client can send — shared by the two Entra doors (`azureOpenai()` here and * `foundry()` in FoundryProvider.ts, which imports it; the dependency points * THIS way because FoundryProvider already composes over `openai()`). * * What it enforces are the credential-surface laws, not conveniences: * • an ABSENT answer — `null`, the SDK's spelling of "no token available", * and `undefined`, which is what a hand-rolled or caching credential * hands back on a miss — is refused by NAME. Both are the same absence * and get the same refusal: passed onward, `null` becomes a bare 401 that * names nothing, and `undefined` becomes a raw `TypeError` thrown from * inside this library, which names less than the 401 does. * • an empty or blank `token` FIELD is refused by field name; the value * itself is a secret when it is right, so no message ever quotes it. * • a throwing credential is reported as operation + error NAME only (auth * SDKs echo request detail into 401/403 text), with no `cause` — a cause * travels into every serializer that walks own properties. * The scope IS quoted: it is a public audience URI, never a secret, and it is * the thing the consumer most likely got wrong. */ export async function entraBearerToken(adapter, credential, scope) { // `| undefined` is deliberate and is NOT in the duck type: `getToken` is // declared `Promise<AccessTokenLike | null>`, but this door is documented as // duck-typed on both public factories, so it is reachable from plain JS AND // from a strict-clean TypeScript wrapper — a token cache spelled // `async (s) => cache.get(String(s))!` type-checks with zero errors and // resolves to `undefined` on a miss. Widening the local (never the port) // is what lets that case be refused by name instead of dereferenced. let access; try { access = await credential.getToken(scope); } catch (err) { const name = err instanceof Error && err.name ? err.name : typeof err; throw new Error(`${adapter}: credential.getToken failed (${name}) for scope ${scope}.\n` + ' Fix: sign in (az login), or pass a `credential` whose identity can mint tokens for ' + 'this audience — the audience is the `scope` option (classic Azure OpenAI documents ' + 'https://cognitiveservices.azure.com/.default; the Foundry v1 route documents ' + 'https://ai.azure.com/.default).'); } // `== null` on purpose: null AND undefined, one refusal. The shape is named // in the message (it is a wiring fact, not a secret) so the reader knows // whether they are looking at a chain that found no identity or at their own // cache handing back a miss. if (access == null) { // The two absences read the same to a `TypeError` and completely // differently to a person, so the SHAPE is named (a wiring fact, never a // secret) and each gets its own diagnosis under one shared fix. throw new Error(`${adapter}: the credential returned no access token for scope ${scope} — ` + (access === null ? '`getToken` resolved to `null`, the SDK\'s spelling of "no token available": the ' + 'chain found no identity that can serve this audience.' : '`getToken` resolved to `undefined`, which no `@azure/identity` credential does — a ' + 'hand-rolled or caching credential handed back a miss (an empty cache lookup, a ' + 'non-null assertion over a `Map`) instead of minting or reporting no token.') + '\n Fix: sign in (az login), or pass a `credential` that holds an identity for this ' + 'audience (DefaultAzureCredential walks env service principal → workload identity → ' + 'managed identity → az login). A `credential` of your own must always RESOLVE to a token ' + 'object or to `null`.'); } if (typeof access.token !== 'string' || access.token.trim().length === 0) { // Field NAME only — the value is a secret when it is right, and an empty // one is described, never quoted. throw new Error(`${adapter}: the credential returned an access token whose \`token\` field is empty.\n` + ' Fix: check the credential wiring — a real Entra token is never an empty string.'); } return access.token; } // ─── Ollama ───────────────────────────────────────────────────────── // // `ollama()` used to live here as a thin wrapper over `openai({ baseURL })`. // It moved to its own native adapter in 8.1.0 (`./OllamaProvider.ts`) so the // free rung of the ladder stops needing the `openai` package, stops labelling // its failures `[openai]`, and starts reporting real token counts. // // The OpenAI-compatible endpoint still works and is still supported — it is // now something you ask for explicitly: // // openai({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' }) // ─── Internals ────────────────────────────────────────────────────── /** * The client seam — one answer per request. * * Three paths, and only the third is new (the same three the Google adapters * take, deliberately: two doors that solved the same problem differently would * be two doors to learn): * * 1. `_client` injected → the double, always (the credential callback still * runs, because the double replaces the client and not the credential). * 2. `apiKey` a string or absent → built ONCE, at factory time, so a missing * `openai` package is still refused where the consumer typed `openai(...)`. * 3. `apiKey` a callback → called before every request; the SDK client is * rebuilt only when the answer changed. Not eager: calling a consumer's * credential provider from a factory fetches a token nobody asked for yet. */ function createClientResolver(options) { const source = options.apiKey; if (options._client) { const injected = options._client; // A double stands in for the SDK client, not for the credential: a // callback is still called per request, so the rotation is observable in a // test that never opens a socket. if (typeof source !== 'function') return () => injected; return async () => { await requireKey(source); return injected; }; } if (typeof source !== 'function') { const built = resolveClient(options); return () => built; } let lastKey; let built; return async () => { const answer = await requireKey(source); if (built === undefined || answer !== lastKey) { built = resolveClient({ ...options, apiKey: answer }); lastKey = answer; } return built; }; } /** Call the credential callback and insist on something usable. */ async function requireKey(source) { const answer = await source(); if (typeof answer === 'string' && answer.trim().length > 0) return answer; // The value is never quoted back: it is a credential when it is right. throw new Error('openai: the `apiKey` callback returned ' + (typeof answer === 'string' ? 'an empty string' : `a ${typeof answer}`) + ', and a key has to be a non-empty string.\n' + ' The callback runs before every request, so this is a live credential failure — a ' + 'token fetch that failed usually throws rather than returning nothing.\n' + ' Fix: return the token, or throw from the callback so openai() can report why.'); } function resolveClient(options) { if (options._client) return options._client; let OpenAI; try { const mod = lazyRequire('openai'); OpenAI = (mod.default ?? mod.OpenAI ?? mod); } catch { throw new Error('OpenAIProvider requires the `openai` package.\n' + ' Install: npm install openai\n' + ' Or pass `_client` for test injection.'); } // A callback has already been resolved to a string by `createClientResolver` // before it reaches here; the guard is what keeps a function from being // stringified into an `Authorization` header by a future call site. const apiKey = (typeof options.apiKey === 'string' ? options.apiKey : undefined) ?? process.env.OPENAI_API_KEY; return new OpenAI({ apiKey, ...(options.baseURL && { baseURL: options.baseURL }) }); } /** * Whatever `create({ stream: true })` handed back → the token stream. * * Two shapes reach here and both are legitimate: * • the real SDK returns an `APIPromise<Stream<…>>` — a Promise that RESOLVES * to the async iterable, so it has to be awaited before it can be iterated; * • an injected `_client` double (and some OpenAI-compatible wrappers) return * the async iterable directly. `await` on a non-thenable is the identity, * so one line covers both. * * Anything else is refused HERE, by name. The raw failure was * `TypeError: stream is not async iterable` — a message that quotes a local * variable, names no endpoint and suggests no fix. */ async function asChunkStream(created) { const resolved = await created; if (typeof resolved === 'object' && resolved !== null && Symbol.asyncIterator in resolved) { return resolved; } // A completed chat completion means the endpoint ignored `stream: true` — // the commonest way an OpenAI-COMPATIBLE server differs from OpenAI. Said // plainly, because the fix is different from "the SDK is old". const looksComplete = typeof resolved === 'object' && resolved !== null && 'choices' in resolved; throw new Error('a streaming request came back as ' + (looksComplete ? 'a finished chat completion, so the endpoint ignored `stream: true`' : `a ${resolved === null ? 'null' : typeof resolved}, which is not a token stream`) + '.\n' + (looksComplete ? ' Fix: point this provider at an endpoint that streams, or drop stream() from it ' + '(an Agent falls back to complete() when a provider has no stream()).' : ' Fix: `chat.completions.create({ stream: true })` must resolve to an async iterable. ' + 'A custom `_client` double has to return one (or a promise of one); the real `openai` ' + 'package has since 4.x.')); } /** o-series reasoning ids (o1, o1-mini, o3, o3-mini, o4-mini, o5, …). `gpt-4o` * starts with `g`, so it is correctly NOT matched. */ function isReasoningModel(model) { return /^o\d/i.test(model); } function buildParams(req, cfg) { const model = req.model === 'openai' || req.model === 'ollama' ? cfg.defaultModel : req.model; const reasoning = cfg.reasoning || isReasoningModel(model); const params = { model, messages: toOpenAIMessages(req.messages, req.systemPrompt, reasoning), }; if (cfg.stream) { params.stream = true; // OpenAI/Azure only emit usage while streaming when asked; without this the // synthesized response reports 0 tokens. A custom endpoint is left out by // default because some reject the field — and opts back in with // `streamUsage`, which is how a local-model deployment stops reading zero. if (!cfg.legacyEndpoint || cfg.streamUsage) { params.stream_options = { include_usage: true }; } } if (req.tools && req.tools.length > 0) params.tools = req.tools.map(toOpenAITool); const maxTokens = req.maxTokens ?? cfg.defaultMaxTokens; if (maxTokens !== undefined) { // `max_tokens` is deprecated and REJECTED by o-series; `max_completion_tokens` is // the current param (accepted by all OpenAI/Azure chat models). Custom compatible // endpoints may only accept `max_tokens`, so keep it there. if (cfg.legacyEndpoint) params.max_tokens = maxTokens; else params.max_completion_tokens = maxTokens; } // Reasoning models reject an explicit `temperature` (only the default is allowed). if (req.temperature !== undefined && !reasoning) params.temperature = req.temperature; if (req.stop && req.stop.length > 0) params.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 && params.tools !== undefined && params.tools.length > 0) { params.tool_choice = { type: 'function', function: { name: req.toolChoice.name } }; } return params; } /** * messages → OpenAI messages. * * Roles map 1:1: system/user/assistant/tool. For reasoning models the system role * becomes `developer` (its replacement). Assistant turns with `toolCalls` get those * serialized into `message.tool_calls` (args JSON-stringified per OpenAI's contract). * Tool messages map to `role: 'tool'` with `tool_call_id`. */ function toOpenAIMessages(messages, systemPrompt, reasoning) { const systemRole = reasoning ? 'developer' : 'system'; const result = []; // OpenAI accepts the system/developer role IN the messages array (unlike Anthropic's // separate `system` field). Prepend systemPrompt as the first such message; subsequent // in-message system entries pass through. 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('[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 { // Malformed JSON in tool args is rare but observed; surface empty // args rather than crash. Consumers see the issue via the // (still-arriving) tool-call event. 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; } } function wrapError(err, signal) { // A cancelled call fails AS the cancellation, whatever the SDK threw for it // (its own `APIUserAbortError` included) — never as a provider error that a // retry would re-send (lib/abort.ts). if (signal?.aborted) return abortErrorOf(signal); // "Your request was too big" is a distinct failure with distinct fixes, so // it leaves this adapter as a typed error rather than as one more opaque // 400 that a retry loop will re-send forever. Nothing else is translated. const tooBig = asContextWindowExceeded(err, { provider: 'openai' }); if (tooBig) return tooBig; if (err instanceof Error) { // The wait the response stated (retry-after-ms / retry-after), declared // for withRetry — absent when none, so the error shape is unchanged. const retryAfterMs = retryAfterMsFromError(err); return Object.assign(new Error(`[openai] ${err.message}`), { name: 'OpenAIProviderError', cause: err, status: err.status, ...(retryAfterMs !== undefined && { retryAfterMs }), }); } return new Error(`[openai] ${String(err)}`); } //# sourceMappingURL=OpenAIProvider.js.map