UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

335 lines • 16.2 kB
import OpenAI from "openai"; import { clampThinkingLevel } from "../models.js"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; import { getIdentityHeaders } from "../utils/identity.js"; import { getProviderEnvValue } from "../utils/provider-env.js"; import { retryProviderRequest } from "../utils/provider-retry.js"; import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.js"; import { createGrammarToolInputProperties } from "./constrained-sampling.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { convertResponsesMessages, convertResponsesTools, getResponsesToolSupport, processResponsesStream, } from "./openai-responses-shared.js"; import { buildBaseOptions, resolveSamplingParams } from "./simple-options.js"; 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; const CHATGPT_USAGE_URL = "https://chatgpt.com/settings/usage"; const CHATGPT_WORKSPACE_LIMIT_HINT = "You've reached your ChatGPT workspace's usage limit. Ask your workspace admin to increase it at admin.openai.com → Workspace → Usage limits."; // Fixed reasoning.effort while configuration_update items carry the thinking level. const CONFIGURATION_UPDATE_REQUEST_EFFORT = "medium"; /** * OpenAI API keys start with `sk-`; a different credential sent directly to OpenAI * is a Sign in with ChatGPT access token. */ function isChatGPTSignIn(model, apiKey) { return (model.provider === "openai" && model.baseUrl === "https://api.openai.com/v1" && apiKey !== undefined && !apiKey.startsWith("sk-")); } function hasHeader(headers, name) { 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, apiKey, headers) { 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) { 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, env) { if (cacheRetention) { return cacheRetention; } if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; } function getCompat(model) { return { supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true, supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false, sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model), promptCacheKeyMode: model.compat?.promptCacheKeyMode ?? "auto", 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, supportsMidConvoEffort: model.compat?.supportsMidConvoEffort ?? false, }; } /** Effort to send in configuration_update items, or undefined to send it as reasoning.effort. */ function getConfigurationUpdateEffort(model, options, compat) { if (!compat.supportsMidConvoEffort || !options?.reasoningEffort || // Sign in with ChatGPT rejects configuration_update items. isChatGPTSignIn(model, options.apiKey)) { return undefined; } return model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; } function getPromptCacheRetention(compat, cacheRetention) { return cacheRetention === "long" && compat.supportsLongCacheRetention && !compat.supportsExplicitPromptCacheMode ? "24h" : undefined; } function getPromptCacheOptions(compat, cacheRetention) { if (!compat.supportsExplicitPromptCacheMode) return undefined; if (cacheRetention === "none") return { mode: "explicit" }; if (cacheRetention === "long" && compat.supportsLongCacheRetention) return { ttl: "30m" }; return undefined; } /** * Generate function for OpenAI Responses API */ export const stream = (model, context, options) => { const stream = new AssistantMessageEventStream(); const normalizedContext = resolveTranscript(context, getCompat(model).supportsMidConvoSystemMessages); // Start async processing (async () => { const compat = getCompat(model); const configurationUpdateEffort = getConfigurationUpdateEffort(model, options, compat); const output = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, ...(configurationUpdateEffort === undefined ? {} : { providerThinkingLevel: configurationUpdateEffort }), 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 grammarToolInputProperties = createGrammarToolInputProperties(getDeclaredTools(normalizedContext.messages), compat.supportsOpenAIGrammarTools); const client = createClient(model, normalizedContext, apiKey, options?.headers, options?.identity, options?.fetch, cacheSessionId); let params = buildParams(model, normalizedContext, options, compat, grammarToolInputProperties, configurationUpdateEffort); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { params = nextParams; } 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, { onProviderStreamEvent: options?.onProviderStreamEvent, 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) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; const errorMessage = formatProviderError(normalizeProviderError(error), `${model.provider === "openai" ? "OpenAI" : model.provider} API error`); // Sign in with ChatGPT shares the subscription's usage limit with other apps. // On ChatGPT Business plans, the workspace admin also sets a per-user limit. if (errorMessage.includes("subscription_sharing_usage_limit_exceeded")) { output.errorMessage = `${errorMessage}\nCheck your ChatGPT usage: ${CHATGPT_USAGE_URL}`; } else if (errorMessage.includes("subscription_sharing_workplace_individual_usage_limit_exceeded")) { output.errorMessage = `${errorMessage}\n${CHATGPT_WORKSPACE_LIMIT_HINT}`; } else { output.errorMessage = errorMessage; } stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })(); return stream; }; export const streamSimple = (model, context, options) => { getClientApiKey(model.provider, options?.apiKey, options?.headers); const base = { ...buildBaseOptions(model, context, options, options?.apiKey), toolChoice: options?.toolChoice, }; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; return stream(model, context, { ...base, reasoningEffort, }); }; function createClient(model, context, apiKey, optionsHeaders, identity, fetch, sessionId) { const compat = getCompat(model); const headers = { ...getIdentityHeaders(model, identity), ...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, context, options, compat, grammarToolInputProperties, configurationUpdateEffort) { const transcriptTools = resolveTranscriptTools(context.messages, getResponsesToolSupport(compat)); const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, { grammarToolInputProperties, supportsMidConvoSystemMessages: compat.supportsMidConvoSystemMessages, supportsAdditionalTools: compat.supportsAdditionalTools, supportsToolSearch: compat.supportsToolSearch, toolOptions: { supportsStrictMode: compat.supportsStrictMode, supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools, }, configurationUpdateEffort, }); const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const sendPromptCacheKey = cacheRetention !== "none" && compat.promptCacheKeyMode !== "disabled"; // Sign in with ChatGPT rejects these request fields. const omitUnsupportedFields = isChatGPTSignIn(model, options?.apiKey); const params = { model: model.id, input: messages, stream: true, prompt_cache_key: sendPromptCacheKey ? clampOpenAIPromptCacheKey(options?.sessionId) : undefined, prompt_cache_retention: omitUnsupportedFields ? undefined : getPromptCacheRetention(compat, cacheRetention), prompt_cache_options: omitUnsupportedFields ? undefined : getPromptCacheOptions(compat, cacheRetention), store: false, }; if (options?.maxTokens && compat.supportsMaxOutputTokens && !omitUnsupportedFields) { params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS); } if (options?.temperature !== undefined && !omitUnsupportedFields) { params.temperature = options?.temperature; } if (options?.serviceTier !== undefined) { params.service_tier = options.serviceTier; } if (transcriptTools.requestTools.length > 0) { params.tools = convertResponsesTools(transcriptTools.requestTools, { supportsStrictMode: compat.supportsStrictMode, supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools, }); } if (options?.toolChoice !== undefined) { params.tool_choice = options.toolChoice; } const reasoningEffort = options?.reasoningEffort ?? (options?.reasoningSummary ? "medium" : undefined); if (model.reasoning) { if (reasoningEffort) { const requestedEffort = options?.reasoningEffort ? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort) : reasoningEffort; const effort = configurationUpdateEffort === undefined ? requestedEffort : CONFIGURATION_UPDATE_REQUEST_EFFORT; params.reasoning = { effort: 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"), }; } if (model.provider === "xai") params.include = ["reasoning.encrypted_content"]; } // Last so model and request sampling parameters override named request fields. const samplingParams = resolveSamplingParams(model, reasoningEffort ?? "off", options?.samplingParams); if (samplingParams) { Object.assign(params, samplingParams); } return params; } function getServiceTierCostMultiplier(model, serviceTier) { switch (serviceTier) { case "flex": return 0.5; case "priority": case "fast": return model.id === "gpt-5.5" ? 2.5 : 2; default: return 1; } } function applyServiceTierPricing(usage, serviceTier, model) { 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; } //# sourceMappingURL=openai-responses.js.map