Files
oh-my-pi/packages/ai/src/providers/openai-shared.ts
T
can1357 ce4ff34205 feat(ai): introduced assistant message phase tracking for stage transitions
- Added optional `phase` field to assistant messages to distinguish between `commentary` and `final_answer` stages.
- Updated `openai-responses-server` to parse and propagate phase metadata via text signatures during streaming and batch processing.
- Modified `encodeStream` and `buildOutputItems` to trigger new message items when an assistant message phase transition is detected.
- Extended schema definitions and test suites to validate phase-aware message handling and history replay.
2026-06-27 13:05:57 +02:00

2614 lines
98 KiB
TypeScript

import type { Effort } from "@oh-my-pi/pi-catalog/effort";
import { toFirepassWireModelId, toFireworksWireModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
import { isGlm52ReasoningEffortModelId } from "@oh-my-pi/pi-catalog/identity";
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
import type {
OpenAICompat,
OpenAIReasoningDisableMode,
OpenAIStreamMarkupHealingPattern,
OpenRouterRouting,
ResolvedOpenAICompat,
ResolvedOpenAIResponsesCompat,
ResolvedOpenAISharedCompat,
VercelGatewayRouting,
} from "@oh-my-pi/pi-catalog/types";
import {
COREWEAVE_PROJECT_HEADER,
coreWeaveProjectHeaders,
hasCoreWeaveProjectHeader,
} from "@oh-my-pi/pi-catalog/wire/coreweave";
import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
import {
$env,
extractHttpStatusFromError,
logger,
parseStreamingJson,
parseStreamingJsonThrottled,
structuredCloneJSON,
} from "@oh-my-pi/pi-utils";
import * as AIError from "../error";
import {
type Api,
type AssistantMessage,
type CacheRetention,
type Context,
type ImageContent,
type Message,
type MessageAttribution,
type Model,
OPENAI_MAX_OUTPUT_TOKENS,
type Provider,
type ResolvedServiceTier,
resolveServiceTier,
type ServiceTier,
type StopReason,
type StreamOptions,
shouldSendServiceTier,
type TextContent,
type TextSignatureV1,
type ThinkingContent,
type Tool,
type ToolCall,
type ToolResultMessage,
} from "../types";
import {
getOpenAIResponsesHistoryItems,
getOpenAIResponsesHistoryPayload,
normalizeResponsesToolCallId,
normalizeSystemPrompts,
resolveCacheRetention,
sanitizeOpenAIResponsesHistoryItemsForReplay,
} from "../utils";
import {
clearStreamingPartialJson,
kStreamingArgumentsDone,
kStreamingLastParseLen,
kStreamingPartialJson,
} from "../utils/block-symbols";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
import { getOpenRouterHeaders } from "../utils/openrouter-headers";
import { isForcedToolChoice } from "../utils/tool-choice";
import {
buildCopilotDynamicHeaders,
hasCopilotVisionInput,
resolveGitHubCopilotBaseUrl,
} from "./github-copilot-headers";
import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire";
import type { InputItem } from "./openai-codex/request-transformer";
import type {
ResponseContentPartAddedEvent,
ResponseCreateParamsStreaming,
ResponseCustomToolCall,
ResponseFunctionToolCall,
ResponseInput,
ResponseInputContent,
ResponseInputImage,
ResponseInputItem,
ResponseInputText,
ResponseOutputItem,
ResponseOutputMessage,
ResponseReasoningItem,
ResponseStatus,
ResponseStreamEvent,
} from "./openai-responses-wire";
import { transformMessages } from "./transform-messages";
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
export interface OpenAIModelIdentity {
provider: string;
id: string;
baseUrl?: string;
}
export interface OpenAIStrictToolsScope {
provider: string;
baseUrl: string | undefined;
modelId: string;
}
export interface OpenAIStrictToolsState {
strictTools: {
disabledModelScopes: Set<string>;
};
}
export interface OpenAIRequestSetupModel extends OpenAIModelIdentity {
headers?: Record<string, string>;
premiumMultiplier?: number;
compat?: Pick<ResolvedOpenAISharedCompat, "promptCacheSessionHeader">;
}
export interface OpenAIResponsesCacheOptions {
cacheRetention?: CacheRetention;
sessionId?: string;
promptCacheKey?: string;
}
export interface OpenAIRequestSetupOptions {
apiKey?: string;
extraHeaders?: Record<string, string>;
initiatorOverride?: MessageAttribution;
messages: Message[];
defaultBaseUrl?: string;
prependHeaders?: () => Record<string, string>;
alibabaCodingPlanAuth?: boolean;
azureChatCompletions?: {
apiVersion: string;
deploymentName: string;
};
openAISessionId?: string;
promptCacheSessionId?: string;
}
export interface OpenAIRequestSetup {
copilotPremiumRequests: number | undefined;
baseUrl: string | undefined;
headers: Record<string, string>;
query: Record<string, string> | undefined;
requestHeaders: Record<string, string>;
}
function normalizeSakanaRequestBaseUrl(baseUrl: string | undefined): string | undefined {
const value = baseUrl?.trim();
if (!value) return undefined;
const normalized = value.replace(/\/+$/, "");
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
}
function resolveSakanaRequestBaseUrl(): string | undefined {
return normalizeSakanaRequestBaseUrl($env.SAKANA_BASE_URL) ?? normalizeSakanaRequestBaseUrl($env.FUGU_BASE_URL);
}
function applyCoreWeaveProjectHeader(headers: Record<string, string>): void {
if (hasCoreWeaveProjectHeader(headers)) {
return;
}
const projectHeaders = coreWeaveProjectHeaders($env);
if (projectHeaders) {
headers[COREWEAVE_PROJECT_HEADER] = projectHeaders[COREWEAVE_PROJECT_HEADER];
}
}
export function resolveOpenAIRequestSetup(
model: OpenAIRequestSetupModel,
options: OpenAIRequestSetupOptions,
): OpenAIRequestSetup {
let apiKey = options.apiKey;
if (!apiKey) {
if (!$env.OPENAI_API_KEY) {
throw new AIError.MissingApiKeyError(
undefined,
"OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
);
}
apiKey = $env.OPENAI_API_KEY;
}
const rawApiKey = apiKey;
let headers = { ...(model.headers ?? {}) };
if (model.provider === "openrouter") {
Object.assign(headers, getOpenRouterHeaders());
}
Object.assign(headers, options.extraHeaders);
if (model.provider === "coreweave") {
applyCoreWeaveProjectHeader(headers);
}
if (options.prependHeaders) {
headers = { ...options.prependHeaders(), ...headers };
}
let copilotPremiumRequests: number | undefined;
let baseUrl = model.baseUrl;
if (model.provider === "moonshot") {
// Bundled `moonshot` catalog models hardcode the international endpoint
// (`api.moonshot.ai`). MOONSHOT_BASE_URL lets users redirect the provider
// at the China platform (`api.moonshot.cn`), which only accepts China keys
// and rejects the international host. (#2883)
const moonshotBaseUrl = $env.MOONSHOT_BASE_URL?.trim();
if (moonshotBaseUrl) {
baseUrl = moonshotBaseUrl;
}
}
if (model.provider === "sakana") {
const sakanaBaseUrl = resolveSakanaRequestBaseUrl();
if (sakanaBaseUrl) {
baseUrl = sakanaBaseUrl;
}
}
if (model.provider === "github-copilot") {
apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken;
const copilot = buildCopilotDynamicHeaders({
messages: options.messages,
hasImages: hasCopilotVisionInput(options.messages),
premiumMultiplier: model.premiumMultiplier,
headers,
initiatorOverride: options.initiatorOverride,
});
Object.assign(headers, copilot.headers);
copilotPremiumRequests = copilot.premiumRequests;
baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl;
}
if (options.alibabaCodingPlanAuth && model.provider === "alibaba-coding-plan") {
try {
const parsed = JSON.parse(rawApiKey);
if (typeof parsed?.token === "string") {
apiKey = parsed.token;
}
if (typeof parsed?.enterpriseUrl === "string") {
baseUrl = parsed.enterpriseUrl;
}
} catch {
// Not JSON — use raw apiKey and catalog baseUrl.
}
}
let query: Record<string, string> | undefined;
if (options.azureChatCompletions && baseUrl?.includes(".openai.azure.com")) {
if (!baseUrl.includes("/deployments/")) {
baseUrl = `${baseUrl}/deployments/${options.azureChatCompletions.deploymentName}`;
}
query = { "api-version": options.azureChatCompletions.apiVersion };
}
if (options.openAISessionId && model.provider === "openai") {
headers.session_id ??= options.openAISessionId;
headers["x-client-request-id"] ??= options.openAISessionId;
}
if (options.promptCacheSessionId && model.compat?.promptCacheSessionHeader) {
headers[model.compat.promptCacheSessionHeader] ??= options.promptCacheSessionId;
}
if (options.defaultBaseUrl !== undefined) {
baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl);
}
const requestHeaders = { ...headers };
headers.Authorization ??= `Bearer ${apiKey}`;
return { copilotPremiumRequests, baseUrl, headers, query, requestHeaders };
}
export function applyOpenAIServiceTier(
params: { service_tier?: ResolvedServiceTier | "auto" | "default" | null | undefined },
serviceTier: ServiceTier | null | undefined,
provider: Provider | undefined,
): void {
if (!shouldSendServiceTier(serviceTier, provider)) return;
const resolved = resolveServiceTier(serviceTier, provider);
if (resolved === "flex" || resolved === "scale" || resolved === "priority") {
params.service_tier = resolved;
}
}
/**
* Standard OpenAI Responses service-tier cost multipliers. The non-Codex
* Responses path bills the tier it was served (or requested): Flex processing is
* half price; Priority is a 2x premium. Codex bills the same tiers with its own
* table (Priority is 2.5x on gpt-5.5) and applies that separately.
*/
function getOpenAIResponsesServiceTierCostMultiplier(tier: string | null | undefined): number {
switch (tier) {
case "flex":
return 0.5;
case "priority":
return 2;
default:
return 1;
}
}
/**
* Adjust resolved cost by the service tier OpenAI actually billed — parity with
* Codex (`applyCodexServiceTierPricing`), but with the standard (non-Codex)
* multipliers. The served tier comes from the response echo, falling back to the
* resolved request tier. Scoped to `provider: "openai"` (the only standard
* Responses biller) so an echoed `service_tier` from an Azure/OpenRouter/Copilot
* proxy can never skew those costs.
*/
export function applyOpenAIResponsesServiceTierCost(
model: Pick<Model, "provider">,
usage: AssistantMessage["usage"],
responseServiceTier: unknown,
requestServiceTier: ServiceTier | null | undefined,
): void {
if (model.provider !== "openai") return;
// The response echo is authoritative when present (OpenAI may downgrade a
// requested priority/flex turn to default under load); only fall back to the
// requested tier when the response omits the echo entirely.
const served =
typeof responseServiceTier === "string"
? responseServiceTier
: resolveServiceTier(requestServiceTier, model.provider);
const multiplier = getOpenAIResponsesServiceTierCostMultiplier(served);
if (multiplier === 1) return;
usage.cost.input *= multiplier;
usage.cost.output *= multiplier;
usage.cost.cacheRead *= multiplier;
usage.cost.cacheWrite *= multiplier;
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
}
export interface OpenAIUsageAccountingInput {
promptTokens: number;
outputTokens: number;
cachedTokens: number;
reasoningTokens: number;
cacheWriteOpenRouter: number | undefined;
cacheWriteDeepSeek: number | undefined;
hasDeepSeekCacheHitAndMiss: boolean;
}
export interface OpenAIUsageAccounting {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
reasoningTokens?: number;
}
export function calculateOpenAIUsageAccounting(accounting: OpenAIUsageAccountingInput): OpenAIUsageAccounting {
const cacheWriteTokens = accounting.cacheWriteOpenRouter ?? accounting.cacheWriteDeepSeek ?? 0;
const isDeepSeekUsage =
accounting.hasDeepSeekCacheHitAndMiss &&
accounting.cacheWriteOpenRouter === undefined &&
(accounting.cacheWriteDeepSeek ?? 0) > 0;
const input = isDeepSeekUsage
? Math.max(0, accounting.promptTokens - accounting.cachedTokens)
: Math.max(0, accounting.promptTokens - accounting.cachedTokens - cacheWriteTokens);
const cacheWrite = isDeepSeekUsage ? 0 : cacheWriteTokens;
return {
input,
output: accounting.outputTokens,
cacheRead: accounting.cachedTokens,
cacheWrite,
totalTokens: input + accounting.outputTokens + accounting.cachedTokens + cacheWrite,
...(accounting.reasoningTokens > 0 ? { reasoningTokens: accounting.reasoningTokens } : {}),
};
}
export function normalizeOpenAIResponsesPromptCacheKey(sessionId: string | undefined): string | undefined {
return normalizeOpenAIStableId(sessionId, 64, "pc_");
}
export function normalizeOpenRouterResponsesSessionId(sessionId: string | undefined): string | undefined {
return normalizeOpenAIStableId(sessionId, 256, "session_");
}
export function getOpenAIResponsesPromptCacheKey(options: OpenAIResponsesCacheOptions | undefined): string | undefined {
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
return normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
}
export function getOpenAIResponsesRoutingSessionId(
options: Pick<OpenAIResponsesCacheOptions, "cacheRetention" | "sessionId"> | undefined,
): string | undefined {
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
return normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
}
export function getOpenRouterResponsesSessionId(
options: Pick<OpenAIResponsesCacheOptions, "cacheRetention" | "sessionId"> | undefined,
): string | undefined {
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
return normalizeOpenRouterResponsesSessionId(options?.sessionId);
}
export function parseAzureDeploymentNameMap(value: string | undefined): Map<string, string> {
const map = new Map<string, string>();
if (!value) return map;
for (const entry of value.split(",")) {
const trimmed = entry.trim();
if (!trimmed) continue;
const [modelId, deploymentName] = trimmed.split("=", 2);
if (!modelId || !deploymentName) continue;
map.set(modelId.trim(), deploymentName.trim());
}
return map;
}
export function createOpenAIStrictToolsState(): OpenAIStrictToolsState {
return {
strictTools: {
disabledModelScopes: new Set<string>(),
},
};
}
export function clearOpenAIStrictToolsState(state: OpenAIStrictToolsState): void {
state.strictTools.disabledModelScopes.clear();
}
export function getOpenAIStrictToolsScope(
model: OpenAIModelIdentity,
resolvedBaseUrl: string | undefined,
): OpenAIStrictToolsScope {
return {
provider: model.provider,
baseUrl: resolvedBaseUrl ?? model.baseUrl,
modelId: model.id,
};
}
export function isStrictToolsDisabledForScope(
state: OpenAIStrictToolsState | undefined,
scope: OpenAIStrictToolsScope | undefined,
): boolean {
if (!scope) return false;
return (
state?.strictTools.disabledModelScopes.has(`${scope.provider}:${scope.baseUrl ?? ""}:${scope.modelId}`) ?? false
);
}
export function disableStrictToolsForScope(
state: OpenAIStrictToolsState | undefined,
scope: OpenAIStrictToolsScope | undefined,
): void {
if (!scope) return;
state?.strictTools.disabledModelScopes.add(`${scope.provider}:${scope.baseUrl ?? ""}:${scope.modelId}`);
}
export function isOpenRouterAnthropicModel(model: OpenAIModelIdentity): boolean {
return model.provider === "openrouter" && model.id.toLowerCase().startsWith("anthropic/");
}
/**
* Append an OpenRouter routing-variant suffix (e.g. `:nitro`, `:floor`, `:online`, `:exacto`)
* to a model id when no explicit variant is already present. A variant is considered
* "already present" when `modelId` contains a colon after the last `/` separator —
* which covers both user-typed selectors (`anthropic/claude-haiku:nitro`) and catalog
* entries that bake the variant in (`deepseek/deepseek-v3.1-terminus:exacto`).
*/
export function applyOpenRouterRoutingVariant(modelId: string, variant: string | undefined): string {
if (!variant) return modelId;
const lastSlash = modelId.lastIndexOf("/");
const lastColon = modelId.lastIndexOf(":");
if (lastColon > lastSlash) return modelId;
return `${modelId}:${variant}`;
}
export function applyWireModelIdTransform(
baseId: string,
mode: ResolvedOpenAISharedCompat["wireModelIdMode"],
openrouterVariant?: string,
): string {
switch (mode) {
case "firepass":
return toFirepassWireModelId(baseId);
case "fireworks":
return toFireworksWireModelId(baseId);
case "openrouter":
return applyOpenRouterRoutingVariant(baseId, openrouterVariant);
default:
return baseId;
}
}
export interface OpenAIOutputTokenParam {
field: "max_tokens" | "max_completion_tokens" | "max_output_tokens";
value: number;
}
export interface ResolveOpenAIOutputTokenInput {
/** Wire field the endpoint expects for the output cap. */
field: OpenAIOutputTokenParam["field"];
/** Caller-supplied output cap (model-defaulted by `stream.ts`, or null/undefined on direct provider calls). */
maxTokens: number | null | undefined;
/** Whether the caller explicitly set `maxTokens` (routing omission only applies when false). */
maxTokensExplicit: boolean;
/** Model output cap (`model.maxTokens`). */
modelMaxTokens: number | null | undefined;
/** Drop the field entirely — proxies with unknown upstream caps (Ollama via `model.omitMaxOutputTokens`). */
omitMaxOutputTokens: boolean;
/** The model sits behind OpenRouter (catalog default caps are omitted so each upstream self-caps). */
isOpenRouterHost: boolean;
/** Endpoint always needs a cap (Kimi-family TPM math); supplies the model default when the caller did not. */
alwaysSendMaxTokens: boolean;
/** Hard provider clamp; defaults to {@link OPENAI_MAX_OUTPUT_TOKENS}. */
providerOutputClamp?: number;
}
/**
* Resolve the single output-token wire parameter shared by Chat Completions
* (`max_tokens`/`max_completion_tokens`) and the Responses family
* (`max_output_tokens`). Centralizes the provider exceptions that previously
* lived inline in both `buildParams`:
* - `alwaysSendMaxTokens`: Kimi-family endpoints derive TPM limits from the
* cap and require one on every call, so default from the model cap (or
* {@link OPENAI_MAX_OUTPUT_TOKENS}) when the caller omitted it.
* - OpenRouter routing omission: OpenRouter fans out to upstreams whose output
* caps differ from the catalog value, so a catalog default above the routed
* upstream's cap makes OpenRouter skip that upstream. Omit catalog defaults
* (explicit caller caps still win) so `provider.order`/`only` is honored.
* - model/provider clamp: never exceed `model.maxTokens` or the provider clamp
* (`OPENAI_MAX_OUTPUT_TOKENS`, raised for GLM-5.2 reasoning by the caller).
* - `omitMaxOutputTokens`: proxies (Ollama) with unknown upstream caps drop it.
*/
export function resolveOpenAIOutputTokenParam(
input: ResolveOpenAIOutputTokenInput,
): OpenAIOutputTokenParam | undefined {
if (input.omitMaxOutputTokens) return undefined;
const requested =
input.maxTokens ?? (input.alwaysSendMaxTokens ? (input.modelMaxTokens ?? OPENAI_MAX_OUTPUT_TOKENS) : undefined);
if (requested === undefined) return undefined;
if (input.isOpenRouterHost && !input.alwaysSendMaxTokens && !input.maxTokensExplicit) return undefined;
const value = Math.min(
requested,
input.modelMaxTokens ?? Number.POSITIVE_INFINITY,
input.providerOutputClamp ?? OPENAI_MAX_OUTPUT_TOKENS,
);
if (!(value > 0)) return undefined;
return { field: input.field, value };
}
export interface OpenAIGatewayRoutingParams {
provider?: OpenRouterRouting;
providerOptions?: { gateway?: { only?: string[]; order?: string[] } };
}
export interface OpenAIGatewayRoutingCompat {
isOpenRouterHost: boolean;
openRouterRouting?: OpenRouterRouting;
isVercelGatewayHost?: boolean;
vercelGatewayRouting?: VercelGatewayRouting;
}
/**
* Apply gateway routing preferences to the request body. OpenRouter routes via
* the top-level `provider` field; the Vercel AI Gateway routes via
* `providerOptions.gateway`. Both Chat Completions and Responses call this; the
* Vercel branch is inert for Responses, whose resolved compat never sets
* `isVercelGatewayHost`.
*/
export function applyOpenAIGatewayRouting(
params: OpenAIGatewayRoutingParams,
compat: OpenAIGatewayRoutingCompat,
): void {
if (compat.isOpenRouterHost && compat.openRouterRouting) {
params.provider = compat.openRouterRouting;
}
if (compat.isVercelGatewayHost && compat.vercelGatewayRouting) {
const routing = compat.vercelGatewayRouting;
if (routing.only || routing.order) {
const gatewayOptions: { only?: string[]; order?: string[] } = {};
if (routing.only) gatewayOptions.only = routing.only;
if (routing.order) gatewayOptions.order = routing.order;
params.providerOptions = { gateway: gatewayOptions };
}
}
}
export interface OpenAIExtraBodyOptions {
/**
* Fireworks rejects DeepSeek-style `thinking` toggles alongside OpenAI-style
* `reasoning_effort`; drop `thinking` when the effort field carries the level.
*/
dropThinkingWhenReasoningEffort?: boolean;
}
/**
* Merge a compat/options `extraBody` blob into the request params. When
* `dropThinkingWhenReasoningEffort` is set and `reasoning_effort` is present,
* delete the conflicting `thinking` toggle (Fireworks rejects both together).
*/
export function applyOpenAIExtraBody<P extends object>(
params: P,
extraBody: Record<string, unknown> | undefined,
options?: OpenAIExtraBodyOptions,
): void {
if (!extraBody) return;
Object.assign(params, extraBody);
if (options?.dropThinkingWhenReasoningEffort) {
const shaped = params as { reasoning_effort?: unknown; thinking?: unknown };
if (shaped.reasoning_effort !== undefined) {
delete shaped.thinking;
}
}
}
/**
* Chat Completions streaming request body shaped by the OpenAI-family providers.
* Extends the vendored SDK params with the compat dialect fields pi-ai emits
* (binary `thinking`, Qwen `enable_thinking`/`chat_template_kwargs`, nested
* `reasoning`, gateway `provider`/`providerOptions`, sampling extras). Lives in
* the shared module beside the request-shaping helpers that mutate it.
*/
export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming, "reasoning_effort" | "service_tier"> & {
top_k?: number;
min_p?: number;
repetition_penalty?: number;
thinking?: { type: "enabled" | "disabled"; keep?: "all" };
enable_thinking?: boolean;
preserve_thinking?: boolean;
chat_template_kwargs?: { enable_thinking?: boolean; preserve_thinking?: boolean };
reasoning?: { effort?: string } | { enabled: false };
reasoning_effort?: string | null;
service_tier?: ResolvedServiceTier;
tool_stream?: boolean;
provider?: OpenAICompat["openRouterRouting"];
providerOptions?: { gateway?: { only?: string[]; order?: string[] } };
};
/** Reasoning-relevant slice of caller options the Chat Completions dialect dispatch reads. */
export interface ChatCompletionsReasoningOptions {
reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
disableReasoning?: boolean;
}
export type OpenAICompatEndpoint = "chat-completions" | "responses";
export type OpenAIReasoningDisableReason = "caller" | "forced-tool-choice" | "tool-choice" | "not-requested";
export type OpenAICompatPolicyCompat = ResolvedOpenAISharedCompat &
Partial<ResolvedOpenAICompat> &
Partial<ResolvedOpenAIResponsesCompat>;
export interface ResolveOpenAICompatPolicyOptions {
endpoint: OpenAICompatEndpoint;
compat?: OpenAICompatPolicyCompat;
reasoning?: string;
disableReasoning?: boolean;
toolChoice?: unknown;
strictResponsesPairing?: boolean;
includeEncryptedReasoning?: boolean;
filterReasoningHistory?: boolean;
omitReasoningEffort?: boolean;
}
export interface OpenAICompatPolicy {
endpoint: OpenAICompatEndpoint;
compat: OpenAICompatPolicyCompat;
reasoning: {
modelSupported: boolean;
supportsParams: boolean;
requestedEffort?: string;
wireEffort?: string;
enabled: boolean;
disabled: boolean;
disableReason?: OpenAIReasoningDisableReason;
dialect: ResolvedOpenAISharedCompat["thinkingFormat"];
disableMode: OpenAIReasoningDisableMode;
omitReasoningEffort: boolean;
includeEncryptedReasoning: boolean;
filterReasoningHistory: boolean;
requiresReasoningContentForToolCalls: boolean;
requiresReasoningContentForAllAssistantTurns: boolean;
allowsSyntheticReasoningContentForToolCalls: boolean;
reasoningContentField?: OpenAICompat["reasoningContentField"];
requiresThinkingAsText: boolean;
};
tools: {
strictResponsesPairing: boolean;
toolCallIdKind: "default" | "openai-40" | "mistral-9-alnum";
};
messages: {
systemRole: "system" | "developer";
supportsDeveloperRole: boolean;
supportsMultipleSystemMessages: boolean;
};
stream: {
stripSpecialTokens: "deepseek" | false;
markupHealingPattern?: OpenAIStreamMarkupHealingPattern;
reasoningDeltasMayBeCumulative: boolean;
emptyLengthFinishIsContextError: boolean;
};
}
function mapOpenAIReasoningEffort(
model: Pick<Model, "thinking">,
compat: OpenAICompatPolicyCompat,
effort: string,
): string {
const level = effort as Effort;
return compat.reasoningEffortMap?.[level] ?? model.thinking?.effortMap?.[level] ?? effort;
}
function isImplicitDisableWhenNotRequested(disableMode: OpenAIReasoningDisableMode): boolean {
return (
disableMode === "zai-thinking-disabled" ||
disableMode === "qwen-enable-thinking-false" ||
disableMode === "qwen-template-false"
);
}
export function resolveOpenAICompatPolicy<TApi extends Api>(
model: Model<TApi>,
options: ResolveOpenAICompatPolicyOptions,
): OpenAICompatPolicy {
const baseCompat = (options.compat ?? model.compat) as OpenAICompatPolicyCompat;
const requestedEffort = options.reasoning;
const modelSupported = Boolean(model.reasoning);
const forcedToolChoiceSuppressesReasoning =
baseCompat.disableReasoningOnForcedToolChoice &&
baseCompat.supportsForcedToolChoice &&
isForcedToolChoice(options.toolChoice);
const anyToolChoiceSuppressesReasoning =
!forcedToolChoiceSuppressesReasoning &&
baseCompat.disableReasoningOnToolChoice &&
options.toolChoice !== undefined;
const requestedAndAllowed = requestedEffort !== undefined && !options.disableReasoning && modelSupported;
const conflictDisableReason: OpenAIReasoningDisableReason | undefined = forcedToolChoiceSuppressesReasoning
? "forced-tool-choice"
: anyToolChoiceSuppressesReasoning
? "tool-choice"
: undefined;
const disableReason: OpenAIReasoningDisableReason | undefined = options.disableReasoning
? "caller"
: conflictDisableReason;
const enabledBeforeThinkingVariant = requestedAndAllowed && disableReason === undefined;
const baseWireEffort =
enabledBeforeThinkingVariant && requestedEffort !== undefined
? mapOpenAIReasoningEffort(model, baseCompat, requestedEffort)
: undefined;
const disabledByNoneEffort =
enabledBeforeThinkingVariant &&
baseCompat.reasoningDisableMode === "zai-thinking-disabled" &&
baseWireEffort === "none";
const enabled = enabledBeforeThinkingVariant && !disabledByNoneEffort;
const compat =
enabled && baseCompat.whenThinking ? (baseCompat.whenThinking as OpenAICompatPolicyCompat) : baseCompat;
const omitReasoningEffort =
options.omitReasoningEffort ?? (compat.omitReasoningEffort || !compat.supportsReasoningEffort);
const disableMode = compat.reasoningDisableMode;
let wireEffort =
enabled && requestedEffort !== undefined ? mapOpenAIReasoningEffort(model, compat, requestedEffort) : undefined;
const disabledWithoutRequest =
modelSupported &&
requestedEffort === undefined &&
!options.disableReasoning &&
isImplicitDisableWhenNotRequested(disableMode);
const disabled =
(modelSupported && disableReason === "caller") ||
conflictDisableReason !== undefined ||
(modelSupported && disabledWithoutRequest) ||
disabledByNoneEffort;
if (
disabled &&
disableReason === "caller" &&
requestedEffort === undefined &&
disableMode === "lowest-effort" &&
compat.supportsReasoningEffort &&
!omitReasoningEffort
) {
const minEffort = getSupportedEfforts(model)[0];
if (minEffort === undefined) {
throw new AIError.ConfigurationError(`Model ${model.provider}/${model.id} has no supported reasoning efforts`);
}
wireEffort = mapOpenAIReasoningEffort(model, compat, minEffort);
}
return {
endpoint: options.endpoint,
compat,
reasoning: {
modelSupported,
supportsParams: compat.supportsReasoningParams,
requestedEffort,
wireEffort,
enabled,
disabled,
disableReason: disableReason ?? (disabledWithoutRequest || disabledByNoneEffort ? "not-requested" : undefined),
dialect: compat.thinkingFormat,
requiresReasoningContentForToolCalls: compat.requiresReasoningContentForToolCalls,
requiresReasoningContentForAllAssistantTurns: compat.requiresReasoningContentForAllAssistantTurns,
allowsSyntheticReasoningContentForToolCalls: compat.allowsSyntheticReasoningContentForToolCalls,
reasoningContentField: compat.reasoningContentField,
requiresThinkingAsText: compat.requiresThinkingAsText,
disableMode,
omitReasoningEffort,
includeEncryptedReasoning: options.includeEncryptedReasoning ?? compat.includeEncryptedReasoning,
filterReasoningHistory: options.filterReasoningHistory ?? compat.filterReasoningHistory,
},
tools: {
strictResponsesPairing: options.strictResponsesPairing ?? compat.strictResponsesPairing ?? false,
toolCallIdKind: compat.requiresMistralToolIds
? "mistral-9-alnum"
: compat.usesOpenAIToolCallIdLimit
? "openai-40"
: "default",
},
messages: {
systemRole: modelSupported && compat.supportsDeveloperRole ? "developer" : "system",
supportsDeveloperRole: compat.supportsDeveloperRole,
supportsMultipleSystemMessages: compat.supportsMultipleSystemMessages ?? true,
},
stream: {
stripSpecialTokens: compat.stripDeepseekSpecialTokens ? "deepseek" : false,
markupHealingPattern: compat.streamMarkupHealingPattern,
reasoningDeltasMayBeCumulative: compat.reasoningDeltasMayBeCumulative,
emptyLengthFinishIsContextError: compat.emptyLengthFinishIsContextError,
},
};
}
function encodeChatCompletionsDisabledReasoning(
params: OpenAICompletionsParams,
disableMode: OpenAIReasoningDisableMode,
): void {
delete params.reasoning_effort;
switch (disableMode) {
case "zai-thinking-disabled":
params.thinking = { type: "disabled" };
break;
case "qwen-enable-thinking-false":
params.enable_thinking = false;
break;
case "qwen-template-false":
params.chat_template_kwargs = { ...params.chat_template_kwargs, enable_thinking: false };
break;
case "openrouter-enabled-false":
(params as typeof params & { reasoning?: { effort?: string } | { enabled: false } }).reasoning = {
enabled: false,
};
break;
default:
delete params.reasoning;
break;
}
}
export function applyChatCompletionsCompatPolicy(params: OpenAICompletionsParams, policy: OpenAICompatPolicy): void {
// `preserve_thinking` is a chat-template HISTORY knob, not a per-turn
// thinking switch — it controls whether OLDER assistant turns render
// with `<think>...</think>` on Qwen3.6+. Emit it BEFORE the reasoning
// state branches and EVERY early-return below, because the wire shape
// must carry the kwarg in three cases the auto-detected
// `qwenPreserveThinking` flag covers but `reasoning.enabled` does not:
//
// 1. Discovered local Qwen models. `discoverOpenAICompatibleModels`
// stamps `reasoning: false` on every spec built from a generic
// `/v1/models` endpoint (the upstream doesn't advertise the
// capability), so `model.reasoning === false` → `reasoning.enabled
// === false`, the body wouldn't otherwise see the kwarg, and the
// encoder's `replayReasoningContent` branch would keep shipping
// `reasoning_content` only for the template to strip `<think>` from
// older turns anyway. Exactly the #3528 / #3541 symptom on every
// discovered Qwen build.
// 2. Caller-disabled reasoning. The slot's KV cache still holds prior
// `<think>...</think>` tokens from earlier thinking turns; the
// template must keep rendering them or cache invalidates at the
// first historic `<think>`.
// 3. Forced-tool-choice / DeepSeek-style auto-disable. Same reasoning
// as (2) — historic thinking blocks have to survive history replay
// even when the current turn cannot think.
//
// Non-Qwen templates ignore the parameter (jinja `is defined` check
// silently no-ops), so emitting it unconditionally for the Qwen-family
// + local-cache compat flag is safe.
if (policy.compat.qwenPreserveThinking) {
// Mirror the dialect split that gates `enable_thinking`. The
// `qwen` dialect rides the top-level field (the only place
// llama.cpp's `--jinja` hook AND Alibaba Cloud Model Studio's
// compatible-mode look) while the `qwen-chat-template` dialect
// (NVIDIA NIM, vLLM/SGLang's chat-template-kwargs path) MUST
// ride only the kwargs copy — NIM's request schema is
// `additionalProperties: false` and rejects every unknown
// top-level field, the very reason `enable_thinking` is
// route-split this way (#2299, see `catalog/src/compat/openai.ts`
// thinkingFormat comment).
if (policy.compat.thinkingFormat === "qwen") {
params.preserve_thinking = true;
}
params.chat_template_kwargs = { ...params.chat_template_kwargs, preserve_thinking: true };
}
const reasoning = policy.reasoning;
if ((!reasoning.modelSupported && !reasoning.disabled) || !reasoning.supportsParams) return;
if (reasoning.enabled) {
switch (reasoning.disableMode) {
case "zai-thinking-disabled":
if (reasoning.wireEffort === "none") {
encodeChatCompletionsDisabledReasoning(params, reasoning.disableMode);
return;
}
params.thinking = { type: "enabled" };
if (policy.compat.thinkingKeep) params.thinking.keep = policy.compat.thinkingKeep;
if (policy.compat.supportsReasoningEffort && reasoning.wireEffort !== undefined) {
params.reasoning_effort = reasoning.wireEffort as Effort;
}
break;
case "qwen-enable-thinking-false":
params.enable_thinking = true;
break;
case "qwen-template-false":
// Spread so the `preserve_thinking` kwarg hoisted above
// survives the merge — a bare `{ enable_thinking: true }`
// would clobber it.
params.chat_template_kwargs = { ...params.chat_template_kwargs, enable_thinking: true };
break;
case "openrouter-enabled-false":
if (reasoning.wireEffort !== undefined) {
(params as typeof params & { reasoning?: { effort?: string } }).reasoning = {
effort: reasoning.wireEffort,
};
}
break;
default:
if (!reasoning.omitReasoningEffort && reasoning.wireEffort !== undefined) {
params.reasoning_effort = reasoning.wireEffort as Effort;
}
break;
}
return;
}
if (!reasoning.disabled) return;
if (
reasoning.disableReason === "caller" &&
reasoning.requestedEffort === undefined &&
reasoning.disableMode === "lowest-effort" &&
reasoning.wireEffort !== undefined
) {
params.reasoning_effort = reasoning.wireEffort as Effort;
return;
}
encodeChatCompletionsDisabledReasoning(params, reasoning.disableMode);
}
export function applyChatCompletionsReasoningParams(
params: OpenAICompletionsParams,
model: Model<"openai-completions">,
compat: ResolvedOpenAICompat,
options: (ChatCompletionsReasoningOptions & { toolChoice?: unknown }) | undefined,
): void {
applyChatCompletionsCompatPolicy(
params,
resolveOpenAICompatPolicy(model, {
endpoint: "chat-completions",
compat,
reasoning: options?.reasoning,
disableReasoning: options?.disableReasoning,
toolChoice: options?.toolChoice,
}),
);
}
export function disableChatCompletionsReasoningForDialect(
params: OpenAICompletionsParams,
compat: ResolvedOpenAICompat,
): void {
encodeChatCompletionsDisabledReasoning(params, compat.reasoningDisableMode);
}
/**
* Z.AI/GLM-5.2 reasoning-effort dialect predicate. GLM-5.2 models served on a
* Z.AI-format host (thinkingFormat "zai") accept `reasoning_effort`, stream tool
* calls via `tool_stream`, and clamp output to the model cap. Moonshot Kimi and
* Xiaomi MiMo also resolve to thinkingFormat "zai" with supportsReasoningEffort
* true but are NOT GLM-5.2, so the model-id check is load-bearing — never swap it
* for `compat.supportsReasoningEffort`.
*/
function isZaiReasoningEffortDialect(model: Model<"openai-completions">, compat: ResolvedOpenAICompat): boolean {
return compat.thinkingFormat === "zai" && isGlm52ReasoningEffortModelId(model.id);
}
/**
* Output-token clamp for the Z.AI/GLM-5.2 reasoning dialect: these hosts accept
* the full model window on reasoning turns, so clamp to the model cap. Returns
* `undefined` for every other model, leaving {@link resolveOpenAIOutputTokenParam}
* on its default `OPENAI_MAX_OUTPUT_TOKENS` clamp.
*/
export function resolveZaiReasoningOutputClamp(
model: Model<"openai-completions">,
compat: ResolvedOpenAICompat,
): number | undefined {
return isZaiReasoningEffortDialect(model, compat) ? (model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS) : undefined;
}
/**
* Enable `tool_stream` for Z.AI/GLM-5.2 reasoning models when tools are present
* (GLM-5.2 streams tool-call arguments incrementally and needs the flag to do so).
*/
export function applyChatCompletionsToolStream(
params: OpenAICompletionsParams,
model: Model<"openai-completions">,
compat: ResolvedOpenAICompat,
): void {
if (
isZaiReasoningEffortDialect(model, compat) &&
compat.supportsReasoningEffort &&
Array.isArray(params.tools) &&
params.tools.length > 0
) {
params.tool_stream = true;
}
}
export function isCompiledGrammarTooLargeStrictError(
error: unknown,
capturedErrorResponse: CapturedHttpErrorResponse | undefined,
): boolean {
const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
if (status !== 400) return false;
const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.join("\n");
return (
/invalid_request_error/i.test(messageParts) &&
/compiled grammar/i.test(messageParts) &&
/too large/i.test(messageParts)
);
}
export function shouldRetryWithoutStrictTools(
error: unknown,
capturedErrorResponse: CapturedHttpErrorResponse | undefined,
strictToolsApplied: boolean,
tools: Tool[] | undefined,
): boolean {
if (!tools || tools.length === 0 || !strictToolsApplied) return false;
const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
if (status !== 400 && status !== 422) return false;
const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.join("\n");
return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|tool parameters? schema|invalid schema for function/i.test(
messageParts,
);
}
function normalizeOpenAIStableId(value: string | undefined, maxLength: number, hashPrefix: string): string | undefined {
if (!value || value.length === 0) return undefined;
const wellFormed = value.toWellFormed();
if (wellFormed.length <= maxLength) return wellFormed;
return `${hashPrefix}${Bun.hash(wellFormed).toString(36)}`;
}
export const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES: ReadonlySet<string> = new Set([
"response.created",
"response.output_item.added",
"response.reasoning_summary_part.added",
"response.reasoning_summary_text.delta",
"response.reasoning_summary_part.done",
"response.reasoning_text.delta",
"response.content_part.added",
"response.output_text.delta",
"response.refusal.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.custom_tool_call_input.delta",
"response.custom_tool_call_input.done",
"response.output_item.done",
"response.completed",
"response.incomplete",
"response.failed",
"error",
]);
export function isOpenAIResponsesProgressEvent(event: unknown): boolean {
if (!event || typeof event !== "object") return false;
const type = (event as { type?: unknown }).type;
return typeof type === "string" && OPENAI_RESPONSES_PROGRESS_EVENT_TYPES.has(type);
}
export function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string {
const payload: TextSignatureV1 = { v: 1, id };
if (phase) payload.phase = phase;
return JSON.stringify(payload);
}
export function parseTextSignature(
signature: string | undefined,
): { id: string; phase?: TextSignatureV1["phase"] } | undefined {
if (!signature) return undefined;
if (signature.startsWith("{")) {
try {
const parsed = JSON.parse(signature) as Partial<TextSignatureV1>;
if (parsed.v === 1 && typeof parsed.id === "string") {
if (parsed.phase === "commentary" || parsed.phase === "final_answer") {
return { id: parsed.id, phase: parsed.phase };
}
return { id: parsed.id };
}
} catch {
// Fall through to legacy plain-string handling.
}
}
return { id: signature };
}
export function encodeResponsesToolCallId(callId: string, itemId: string | null | undefined): string {
const stableItemId = itemId && itemId.length > 0 ? itemId : `fc_${Bun.hash(callId).toString(36)}`;
return `${callId}|${stableItemId}`;
}
export function normalizeResponsesToolCallIdForTransform(
id: string,
model?: Model<Api>,
source?: AssistantMessage,
): string {
if (!id.includes("|")) return id;
const isForeignToolCall =
source != null && model != null && (source.provider !== model.provider || source.api !== model.api);
if (isForeignToolCall) {
const [callId, itemId] = id.split("|");
const normalizeIdPart = (part: string): string => {
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
return truncated.replace(/_+$/, "");
};
const normalizedCallId = normalizeIdPart(callId);
let normalizedItemId = `fc_${Bun.hash(itemId).toString(36)}`;
if (normalizedItemId.length > 64) normalizedItemId = normalizedItemId.slice(0, 64);
return `${normalizedCallId}|${normalizedItemId}`;
}
const normalized = normalizeResponsesToolCallId(id);
return `${normalized.callId}|${normalized.itemId}`;
}
export function collectKnownCallIds(messages: ResponseInput): Set<string> {
const knownCallIds = new Set<string>();
for (const item of messages) {
if (item.type === "function_call" && typeof item.call_id === "string") {
knownCallIds.add(item.call_id);
} else if (
(item as { type?: string }).type === "custom_tool_call" &&
typeof (item as { call_id?: string }).call_id === "string"
) {
knownCallIds.add((item as { call_id: string }).call_id);
}
}
return knownCallIds;
}
/** Scan replay items for call_ids that were originally custom tool calls. */
export function collectCustomCallIds(messages: ResponseInput): Set<string> {
const customCallIds = new Set<string>();
for (const item of messages) {
if (
(item as { type?: string }).type === "custom_tool_call" &&
typeof (item as { call_id?: string }).call_id === "string"
) {
customCallIds.add((item as { call_id: string }).call_id);
}
}
return customCallIds;
}
/**
* Convert orphan `function_call_output` / `custom_tool_call_output` items —
* those whose `call_id` has no matching preceding `function_call` /
* `custom_tool_call` in the same input — into assistant text notes.
*
* The Responses API rejects unpaired outputs with
* `400 No tool call found for function call output with call_id …`. Orphans
* sneak in through two paths today:
*
* - A previous turn's `providerPayload` snapshot replaces the input array via
* the `dt: false` splice (see {@link convertConversationMessages}), wiping
* the matching `function_call` while leaving the matching
* `function_call_output` queued in a later `toolResult`.
* - A locally-rejected tool call (argument-validation failure, hook reject,
* aborted turn before the call streamed) produces a tool result without a
* `function_call` ever landing in any persisted provider payload.
*
* Dropping the result loses information the model needs to recover; sending
* it as-is 400s the request. Folding it into an assistant `message` preserves
* the payload (call_id + truncated output) while staying within the Responses
* input grammar. Matches the behavior of {@link transformRequestBody} in the
* codex provider — issue #1351 / regression of #472.
*/
export function repairOrphanResponsesToolOutputs(input: ResponseInput): ResponseInput {
const knownCallIds = new Set<string>();
for (const item of input) {
const t = (item as { type?: string }).type;
const callId = (item as { call_id?: unknown }).call_id;
if (typeof callId !== "string") continue;
if (t === "function_call" || t === "custom_tool_call") knownCallIds.add(callId);
}
let hasOrphan = false;
for (const item of input) {
const t = (item as { type?: string }).type;
if (t !== "function_call_output" && t !== "custom_tool_call_output") continue;
const callId = (item as { call_id?: unknown }).call_id;
if (typeof callId === "string" && !knownCallIds.has(callId)) {
hasOrphan = true;
break;
}
}
if (!hasOrphan) return input;
return input.map(item => {
const t = (item as { type?: string }).type;
if (t !== "function_call_output" && t !== "custom_tool_call_output") return item;
const record = item as { call_id?: unknown; output?: unknown; name?: unknown };
const callId = record.call_id;
if (typeof callId !== "string" || knownCallIds.has(callId)) return item;
const toolName = typeof record.name === "string" && record.name.length > 0 ? record.name : "tool";
const rawOutput = record.output;
let text: string;
if (typeof rawOutput === "string") text = rawOutput;
else if (rawOutput == null) text = "";
else {
try {
text = JSON.stringify(rawOutput);
} catch {
text = String(rawOutput);
}
}
const ORPHAN_OUTPUT_LIMIT = 16_000;
if (text.length > ORPHAN_OUTPUT_LIMIT) text = `${text.slice(0, ORPHAN_OUTPUT_LIMIT)}\n...[truncated]`;
return {
type: "message",
role: "assistant",
content: `[Orphan ${toolName} result; call_id=${callId}]: ${text}`,
} as ResponseInput[number];
});
}
/** Placeholder output for a tool call whose result is absent from the input. */
const ORPHAN_TOOL_CALL_PLACEHOLDER =
"[No tool output recorded: the tool call was interrupted before it produced a result.]";
/**
* Synthesize a placeholder `function_call_output` / `custom_tool_call_output`
* for every `function_call` / `custom_tool_call` whose `call_id` has no matching
* output later in the same input. The Responses API rejects an unpaired call
* with `400 No tool output found for function call …`.
*
* Orphan calls surface when the user branches/navigates the session tree to a
* node that ends on a tool call (the tool-result child is excluded from the
* reconstructed history) or when a turn is aborted/crashes after the call
* streamed but before its result persisted. Dropping the call would erase the
* assistant's action; a placeholder output keeps the call visible so the model
* can recover (e.g. re-issue the call). Symmetric to
* {@link repairOrphanResponsesToolOutputs}.
*/
export function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseInput {
const outputCallIds = new Set<string>();
for (const item of input) {
const t = (item as { type?: string }).type;
if (t !== "function_call_output" && t !== "custom_tool_call_output") continue;
const callId = (item as { call_id?: unknown }).call_id;
if (typeof callId === "string") outputCallIds.add(callId);
}
let hasOrphan = false;
for (const item of input) {
const t = (item as { type?: string }).type;
if (t !== "function_call" && t !== "custom_tool_call") continue;
const callId = (item as { call_id?: unknown }).call_id;
if (typeof callId === "string" && !outputCallIds.has(callId)) {
hasOrphan = true;
break;
}
}
if (!hasOrphan) return input;
const repaired: ResponseInput = [];
for (const item of input) {
repaired.push(item);
const t = (item as { type?: string }).type;
if (t !== "function_call" && t !== "custom_tool_call") continue;
const callId = (item as { call_id?: unknown }).call_id;
if (typeof callId !== "string" || outputCallIds.has(callId)) continue;
repaired.push({
type: t === "custom_tool_call" ? "custom_tool_call_output" : "function_call_output",
call_id: callId,
output: ORPHAN_TOOL_CALL_PLACEHOLDER,
} as ResponseInput[number]);
}
return repaired;
}
/**
* Some Responses backends (notably GitHub Copilot) reject the OpenAI image
* `detail: "original"` value with a 400. When the model does not advertise
* support for it, degrade `"original"` to `"auto"` so the request still goes
* through with the closest valid fidelity instead of failing outright. See #2822.
*/
function clampResponsesImageDetail(
detail: ImageContent["detail"],
supportsImageDetailOriginal: boolean,
): ResponseInputImage["detail"] {
const resolved = detail ?? "auto";
return resolved === "original" && !supportsImageDetailOriginal ? "auto" : resolved;
}
export function convertResponsesInputContent(
content: string | Array<TextContent | ImageContent>,
supportsImages: boolean,
supportsImageDetailOriginal: boolean,
): ResponseInputContent[] | undefined {
if (typeof content === "string") {
if (content.trim().length === 0) return undefined;
return [{ type: "input_text", text: content.toWellFormed() } satisfies ResponseInputText];
}
const { textBlocks, imageBlocks, omittedImages } = partitionVisionContent(content, supportsImages);
const normalizedContent: ResponseInputContent[] = [];
for (const item of textBlocks) {
const text = item.text.toWellFormed();
if (text.trim().length === 0) continue;
normalizedContent.push({
type: "input_text",
text,
} satisfies ResponseInputText);
}
for (const item of imageBlocks) {
normalizedContent.push({
type: "input_image",
detail: clampResponsesImageDetail(item.detail, supportsImageDetailOriginal),
image_url: `data:${item.mimeType};base64,${item.data}`,
} satisfies ResponseInputImage);
}
if (omittedImages) {
normalizedContent.push({
type: "input_text",
text: NON_VISION_IMAGE_PLACEHOLDER,
} satisfies ResponseInputText);
}
return normalizedContent.length > 0 ? normalizedContent : undefined;
}
export interface BuildResponsesInputOptions<TApi extends Api> {
model: Model<TApi>;
context: Context;
strictResponsesPairing: boolean;
supportsImageDetailOriginal: boolean;
systemRole?: "system" | "developer";
nativeHistory?: {
replay: boolean;
filterReasoning: boolean;
};
includeThinkingSignatures?: boolean;
developerStringContent?: boolean;
repairOrphanOutputs?: boolean;
}
export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput {
const messages: ResponseInput = [];
const systemPrompts = options.systemRole ? normalizeSystemPrompts(options.context.systemPrompt) : [];
for (const systemPrompt of systemPrompts) {
messages.push({ role: options.systemRole as "system" | "developer", content: systemPrompt });
}
let knownCallIds = new Set<string>();
const customCallIds = new Set<string>();
const transformedMessages = transformMessages(
options.context.messages,
options.model,
normalizeResponsesToolCallIdForTransform,
);
const filterReasoning = <T extends { type?: string }>(items: T[]): T[] =>
options.nativeHistory?.filterReasoning ? items.filter(item => item?.type !== "reasoning") : items;
const includeThinkingSignatures = options.includeThinkingSignatures ?? options.nativeHistory?.replay ?? true;
let msgIndex = 0;
for (const msg of transformedMessages) {
if (msg.role === "user" || msg.role === "developer") {
const providerPayload = (msg as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
const historyItems = options.nativeHistory
? getOpenAIResponsesHistoryItems(providerPayload, options.model.provider)
: undefined;
const shouldReplayPayloadItems =
options.nativeHistory?.replay ||
(historyItems?.some(item => {
if (!item || typeof item !== "object") return false;
const candidate = item as { type?: unknown };
return candidate.type === "compaction" || candidate.type === "compaction_summary";
}) ??
false);
if (historyItems && shouldReplayPayloadItems) {
messages.push(...sanitizeOpenAIResponsesHistoryItemsForReplay(filterReasoning(historyItems)));
knownCallIds = collectKnownCallIds(messages);
for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
msgIndex++;
continue;
}
const content = convertResponsesInputContent(
msg.content,
options.model.input.includes("image"),
options.supportsImageDetailOriginal,
);
if (!content) continue;
messages.push({
role: "user",
content:
options.developerStringContent && msg.role === "developer" && typeof msg.content === "string"
? msg.content.toWellFormed()
: content,
});
} else if (msg.role === "assistant") {
const assistantMsg = msg as AssistantMessage;
const providerPayload =
options.nativeHistory?.replay &&
assistantMsg.api === options.model.api &&
assistantMsg.model === options.model.id
? getOpenAIResponsesHistoryPayload(
assistantMsg.providerPayload,
options.model.provider,
assistantMsg.provider,
)
: undefined;
const historyItems = providerPayload?.items;
if (historyItems) {
const sanitizedHistoryItems = sanitizeOpenAIResponsesHistoryItemsForReplay(filterReasoning(historyItems));
if (providerPayload?.dt) {
messages.push(...sanitizedHistoryItems);
} else {
messages.splice(0, messages.length, ...sanitizedHistoryItems);
}
knownCallIds = collectKnownCallIds(messages);
for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
msgIndex++;
continue;
}
const outputItems = convertResponsesAssistantMessage(
assistantMsg,
options.model,
msgIndex,
knownCallIds,
includeThinkingSignatures,
customCallIds,
);
if (outputItems.length === 0) continue;
messages.push(...outputItems);
} else if (msg.role === "toolResult") {
appendResponsesToolResultMessages(
messages,
msg,
options.model,
options.strictResponsesPairing,
options.supportsImageDetailOriginal,
knownCallIds,
customCallIds,
);
}
msgIndex++;
}
const withRepairedOutputs = options.repairOrphanOutputs ? repairOrphanResponsesToolOutputs(messages) : messages;
return repairOrphanResponsesToolCalls(withRepairedOutputs);
}
export function convertResponsesAssistantMessage<TApi extends Api>(
assistantMsg: AssistantMessage,
model: Model<TApi>,
msgIndex: number,
knownCallIds: Set<string>,
includeThinkingSignatures = true,
customCallIds?: Set<string>,
): ResponseInput {
const outputItems: ResponseInput = [];
let unsignedTextBlocks = 0;
const isDifferentModel =
assistantMsg.model !== model.id && assistantMsg.provider === model.provider && assistantMsg.api === model.api;
for (const block of assistantMsg.content) {
if (block.type === "thinking" && assistantMsg.stopReason !== "error") {
if (!includeThinkingSignatures) {
continue;
}
if (block.thinkingSignature) {
try {
outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem);
} catch {
// Legacy/corrupt persisted signature — skip the reasoning item
// rather than failing the whole request build.
}
}
continue;
}
if (block.type === "text") {
const parsedSignature = parseTextSignature(block.textSignature);
let msgId = parsedSignature?.id;
if (!msgId) {
// Distinct ids per unsigned block: several text blocks in one message
// (cross-provider replay downgrades thinking → text) must not share an id.
msgId = unsignedTextBlocks === 0 ? `msg_${msgIndex}` : `msg_${msgIndex}_${unsignedTextBlocks}`;
unsignedTextBlocks += 1;
} else if (msgId.length > 64) {
msgId = `msg_${Bun.hash(msgId).toString(36)}`;
}
outputItems.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: block.text.toWellFormed(), annotations: [] }],
status: "completed",
id: msgId,
phase: parsedSignature?.phase,
} satisfies ResponseOutputMessage);
continue;
}
if (block.type !== "toolCall") {
continue;
}
const normalized = normalizeResponsesToolCallId(block.id, block.customWireName ? "ctc" : "fc");
let itemId: string | undefined = normalized.itemId;
if (isDifferentModel && (itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))) {
itemId = undefined;
}
knownCallIds.add(normalized.callId);
if (block.customWireName) {
const rawInput = typeof block.arguments?.input === "string" ? block.arguments.input : "";
customCallIds?.add(normalized.callId);
outputItems.push({
type: "custom_tool_call",
id: itemId,
call_id: normalized.callId,
name: block.customWireName,
input: rawInput,
} as ResponseInput[number]);
continue;
}
outputItems.push({
type: "function_call",
id: itemId,
call_id: normalized.callId,
name: block.name,
arguments: JSON.stringify(block.arguments),
});
}
return outputItems;
}
export function appendResponsesToolResultMessages<TApi extends Api>(
messages: ResponseInput,
toolResult: ToolResultMessage,
model: Model<TApi>,
strictResponsesPairing: boolean,
supportsImageDetailOriginal: boolean,
knownCallIds: ReadonlySet<string>,
customCallIds?: ReadonlySet<string>,
): void {
const supportsImages = model.input.includes("image");
const textResult = toolResult.content
.filter((block): block is TextContent => block.type === "text")
.map(block => block.text)
.join("\n");
const hasImages = toolResult.content.some((block): block is ImageContent => block.type === "image");
const omittedImages = hasImages && !supportsImages;
const normalized = normalizeResponsesToolCallId(toolResult.toolCallId);
const output = (
omittedImages
? joinTextWithImagePlaceholder(textResult, true)
: textResult.length > 0
? textResult
: "(see attached image)"
).toWellFormed();
if (strictResponsesPairing && !knownCallIds.has(normalized.callId)) {
// Strict backends (Azure, Copilot) reject unpaired outputs outright, but
// silently dropping the result loses information the model needs. Fold it
// into an assistant note instead (same shape as repairOrphanResponsesToolOutputs).
const limit = 16_000;
const noteText = output.length > limit ? `${output.slice(0, limit)}\n...[truncated]` : output;
messages.push({
type: "message",
role: "assistant",
content: `[Orphan ${toolResult.toolName || "tool"} result; call_id=${normalized.callId}]: ${noteText}`,
} as ResponseInput[number]);
return;
}
if (customCallIds?.has(normalized.callId)) {
messages.push({
type: "custom_tool_call_output",
call_id: normalized.callId,
output,
} as ResponseInput[number]);
} else {
messages.push({
type: "function_call_output",
call_id: normalized.callId,
output,
});
}
if (!hasImages || !supportsImages) {
return;
}
const contentParts: ResponseInputContent[] = [
{ type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText,
];
for (const block of toolResult.content) {
if (block.type === "image") {
contentParts.push({
type: "input_image",
detail: clampResponsesImageDetail(block.detail, supportsImageDetailOriginal),
image_url: `data:${block.mimeType};base64,${block.data}`,
} satisfies ResponseInputImage);
}
}
messages.push({ role: "user", content: contentParts });
}
/**
* Per-block accumulation helpers shared by the two Responses decode loops —
* {@link processResponsesStream} (generic Responses) and the Codex stream
* handler in `openai-codex-responses.ts`. Each endpoint keeps its own
* item-routing, terminal handling, and transport bookkeeping; these own only
* the leaf mutations on an already-resolved open block, so the
* append/parse/finalize logic lives in exactly one place. The caller passes the
* `contentIndex` its router resolved (generic uses `output.content.indexOf`;
* Codex uses the open item's recorded index) so the emitted stream events match
* each decoder's existing behavior byte-for-byte.
*/
type ResponsesToolCallBlock = ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number };
export function appendReasoningSummaryPart(
item: ResponseReasoningItem,
part: ResponseReasoningItem["summary"][number],
): void {
item.summary = item.summary || [];
item.summary.push(part);
}
export function appendReasoningSummaryTextDelta(
item: ResponseReasoningItem,
block: ThinkingContent,
delta: string,
stream: AssistantMessageEventStream,
output: AssistantMessage,
contentIndex: number,
): void {
item.summary = item.summary || [];
const lastPart = item.summary[item.summary.length - 1];
if (!lastPart) return;
block.thinking += delta;
lastPart.text += delta;
stream.push({ type: "thinking_delta", contentIndex, delta, partial: output });
}
export function appendReasoningSummaryPartDone(
item: ResponseReasoningItem,
block: ThinkingContent,
stream: AssistantMessageEventStream,
output: AssistantMessage,
contentIndex: number,
): void {
item.summary = item.summary || [];
const lastPart = item.summary[item.summary.length - 1];
if (!lastPart) return;
block.thinking += "\n\n";
lastPart.text += "\n\n";
stream.push({ type: "thinking_delta", contentIndex, delta: "\n\n", partial: output });
}
export function appendMessageContentPart(
item: ResponseOutputMessage,
part: ResponseContentPartAddedEvent["part"] | undefined,
): void {
item.content = item.content || [];
if (part && (part.type === "output_text" || part.type === "refusal")) {
item.content.push(part);
}
}
export function appendMessageTextDelta(
item: ResponseOutputMessage,
block: TextContent,
delta: string,
stream: AssistantMessageEventStream,
output: AssistantMessage,
contentIndex: number,
partType: "output_text" | "refusal",
): void {
item.content = item.content || [];
let lastPart = item.content[item.content.length - 1];
if (lastPart?.type !== partType) {
// `content_part.added` never arrived (lossy proxy) — synthesize the part
// so live text still streams instead of freezing until output_item.done.
lastPart =
partType === "output_text"
? { type: "output_text", text: "", annotations: [] }
: { type: "refusal", refusal: "" };
item.content.push(lastPart);
}
block.text += delta;
if (lastPart.type === "output_text") {
lastPart.text += delta;
} else {
lastPart.refusal += delta;
}
stream.push({ type: "text_delta", contentIndex, delta, partial: output });
}
export function accumulateToolCallArgumentsDelta(
block: ResponsesToolCallBlock,
delta: string,
stream: AssistantMessageEventStream,
output: AssistantMessage,
contentIndex: number,
): void {
block[kStreamingPartialJson] += delta;
const throttled = parseStreamingJsonThrottled(block[kStreamingPartialJson], block[kStreamingLastParseLen] ?? 0);
if (throttled) {
block.arguments = throttled.value;
block[kStreamingLastParseLen] = throttled.parsedLen;
}
stream.push({ type: "toolcall_delta", contentIndex, delta, partial: output });
}
/**
* Finalize streamed function-call arguments from the authoritative `.done`
* payload. The caller owns the `argumentsDone` flag (generic Responses sets it;
* Codex's block shape has no such field), so this only rewrites `arguments` and
* drops the transient accumulation fields.
*/
export function finalizeToolCallArgumentsDone(block: ResponsesToolCallBlock, args: string): void {
block[kStreamingPartialJson] = args;
block.arguments = parseStreamingJson(block[kStreamingPartialJson]);
clearStreamingPartialJson(block);
}
export function accumulateCustomToolCallInputDelta(
block: ResponsesToolCallBlock,
delta: string,
stream: AssistantMessageEventStream,
output: AssistantMessage,
contentIndex: number,
): void {
block[kStreamingPartialJson] += delta;
block.arguments = { input: block[kStreamingPartialJson] };
stream.push({ type: "toolcall_delta", contentIndex, delta, partial: output });
}
export function finalizeCustomToolCallInputDone(block: ResponsesToolCallBlock, input: string): void {
block[kStreamingPartialJson] = input;
block.arguments = { input };
}
export interface ProcessResponsesStreamOptions {
onFirstToken?: () => void;
onOutputItemDone?: (item: ResponseOutputItem) => void;
/**
* Called when a terminal `response.completed` or `response.incomplete` event
* is successfully processed. Only invoked on the successful-completion path;
* thrown failure (`response.failed`) and cancellation paths never call this.
* Used by callers to detect premature stream closure (i.e. the stream ended
* without a recognized terminal event).
*/
onCompleted?: () => void;
/**
* Caller-requested service tier, used to bill the served tier when the
* response omits the `service_tier` echo. Only applied for `provider: "openai"`.
*/
requestServiceTier?: ServiceTier;
}
export async function processResponsesStream<TApi extends Api>(
openaiStream: AsyncIterable<ResponseStreamEvent>,
output: AssistantMessage,
stream: AssistantMessageEventStream,
model: Model<TApi>,
options?: ProcessResponsesStreamOptions,
): Promise<void> {
type StreamingToolCallBlock = ToolCall & {
[kStreamingPartialJson]: string;
[kStreamingLastParseLen]?: number;
[kStreamingArgumentsDone]?: boolean;
};
interface StreamingItem {
item: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
block: ThinkingContent | TextContent | StreamingToolCallBlock;
}
// Multiple items (parallel function_calls in particular) can be open at the same
// time. OpenAI's spec routes every per-item event by `output_index`/`item_id`;
// see https://github.com/can1357/oh-my-pi/issues/1880 — llama.cpp emits parallel
// function_call deltas interleaved, and a singleton `current` reference would
// fold them into the wrong block and drop arguments on every call but the last.
//
// OpenAI-compatible hosts can compound this by omitting `item.id` and
// `output_index` on `output_item.added` while routing later argument deltas to
// either the bare `call_id` or a synthesized `fc_<call_id>` item id. Register
// both keys so each delta reaches its own block instead of falling back to the
// most recently added parallel call.
const openItemsByOutputIndex = new Map<number, StreamingItem>();
const openItemsByItemId = new Map<string, StreamingItem>();
const openItemsByPrefixedCallId = new Map<string, StreamingItem>();
let lastOpenItem: StreamingItem | null = null;
const openItemsInOrder: StreamingItem[] = [];
const prefixedFunctionCallItemKey = (callId: string | undefined): string | undefined =>
callId ? `fc_${callId}` : undefined;
const registerOpenItem = (
outputIndex: number | undefined,
itemId: string | undefined,
entry: StreamingItem,
alternateItemKey?: string,
prefixedAlternateItemKey?: string,
): void => {
if (typeof outputIndex === "number") openItemsByOutputIndex.set(outputIndex, entry);
if (itemId) openItemsByItemId.set(itemId, entry);
if (alternateItemKey && alternateItemKey !== itemId) openItemsByItemId.set(alternateItemKey, entry);
if (
prefixedAlternateItemKey &&
prefixedAlternateItemKey !== itemId &&
prefixedAlternateItemKey !== alternateItemKey
) {
openItemsByPrefixedCallId.set(prefixedAlternateItemKey, entry);
}
openItemsInOrder.push(entry);
lastOpenItem = entry;
};
const lookupOpenItem = (event: { output_index?: number; item_id?: string }): StreamingItem | undefined => {
const hasKey = typeof event.output_index === "number" || event.item_id !== undefined;
if (typeof event.output_index === "number") {
const found = openItemsByOutputIndex.get(event.output_index);
if (found) return found;
}
if (event.item_id) {
const found = openItemsByItemId.get(event.item_id);
if (found) return found;
}
// Keyed events whose item already closed are stale; drop them instead of
// routing to a sibling. Only fully identifierless mock/proxy events use the
// legacy singleton fallback.
return hasKey ? undefined : (lastOpenItem ?? undefined);
};
const hasOpenItemKey = (event: { output_index?: number; item_id?: string }): boolean =>
typeof event.output_index === "number" || event.item_id !== undefined;
const lookupOpenToolCallAlias = (
event: { output_index?: number; item_id?: string },
type: "function_call" | "custom_tool_call",
): StreamingItem | undefined => {
if (typeof event.output_index === "number") {
const byOutputIndex = openItemsByOutputIndex.get(event.output_index);
if (byOutputIndex) return byOutputIndex;
// A lossy host (llama.cpp/Ollama, issue #2015) can omit `output_index` on
// `output_item.added` while still stamping the spec-required field on the
// delta. The index was never registered, so fall through to the prefixed
// alias / exact item-id maps instead of dropping to `lastOpenItem`.
}
if (event.item_id) {
// Prefixed call-id aliases share the same wire namespace as real call ids.
// Argument/input events can use the prefixed form, while final
// output_item.done events below use exact call ids; keep aliases in a
// separate map so a real `call_id: "fc_x"` cannot overwrite the alias
// for `call_id: "x"`.
const alias = openItemsByPrefixedCallId.get(event.item_id);
if (alias?.item.type === type) return alias;
const exact = openItemsByItemId.get(event.item_id);
if (exact) return exact;
}
return lookupOpenItem(event);
};
const lookupOpenFunctionCallItem = (event: {
output_index?: number;
item_id?: string;
}): StreamingItem | undefined => {
if (hasOpenItemKey(event)) return lookupOpenToolCallAlias(event, "function_call");
for (const candidate of openItemsInOrder) {
if (
candidate.item.type === "function_call" &&
candidate.block.type === "toolCall" &&
!candidate.block[kStreamingArgumentsDone]
) {
return candidate;
}
}
return lastOpenItem?.item.type === "function_call" ? lastOpenItem : undefined;
};
const closeOpenItem = (
outputIndex: number | undefined,
itemId: string | undefined,
entry: StreamingItem | undefined,
alternateItemKey?: string,
prefixedAlternateItemKey?: string,
): void => {
if (typeof outputIndex === "number") openItemsByOutputIndex.delete(outputIndex);
if (itemId) openItemsByItemId.delete(itemId);
if (alternateItemKey && alternateItemKey !== itemId) openItemsByItemId.delete(alternateItemKey);
if (
prefixedAlternateItemKey &&
prefixedAlternateItemKey !== itemId &&
prefixedAlternateItemKey !== alternateItemKey &&
openItemsByPrefixedCallId.get(prefixedAlternateItemKey) === entry
) {
openItemsByPrefixedCallId.delete(prefixedAlternateItemKey);
}
if (entry) {
const index = openItemsInOrder.indexOf(entry);
if (index >= 0) openItemsInOrder.splice(index, 1);
}
if (entry && lastOpenItem === entry) lastOpenItem = null;
};
const contentIndexOf = (block: ThinkingContent | TextContent | StreamingToolCallBlock): number =>
output.content.indexOf(block);
let sawFirstToken = false;
for await (const event of openaiStream) {
if (event.type === "response.created") {
output.responseId = event.response.id;
} else if (event.type === "response.output_item.added") {
if (!sawFirstToken) {
sawFirstToken = true;
options?.onFirstToken?.();
}
const item = event.item;
if (item.type === "reasoning") {
const block: ThinkingContent = { type: "thinking", thinking: "", itemId: item.id };
output.content.push(block);
registerOpenItem(event.output_index, item.id, { item, block });
stream.push({ type: "thinking_start", contentIndex: contentIndexOf(block), partial: output });
} else if (item.type === "message") {
const block: TextContent = {
type: "text",
text: "",
textSignature: encodeTextSignatureV1(item.id, item.phase ?? undefined),
};
output.content.push(block);
registerOpenItem(event.output_index, item.id, { item, block });
stream.push({ type: "text_start", contentIndex: contentIndexOf(block), partial: output });
} else if (item.type === "function_call") {
const block: StreamingToolCallBlock = {
type: "toolCall",
id: encodeResponsesToolCallId(item.call_id, item.id),
name: item.name,
arguments: {},
[kStreamingPartialJson]: item.arguments || "",
};
output.content.push(block);
registerOpenItem(
event.output_index,
item.id,
{ item, block },
item.call_id,
prefixedFunctionCallItemKey(item.call_id),
);
stream.push({ type: "toolcall_start", contentIndex: contentIndexOf(block), partial: output });
} else if (item.type === "custom_tool_call") {
const block: StreamingToolCallBlock = {
type: "toolCall",
id: encodeResponsesToolCallId(item.call_id, item.id),
// Preserve the raw wire name (e.g. `apply_patch`). The agent-loop
// dispatcher matches it against both `Tool.name` and
// `Tool.customWireName`, so this stays wire-accurate through
// history replay while still routing to the right handler.
name: item.name,
arguments: { input: item.input ?? "" },
customWireName: item.name,
// Custom tools stream a raw string, but we reuse `partialJson` as the
// accumulation buffer so later code that inspects the field still works.
[kStreamingPartialJson]: item.input ?? "",
};
output.content.push(block);
registerOpenItem(
event.output_index,
item.id,
{ item, block },
item.call_id,
prefixedFunctionCallItemKey(item.call_id),
);
stream.push({ type: "toolcall_start", contentIndex: contentIndexOf(block), partial: output });
}
} else if (event.type === "response.reasoning_summary_part.added") {
const entry = lookupOpenItem(event);
if (entry?.item.type === "reasoning") appendReasoningSummaryPart(entry.item, event.part);
} else if (event.type === "response.reasoning_summary_text.delta") {
const entry = lookupOpenItem(event);
if (entry?.item.type === "reasoning" && entry.block.type === "thinking") {
appendReasoningSummaryTextDelta(
entry.item,
entry.block,
event.delta,
stream,
output,
contentIndexOf(entry.block),
);
}
} else if (event.type === "response.reasoning_summary_part.done") {
const entry = lookupOpenItem(event);
if (entry?.item.type === "reasoning" && entry.block.type === "thinking") {
appendReasoningSummaryPartDone(entry.item, entry.block, stream, output, contentIndexOf(entry.block));
}
} else if (event.type === "response.reasoning_text.delta") {
// Raw reasoning text delta from local providers that stream thinking
// directly rather than via the OpenAI summary tracking protocol.
const entry = lookupOpenItem(event);
if (entry?.item.type === "reasoning" && entry.block.type === "thinking") {
entry.block.thinking += event.delta;
stream.push({
type: "thinking_delta",
contentIndex: contentIndexOf(entry.block),
delta: event.delta,
partial: output,
});
}
} else if (event.type === "response.content_part.added") {
const entry = lookupOpenItem(event);
if (entry?.item.type === "message") appendMessageContentPart(entry.item, event.part);
} else if (event.type === "response.output_text.delta") {
const entry = lookupOpenItem(event);
if (entry?.item.type === "message" && entry.block.type === "text") {
appendMessageTextDelta(
entry.item,
entry.block,
event.delta,
stream,
output,
contentIndexOf(entry.block),
"output_text",
);
}
} else if (event.type === "response.refusal.delta") {
const entry = lookupOpenItem(event);
if (entry?.item.type === "message" && entry.block.type === "text") {
appendMessageTextDelta(
entry.item,
entry.block,
event.delta,
stream,
output,
contentIndexOf(entry.block),
"refusal",
);
}
} else if (event.type === "response.function_call_arguments.delta") {
const entry = lookupOpenFunctionCallItem(event);
if (entry?.item.type === "function_call" && entry.block.type === "toolCall") {
accumulateToolCallArgumentsDelta(entry.block, event.delta, stream, output, contentIndexOf(entry.block));
}
} else if (event.type === "response.function_call_arguments.done") {
const entry = lookupOpenFunctionCallItem(event);
if (entry?.item.type === "function_call" && entry.block.type === "toolCall") {
finalizeToolCallArgumentsDone(entry.block, event.arguments);
entry.block[kStreamingArgumentsDone] = true;
}
} else if (event.type === "response.custom_tool_call_input.delta") {
const entry = lookupOpenToolCallAlias(event, "custom_tool_call");
if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
accumulateCustomToolCallInputDelta(entry.block, event.delta, stream, output, contentIndexOf(entry.block));
}
} else if (event.type === "response.custom_tool_call_input.done") {
const entry = lookupOpenToolCallAlias(event, "custom_tool_call");
if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
finalizeCustomToolCallInputDone(entry.block, event.input);
}
} else if (event.type === "response.output_item.done") {
const item = structuredCloneJSON(event.item);
options?.onOutputItemDone?.(item);
const entry =
item.type === "function_call" || item.type === "custom_tool_call"
? lookupOpenItem({ output_index: event.output_index, item_id: item.id ?? item.call_id })
: lookupOpenItem({ output_index: event.output_index, item_id: item.id });
if (item.type === "reasoning") {
const thinking =
item.summary?.length > 0
? item.summary.map(part => part.text).join("\n\n")
: item.content?.[0]?.type === "reasoning_text"
? (item.content[0].text ?? "")
: "";
// Prefer the routed entry; the bare itemId find misroutes when ids are
// absent (`undefined === undefined` matches the FIRST thinking block) and
// misses entirely when the done-event id drifts from the added-event id.
const reasoningBlock =
entry?.block.type === "thinking"
? entry.block
: (output.content.find(b => b.type === "thinking" && (b as ThinkingContent).itemId === item.id) as
| ThinkingContent
| undefined);
if (reasoningBlock) {
reasoningBlock.thinking = thinking;
reasoningBlock.thinkingSignature = JSON.stringify(item);
stream.push({
type: "thinking_end",
contentIndex: contentIndexOf(reasoningBlock),
content: thinking,
partial: output,
});
}
closeOpenItem(event.output_index, item.id, entry);
} else if (item.type === "message") {
const block = entry?.block.type === "text" ? entry.block : undefined;
const text = item.content
.map(part => (part.type === "output_text" ? (part.text ?? "") : (part.refusal ?? "")))
.join("");
const textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
let contentIndex: number;
if (block) {
block.text = text;
block.textSignature = textSignature;
contentIndex = contentIndexOf(block);
} else {
// `output_item.added` never arrived (lossy proxy) — synthesize the
// block so the final message still carries the authoritative text.
const synthesized: TextContent = { type: "text", text, textSignature };
output.content.push(synthesized);
contentIndex = output.content.length - 1;
}
stream.push({ type: "text_end", contentIndex, content: text, partial: output });
closeOpenItem(event.output_index, item.id, entry);
} else if (item.type === "function_call") {
const block = entry?.block.type === "toolCall" ? entry.block : undefined;
const args = block?.[kStreamingArgumentsDone]
? block.arguments
: block?.[kStreamingPartialJson]
? parseStreamingJson(block[kStreamingPartialJson])
: parseStreamingJson(item.arguments || "{}");
const toolCall: ToolCall = {
type: "toolCall",
id: encodeResponsesToolCallId(item.call_id, item.id),
name: item.name,
arguments: args,
};
let contentIndex: number;
if (block) {
// Persist the authoritative final args on the stored block. The
// throttled delta parser may have skipped the last partial parse,
// leaving block.arguments stale (often `{}`); the emitted toolCall
// and the persisted block must agree.
block.arguments = args;
clearStreamingPartialJson(block);
contentIndex = contentIndexOf(block);
} else {
// `output_item.added` never arrived (lossy proxy) — synthesize the
// block so the final message carries the call the consumer was told
// completed (the agent loop executes tools from message.content).
output.content.push(toolCall);
contentIndex = output.content.length - 1;
}
closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
} else if (item.type === "custom_tool_call") {
const block = entry?.block.type === "toolCall" ? entry.block : undefined;
const rawInput = block?.[kStreamingPartialJson] ? block[kStreamingPartialJson] : (item.input ?? "");
const toolCall: ToolCall = {
type: "toolCall",
id: encodeResponsesToolCallId(item.call_id, item.id),
name: item.name,
arguments: { input: rawInput },
customWireName: item.name,
};
let contentIndex: number;
if (block) {
// Persist the final input on the stored block and drop the transient
// accumulation buffer, mirroring the function_call branch above.
block.arguments = { input: rawInput };
clearStreamingPartialJson(block);
contentIndex = contentIndexOf(block);
} else {
output.content.push(toolCall);
contentIndex = output.content.length - 1;
}
closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
}
} else if (event.type === "response.completed" || event.type === "response.incomplete") {
const response = event.response;
finalizePendingResponsesToolCalls(output);
if (response?.id) {
output.responseId = response.id;
}
populateResponsesUsageFromResponse(output, response?.usage);
calculateCost(model, output.usage);
applyOpenAIResponsesServiceTierCost(
model,
output.usage,
(response as { service_tier?: unknown } | undefined)?.service_tier,
options?.requestServiceTier,
);
output.stopReason = mapOpenAIResponsesStopReason(response?.status);
if (response?.status === "failed" || response?.status === "cancelled") {
const error = response?.error ?? (response as any)?.status_details?.error;
const details = response?.incomplete_details;
const statusDetailsReason = (response as any)?.status_details?.reason;
const message = error
? `${error.code || "unknown"}: ${error.message || "no message"}`
: details?.reason
? `incomplete: ${details.reason}`
: typeof statusDetailsReason === "string" && statusDetailsReason.length > 0
? `status_details: ${statusDetailsReason}`
: "Unknown error (no error details in response)";
throw new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" });
}
if (response?.status === "incomplete" && response.incomplete_details?.reason === "content_filter") {
// A content-filtered turn is a failure, not a token-cap truncation —
// mapping it to "length" would route the agent loop into "shorten your
// output" recovery against a filtered prompt.
throw new AIError.ProviderResponseError("incomplete: content_filter", {
provider: model.provider,
kind: "content-blocked",
});
}
promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn);
options?.onCompleted?.();
// `response.completed`/`response.incomplete` is the last event of a
// Responses stream. Stop pulling instead of waiting for the server to
// close the connection: misbehaving providers keep the socket open
// after the terminal event, which would park this loop until the idle
// watchdog converts an already-successful turn into a timeout error.
// Breaking unwinds the iterator chain (the consumer's `.return()`
// reaches the SDK stream), actively releasing the connection.
break;
} else if (event.type === "error") {
const err = (event as any).error ?? event;
const code = err.code ?? "unknown";
const message = err.message ?? "no message";
throw new AIError.ProviderResponseError(`Error Code ${code}: ${message}`, {
provider: model.provider,
kind: "output",
});
} else if (event.type === "response.failed") {
populateResponsesUsageFromResponse(output, event.response?.usage);
const error = event.response?.error ?? (event.response as any)?.status_details?.error;
const details = event.response?.incomplete_details;
const message = error
? `${error.code || "unknown"}: ${error.message || "no message"}`
: details?.reason
? `incomplete: ${details.reason}`
: "Unknown error (no error details in response)";
throw new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" });
}
}
}
export function mapOpenAIResponsesStopReason(status: ResponseStatus | undefined): StopReason {
if (!status) return "stop";
switch (status) {
case "completed":
return "stop";
case "incomplete":
return "length";
case "failed":
case "cancelled":
return "error";
case "in_progress":
case "queued":
return "stop";
default: {
// Compile-time exhaustiveness; at runtime a brand-new status from the
// server must degrade gracefully instead of failing a fully-streamed
// response.
const exhaustive: never = status;
logger.warn("Unhandled OpenAI Responses stop reason", { status: exhaustive });
return "stop";
}
}
}
/**
* Finalize any streamed toolCall block whose `output_item.done` never arrived
* (lossy proxy, or a terminal event that raced the per-item done): parse the
* accumulated `partialJson` into authoritative arguments and strip the transient
* streaming fields so they never persist. Shared by the chat-Responses decoder
* and the Codex decoder. Closed blocks already cleared these fields, so walking
* the full content list leaves them untouched.
*/
export function finalizePendingResponsesToolCalls(output: AssistantMessage): void {
for (const block of output.content) {
if (block.type !== "toolCall") continue;
const pending = block as ToolCall & {
[kStreamingPartialJson]?: string;
[kStreamingLastParseLen]?: number;
[kStreamingArgumentsDone]?: boolean;
};
if (pending[kStreamingPartialJson] && !pending[kStreamingArgumentsDone]) {
pending.arguments =
pending.customWireName !== undefined
? { input: pending[kStreamingPartialJson] }
: parseStreamingJson(pending[kStreamingPartialJson]);
}
clearStreamingPartialJson(pending);
}
}
/**
* Apply the Responses terminal stop-reason invariants shared by the chat-Responses
* and Codex decoders: a turn that produced tool calls becomes `toolUse`, and a
* Codex-lineage `end_turn: false` marker pauses the turn so the agent loop
* re-samples instead of ending. Callers set `output.stopReason` from the wire
* status first via {@link mapOpenAIResponsesStopReason}.
*/
export function promoteResponsesToolUseStopReason(output: AssistantMessage, endTurn: boolean | undefined): void {
if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
output.stopReason = "toolUse";
}
if (endTurn === false && output.stopReason === "stop") {
output.stopDetails = { type: "pause_turn" };
}
}
/** Initial empty `AssistantMessage` that streaming providers accumulate into. */
export function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage {
return {
role: "assistant",
content: [],
api,
provider,
model: modelId,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
/** Extension fields we add on top of `ResponseCreateParamsStreaming` across the Responses-family providers. */
export type ResponsesSamplingParamsExtras = {
top_p?: number;
top_k?: number;
min_p?: number;
presence_penalty?: number;
repetition_penalty?: number;
};
type CommonResponsesParams = ResponseCreateParamsStreaming & ResponsesSamplingParamsExtras;
type CommonSamplingOptions = Pick<
StreamOptions,
"temperature" | "topP" | "topK" | "minP" | "presencePenalty" | "repetitionPenalty" | "maxTokens"
> & { serviceTier?: ServiceTier };
/**
* Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens,
* temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`.
*
* `max_output_tokens` is suppressed when {@link Model.omitMaxOutputTokens} is `true`, so
* proxies (notably Ollama) that forward to upstream APIs with an unknown output-token cap
* can let the upstream apply its own default instead of 400-ing on `maxTokens` values that
* reflect the model's context window rather than the upstream output limit.
*/
export function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(
params: P,
options: CommonSamplingOptions | undefined,
model: Pick<Model, "provider" | "omitMaxOutputTokens" | "maxTokens">,
): void {
if (options?.maxTokens && !model.omitMaxOutputTokens) {
params.max_output_tokens = Math.min(
options.maxTokens,
model.maxTokens ?? Number.POSITIVE_INFINITY,
OPENAI_MAX_OUTPUT_TOKENS,
);
}
if (options?.temperature !== undefined) params.temperature = options.temperature;
if (options?.topP !== undefined) params.top_p = options.topP;
if (options?.topK !== undefined) params.top_k = options.topK;
if (options?.minP !== undefined) params.min_p = options.minP;
if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
applyOpenAIServiceTier(params, options?.serviceTier, model.provider);
}
type ReasoningOptions = {
reasoning?: string;
reasoningSummary?: "auto" | "detailed" | "concise" | null;
disableReasoning?: boolean;
toolChoice?: unknown;
};
export interface ApplyResponsesCompatPolicyOptions {
reasoningSummary?: "auto" | "detailed" | "concise" | null;
mapEffort?: (effort: string) => string;
}
export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreaming>(
params: P,
messages: ResponseInput,
policy: OpenAICompatPolicy,
options: ApplyResponsesCompatPolicyOptions | undefined,
): number {
const reasoning = policy.reasoning;
if (!reasoning.modelSupported) return 0;
if (reasoning.includeEncryptedReasoning) {
const include = params.include ?? [];
if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content");
params.include = include;
}
if (reasoning.disabled) {
if (reasoning.disableMode === "openrouter-enabled-false") {
params.reasoning = { enabled: false } as P["reasoning"];
return 0;
}
if (
reasoning.disableMode === "lowest-effort" &&
reasoning.wireEffort !== undefined &&
!reasoning.omitReasoningEffort
) {
type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
params.reasoning = { effort: reasoning.wireEffort as ReasoningParam["effort"] } as P["reasoning"] &
ReasoningParam;
return 0;
}
if (policy.compat.requiresJuiceZeroHack && reasoning.requestedEffort === undefined) {
messages.push({
role: "developer",
content: [{ type: "input_text", text: "# Juice: 0 !important" }],
});
return 1;
}
return 0;
}
if (reasoning.requestedEffort !== undefined || options?.reasoningSummary !== undefined) {
if (reasoning.omitReasoningEffort) {
if (options?.reasoningSummary !== undefined && options.reasoningSummary !== null) {
type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
params.reasoning = { summary: options.reasoningSummary || "auto" } as P["reasoning"] & ReasoningParam;
}
return 0;
}
const requested = reasoning.requestedEffort ?? "medium";
const wireEffort = reasoning.wireEffort ?? options?.mapEffort?.(requested) ?? requested;
type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
const reasoningParams: ReasoningParam = {
effort: wireEffort as ReasoningParam["effort"],
};
if (options?.reasoningSummary !== null) {
reasoningParams.summary = options?.reasoningSummary || "auto";
}
params.reasoning = reasoningParams as P["reasoning"];
return 0;
}
if (policy.compat.requiresJuiceZeroHack) {
messages.push({
role: "developer",
content: [{ type: "input_text", text: "# Juice: 0 !important" }],
});
return 1;
}
return 0;
}
/**
* Apply reasoning-related Responses parameters. Default behavior comes from
* catalog compat; include/omit arguments are explicit adapter-wrapper overrides.
*/
export function applyResponsesReasoningParams<P extends ResponseCreateParamsStreaming>(
params: P,
model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
options: ReasoningOptions | undefined,
messages: ResponseInput,
mapEffort?: (effort: string) => string,
includeEncryptedReasoning?: boolean,
omitReasoningEffort?: boolean,
): number {
return applyResponsesCompatPolicy(
params,
messages,
resolveOpenAICompatPolicy(model, {
endpoint: "responses",
reasoning: options?.reasoning,
disableReasoning: options?.disableReasoning,
toolChoice: options?.toolChoice,
includeEncryptedReasoning,
omitReasoningEffort,
}),
{ reasoningSummary: options?.reasoningSummary, mapEffort },
);
}
/** Populate `output.usage` from a Responses-API `response.usage` payload. Does not invoke `calculateCost`. */
export function populateResponsesUsageFromResponse(
output: AssistantMessage,
usage:
| {
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
prompt_cache_hit_tokens?: number | null;
prompt_cache_miss_tokens?: number | null;
input_tokens_details?: {
cached_tokens?: number | null;
cache_write_tokens?: number | null;
orchestration_input_tokens?: number | null;
orchestration_input_cached_tokens?: number | null;
} | null;
output_tokens_details?: {
reasoning_tokens?: number | null;
orchestration_output_tokens?: number | null;
} | null;
}
| null
| undefined,
): void {
if (!usage) return;
const details = usage.input_tokens_details;
const outputDetails = usage.output_tokens_details;
const orchestrationInputTokens = details?.orchestration_input_tokens ?? 0;
const orchestrationInputCachedTokens = details?.orchestration_input_cached_tokens ?? 0;
const orchestrationOutputTokens = outputDetails?.orchestration_output_tokens ?? 0;
const accounting = calculateOpenAIUsageAccounting({
promptTokens: (usage.input_tokens ?? 0) + orchestrationInputTokens,
outputTokens: (usage.output_tokens ?? 0) + orchestrationOutputTokens,
cachedTokens: (details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? 0) + orchestrationInputCachedTokens,
reasoningTokens: outputDetails?.reasoning_tokens ?? 0,
cacheWriteOpenRouter: details?.cache_write_tokens ?? undefined,
cacheWriteDeepSeek: usage.prompt_cache_miss_tokens ?? undefined,
hasDeepSeekCacheHitAndMiss:
usage.prompt_cache_hit_tokens !== undefined && usage.prompt_cache_miss_tokens !== undefined,
});
// Wholesale replacement must not drop provider-annotated extras (Copilot
// premium-request accounting): the failed/cancelled paths throw right after
// this call with no later chance to re-apply.
const premiumRequests = output.usage.premiumRequests;
output.usage = {
...accounting,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
if (premiumRequests !== undefined) {
output.usage.premiumRequests = premiumRequests;
}
}
/**
* Structural equality for the chain prefix/option check, equivalent to the
* default {@link Bun.deepEquals} (own enumerable keys, `absent ≡ own-undefined`)
* except for two deliberate exclusions:
* - **symbol-keyed properties are ignored** — `for…in` walks enumerable
* *string* keys only (never symbols); these are plain wire items whose
* prototype contributes no enumerable keys, so iteration is effectively
* own-string-keyed. That is how the transient streaming symbols
* (`block-symbols.ts`) stamped onto live request items are excluded (the
* deep-cloned baseline never carries them). Do NOT add an
* `Object.getOwnPropertySymbols` pass, or those symbols resurface and break
* chaining.
* - keys listed in `omitKeys` are skipped (the option compare omits `input`
* and the per-turn `client_metadata`).
* A defined value differing across sides IS a difference; a key undefined or
* absent on both stays equal. Nested values use full {@link Bun.deepEquals}.
*/
function deepEqualsWithout(a: unknown, b: unknown, omitKeys?: Record<string, boolean>): boolean {
if (!a || !b || typeof a !== "object" || typeof b !== "object") return Bun.deepEquals(a, b);
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
for (const key in ao) {
if (omitKeys?.[key]) continue;
const av = ao[key];
const bv = bo[key];
if (av !== bv && !Bun.deepEquals(av, bv)) return false;
}
for (const key in bo) {
if (omitKeys?.[key]) continue;
if (bo[key] !== undefined && !(key in ao)) return false;
}
return true;
}
const TOP_LEVEL_EXCLUDE_MAP = {
input: true,
client_metadata: true,
};
/**
* Strict-prefix delta for stateful `previous_response_id` chaining (used by the
* platform Responses provider and the Codex provider on both transports):
* returns the input items the current request appends beyond the previous
* request's input plus the previous response's output items, or null when the
* request options differ or history mutated (the chain must break). Per-turn
* `client_metadata` (e.g. rotating turn ids) is excluded from the option
* comparison; codex-rs excludes it from the same check.
*/
export function buildResponsesDeltaInput<TItem extends ResponseInputItem | InputItem>(
previous: { input?: TItem[] } | undefined,
previousResponseItems: readonly TItem[] | undefined,
current: { input?: TItem[] },
): TItem[] | null {
if (!previous) return null;
if (!Array.isArray(previous.input) || !Array.isArray(current.input)) return null;
if (!deepEqualsWithout(previous, current, TOP_LEVEL_EXCLUDE_MAP)) {
return null;
}
const baselineLen = (previous.input?.length ?? 0) + (previousResponseItems?.length ?? 0);
if (current.input.length <= baselineLen) return null;
let index = 0;
for (const series of [previous.input, previousResponseItems]) {
if (!series) continue;
for (const item of series) {
if (deepEqualsWithout(item, current.input[index])) {
index++;
} else {
return null;
}
}
}
return current.input.slice(index) as TItem[];
}