export type Message = import("../types").Message; export type ToolDef = import("../types").ToolDef; export type ToolCall = import("../types").ToolCall; export type GenerateResult = import("../types").GenerateResult; export type OpenAIOptions = { apiKey?: string | undefined; model?: string | undefined; baseUrl?: string | undefined; /** * - Attach the full upstream * response to `err.body` on HTTP errors. Off by default so an unexpected * field in an error payload can't leak through logs that dump the error * object; `err.message` still carries the API's error message. Turn on for * debugging only. */ exposeErrorBody?: boolean | undefined; /** * - BA-18: request/idle timeout in ms. Bounds a silent or * never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` * (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h). * `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`. */ timeoutMs?: number | undefined; /** * - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. * The idle bound resets on any socket activity, so a response that trickles a byte forever never * trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` * rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, * `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via * `generate(..., { deadlineMs })`. */ deadlineMs?: number | undefined; /** * - BA-24 (fwdloop): send the legacy `max_tokens` request * key instead of `max_completion_tokens`. The default is `max_completion_tokens` because current * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'"). * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect. */ legacyMaxTokens?: boolean | undefined; /** * - BA-27 follow-up: when a tool call's `function.arguments` * JSON fails to parse, also attach the FULL raw string (verbatim, uncapped) to `malformedToolCall.rawArguments`. * Off by default, independent of `exposeErrorBody` — the raw blob is model-generated and may be large * or sensitive, so it only reaches logs/audit rows if the caller opts in. See `src/provider-toolcalls.js`. */ exposeMalformedArgs?: boolean | undefined; /** * - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `reasoning_effort` (OpenAI) / `thinking` (DeepSeek — selected by model id `deepseek-*` or a `deepseek.com` baseUrl) request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`. * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through). * DeepSeek: `'on'` + a forced `toolChoice` (`'required'`/`{name}`) cannot work (the API 400s thinking mode on a forced tool_choice) ⇒ the call is sent with `thinking:{type:'disabled'}` (the only shape that works there) and reports `thinkingStatus:'unsupported'`. */ thinkingMode?: "on" | "off" | null | undefined; /** * - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking` * VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to * OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl` * `https://api.deepseek.com`, model `deepseek-flash`) runs thinking mode by default and 400s "Thinking mode * does not support this tool_choice" on a named or `'required'` `toolChoice`; `{ type: 'disabled' }` makes the * forced tool call return 200. Deliberately opaque (no model sniffing, no free-form extra-body field) — a * library that reshapes it would need a release every time a vendor moves. Unset ⇒ request body unchanged. * Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default. * WARNING: per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain — for a mixed-vendor * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call. */ thinking?: any; }; /** * @typedef {object} OpenAIOptions * @property {string} [apiKey] * @property {string} [model='gpt-4o-mini'] * @property {string} [baseUrl='https://api.openai.com/v1'] * @property {boolean} [exposeErrorBody=false] - Attach the full upstream * response to `err.body` on HTTP errors. Off by default so an unexpected * field in an error payload can't leak through logs that dump the error * object; `err.message` still carries the API's error message. Turn on for * debugging only. * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or * never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` * (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h). * `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`. * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. * The idle bound resets on any socket activity, so a response that trickles a byte forever never * trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` * rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, * `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via * `generate(..., { deadlineMs })`. * @property {boolean} [legacyMaxTokens=false] - BA-24 (fwdloop): send the legacy `max_tokens` request * key instead of `max_completion_tokens`. The default is `max_completion_tokens` because current * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'"). * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect. * @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's `function.arguments` * JSON fails to parse, also attach the FULL raw string (verbatim, uncapped) to `malformedToolCall.rawArguments`. * Off by default, independent of `exposeErrorBody` — the raw blob is model-generated and may be large * or sensitive, so it only reaches logs/audit rows if the caller opts in. See `src/provider-toolcalls.js`. * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `reasoning_effort` (OpenAI) / `thinking` (DeepSeek — selected by model id `deepseek-*` or a `deepseek.com` baseUrl) request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`. * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through). * DeepSeek: `'on'` + a forced `toolChoice` (`'required'`/`{name}`) cannot work (the API 400s thinking mode on a forced tool_choice) ⇒ the call is sent with `thinking:{type:'disabled'}` (the only shape that works there) and reports `thinkingStatus:'unsupported'`. * @property {any} [thinking] - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking` * VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to * OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl` * `https://api.deepseek.com`, model `deepseek-flash`) runs thinking mode by default and 400s "Thinking mode * does not support this tool_choice" on a named or `'required'` `toolChoice`; `{ type: 'disabled' }` makes the * forced tool call return 200. Deliberately opaque (no model sniffing, no free-form extra-body field) — a * library that reshapes it would need a release every time a vendor moves. Unset ⇒ request body unchanged. * Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default. * WARNING: per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain — for a mixed-vendor * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call. */ export class OpenAIProvider { /** * @param {OpenAIOptions} [options] * @when you want OpenAI or any OpenAI-compatible endpoint as the Loop's provider — tool-calling, toolChoice, and a custom baseUrl * @fails throws on a missing apiKey; a malformed tool-call JSON returns no usable calls with usage still metered (BA-27); a socket idle/deadline/transport cut rejects with a retryable error. * @example * const provider = new OpenAIProvider({ apiKey, model: 'gpt-5' }); */ constructor(options?: OpenAIOptions); apiKey: string | undefined; model: string; baseUrl: string; exposeErrorBody: boolean; timeoutMs: number | undefined; deadlineMs: number | undefined; legacyMaxTokens: boolean; exposeMalformedArgs: boolean; thinking: any; thinkingMode: "on" | "off" | null; /** @type {Map} shape that worked per mode+model (no re-probing) */ _thinkingShapes: Map; /** * Generate a response from the OpenAI API. * @param {Message[]} messages - Conversation messages. * @param {ToolDef[]} [tools=[]] - Tool definitions. * @param {Record} [options={}] - Options (thinkingMode — per-call override of the constructor's, `null` suppresses; temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present; thinking — per-call override of the constructor's `thinking`, `null` suppresses an instance default, see BA-7 (b)). * @returns {Promise} * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response. */ generate(messages: Message[], tools?: ToolDef[], options?: Record): Promise; /** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */ _warnTemperatureDropped(reason?: string): void; /** * Normalize OpenAI usage to the neutral {@link Usage} shape. OpenAI auto-caches prompt prefixes * (>=1024 tokens) and reports the cached portion in `prompt_tokens_details.cached_tokens` — * crucially, `prompt_tokens` INCLUDES those cached tokens, so we subtract them to get the uncached * remainder (else the cached tokens are double-counted and priced at the full input rate, a ~2x * over-charge on a warm prompt). OpenAI has no separate cache-write tier → cacheCreationTokens 0. * @param {any} u - raw `data.usage` * @returns {import('../types').Usage|null} */ _normalizeUsage(u: any): import("../types").Usage | null; /** * @param {string} path * @param {Record} body * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http. * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http. * @returns {Promise} */ _request(path: string, body: Record, timeoutMs?: number, deadlineMs?: number): Promise; _warnedInsecure: boolean | undefined; }