import OpenAI from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; import { clampThinkingLevel } from "../models.ts"; import type { Api, AssistantMessage, CacheRetention, Context, Model, OpenAIResponsesCompat, ProviderEnv, ProviderHeaders, SimpleStreamOptions, StreamFunction, StreamOptions, Usage, } from "../types.ts"; import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { buildBaseOptions } from "./simple-options.ts"; const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); // OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265 const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16; function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean { if (!headers) return false; const expected = name.toLowerCase(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === expected && value !== null && value.trim().length > 0) return true; } return false; } function getClientApiKey(provider: string, apiKey: string | undefined, headers: ProviderHeaders | undefined): string { if (apiKey) return apiKey; if (hasHeader(headers, "authorization") || hasHeader(headers, "cf-aig-authorization")) return "unused"; throw new Error(`No API key for provider: ${provider}`); } function detectSessionAffinityFormat(model: Pick, "provider" | "baseUrl">) { return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai") ? "openrouter" : "openai"; } /** * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */ function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; } function getCompat(model: Model<"openai-responses">): Required { return { supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true, sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model), supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, supportsStrictMode: model.compat?.supportsStrictMode ?? false, supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false, supportsAdditionalTools: model.compat?.supportsAdditionalTools ?? false, supportsToolSearch: model.compat?.supportsToolSearch ?? false, supportsExplicitPromptCacheMode: model.compat?.supportsExplicitPromptCacheMode ?? false, supportsMaxOutputTokens: model.compat?.supportsMaxOutputTokens ?? true, }; } function getPromptCacheRetention( compat: Required, cacheRetention: CacheRetention, ): "24h" | undefined { return cacheRetention === "long" && compat.supportsLongCacheRetention && !compat.supportsExplicitPromptCacheMode ? "24h" : undefined; } function getPromptCacheOptions( compat: Required, cacheRetention: CacheRetention, ): { mode?: "explicit"; ttl?: "30m" } | undefined { if (!compat.supportsExplicitPromptCacheMode) return undefined; if (cacheRetention === "none") return { mode: "explicit" }; if (cacheRetention === "long" && compat.supportsLongCacheRetention) return { ttl: "30m" }; return undefined; } function formatOpenAIResponsesError(error: unknown): string { return formatProviderError(normalizeProviderError(error), "OpenAI API error"); } // OpenAI Responses-specific options export interface OpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "detailed" | "concise" | null; serviceTier?: ResponseCreateParamsStreaming["service_tier"]; toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; } /** * Generate function for OpenAI Responses API */ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions> = ( model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); // Start async processing (async () => { const output: AssistantMessage = { role: "assistant", content: [], api: model.api as Api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "pending", timestamp: Date.now(), }; try { // Create OpenAI client const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers); const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; const compat = getCompat(model); const grammarToolInputProperties = createGrammarToolInputProperties( context.tools, compat.supportsOpenAIGrammarTools, ); const client = createClient(model, context, apiKey, options?.headers, options?.fetch, cacheSessionId); let params = buildParams(model, context, options, compat, grammarToolInputProperties); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { params = nextParams as ResponseCreateParamsStreaming; } const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), maxRetries: 0, }; const { data: openaiStream, response } = await retryProviderRequest( () => client.responses.create(params, requestOptions).withResponse(), { maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, signal: options?.signal, }, ); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); await processResponsesStream(openaiStream, output, stream, model, { serviceTier: options?.serviceTier, grammarToolInputProperties, applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), }); if (options?.signal?.aborted) { throw new Error("Request was aborted"); } if (output.stopReason === "pending") { throw new Error("OpenAI Responses stream ended without a stop reason"); } if (output.stopReason === "aborted" || output.stopReason === "error") { throw new Error(output.errorMessage || "An unknown error occurred"); } stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { for (const block of output.content) { delete (block as { index?: number }).index; // Streaming scratch buffers are only used during parsing; never persist them. delete (block as { partialJson?: string }).partialJson; delete (block as { customInput?: unknown }).customInput; } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatOpenAIResponsesError(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })(); return stream; }; export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOptions> = ( model: Model<"openai-responses">, context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { getClientApiKey(model.provider, options?.apiKey, options?.headers); const base = { ...buildBaseOptions(model, context, options, options?.apiKey), toolChoice: options?.toolChoice, } satisfies OpenAIResponsesOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; return stream(model, context, { ...base, reasoningEffort, } satisfies OpenAIResponsesOptions); }; function createClient( model: Model<"openai-responses">, context: Context, apiKey: string, optionsHeaders?: ProviderHeaders, fetch?: typeof globalThis.fetch, sessionId?: string, ) { const compat = getCompat(model); const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ messages: context.messages, hasImages, }); Object.assign(headers, copilotHeaders); } if (sessionId) { if (compat.sessionAffinityFormat === "openrouter") { headers["x-session-id"] = sessionId; } else { if (compat.sessionAffinityFormat === "openai") { headers.session_id = sessionId; } headers["x-client-request-id"] = sessionId; } } // Merge options headers last so they can override defaults if (optionsHeaders) { Object.assign(headers, optionsHeaders); } return new OpenAI({ apiKey, baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, defaultHeaders: headers, }); } function buildParams( model: Model<"openai-responses">, context: Context, options: OpenAIResponsesOptions | undefined, compat: Required = getCompat(model), grammarToolInputProperties: ReadonlyMap = createGrammarToolInputProperties( context.tools, compat.supportsOpenAIGrammarTools, ), ) { const deferredToolsMode = compat.supportsAdditionalTools ? "additional-tools" : compat.supportsToolSearch ? "tool-search" : undefined; const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined); const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, { grammarToolInputProperties, deferredTools: toolPlacement.deferred, deferredToolsMode, toolOptions: { supportsStrictMode: compat.supportsStrictMode, supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools, }, }); const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const params: ResponseCreateParamsStreaming & { prompt_cache_options?: { mode?: "explicit"; ttl?: "30m" }; } = { model: model.id, input: messages, stream: true, prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention), prompt_cache_options: getPromptCacheOptions(compat, cacheRetention), store: false, }; if (options?.maxTokens && compat.supportsMaxOutputTokens) { params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS); } if (options?.temperature !== undefined) { params.temperature = options?.temperature; } if (options?.serviceTier !== undefined) { params.service_tier = options.serviceTier; } if (toolPlacement.immediate.length > 0) { params.tools = convertResponsesTools(toolPlacement.immediate, { supportsStrictMode: compat.supportsStrictMode, supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools, }); } if (options?.toolChoice !== undefined) { params.tool_choice = options.toolChoice; } if (model.reasoning) { if (options?.reasoningEffort || options?.reasoningSummary) { const effort = options?.reasoningEffort ? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort) : "medium"; params.reasoning = { effort: effort as NonNullable["effort"], summary: options?.reasoningSummary || "auto", }; params.include = ["reasoning.encrypted_content"]; } else if (model.provider !== "github-copilot" && model.thinkingLevelMap?.off !== null) { params.reasoning = { effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable["effort"], }; } if (model.provider === "xai") params.include = ["reasoning.encrypted_content"]; } // Last so custom keys override the named request fields. if (options?.samplingParams) { Object.assign(params, options.samplingParams); } return params; } function getServiceTierCostMultiplier( model: Pick, "id">, serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, ): number { switch (serviceTier) { case "flex": return 0.5; case "priority": return model.id === "gpt-5.5" ? 2.5 : 2; default: return 1; } } function applyServiceTierPricing( usage: Usage, serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, model: Pick, "id">, ) { const multiplier = getServiceTierCostMultiplier(model, serviceTier); 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; }