/** * 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 type { LLMCallHooks, LLMChunk, LLMProvider, LLMRequest, LLMResponse, WireRole } from '../types.js'; import type { TokenCredentialLike } from '../identity/azure.js'; interface OpenAIClient { chat: { completions: { create(params: OpenAICreateParams): Promise | AsyncIterable; }; }; } interface OpenAICreateParams { model: string; messages: OpenAIMessage[]; tools?: OpenAITool[]; /** Legacy token cap — DEPRECATED by OpenAI and REJECTED by o-series reasoning * models. Kept only for custom OpenAI-compatible endpoints (Ollama/vLLM/…). */ max_tokens?: number; /** Current token cap — accepted by all OpenAI/Azure chat models incl. o-series. */ max_completion_tokens?: number; temperature?: number; stop?: string[]; stream?: boolean; /** Ask OpenAI/Azure to emit a final usage chunk while streaming. */ stream_options?: { include_usage: boolean; }; /** v7.26 — forced choice of one named tool, OpenAI's dialect of * `LLMRequest.toolChoice`. */ tool_choice?: { type: 'function'; function: { name: string; }; }; } interface OpenAIMessage { role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; content: string | null; tool_calls?: OpenAIToolCall[]; tool_call_id?: string; } interface OpenAIToolCall { id: string; type: 'function'; function: { name: string; arguments: string; }; } interface OpenAITool { type: 'function'; function: { name: string; description: string; parameters: Record; }; } interface OpenAIChatCompletion { id: string; model: string; choices: Array<{ index: number; message: { role: 'assistant'; content: string | null; tool_calls?: OpenAIToolCall[]; }; finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | string; }>; usage?: { prompt_tokens: number; completion_tokens: number; }; } interface OpenAIStreamChunk { id: string; model: string; choices: Array<{ index: number; delta: { role?: string; content?: string | null; tool_calls?: Array<{ index: number; id?: string; type?: string; function?: { name?: string; arguments?: string; }; }>; }; finish_reason: string | null; }>; usage?: { prompt_tokens: number; completion_tokens: number; }; } export interface OpenAIProviderOptions { /** * API key. Defaults to the `OPENAI_API_KEY` env var. * * **A FUNCTION here is re-read before every request (9.29.0).** That is what * makes this adapter usable in front of an endpoint whose credential is a * short-lived OAuth token rather than a key — Vertex AI's OpenAI-compatible * endpoint being the case that forced it. An independent field trial ran a * real call through that endpoint with a current token, then repeated it * with an expired one and got HTTP 401 with nowhere to put a fresh token: * *"`OpenAIProviderOptions` accepts a fixed `apiKey` and exposes no * credential callback"* (FINDINGS "Part 2B"). A process living longer than * an hour had to rebuild the provider, and usually found out it hadn't at * 3am. * * The boundary, stated so nobody has to guess: * • called ONCE PER REQUEST, before the request is built; * • the SDK client is rebuilt only when the returned string CHANGED, so a * cached token costs one function call; * • a stream keeps the key it started with — nothing can re-authenticate * a socket that is already open; * • what expiry means is the callback's business. This adapter does not * inspect, decode or schedule anything; it asks every time and uses what * it is given. * * ```ts * const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] }); * const client = await auth.getClient(); * openai({ * baseURL: 'https://…/openapi', * apiKey: async () => (await client.getAccessToken()).token ?? '', * }); * ``` */ readonly apiKey?: string | (() => string | Promise); /** Base URL — set for OpenAI-compatible APIs (Ollama, Together, vLLM). */ readonly baseURL?: string; /** * Declare the endpoint's DIALECT instead of letting `baseURL` imply it (9.74.0). * * ── What "legacy" means here ───────────────────────────────────────────── * An OpenAI-compatible server that predates two of OpenAI's own moves: it * accepts only the deprecated `max_tokens` (never `max_completion_tokens`), * and it may reject `stream_options` outright. Setting `baseURL` has always * implied that dialect, because the compatible servers the option was built * for (Ollama/vLLM/Together/Groq) are exactly the ones that break on the * modern fields — and a hard failure is worse than a conservative request. * * That implication is a DEFAULT, not a law. `legacyEndpoint: false` declares * "this baseURL speaks the CURRENT dialect": send `max_completion_tokens`, * send `stream_options.include_usage` on streams, and declare forced tool * choice. The worked example is Azure's v1 inference route * (`https://….services.ai.azure.com/api/projects/{project}/openai/v1`) — a * custom `baseURL` that IS current OpenAI wire; `foundry()` sets this for * you. `legacyEndpoint: true` with no `baseURL` is legal and means what it * says, though nothing today needs it. * * `streamUsage` interplay: a non-legacy endpoint already sends * `stream_options`, exactly as before — `streamUsage` remains the opt-in * for endpoints that stay legacy, and this flag does not change what either * value of it does. * * Unset, nothing changes anywhere: the default is exactly `!!baseURL`. */ readonly legacyEndpoint?: boolean; /** * Ask a CUSTOM endpoint for token usage while streaming (9.73.0). * * ── The silence this breaks ────────────────────────────────────────────── * OpenAI and Azure only report usage on a stream when asked, via * `stream_options: { include_usage: true }`. This adapter sends that — but * NOT when you set `baseURL`, because some OpenAI-compatible servers reject * an unknown field outright, and a hard failure is worse than a missing * number. The cost of that caution went unnoticed: **every local-model user * streaming through `openai({ baseURL })` sees zero tokens**, everywhere * usage is read — dashboards, cost recorders, the thinking trace's per-step * cost. Nothing is broken and nothing says so. * * Most current local servers do support it (llama.cpp and Ollama both send a * final usage chunk when asked). Set this to `true` and get the numbers * back; leave it out and nothing changes. * * ── When it is ignored ─────────────────────────────────────────────────── * Ignored whenever the endpoint is treated as MODERN, because there the * field is already sent. "Modern" is `legacyEndpoint === false`, which is * `!baseURL` by default — so with no `baseURL` and no `legacyEndpoint` this * flag still changes nothing, exactly as it did before `legacyEndpoint` * existed. * * But `legacyEndpoint` is now the thing that decides, not `baseURL`: * `openai({ legacyEndpoint: true })` with NO `baseURL` is legal and means * what it says, and there `stream_options` is withheld and this flag is what * turns it back on. So read the pair, not `baseURL` alone. */ readonly streamUsage?: boolean; /** * Default model used when `LLMRequest.model` is `'openai'` (the * shorthand). Full model ids pass through unchanged. */ readonly defaultModel?: string; /** Default max tokens when the request doesn't set it. Optional. */ readonly defaultMaxTokens?: number; /** * Treat the target as a **reasoning model** (o-series: o1 / o3 / o4-mini, or an * Azure reasoning deployment). Reasoning models reject `max_tokens` and an explicit * `temperature`, and use the `developer` role in place of `system`. Standard o-series * model ids are auto-detected; set this explicitly for Azure deployments whose name * does not reveal the underlying model. */ readonly reasoning?: boolean; /** @internal Pre-built client for testing. Skips SDK import. */ readonly _client?: OpenAIClient; } /** * 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 declare function openai(options?: OpenAIProviderOptions): LLMProvider; /** * Class form for consumers who prefer `new OpenAIProvider(...)`. */ export declare class OpenAIProvider implements LLMProvider { readonly name = "openai"; readonly carriesInMessages: readonly WireRole[]; /** 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. */ readonly carriesForcedToolChoice: boolean; private readonly inner; constructor(options?: OpenAIProviderOptions); complete(req: LLMRequest, hooks?: LLMCallHooks): Promise; stream(req: LLMRequest, hooks?: LLMCallHooks): AsyncIterable; } export interface AzureOpenAIProviderOptions { /** * Resource endpoint, e.g. `https://my-co.openai.azure.com`. Env fallbacks: * `AZURE_OPENAI_ENDPOINT`, then `OPENAI_BASE_URL` — the two spellings are * interchangeable HERE and produce the same URL, whichever one your gateway * config already uses. A value that already ends in `/openai` is taken as-is; * anything else gets `/openai` appended, which is the path Azure serves * deployments under. */ readonly endpoint?: string; /** API key. Env fallbacks: `AZURE_OPENAI_API_KEY`, then `OPENAI_API_KEY`. */ readonly apiKey?: string; /** * Keyless (Microsoft Entra ID) auth — pass any `@azure/identity` credential * (`DefaultAzureCredential`, `ManagedIdentityCredential`, …); the type is * duck-typed so this file never imports that SDK. The token is minted (or * served from MSAL's cache) on EVERY request by the underlying client, so a * long-lived agent process never holds an expired token. * * Mutually exclusive with `apiKey`: two credentials is a config bug, not * extra security, and is refused by name rather than silently ranked. */ readonly credential?: TokenCredentialLike; /** * Token audience for `credential`. Default * {@link AZURE_COGNITIVE_SERVICES_SCOPE} * (`https://cognitiveservices.azure.com/.default`) — the audience * Microsoft's own keyless guidance names for the CLASSIC deployment-scoped * route this door builds * (`{endpoint}/openai/deployments/{d}/chat/completions?api-version=…`). * Each door defaults to the audience ITS route documents: `foundry()`'s * v1/project route is documented against {@link AZURE_AI_SCOPE} * (`https://ai.azure.com/.default`), and current resources widely accept * both — but an older `*.openai.azure.com` resource may only accept this * one, and a default that 401s on the oldest resources it exists to serve * would be the wrong default. The ARM control plane * (`https://management.azure.com/.default`) is a THIRD audience whose * tokens never work here. Azure Government spells this audience * `https://cognitiveservices.azure.us/.default`. Ignored without * `credential`. */ readonly scope?: string; /** Azure API version, e.g. `2024-12-01-preview`. Env fallback: * `AZURE_OPENAI_API_VERSION`. Required. */ readonly apiVersion?: string; /** The DEPLOYMENT name (Azure's "model"), e.g. `gpt-4o-128k`. Env fallbacks: * `AZURE_OPENAI_DEPLOYMENT`, then `MODEL_NAME`. Required. */ readonly deployment?: string; /** Default max tokens when the request doesn't set it. Optional. */ readonly defaultMaxTokens?: number; /** * Set when the Azure DEPLOYMENT is a **reasoning model** (o1/o3/o4-mini). Azure * deployment names are arbitrary, so this cannot be auto-detected — declare it to * omit `temperature` and send the `developer` role. (`max_completion_tokens` is used * for all Azure deployments regardless.) */ readonly reasoning?: boolean; /** @internal Pre-built client for testing. Skips SDK import. */ readonly _client?: OpenAIClient; } /** * 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 declare function azureOpenai(options?: AzureOpenAIProviderOptions): LLMProvider; /** * `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 declare function entraBearerToken(adapter: string, credential: TokenCredentialLike, scope: string): Promise; export {}; //# sourceMappingURL=OpenAIProvider.d.ts.map