import OpenAI from "openai"; import type { ResponseCreateParamsStreaming, Tool as ResponsesTool, ToolChoiceOptions } from "openai/resources/responses/responses"; import type { Model as OpenAIModel } from "openai/resources/models"; import { type AIProviderAdapter, type PolyModel, type UnifiedChatRequest, type UnifiedChatResponse, type UnifiedStreamEvent, type ValidationResult, type TokenEstimate, PolyProviderError, modelKey, } from "@/lib/ai/core/types"; import { normalizeGenericError, refineByMessage, codeFromStatus, isRetryableStatus, parseRetryAfter } from "@/lib/ai/core/errors"; import { filterSettings, heuristicTokens } from "@/lib/ai/core/normalize"; import { collectStream } from "@/lib/ai/core/stream-utils"; import { toResponsesInput, normalizeResponsesStream } from "../shared/openai-compat/responses"; import { OPENAI_CATALOG, OPENAI_NON_CHAT, OPENAI_DEAD_IDS } from "./catalog"; const DEFAULT_TIMEOUT_MS = 10 * 60_000; function client(apiKey: string, timeoutMs = DEFAULT_TIMEOUT_MS) { return new OpenAI({ apiKey, maxRetries: 1, timeout: timeoutMs }); } type ModelWithShutdown = OpenAIModel & { shutdown_date?: string | null }; export function normalizeOpenAIModel(m: ModelWithShutdown): PolyModel | null { if (OPENAI_NON_CHAT.test(m.id) || OPENAI_DEAD_IDS.has(m.id)) return null; if (!/^(gpt-|o[1-9]|chatgpt|chat-latest)/.test(m.id)) return null; const shutdown = m.shutdown_date ?? null; if (shutdown && new Date(shutdown).getTime() < Date.now()) return null; const cat = OPENAI_CATALOG.get(m.id); const reasoningGuess = /^(o[1-9]|gpt-5|gpt-6)/.test(m.id); return { key: modelKey("openai", m.id), id: m.id, provider: "openai", displayName: cat?.displayName ?? m.id, family: cat?.family ?? m.id.split("-").slice(0, 2).join("-"), capabilities: cat?.capabilities ?? { text: true, vision: true, audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning: reasoningGuess, tools: true, structuredOutput: true, streaming: true, files: true, webSearch: true, }, limits: cat?.limits ?? {}, parameters: cat?.parameters ?? { temperature: !reasoningGuess, topP: !reasoningGuess, maxTokens: true, stop: false, seed: false }, status: cat?.status ?? "unknown", pricing: cat?.pricing ?? null, metadata: { ...(cat?.metadata ?? {}), shutdownDate: shutdown, createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined, sortWeight: cat?.sortWeight ?? 0, verified: Boolean(cat) }, }; } function buildParams(req: UnifiedChatRequest): ResponseCreateParamsStreaming { const { settings } = filterSettings(req.settings, req.modelInfo); const meta = (req.modelInfo?.metadata ?? {}) as Record; const reasoningCapable = req.modelInfo?.capabilities.reasoning ?? /^(o[1-9]|gpt-5|gpt-6)/.test(req.model); const effort = settings.reasoningEffort; const params: ResponseCreateParamsStreaming = { model: req.model, input: toResponsesInput(req.messages, { nativeFiles: true, replayReasoning: true }), stream: true, store: false, truncation: "auto", }; if (req.system?.trim()) params.instructions = req.system; if (settings.maxTokens !== undefined) params.max_output_tokens = Math.max(16, settings.maxTokens); // Sampling: only for non-reasoning models, or reasoning models explicitly at effort "none". const samplingMode = (meta.samplingMode as string | undefined) ?? (reasoningCapable ? "conditional" : "yes"); const samplingOk = samplingMode === "yes" || (samplingMode === "conditional" && effort === "none"); if (samplingOk) { if (settings.temperature !== undefined) params.temperature = settings.temperature; if (settings.topP !== undefined) params.top_p = settings.topP; if (settings.frequencyPenalty !== undefined) (params as unknown as Record).frequency_penalty = settings.frequencyPenalty; if (settings.presencePenalty !== undefined) (params as unknown as Record).presence_penalty = settings.presencePenalty; } if (reasoningCapable) { params.reasoning = {}; if (effort) params.reasoning.effort = effort as NonNullable["effort"]; if (settings.includeReasoning !== false && effort !== "none") params.reasoning.summary = "auto"; params.include = ["reasoning.encrypted_content"]; } if (settings.verbosity) params.text = { ...(params.text ?? {}), verbosity: settings.verbosity }; if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) { params.text = { ...(params.text ?? {}), format: { type: "json_schema", name: settings.responseFormat.schemaName ?? "response", schema: settings.responseFormat.schema, strict: settings.responseFormat.strict ?? true } }; } else if (settings.responseFormat?.type === "json") { params.text = { ...(params.text ?? {}), format: { type: "json_object" } }; } const tools: ResponsesTool[] = []; if (settings.webSearch) tools.push({ type: "web_search" } as ResponsesTool); if (settings.codeExecution) tools.push({ type: "code_interpreter", container: { type: "auto" } } as ResponsesTool); for (const t of req.tools ?? []) tools.push({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: t.strict ?? true }); if (tools.length) { params.tools = tools; const tc = settings.toolChoice; if (tc === "none" || tc === "required" || tc === "auto") params.tool_choice = tc as ToolChoiceOptions; else if (tc && typeof tc === "object") params.tool_choice = { type: "function", name: tc.name }; } return params; } export const openaiAdapter: AIProviderAdapter = { id: "openai", name: "OpenAI", keyDocsUrl: "https://platform.openai.com/api-keys", keyPrefixHint: "sk-", async validateApiKey(apiKey, signal): Promise { const t0 = Date.now(); try { const page = await client(apiKey, 20_000).models.list({ signal }); let n = 0; for await (const m of page) if (normalizeOpenAIModel(m as ModelWithShutdown)) n++; return { ok: true, modelsAvailable: n, latencyMs: Date.now() - t0 }; } catch (e) { return { ok: false, error: this.normalizeError(e).toJSON(), latencyMs: Date.now() - t0 }; } }, async listModels(apiKey, signal): Promise { try { const out: PolyModel[] = []; for await (const m of client(apiKey, 30_000).models.list({ signal })) { const n = normalizeOpenAIModel(m as ModelWithShutdown); if (n) out.push(n); } // `gpt-5.6` is a documented alias (→ gpt-5.6-sol) that /v1/models does not list. if (out.some((m) => m.id === "gpt-5.6-sol") && !out.some((m) => m.id === "gpt-5.6")) { const alias = normalizeOpenAIModel({ id: "gpt-5.6", object: "model", created: 0, owned_by: "openai" }); if (alias) out.push(alias); } return out; } catch (e) { throw this.normalizeError(e); } }, async chat(req): Promise { return collectStream("openai", req.model, this.streamChat(req)); }, async *streamChat(req: UnifiedChatRequest): AsyncIterable { try { const stream = await client(req.apiKey, req.timeoutMs).responses.create(buildParams(req), { signal: req.signal }); yield* normalizeResponsesStream(stream, "openai"); } catch (e) { yield { type: "error", error: this.normalizeError(e).toJSON() }; } }, async estimateTokens(req): Promise { try { const params = buildParams(req); const res = await client(req.apiKey, 20_000).responses.inputTokens.count({ model: params.model, input: params.input, instructions: params.instructions, tools: params.tools, reasoning: params.reasoning }); return { inputTokens: res.input_tokens, method: "provider" }; } catch { const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? ""); return { inputTokens: heuristicTokens(text), method: "heuristic" }; } }, normalizeError(error: unknown): PolyProviderError { if (error instanceof PolyProviderError) return error; if (error instanceof OpenAI.APIError) { const code = (error.code ?? undefined) as string | undefined; const param = (error as { param?: string | null }).param ?? undefined; const message = error.message ?? "OpenAI error"; let poly = codeFromStatus(error.status); if (code === "invalid_api_key" || error.status === 401) poly = "INVALID_API_KEY"; else if (code === "model_not_found") poly = "MODEL_NOT_FOUND"; else if (code === "insufficient_quota") poly = "INSUFFICIENT_CREDITS"; else if (code === "context_length_exceeded") poly = "CONTEXT_TOO_LONG"; else if (code === "rate_limit_exceeded" || code === "slow_down") poly = "RATE_LIMITED"; else if (code === "server_is_overloaded") poly = "PROVIDER_UNAVAILABLE"; else if (code === "unsupported_parameter" || code === "unsupported_value" || code === "unknown_parameter" || code === "integer_below_min_value" || code === "invalid_type" || /unsupported parameter/i.test(message)) poly = "INVALID_PARAMETER"; else if (error.status === 400) poly = refineByMessage("INVALID_PARAMETER", message); return new PolyProviderError({ code: poly, message: poly === "INVALID_API_KEY" ? "Invalid API key" : message.slice(0, 600), provider: "openai", status: error.status, retryable: isRetryableStatus(error.status) && poly !== "INSUFFICIENT_CREDITS", retryAfterMs: parseRetryAfter(error.headers ?? null), providerCode: code ?? (param ? `param:${param}` : undefined), cause: error, }); } return normalizeGenericError("openai", error); }, };