agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
387 lines (386 loc) • 18 kB
TypeScript
/**
* 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 '../../provider-contract/types.js';
import type { ThinkingHandler } from '../../thinking/types.js';
import type { TokenCredentialLike } from '../identity/azure.js';
interface OpenAIClient {
chat: {
completions: {
create(params: OpenAICreateParams, options?: OpenAIRequestOptions): Promise<OpenAIChatCompletion> | AsyncIterable<OpenAIStreamChunk>;
};
};
}
/** The SDK's per-request options — the one this adapter sends is the signal,
* so an aborted run cancels the HTTP request itself (`lib/abort.ts`). */
interface OpenAIRequestOptions {
signal?: AbortSignal;
}
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<string, unknown>;
};
}
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<string>);
/** 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;
/** Read off `inner` for the same reason. */
readonly promptCaching?: LLMProvider['promptCaching'];
readonly thinkingHandler?: ThinkingHandler;
private readonly inner;
constructor(options?: OpenAIProviderOptions);
complete(req: LLMRequest, hooks?: LLMCallHooks): Promise<LLMResponse>;
stream(req: LLMRequest, hooks?: LLMCallHooks): AsyncIterable<LLMChunk>;
}
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<string>;
export {};