import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction, TranscriptContext } from "@earendil-works/pi-ai"; import { streamSimple } from "@earendil-works/pi-ai/api/openai-responses"; import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream"; import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages"; import { chatGPTAbortController, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTDenial } from "./chatgpt-bridge.ts"; import type { AdmissionScope, ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts"; const USAGE = "https://chatgpt.com/settings/usage"; export function chatGPTStreamError(message: string): string { if (/subscription_sharing_usage_limit_exceeded|quota|429/.test(message)) return `ChatGPT plan usage limit reached. Manage usage at ${USAGE}. No API-key fallback was used.`; if (/scope|authorization_context|permission/.test(message)) return "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used."; if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used."; if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used."; if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility."; if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used."; if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used."; if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used."; return "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used."; } /** Restrict the body at the final payload seam, even if caller samplingParams add fields. */ export function chatGPTPayload(raw: unknown, selectedModelId: string): Record { const payload = raw as Record; const out: Record = { model: selectedModelId, store: false, stream: true }; for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) { if (payload[key] !== undefined) out[key] = payload[key]; } if (!Array.isArray(payload.input)) throw new Error("ChatGPT requires stateless input history"); out.input = payload.input.map((item) => item?.role === "system" ? { ...item, role: "developer" } : item); if (Array.isArray(payload.tools) && payload.tools.length) { if (payload.tools.some((tool) => tool.type !== "function" && tool.type !== "custom")) throw new Error("Unsupported ChatGPT tool capability"); out.tools = [{ type: "namespace", name: "learnharness", description: "LearnHarness local learning tools", tools: payload.tools }]; } return out; } /** Only ChatGPT history is translated. Mixed API-key history keeps its actual identity. */ export function chatGPTHistory(context: TranscriptContext, logicalModel: Model): TranscriptContext { // Apply Pi's normal foreign-model signature treatment BEFORE the alias // becomes openai, or real API-key turns could look like subscription turns. const messages = transformMessages(context.messages, logicalModel); return { ...context, messages: messages.map((m) => m.role === "assistant" && m.provider === "chatgpt" ? { ...m, provider: "openai" } : m) }; } function empty(model: Model): AssistantMessage { return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; } export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction { return (logicalModel, context, options) => { const output = new AssistantMessageEventStream(); const purpose = background(); const { controller, release } = chatGPTAbortController(purpose); const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal; void (async () => { let partial = empty(logicalModel); let completed = false; let failure = ""; let scope: AdmissionScope | undefined; let committed = false; let terminal = false; const check = () => { if (signal.aborted || !scope || !chatGPTScopeCurrent(scope)) throw new Error("cancelled or catalog authority changed"); }; try { if (logicalModel.provider !== "chatgpt") throw new Error("ChatGPT account catalog has no selected model"); if (Object.keys(options?.headers ?? {}).some((key) => key.toLowerCase() === "authorization")) throw new Error("Authorization override is forbidden"); scope = chatGPTScope(logicalModel.id); lifecycle?.captured(scope); const credentials = await chatGPTLease(scope, purpose); check(); // NEVER dispatch through Models under openai: that would expose ambient API-key fallback. const transport: Model<"openai-responses"> = { ...logicalModel, api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", headers: undefined, samplingParams: undefined, compat: { ...logicalModel.compat, supportsDeveloperRole: true } }; const delegate = options?.fetch ?? globalThis.fetch; const source = streamSimple(transport, chatGPTHistory(context, logicalModel), { ...options, apiKey: credentials.accessToken, signal, maxRetries: 0, headers: { "OpenAI-Beta": null, ...options?.headers }, fetch: (input, init) => { check(); if (committed) throw new Error("ChatGPT does not retry admitted requests"); lifecycle?.beforeDispatch(scope!); committed = true; return delegate(input, init); }, onPayload: async (raw, model) => { const replacement = await options?.onPayload?.(raw, model); const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id); check(); return payload; }, onProviderStreamEvent: async (raw, model) => { const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } }; if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true; if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response"; await options?.onProviderStreamEvent?.(raw, model); }, }); for await (const event of source) { const message = "partial" in event ? event.partial : event.type === "done" ? event.message : event.error; // Copy the message, not its tool/reasoning blocks: IDs/signatures remain byte-for-byte intact. partial = { ...message, provider: "chatgpt" }; if (event.type === "done") { if (!completed || failure) throw new Error(failure || "missing terminal response.completed"); terminal = true; output.push({ ...event, message: partial }); } else if (event.type === "error") { throw new Error(failure || event.error.errorMessage || "ChatGPT stream error"); } else output.push({ ...event, partial }); } } catch (err) { if (terminal) return; if (!committed) lifecycle?.noDispatch(await chatGPTDenial(scope, controller, options?.signal?.aborted === true, purpose)); partial = { ...partial, stopReason: signal.aborted ? "aborted" : "error", errorMessage: chatGPTStreamError(err instanceof Error ? err.message : "") }; output.push({ type: "error", reason: partial.stopReason as "error" | "aborted", error: partial }); } finally { release(); output.end(); } })(); return output; }; }