Files
oh-my-pi/packages/ai/src/providers/openai-responses.ts
T
2026-07-24 01:40:04 +00:00

1374 lines
54 KiB
TypeScript

import { scheduler } from "node:timers/promises";
import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
import { $flag, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
import * as AIError from "../error";
import { getEnvApiKey } from "../stream";
import type {
AssistantMessage,
CacheRetention,
Context,
Model,
OpenAICompat,
ProviderSessionState,
RawSseEvent,
ServiceTier,
StreamFunction,
StreamOptions,
Tool,
ToolChoice,
} from "../types";
import {
createOpenAIResponsesHistoryPayload,
normalizeSystemPrompts,
resolveCacheRetention,
sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
} from "../utils";
import { createAbortSourceTracker } from "../utils/abort";
import { withEmptyCompletionRetry } from "../utils/empty-completion-retry";
import { AssistantMessageEventStream } from "../utils/event-stream";
import type { RawHttpRequestDump } from "../utils/http-inspector";
import {
getOpenAIStreamFirstEventTimeoutMs,
getOpenAIStreamIdleTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
import { notifyProviderResponse } from "../utils/provider-response";
import { callWithCopilotModelRetry } from "../utils/retry";
import {
adaptSchemaForStrict,
findStrictToolSchemaViolation,
NO_STRICT,
normalizeSchemaForMoonshot,
sanitizeSchemaForOpenAIResponses,
toolWireSchema,
} from "../utils/schema";
import {
isForcedToolChoice,
mapToOpenAIResponsesToolChoice,
type OpenAIResponsesToolChoice,
} from "../utils/tool-choice";
import { compactGrammarDefinition } from "./grammar";
import {
applyOpenAIReasoningEffortFallback,
clearOpenAIReasoningEffortFallbackState,
createOpenAIReasoningEffortFallbackKey,
createOpenAIReasoningEffortFallbackState,
getOpenAIReasoningEffortFallback,
type OpenAIReasoningEffortFallback,
type OpenAIReasoningEffortFallbackState,
rememberOpenAIReasoningEffortFallback,
resolveOpenAIReasoningEffortFallback,
} from "./openai-reasoning-fallback";
import type {
Tool as OpenAITool,
ResponseCreateParamsStreaming,
ResponseInput,
ResponseInputContent,
ResponseStreamEvent,
} from "./openai-responses-wire";
import {
applyCommonResponsesSamplingParams,
applyOpenAIExtraBody,
applyOpenAIGatewayRouting,
applyResponsesCompatPolicy,
applyVercelResponsesCacheControls,
applyWireModelIdTransform,
buildResponsesDeltaInput,
buildResponsesInput,
clearOpenAIStrictToolsState,
createInitialResponsesAssistantMessage,
createOpenAIStrictToolsState,
disableStrictToolsForScope,
getOpenAIPromptCacheKey,
getOpenAIResponsesRoutingSessionId,
getOpenAIStrictToolsScope,
getOpenRouterResponsesSessionId,
isCompiledGrammarTooLargeStrictError,
isOpenAIResponsesProgressEvent,
isOpenRouterAnthropicModel,
isStrictToolsDisabledForScope,
type OpenAIPromptCacheOptions,
type OpenAIStrictToolsScope,
type OpenAIStrictToolsState,
processResponsesStream,
resolveOpenAICompatPolicy,
resolveOpenAIOutputTokenParam,
resolveOpenAIRequestSetup,
resolveOpenAIResponsesOutputClamp,
shouldRetryWithoutStrictTools,
} from "./openai-shared";
// OpenAI Responses-specific options
export interface OpenAIResponsesOptions extends StreamOptions {
reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
reasoningSummary?: "auto" | "detailed" | "concise" | null;
serviceTier?: ServiceTier;
textVerbosity?: "low" | "medium" | "high";
toolChoice?: ToolChoice;
openrouterVariant?: string;
maxTokensExplicit?: boolean;
disableReasoning?: boolean;
/**
* Stateful turns: chain via `previous_response_id` + delta input instead of
* replaying the full transcript. Forces `store: true` (the platform only
* resolves stored responses). Defaults ON against the official OpenAI API
* and OFF for other Responses endpoints; `PI_OPENAI_STATEFUL` overrides the
* default, and `false` here vetoes everything. Requires `sessionId` +
* `providerSessionState`. Falls back to a full replay whenever history
* mutates or the server reports a stale id.
*/
statefulResponses?: boolean;
/**
* Override catalog compat for strict tool call/result pairing when building
* Responses API inputs. Default behavior is catalog compat; this is only for
* debugging/adapter wrappers.
*/
strictResponsesPairing?: boolean;
/**
* Override catalog compat for `include: ["reasoning.encrypted_content"]`.
* Default behavior is catalog compat; this is only for debugging/adapter wrappers.
*/
includeEncryptedReasoning?: boolean;
/**
* Override catalog compat for stripping `type: "reasoning"` items from
* replayed conversation history before request encoding. Default behavior is
* catalog compat; this is only for debugging/adapter wrappers.
*/
filterReasoningHistory?: boolean;
/**
* Override catalog compat for suppressing the `reasoning.effort` wire param.
* Default behavior is catalog compat; this is only for debugging/adapter wrappers.
*/
omitReasoningEffort?: boolean;
/**
* Extra request headers merged onto the model/copilot defaults. Used by
* adapter wrappers to inject provider-specific
* routing or cache hints.
*/
headers?: Record<string, string>;
/**
* Extra body fields merged into the Responses request payload. Used by
* adapter wrappers to inject provider-specific body keys (e.g.,
* prompt_cache_key for prompt-cache routing).
*/
extraBody?: Record<string, unknown>;
/** Opt-in GPT-5.6+ prompt-cache policy. Unsupported explicit mode fails locally. */
promptCache?: OpenAIPromptCacheOptions;
}
const OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX = "openai-responses:";
const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
"OpenAI responses stream timed out while waiting for the first event";
/** Consecutive stale-previous-response failures before chaining is disabled for the session. */
const OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT = 3;
const OPENAI_RESPONSES_MAX_TRANSIENT_STREAM_RETRIES = 1;
const OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS = 500;
function isOpenAIResponsesReplayUnsafeEvent(event: ResponseStreamEvent): boolean {
switch (event.type) {
case "response.output_text.delta":
case "response.refusal.delta":
case "response.reasoning_summary_text.delta":
case "response.reasoning_text.delta":
case "response.function_call_arguments.delta":
case "response.custom_tool_call_input.delta":
return typeof event.delta === "string" && event.delta.length > 0;
case "response.reasoning_summary_part.done":
return true;
case "response.output_item.done":
return true;
default:
return false;
}
}
function isRetryableOpenAIResponsesStreamFailure(error: unknown): boolean {
return (
AIError.isTransientStreamParseError(error) ||
(error instanceof AIError.ProviderResponseError && error.kind === "incomplete-stream")
);
}
interface OpenAIResponsesProviderSessionState
extends ProviderSessionState,
OpenAIStrictToolsState,
OpenAIReasoningEffortFallbackState {
nativeHistoryReplayWarmed: boolean;
/** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
chains: Map<string, OpenAIResponsesChainState>;
}
interface OpenAIResponsesChainState {
/**
* Wire params of the last successful turn; never carries
* `previous_response_id`.
*/
lastParams?: OpenAIResponsesSamplingParams;
lastPromptCacheBreakpointPolicy?: "latest-stable-message" | "none";
lastResponseId?: string;
/** Output items of the last response, in replay-sanitized form (matches next-turn input). */
lastResponseItems?: ResponseInput;
canAppend: boolean;
/** Consecutive stale-previous-response failures; reset on a successful chained completion. */
staleFailures: number;
/** Set once chaining is judged unsupported for this session (circuit breaker). */
disabled: boolean;
}
function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSessionState {
const strictToolsState = createOpenAIStrictToolsState();
const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState();
const state: OpenAIResponsesProviderSessionState = {
...strictToolsState,
...reasoningEffortFallbackState,
nativeHistoryReplayWarmed: false,
chains: new Map(),
close: () => {
state.nativeHistoryReplayWarmed = false;
state.chains.clear();
clearOpenAIStrictToolsState(state);
clearOpenAIReasoningEffortFallbackState(state);
},
};
return state;
}
function getOpenAIResponsesProviderSessionState(
model: Model<"openai-responses">,
providerSessionState: Map<string, ProviderSessionState> | undefined,
): OpenAIResponsesProviderSessionState | undefined {
if (!providerSessionState) return undefined;
const key = `${OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX}${model.provider}`;
const existing = providerSessionState.get(key) as OpenAIResponsesProviderSessionState | undefined;
if (existing) return existing;
const created = createOpenAIResponsesProviderSessionState();
providerSessionState.set(key, created);
return created;
}
function isOpenAIResponsesStatefulEnabled(
options: OpenAIResponsesOptions | undefined,
baseUrl: string | undefined,
): boolean {
if (options?.statefulResponses === false) return false;
if (options?.statefulResponses === true) return true;
// Default ON only against the official OpenAI API: chaining forces
// `store: true`, and third-party /v1/responses proxies routinely ignore or
// reject `previous_response_id`. An unset baseUrl means the default
// endpoint (api.openai.com).
return $flag("PI_OPENAI_STATEFUL", !baseUrl || hostMatchesUrl(baseUrl, "openai"));
}
function getOpenAIResponsesChainState(
providerSessionState: OpenAIResponsesProviderSessionState,
model: Model<"openai-responses">,
resolvedBaseUrl: string | undefined,
sessionId: string,
): OpenAIResponsesChainState {
const key = `${resolvedBaseUrl ?? model.baseUrl ?? ""}\u0000${model.id}\u0000${sessionId}`;
const existing = providerSessionState.chains.get(key);
if (existing) return existing;
const created: OpenAIResponsesChainState = { canAppend: false, staleFailures: 0, disabled: false };
providerSessionState.chains.set(key, created);
return created;
}
function resetOpenAIResponsesChainState(state: OpenAIResponsesChainState): void {
state.canAppend = false;
state.lastParams = undefined;
state.lastResponseId = undefined;
state.lastResponseItems = undefined;
state.lastPromptCacheBreakpointPolicy = undefined;
}
interface OpenAIResponsesChainedParams {
params: OpenAIResponsesSamplingParams;
/** Set iff the params carry previous_response_id (delta request). */
previousResponseId?: string;
}
/**
* Shape the next turn's request: when the session's append baseline is intact
* (same options, strict history prefix), chain via `previous_response_id` +
* delta-only `input`; otherwise break the chain and replay the full transcript.
*
* The prefix check runs on the wire form of the conversation arguments, so
* history mutations or option changes force a full replay.
*/
function buildOpenAIResponsesChainedParams(
params: OpenAIResponsesSamplingParams,
chain: OpenAIResponsesChainState,
): OpenAIResponsesChainedParams {
const deltaInput = chain.canAppend
? buildResponsesDeltaInput(chain.lastParams, chain.lastResponseItems, params)
: null;
if (deltaInput && deltaInput.length > 0 && chain.lastResponseId) {
return {
params: { ...params, previous_response_id: chain.lastResponseId, input: deltaInput },
previousResponseId: chain.lastResponseId,
};
}
if (chain.canAppend) {
// History mutated or options changed — break the chain and replay in full.
resetOpenAIResponsesChainState(chain);
}
return { params };
}
function isOpenAIResponsesStalePreviousResponseError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if ((error as { code?: string }).code === "previous_response_not_found") return true;
// "unsupported" covers endpoints that reject the parameter outright
// (e.g. "Unsupported parameter: previous_response_id").
return (
/previous[ _]?response/i.test(error.message) &&
/not[ _]?found|invalid|expired|stale|unsupported/i.test(error.message)
);
}
function registerOpenAIResponsesChainStaleFailure(chain: OpenAIResponsesChainState, error: unknown): void {
resetOpenAIResponsesChainState(chain);
chain.staleFailures += 1;
if (chain.staleFailures >= OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT) {
chain.disabled = true;
}
logger.debug("OpenAI responses previous_response_id rejected; falling back to full context", {
error: error instanceof Error ? error.message : String(error),
consecutiveFailures: chain.staleFailures,
disabled: chain.disabled,
});
}
/**
* One-shot ZDR signal: the org will never resolve a stored response, so skip
* the staleFailures counter and disable chaining immediately for this session.
*/
function markOpenAIResponsesChainZeroDataRetention(chain: OpenAIResponsesChainState, error: unknown): void {
resetOpenAIResponsesChainState(chain);
chain.disabled = true;
chain.staleFailures = OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT;
logger.debug("OpenAI responses chaining disabled (Zero Data Retention)", {
error: error instanceof Error ? error.message : String(error),
});
}
type OpenRouterAnthropicCacheControl = { type: "ephemeral"; ttl?: "1h" };
type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
top_p?: number;
top_k?: number;
min_p?: number;
presence_penalty?: number;
repetition_penalty?: number;
session_id?: string;
stream_options?: { include_obfuscation?: boolean };
provider?: OpenAICompat["openRouterRouting"];
reasoning?: { effort?: string } | { enabled: false };
cache_control?: OpenRouterAnthropicCacheControl;
caching?: "auto";
cache_anchor_items?: number;
cache_ttl?: "5m" | "1h";
};
function maybeAddOpenRouterAnthropicCacheControl(
params: OpenAIResponsesSamplingParams,
model: Model<"openai-responses">,
cacheRetention: CacheRetention,
): void {
if (cacheRetention === "none" || !isOpenRouterAnthropicModel(model)) return;
if (params.cache_control != null) return;
params.cache_control = cacheRetention === "long" ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" };
}
/**
* Generate function for OpenAI Responses API
*/
const streamOpenAIResponsesOnce = (
model: Model<"openai-responses">,
context: Context,
options?: OpenAIResponsesOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
// Start async processing
(async () => {
const startTime = performance.now();
let firstTokenTime: number | undefined;
const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
let rawRequestDump: RawHttpRequestDump | undefined;
let chainState: OpenAIResponsesChainState | undefined;
let sentPreviousResponseId: string | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new AIError.StreamTimeoutError(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
const { requestAbortController, requestSignal } = abortTracker;
const onSseEvent = options?.onSseEvent;
const rawSseObserver = onSseEvent
? (event: RawSseEvent) => {
if (!event.event && event.data && event.data !== "[DONE]") {
try {
const parsed = JSON.parse(event.data);
const resolvedEvent =
typeof parsed.type === "string"
? parsed.type
: typeof parsed.object === "string"
? parsed.object
: null;
if (resolvedEvent) {
event.event = resolvedEvent;
event.raw = [`event: ${resolvedEvent}`, ...event.raw];
}
} catch {}
}
onSseEvent(event, model);
}
: undefined;
try {
// Keep request routing on `sessionId` while allowing callers to pin a
// stable prompt-cache key independently. Side-channel calls use this to
// avoid perturbing provider conversation state without cold-starting the cache.
const routingSessionId = getOpenAIResponsesRoutingSessionId(options);
const promptCacheSessionId = getOpenAIPromptCacheKey(options);
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const { headers, copilotPremiumRequests, baseUrl } = resolveOpenAIRequestSetup(model, {
apiKey,
extraHeaders: options?.headers,
initiatorOverride: options?.initiatorOverride,
messages: context.messages,
openAISessionId: routingSessionId,
promptCacheSessionId,
});
const premiumRequestsTotal = copilotPremiumRequests;
const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
const strictToolsScope = getOpenAIStrictToolsScope(model, baseUrl);
const promptCacheBreakpointPolicy =
resolveCacheRetention(options?.cacheRetention) !== "none" && options?.promptCache?.mode === "explicit"
? (options.promptCache.breakpoint ?? "latest-stable-message")
: undefined;
if (isOpenAIResponsesStatefulEnabled(options, baseUrl) && routingSessionId && providerSessionState) {
chainState = getOpenAIResponsesChainState(providerSessionState, model, baseUrl, routingSessionId);
if (chainState.canAppend && chainState.lastPromptCacheBreakpointPolicy !== promptCacheBreakpointPolicy) {
resetOpenAIResponsesChainState(chainState);
}
}
const builtParams = buildParams(
model,
context,
options,
providerSessionState,
strictToolsScope,
false,
chainState?.canAppend ? chainState.lastParams?.input : undefined,
);
const params = builtParams.params;
let activeParams = params;
const resolvedBaseUrl = (baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "");
const requestReasoningEffortFallbacks = new Map<string, OpenAIReasoningEffortFallback>();
const attemptedReasoningEffortFallbacks = new Set<string>();
let pendingReasoningEffortFallback: { key: string; fallback: OpenAIReasoningEffortFallback } | undefined;
let activeReasoningEffortFallbackKey: string | undefined;
let activeRequestParams: OpenAIResponsesSamplingParams | undefined;
const applyReasoningEffortFallbackForRequest = (requestParams: OpenAIResponsesSamplingParams): string => {
const fallbackKey = createOpenAIReasoningEffortFallbackKey(
"responses",
resolvedBaseUrl,
typeof requestParams.model === "string" ? requestParams.model : model.id,
);
const requestReasoningEffortFallback = requestReasoningEffortFallbacks.has(fallbackKey)
? requestReasoningEffortFallbacks.get(fallbackKey)
: getOpenAIReasoningEffortFallback(providerSessionState, fallbackKey);
if (requestReasoningEffortFallback !== undefined) {
applyOpenAIReasoningEffortFallback(requestParams, requestReasoningEffortFallback);
}
return fallbackKey;
};
if (chainState && !chainState.disabled) {
// Platform `previous_response_id` chaining only resolves stored responses.
params.store = true;
}
applyReasoningEffortFallbackForRequest(params);
let chained: OpenAIResponsesChainedParams =
chainState && !chainState.disabled ? buildOpenAIResponsesChainedParams(params, chainState) : { params };
sentPreviousResponseId = chained.previousResponseId;
const idleTimeoutMs =
options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
const firstEventTimeoutMs =
options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
const requestUrl = `${resolvedBaseUrl}/responses`;
const applyPayloadReplacement = async (requestParams: OpenAIResponsesSamplingParams) => {
const replacementPayload = await options?.onPayload?.(requestParams, model);
const payload =
replacementPayload !== undefined ? (replacementPayload as OpenAIResponsesSamplingParams) : requestParams;
applyReasoningEffortFallbackForRequest(payload);
return payload;
};
chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
const activeRawRequestDump: RawHttpRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url: requestUrl,
body: chained.params,
};
rawRequestDump = activeRawRequestDump;
const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => {
activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
"responses",
resolvedBaseUrl,
typeof requestParams.model === "string" ? requestParams.model : model.id,
);
activeRequestParams = requestParams;
return callWithCopilotModelRetry(
async () => {
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
requestTimeoutMs,
);
}
try {
const headersWithTimeout = { ...headers };
if (requestTimeoutMs !== undefined) {
headersWithTimeout["X-Stainless-Timeout"] = Math.floor(requestTimeoutMs / 1000).toString();
}
const { events, response, requestId } = await postOpenAIStream<ResponseStreamEvent>({
url: requestUrl,
headers: headersWithTimeout,
body: requestParams,
signal: requestSignal,
fetch: options?.fetch,
// Transient 408/429/5xx get Retry-After-aware transport
// retries; the first-event watchdog aborts `requestSignal`,
// so retries cannot extend the caller's deadline.
onSseEvent: rawSseObserver,
});
// Disarm the first-event watchdog as soon as headers arrive — a slow
// onResponse callback must not abort an already-connected stream.
if (requestTimeout !== undefined) {
clearTimeout(requestTimeout);
requestTimeout = undefined;
}
await notifyProviderResponse(options, response, model, requestId);
return events;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
},
{ provider: model.provider, signal: requestSignal },
);
};
let strictRetryAvailable = true;
let activeStrictToolsApplied = builtParams.strictToolsApplied;
let forceDisableStrictTools = false;
const openResponsesStreamWithFallbacks = async (): Promise<AsyncIterable<ResponseStreamEvent>> => {
let openaiStream: AsyncIterable<ResponseStreamEvent>;
while (true) {
try {
openaiStream = await openResponsesStream(chained.params);
if (pendingReasoningEffortFallback) {
rememberOpenAIReasoningEffortFallback(
providerSessionState,
pendingReasoningEffortFallback.key,
pendingReasoningEffortFallback.fallback,
);
pendingReasoningEffortFallback = undefined;
}
break;
} catch (error) {
const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
const reasoningEffortFallback =
activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
})
: undefined;
if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
attemptedReasoningEffortFallbacks.add(retryMarker);
requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
activeRawRequestDump.body = chained.params;
pendingReasoningEffortFallback = {
key: activeReasoningEffortFallbackKey,
fallback: reasoningEffortFallback,
};
continue;
}
const compiledGrammarTooLarge =
isOpenRouterAnthropicModel(model) &&
isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse);
const canRetryWithoutStrictTools =
strictRetryAvailable &&
!requestSignal.aborted &&
(compiledGrammarTooLarge ||
shouldRetryWithoutStrictTools(
error,
capturedErrorResponse,
activeStrictToolsApplied,
context.tools,
));
if (canRetryWithoutStrictTools) {
strictRetryAvailable = false;
forceDisableStrictTools = true;
disableStrictToolsForScope(providerSessionState, strictToolsScope);
const fallbackBuilt = buildParams(
model,
context,
options,
providerSessionState,
strictToolsScope,
true,
chainState?.canAppend ? chainState.lastParams?.input : undefined,
);
const fallbackParams = fallbackBuilt.params;
if (chainState && !chainState.disabled) fallbackParams.store = true;
let fallbackChained: OpenAIResponsesChainedParams =
chainState && !chainState.disabled
? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
: { params: fallbackParams };
sentPreviousResponseId = fallbackChained.previousResponseId;
fallbackChained = {
...fallbackChained,
params: await applyPayloadReplacement(fallbackChained.params),
};
chained = fallbackChained;
activeRawRequestDump.body = chained.params;
activeParams = fallbackParams;
activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
continue;
}
if (!chainState || !sentPreviousResponseId || requestSignal.aborted) {
throw error;
}
const zdrRejection =
error instanceof Error &&
/previous[ _]?response/i.test(error.message) &&
/zero[ _-]?data[ _-]?retention/i.test(error.message);
const isPromptBlocked =
error instanceof Error &&
((error as { code?: string }).code === "invalid_prompt" ||
/invalid_prompt|Request blocked/i.test(error.message));
if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) {
throw error;
}
// Server rejected the chain baseline: reset, count the failure (or
// disable categorically on ZDR), and retry once with the full
// transcript. Structurally cannot loop — the retry carries no
// previous_response_id.
if (zdrRejection) {
markOpenAIResponsesChainZeroDataRetention(chainState, error);
// ZDR orgs cannot store responses; the retry uses `store: false`.
} else {
registerOpenAIResponsesChainStaleFailure(chainState, error);
}
sentPreviousResponseId = undefined;
const currentBuilt = buildParams(
model,
context,
options,
providerSessionState,
strictToolsScope,
forceDisableStrictTools,
);
const currentParams = currentBuilt.params;
// Only ZDR forces `store: false` (the org never persists responses). A
// non-ZDR stale baseline is transient, so keep storing: the full-context
// retry must be chainable next turn, and the consecutive stale-failure
// breaker only trips when each retry stores and the next turn re-chains.
currentParams.store = !zdrRejection;
const retryParams = await applyPayloadReplacement(currentParams);
chained = { params: retryParams };
activeRawRequestDump.body = retryParams;
activeParams = currentParams;
activeStrictToolsApplied = currentBuilt.strictToolsApplied;
}
}
return openaiStream;
};
let openaiStream = await openResponsesStreamWithFallbacks();
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
stream.push({ type: "start", partial: output });
const nativeOutputItems: Array<Record<string, unknown>> = [];
let transientStreamRetryAttempt = 0;
while (true) {
let sawReplayUnsafeOutput = false;
let sawTerminalResponseEvent = false;
const attemptStream = new AssistantMessageEventStream();
let forwardAttemptLive = false;
const forwardAttemptEvents = () => {
for (const event of attemptStream.queue) stream.push(event);
attemptStream.queue.length = 0;
};
nativeOutputItems.length = 0;
const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
onIdle: () => requestAbortController.abort(),
abortSignal: options?.signal,
isProgressItem: isOpenAIResponsesProgressEvent,
});
const observedOpenaiStream = (async function* (): AsyncGenerator<ResponseStreamEvent> {
for await (const event of timedOpenaiStream) {
if (isOpenAIResponsesReplayUnsafeEvent(event)) {
sawReplayUnsafeOutput = true;
if (!forwardAttemptLive) {
forwardAttemptEvents();
forwardAttemptLive = true;
}
}
yield event;
if (forwardAttemptLive) forwardAttemptEvents();
}
})();
try {
await processResponsesStream(observedOpenaiStream, output, attemptStream, model, {
onFirstToken: () => {
if (!firstTokenTime) firstTokenTime = performance.now();
},
onOutputItemDone: item => {
// `processResponsesStream` hands over a private clone already; no
// second deep copy needed (reasoning items carry multi-KB blobs).
nativeOutputItems.push(item as unknown as Record<string, unknown>);
},
onCompleted: () => {
sawTerminalResponseEvent = true;
},
requestServiceTier: options?.serviceTier,
});
const localAbortReason = abortTracker.getLocalAbortReason();
if (localAbortReason) throw localAbortReason;
if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
// Detect premature stream closure: the HTTP stream ended without the
// provider sending a recognized terminal response event.
if (!sawTerminalResponseEvent) {
throw new AIError.ProviderResponseError(
"OpenAI responses stream closed before a terminal response event was received",
{ provider: model.provider, kind: "incomplete-stream" },
);
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", {
provider: model.provider,
kind: "runtime",
});
}
forwardAttemptEvents();
break;
} catch (error) {
const streamFailure = abortTracker.getLocalAbortReason() ?? error;
const canRetry =
!sawReplayUnsafeOutput &&
!requestSignal.aborted &&
!abortTracker.wasCallerAbort() &&
transientStreamRetryAttempt < OPENAI_RESPONSES_MAX_TRANSIENT_STREAM_RETRIES &&
isRetryableOpenAIResponsesStreamFailure(streamFailure);
if (!canRetry) {
forwardAttemptEvents();
throw streamFailure;
}
transientStreamRetryAttempt++;
logger.debug("OpenAI responses stream ended before replay-unsafe output; retrying", {
provider: model.provider,
model: model.id,
attempt: transientStreamRetryAttempt,
error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
});
const retryOutput = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
output.content.length = 0;
output.responseId = undefined;
output.upstreamProvider = undefined;
output.errorMessage = undefined;
output.errorStatus = undefined;
output.errorId = undefined;
output.stopDetails = undefined;
output.providerPayload = undefined;
output.usage = retryOutput.usage;
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
output.stopReason = "stop";
output.duration = undefined;
output.ttft = undefined;
firstTokenTime = undefined;
nativeOutputItems.length = 0;
if (options?.providerRetryWait) {
await options.providerRetryWait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, options.signal);
} else {
await scheduler.wait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, { signal: options?.signal });
}
if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
openaiStream = await openResponsesStreamWithFallbacks();
}
}
output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
const replayableResponseItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
structuredCloneJSON(nativeOutputItems),
);
if (replayableResponseItems) {
if (providerSessionState) providerSessionState.nativeHistoryReplayWarmed = true;
if (chainState) {
chainState.lastParams = structuredCloneJSON(activeParams);
chainState.lastPromptCacheBreakpointPolicy = promptCacheBreakpointPolicy;
if (output.responseId) {
chainState.lastResponseId = output.responseId;
chainState.lastResponseItems = replayableResponseItems;
chainState.canAppend = true;
// Only a successful CHAINED completion clears the stale counter — a
// full-context success must not mask categorical rejection.
if (sentPreviousResponseId) chainState.staleFailures = 0;
} else {
// Without a response id the append baseline cannot be trusted.
chainState.canAppend = false;
}
}
} else if (chainState) {
// Hidden-empty / fully sanitized successes cannot be used as an append
// baseline, but `lastParams` still records the successful wire controls
// without re-enabling `previous_response_id` chaining.
chainState.canAppend = false;
chainState.lastParams = structuredCloneJSON(activeParams);
chainState.lastPromptCacheBreakpointPolicy = promptCacheBreakpointPolicy;
chainState.lastResponseId = undefined;
chainState.lastResponseItems = undefined;
}
output.duration = performance.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
if (chainState) resetOpenAIResponsesChainState(chainState);
const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
const result = await AIError.finalize(error, {
api: model.api,
provider: model.provider,
abortTracker,
rawRequestDump,
capturedErrorResponse,
});
output.stopReason = result.stopReason;
output.errorStatus = result.status;
output.errorId = result.id;
output.errorMessage = result.message;
// Some providers via OpenRouter include extra details here.
const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
output.duration = performance.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
/**
* Public entry: wrap the single-attempt Responses streamer with bounded
* empty-completion retries — a `response.completed` carrying no content/usage
* would otherwise stall the agent loop. Shared with the OpenAI-completions and
* Anthropic providers via `withEmptyCompletionRetry`.
*/
export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (model, context, options) =>
withEmptyCompletionRetry(model, context, options, streamOpenAIResponsesOnce);
function isOfficialOpenAIResponsesEndpoint(model: Model<"openai-responses">): boolean {
if (model.provider !== "openai") return false;
if (!model.baseUrl) return true;
try {
return new URL(model.baseUrl).hostname === "api.openai.com";
} catch {
return false;
}
}
function isResponsesPromptCacheableContentBlock(block: unknown): block is ResponseInputContent {
if (typeof block !== "object" || block === null || !("type" in block)) return false;
return block.type === "input_text" || block.type === "input_image" || block.type === "input_file";
}
type ResponsesPromptCacheableMessage = {
role: "assistant" | "developer" | "system" | "user";
content: ResponseInputContent[];
};
function isResponsesPromptCacheableMessage(item: unknown): item is ResponsesPromptCacheableMessage {
if (typeof item !== "object" || item === null || !("role" in item) || !("content" in item)) return false;
if (item.role !== "assistant" && item.role !== "developer" && item.role !== "system" && item.role !== "user")
return false;
return Array.isArray(item.content) && item.content.every(isResponsesPromptCacheableContentBlock);
}
type ResponsesStringInstruction = {
role: "developer" | "system";
content: string;
};
function isStableStringResponsesInstruction(item: unknown): item is ResponsesStringInstruction {
if (typeof item !== "object" || item === null || !("role" in item) || !("content" in item)) return false;
return (
(item.role === "developer" || item.role === "system") &&
typeof item.content === "string" &&
item.content.length > 0
);
}
function matchesResponsesCacheBaseline(
baseline: ResponsesPromptCacheableMessage,
current: ResponsesPromptCacheableMessage,
): boolean {
if (baseline.role !== current.role || baseline.content.length !== current.content.length) return false;
for (let index = 0; index < baseline.content.length; index++) {
const baselineBlock = baseline.content[index];
const currentBlock = current.content[index];
if (!baselineBlock || !currentBlock) return false;
const breakpoint = baselineBlock.prompt_cache_breakpoint;
if (breakpoint) {
if (!Bun.deepEquals(baselineBlock, { ...currentBlock, prompt_cache_breakpoint: breakpoint })) return false;
} else if (!Bun.deepEquals(baselineBlock, currentBlock)) {
return false;
}
}
return true;
}
function restoreResponsesCacheBreakpointsFromBaseline(
input: ResponseInput | undefined,
baseline: ResponseInput | undefined,
): boolean {
if (!input || !baseline) return false;
let restored = false;
for (let i = 0; i < baseline.length && i < input.length; i++) {
const baselineMessage = baseline[i];
const message = input[i];
if (!isResponsesPromptCacheableMessage(baselineMessage)) continue;
if (isStableStringResponsesInstruction(message)) {
const [baselineBlock] = baselineMessage.content;
if (
baselineMessage.role === message.role &&
baselineBlock?.type === "input_text" &&
baselineBlock.text === message.content &&
baselineBlock.prompt_cache_breakpoint
) {
Object.assign(message, {
content: [
{
type: "input_text",
text: message.content,
prompt_cache_breakpoint: baselineBlock.prompt_cache_breakpoint,
},
],
});
restored = true;
}
continue;
}
if (!isResponsesPromptCacheableMessage(message) || !matchesResponsesCacheBaseline(baselineMessage, message))
continue;
for (let j = 0; j < baselineMessage.content.length; j++) {
const baselineBlock = baselineMessage.content[j];
const block = message.content[j];
if (!baselineBlock?.prompt_cache_breakpoint || !block) continue;
Object.assign(block, { prompt_cache_breakpoint: baselineBlock.prompt_cache_breakpoint });
restored = true;
}
}
return restored;
}
function hasResponsesCacheBreakpoint(input: ResponseInput | undefined): boolean {
return (
input?.some(
message =>
isResponsesPromptCacheableMessage(message) &&
message.content.some(block => block.prompt_cache_breakpoint !== undefined),
) ?? false
);
}
function markLatestStableResponsesCacheBreakpoint(
input: ResponseInput | undefined,
statefulBaseline?: ResponseInput,
): boolean {
if (!input) return false;
// Stateful appends use a strict wire-prefix comparison. Retain the exact
// marker from that prefix rather than recomputing a newer boundary.
if (statefulBaseline) {
if (restoreResponsesCacheBreakpointsFromBaseline(input, statefulBaseline)) return true;
// A prior marker whose content no longer matches means chaining will
// reset to a full replay. Recompute a fresh boundary for that replay.
// Markerless baselines stay markerless so appends do not mutate them.
if (!hasResponsesCacheBreakpoint(statefulBaseline)) return false;
}
let latestInputMessage = -1;
for (let i = input.length - 1; i >= 0; i--) {
const message = input[i];
if (!("role" in message)) continue;
if (message.role === "user" || message.role === "developer") {
latestInputMessage = i;
break;
}
}
if (latestInputMessage <= 0) return false;
for (let i = latestInputMessage - 1; i >= 0; i--) {
const message = input[i];
if (isStableStringResponsesInstruction(message)) {
const text = message.content;
Object.assign(message, {
content: [
{
type: "input_text",
text,
prompt_cache_breakpoint: { mode: "explicit" },
},
],
});
return true;
}
if (!isResponsesPromptCacheableMessage(message)) continue;
for (let j = message.content.length - 1; j >= 0; j--) {
const block = message.content[j];
if (!isResponsesPromptCacheableContentBlock(block)) continue;
Object.assign(block, { prompt_cache_breakpoint: { mode: "explicit" } });
return true;
}
}
return false;
}
function applyOpenAIResponsesPromptCachePolicy(
params: OpenAIResponsesSamplingParams,
model: Model<"openai-responses">,
options: OpenAIResponsesOptions | undefined,
statefulCacheBaseline?: ResponseInput,
): void {
const promptCache = options?.promptCache;
if (!promptCache || resolveCacheRetention(options?.cacheRetention) === "none") return;
if (!model.compat.supportsPromptCacheBreakpoints) {
if (promptCache.mode === "explicit") {
throw new AIError.ConfigurationError(
`OpenAI explicit prompt caching is unsupported for ${model.provider}/${model.id}; enable compat.supportsPromptCacheBreakpoints only for a compatible endpoint.`,
);
}
return;
}
params.prompt_cache_options = {
mode: promptCache.mode,
ttl: promptCache.ttl ?? model.compat.promptCacheBreakpointTtl,
};
if (promptCache.mode === "explicit" && promptCache.breakpoint !== "none")
markLatestStableResponsesCacheBreakpoint(params.input, statefulCacheBaseline);
}
export function buildParams(
model: Model<"openai-responses">,
context: Context,
options: OpenAIResponsesOptions | undefined,
providerSessionState: OpenAIResponsesProviderSessionState | undefined,
strictToolsScope?: OpenAIStrictToolsScope,
disableStrictToolsOverride = false,
statefulCacheBaseline?: ResponseInput,
): { params: OpenAIResponsesSamplingParams; strictToolsApplied: boolean } {
const policy = resolveOpenAICompatPolicy(model, {
endpoint: "responses",
reasoning: options?.reasoning,
disableReasoning: options?.disableReasoning,
toolChoice: options?.toolChoice,
strictResponsesPairing: options?.strictResponsesPairing,
includeEncryptedReasoning: options?.includeEncryptedReasoning,
filterReasoningHistory: options?.filterReasoningHistory,
omitReasoningEffort: options?.omitReasoningEffort,
});
const strictResponsesPairing = policy.tools.strictResponsesPairing;
const shouldReplayNativeHistory = providerSessionState?.nativeHistoryReplayWarmed ?? true;
const messages = buildResponsesInput({
model,
context,
strictResponsesPairing,
supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
nativeHistory: {
replay: shouldReplayNativeHistory,
filterReasoning: policy.reasoning.filterReasoningHistory,
},
includeThinkingSignatures: shouldReplayNativeHistory && !policy.reasoning.filterReasoningHistory,
repairOrphanOutputs: true,
});
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
let systemInstructions: string | undefined;
if (systemPrompts.length > 0) {
const needsDeveloperRole = policy.messages.systemRole === "developer";
if (needsDeveloperRole) {
// Reasoning models on known OpenAI-compatible endpoints require the
// `developer` role. Send all system prompts inline in `input`.
messages.unshift(
...systemPrompts.map(systemPrompt => ({ role: "developer" as const, content: systemPrompt })),
);
} else {
// All other endpoints (including third-party /v1/responses proxies) use
// the canonical top-level `instructions` field so that proxies that
// reject `input[{role:"system"}]` work out of the box.
systemInstructions = systemPrompts.join("\n\n");
}
}
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const promptCacheKey = getOpenAIPromptCacheKey(options);
const modelId = applyWireModelIdTransform(
model.requestModelId ?? model.id,
model.compat.wireModelIdMode,
options?.openrouterVariant,
);
const params: OpenAIResponsesSamplingParams = {
model: modelId,
input: messages,
instructions: systemInstructions,
stream: true,
prompt_cache_key: promptCacheKey,
prompt_cache_retention: promptCacheKey
? cacheRetention === "long" && model.compat.supportsLongPromptCacheRetention
? "24h"
: undefined
: undefined,
// Gateway routing: OpenRouter-only Responses wire field for sticky upstream
// routing + observability grouping; no equivalent on direct OpenAI.
session_id: model.compat.isOpenRouterHost ? getOpenRouterResponsesSessionId(options) : undefined,
store: false,
stream_options: model.compat.supportsObfuscationOptOut ? { include_obfuscation: false } : undefined,
};
if (options?.include?.length) params.include = Array.from(new Set(options.include));
maybeAddOpenRouterAnthropicCacheControl(params, model, cacheRetention);
const outputToken = resolveOpenAIOutputTokenParam({
field: "max_output_tokens",
maxTokens: options?.maxTokens,
maxTokensExplicit: options?.maxTokensExplicit ?? options?.maxTokens !== undefined,
modelMaxTokens: model.maxTokens,
omitMaxOutputTokens: model.omitMaxOutputTokens ?? false,
isOpenRouterHost: model.compat.isOpenRouterHost,
alwaysSendMaxTokens: model.compat.alwaysSendMaxTokens,
providerOutputClamp: resolveOpenAIResponsesOutputClamp(model),
});
applyCommonResponsesSamplingParams(params, { ...options, maxTokens: outputToken?.value }, model);
if (options?.textVerbosity && isOfficialOpenAIResponsesEndpoint(model)) {
params.text = { ...params.text, verbosity: options.textVerbosity };
}
// TODO: openai responses has no top-level `stop`/`stop_sequences`; surface via reasoning.stop?
// `StreamOptions.stopSequences` is intentionally dropped for this provider.
// TODO: openai responses has no top-level `frequency_penalty` field as of the current SDK;
// `StreamOptions.frequencyPenalty` is intentionally dropped for this provider.
let strictToolsApplied = false;
if (context.tools) {
const disableStrictTools =
disableStrictToolsOverride || isStrictToolsDisabledForScope(providerSessionState, strictToolsScope);
const strictMode = !disableStrictTools && model.compat.supportsStrictMode !== false;
params.tools = convertTools(context.tools, strictMode, model);
strictToolsApplied = params.tools.some(t => (t as { strict?: boolean }).strict === true);
if (options?.toolChoice) {
// Map tool_choice against the tools that survived quarantine, not the
// original list: a forced choice for a dropped tool — or "required" when
// every tool was dropped — would otherwise send a tool_choice with no
// matching tool, which the provider rejects just like the bad schema did (#2652).
const emittedNames = new Set(
params.tools.map(t => (t as { name?: string }).name).filter((n): n is string => n !== undefined),
);
const survivingTools =
params.tools.length === context.tools.length
? context.tools
: context.tools.filter(t => emittedNames.has(t.customWireName ?? t.name));
const toolChoice = mapOpenAIResponsesToolChoiceForTools(options.toolChoice, survivingTools, model);
if (toolChoice !== undefined && params.tools.length > 0) {
params.tool_choice = toolChoice;
}
}
}
const reasoningPolicy = resolveOpenAICompatPolicy(model, {
endpoint: "responses",
reasoning: options?.reasoning,
disableReasoning: options?.disableReasoning,
toolChoice: params.tool_choice,
strictResponsesPairing: options?.strictResponsesPairing,
includeEncryptedReasoning: options?.includeEncryptedReasoning,
filterReasoningHistory: options?.filterReasoningHistory,
omitReasoningEffort: options?.omitReasoningEffort,
});
const reasoningSummary =
model.provider === "xai-oauth"
? options?.reasoning === undefined
? undefined
: null
: options?.reasoningSummary;
applyResponsesCompatPolicy(params, reasoningPolicy, {
reasoningSummary,
mapEffort: effort =>
model.compat.reasoningEffortMap?.[effort as NonNullable<OpenAIResponsesOptions["reasoning"]>] ??
model.thinking?.effortMap?.[effort as NonNullable<OpenAIResponsesOptions["reasoning"]>] ??
effort,
});
// Catalog pro aliases (`gpt-5.6-*-pro`): merge AFTER the compat policy so the
// mode survives every policy branch (disabled/omitted effort included) while
// keeping whatever effort/summary the policy produced — mode and effort are
// independent wire fields.
if (model.reasoningMode) {
params.reasoning = { ...params.reasoning, mode: model.reasoningMode };
}
if (model.compat.isVercelGatewayHost) {
applyVercelResponsesCacheControls(params, model.compat, cacheRetention);
} else {
applyOpenAIGatewayRouting(params, model.compat);
}
applyOpenAIExtraBody(params, options?.extraBody);
applyOpenAIResponsesPromptCachePolicy(params, model, options, statefulCacheBaseline);
return { params, strictToolsApplied };
}
/**
* Whether this model should get the OpenAI custom-tool grammar variant
* for `apply_patch`. The generated model catalog sets
* `model.applyPatchToolType` for first-party GPT-5 Responses models; this
* runtime path only consumes that metadata.
* @internal Exported for tests.
*/
export function supportsFreeformApplyPatch(
model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
): boolean {
return model.applyPatchToolType === "freeform";
}
/** @internal Exported for tests. */
export function mapOpenAIResponsesToolChoiceForTools(
choice: ToolChoice | undefined,
tools: Tool[],
model: Model<"openai-responses">,
): OpenAIResponsesToolChoice {
if (!model.compat.supportsToolChoice) return undefined;
if (
typeof choice !== "string" &&
choice?.type === "computer" &&
(model.supportsComputerUse !== true || !tools.some(tool => tool.native?.type === "computer"))
) {
return undefined;
}
if (isForcedToolChoice(choice) && !model.compat.supportsForcedToolChoice) {
return "auto";
}
const mapped = mapToOpenAIResponsesToolChoice(choice);
if (!mapped || typeof mapped === "string" || mapped.type !== "function") {
return mapped;
}
const directTool = tools.find(tool => tool.name === mapped.name);
const customTool = supportsFreeformApplyPatch(model)
? tools.find(tool => tool.customFormat && (tool.name === mapped.name || tool.customWireName === mapped.name))
: undefined;
const offeredTool = customTool ?? directTool;
if (!offeredTool) {
return undefined;
}
return customTool ? { type: "custom", name: customTool.customWireName ?? customTool.name } : mapped;
}
/** @internal Exported for tests. */
export function convertTools(
tools: Tool[],
strictMode: boolean,
model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
onQuarantine: (toolName: string, schemaPath: string) => void = (toolName, schemaPath) =>
logger.warn(
`Tool "${toolName}" omitted from the openai-responses request: its parameter schema is invalid for this provider at ${schemaPath} (an enum/const value cannot match its declared type). Other tools are unaffected.`,
),
): OpenAITool[] {
const allowFreeform = supportsFreeformApplyPatch(model);
const out: OpenAITool[] = [];
for (const tool of tools) {
if (tool.native?.type === "computer") {
if (model.supportsComputerUse === true) out.push({ type: "computer" });
continue;
}
if (allowFreeform && tool.customFormat) {
out.push({
type: "custom",
// Tool advertises its wire-level name (e.g. `apply_patch`) — the
// agent-loop dispatcher will match incoming calls by either the
// internal `name` or `customWireName`.
name: tool.customWireName ?? tool.name,
description: tool.description || "",
format: {
type: "grammar",
syntax: tool.customFormat.syntax,
definition: compactGrammarDefinition(tool.customFormat.syntax, tool.customFormat.definition),
},
} as unknown as OpenAITool);
continue;
}
const strict = !NO_STRICT && strictMode && tool.strict !== false;
const baseParameters = toolWireSchema(tool);
// MFJS must run AFTER the Responses sanitizer: the sanitizer normalizes
// `{}` → `true` (issue #1179), and Moonshot's validator rejects boolean
// subschemas ("property schema … must be an object"), so the Moonshot
// pass re-coerces them last.
const sanitized = sanitizeSchemaForOpenAIResponses(baseParameters);
const responseParameters =
model.compat.toolSchemaFlavor === "moonshot-mfjs"
? (normalizeSchemaForMoonshot(sanitized) as Record<string, unknown>)
: sanitized;
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict);
// Quarantine a tool whose emitted schema carries a provider-rejecting
// enum/const-vs-type contradiction: dropping just that tool keeps the rest
// of the request valid instead of letting one bad MCP schema 400 the whole
// turn (#2652). Other tools and built-ins are unaffected.
const violation = findStrictToolSchemaViolation(parameters);
if (violation) {
onQuarantine(tool.name, violation);
continue;
}
out.push({
type: "function",
name: tool.name,
description: tool.description || "",
parameters,
// `strict: false` and an omitted `strict` are NOT equivalent for every
// OpenAI-compat backend — some over-fill optional args when the flag is
// absent (#4336). Preserve the author's explicit `false` unless the
// provider is explicitly known not to understand the field
// (`supportsStrictMode: false`) or the strict-schema fallback is
// active — both paths rely on a uniformly absent wire flag. Mirrors the
// `supportsStrictMode !== false` gate used by openai-completions
// (#4527).
...(effectiveStrict
? { strict: true }
: !NO_STRICT && strictMode && tool.strict === false
? { strict: false }
: {}),
} as OpenAITool);
}
return out;
}