diff --git a/apps/desktop/src/lib/ai/call-small-model.ts b/apps/desktop/src/lib/ai/call-small-model.ts index 2f003672c1e..369c548ee28 100644 --- a/apps/desktop/src/lib/ai/call-small-model.ts +++ b/apps/desktop/src/lib/ai/call-small-model.ts @@ -1,18 +1,21 @@ -// FORK NOTE: upstream #3517 removed provider-diagnostics and the -// SmallModelProviders array based on per-attempt reporting — mastracode's -// AuthStorage is now the only credential source and getSmallModel() returns -// a single LanguageModel (Anthropic-then-OpenAI, API key only). +// FORK NOTE: upstream #3517 removed fork's SmallModelProviders array +// and the provider-diagnostics store. Fork code (enhance-text.ts, +// git-operations.ts) still calls callSmallModel({ invoke }) expecting +// { result, attempts } with per-provider fallback. This shim restores +// that behavior on top of getSmallModelCandidates() (a fork-maintained +// replacement that returns the full priority list with OAuth / API key +// / proxy AUTH_TOKEN correctly wired via getAnthropicProviderOptions). // -// Fork code (enhance-text.ts, git-operations.ts) still consumes the old -// callSmallModel({ invoke }) -> { result, attempts } shape. Rather than -// rewriting every callsite, expose a thin shim backed by the new -// getSmallModel() so the existing logging / error mapping keeps working. -// -// Trade-offs vs. upstream #3517: -// - OAuth-only users get no small-model service (upstream accepts this). -// - The attempts[] list collapses to a single entry (we don't try per- -// provider anymore — getSmallModel() picks one and returns it). -import { getSmallModel } from "@superset/chat/server/shared"; +// Trade-offs vs. the pre-#3517 fork: +// - ProviderIssue reporting collapsed to generic `failed` — upstream +// removed the diagnostic classifiers when it dropped +// provider-diagnostics, and fork no longer surfaces them anywhere +// except describeEnhanceFailure's reason string. +// - Credential resolution happens synchronously (mastracode token +// refresh is not awaited in the candidate list). If an OAuth access +// token is actually expired, the next candidate in the priority +// chain is tried. +import { getSmallModelCandidates } from "@superset/chat/server/shared"; import type { ProviderId, ProviderIssue } from "shared/ai/provider-status"; export type SmallModelCredentialKind = "api_key" | "oauth" | "env"; @@ -44,125 +47,15 @@ export interface SmallModelInvocationContext { credentials: SmallModelCredential; } -function providerNameFor(providerId: ProviderId): string { - return providerId === "anthropic" ? "Anthropic" : "OpenAI"; -} - -// Mirror getSmallModel()'s resolution precedence so the synthesized -// attempt shows the provider that actually got used: Anthropic if env or -// mastracode auth.json carries an API key, else OpenAI, else fallback to -// Anthropic. We duplicate the path resolution rather than import -// getSmallModel internals because the store file path is stable -// (mastracode's CLI installs to the same OS-conventional dir). -function hasAnthropicAuthKey(): boolean { - if (process.env.ANTHROPIC_API_KEY?.trim()) return true; - if (hasStoredApiKeyIn("anthropic")) return true; - // FORK NOTE: match get-small-model.ts's read of - // ~/.superset/chat-anthropic-env.json managed env config. - return hasAnthropicEnvConfigKey(); -} - -function hasOpenAIAuthKey(): boolean { - if (process.env.OPENAI_API_KEY?.trim()) return true; - // FORK NOTE: fork stores OpenAI under the `openai-codex` slot; also - // check the stock `openai` slot. - if (hasStoredApiKeyIn("openai")) return true; - return hasStoredApiKeyIn("openai-codex"); -} - -function hasAnthropicEnvConfigKey(): boolean { - // Mirrors the same shape parseAnthropicEnvText in - // packages/chat/src/server/desktop/chat-service/anthropic-env-config.ts - // accepts: optional `export ` prefix, optional single/double quotes. - // Whatever it can't parse, it falls back to false — the actual key - // resolution lives in get-small-model.ts (which imports the real - // parseAnthropicEnvText), so a miss here only degrades attempt-logging - // labels, not small-model functionality. - try { - const fs = require("node:fs") as typeof import("node:fs"); - const os = require("node:os") as typeof import("node:os"); - const path = require("node:path") as typeof import("node:path"); - const supersetHome = - process.env.SUPERSET_HOME_DIR?.trim() || - path.join(os.homedir(), ".superset"); - const configPath = path.join(supersetHome, "chat-anthropic-env.json"); - if (!fs.existsSync(configPath)) return false; - const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { - envText?: string; - }; - if (typeof parsed.envText !== "string") return false; - for (const line of parsed.envText.split("\n")) { - const trimmed = line.trim().replace(/^export\s+/, ""); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq === -1) continue; - const key = trimmed.slice(0, eq).trim(); - let value = trimmed.slice(eq + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - if ( - (key === "ANTHROPIC_API_KEY" || key === "ANTHROPIC_AUTH_TOKEN") && - value.length > 0 - ) { - return true; - } - } - } catch { - return false; - } - return false; -} - -function hasStoredApiKeyIn(providerId: string): boolean { - try { - // Lazy require so this never blocks in non-Node environments. - const fs = require("node:fs") as typeof import("node:fs"); - const os = require("node:os") as typeof import("node:os"); - const path = require("node:path") as typeof import("node:path"); - const p = os.platform(); - let base: string; - if (p === "darwin") { - base = path.join(os.homedir(), "Library", "Application Support"); - } else if (p === "win32") { - base = - process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"); - } else { - base = - process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"); - } - const authPath = path.join(base, "mastracode", "auth.json"); - if (!fs.existsSync(authPath)) return false; - const data = JSON.parse(fs.readFileSync(authPath, "utf-8")) as Record< - string, - unknown - >; - const entry = data[`apikey:${providerId}`]; - return ( - typeof entry === "object" && - entry !== null && - "type" in entry && - (entry as { type?: unknown }).type === "api_key" && - "key" in entry && - typeof (entry as { key?: unknown }).key === "string" && - (entry as { key: string }).key.trim().length > 0 - ); - } catch { - return false; - } -} - -function detectProviderId(): ProviderId { - if (hasAnthropicAuthKey()) return "anthropic"; - if (hasOpenAIAuthKey()) return "openai"; - return "anthropic"; +function toShimCredentialKind( + kind: "apiKey" | "oauth", +): SmallModelCredentialKind { + return kind === "oauth" ? "oauth" : "api_key"; } export async function callSmallModel({ invoke, + providerOrder, }: { invoke: ( context: SmallModelInvocationContext, @@ -172,72 +65,99 @@ export async function callSmallModel({ result: TResult | null; attempts: SmallModelAttempt[]; }> { - const model = getSmallModel(); - if (!model) { + const allCandidates = getSmallModelCandidates(); + + const ordered = providerOrder + ? [...allCandidates].sort((a, b) => { + const ai = providerOrder.indexOf(a.providerId); + const bi = providerOrder.indexOf(b.providerId); + return ( + (ai === -1 ? Number.MAX_SAFE_INTEGER : ai) - + (bi === -1 ? Number.MAX_SAFE_INTEGER : bi) + ); + }) + : allCandidates; + + const attempts: SmallModelAttempt[] = []; + + if (ordered.length === 0) { + // No credentials at all for either provider. Fabricate two + // missing-credentials attempts so describeEnhanceFailure's + // "every attempt is missing-credentials" branch triggers the + // correct "アカウントが接続されていません" message. return { result: null, attempts: [ { providerId: "anthropic", - providerName: providerNameFor("anthropic"), + providerName: "Anthropic", outcome: "missing-credentials", }, { providerId: "openai", - providerName: providerNameFor("openai"), + providerName: "OpenAI", outcome: "missing-credentials", }, ], }; } - const providerId = detectProviderId(); - const providerName = providerNameFor(providerId); - const credentials: SmallModelCredential = { kind: "api_key" }; + for (const candidate of ordered) { + const credentials: SmallModelCredential = { + kind: toShimCredentialKind(candidate.credentialKind), + source: candidate.credentialSource, + }; + let model: unknown; + try { + model = candidate.createModel(); + } catch (error) { + attempts.push({ + providerId: candidate.providerId, + providerName: candidate.providerName, + credentialKind: credentials.kind, + credentialSource: candidate.credentialSource, + outcome: "failed", + reason: error instanceof Error ? error.message : String(error), + }); + continue; + } - try { - const result = await invoke({ - providerId, - providerName, - model, - credentials, - }); - if (result === null || result === undefined) { - return { - result: null, - attempts: [ - { - providerId, - providerName, - credentialKind: "api_key", - outcome: "empty-result", - }, - ], - }; + try { + const result = await invoke({ + providerId: candidate.providerId, + providerName: candidate.providerName, + model, + credentials, + }); + if (result === null || result === undefined) { + attempts.push({ + providerId: candidate.providerId, + providerName: candidate.providerName, + credentialKind: credentials.kind, + credentialSource: candidate.credentialSource, + outcome: "empty-result", + }); + continue; + } + attempts.push({ + providerId: candidate.providerId, + providerName: candidate.providerName, + credentialKind: credentials.kind, + credentialSource: candidate.credentialSource, + outcome: "succeeded", + }); + return { result, attempts }; + } catch (error) { + attempts.push({ + providerId: candidate.providerId, + providerName: candidate.providerName, + credentialKind: credentials.kind, + credentialSource: candidate.credentialSource, + outcome: "failed", + reason: error instanceof Error ? error.message : String(error), + }); } - return { - result, - attempts: [ - { - providerId, - providerName, - credentialKind: "api_key", - outcome: "succeeded", - }, - ], - }; - } catch (error) { - return { - result: null, - attempts: [ - { - providerId, - providerName, - credentialKind: "api_key", - outcome: "failed", - reason: error instanceof Error ? error.message : String(error), - }, - ], - }; } + + return { result: null, attempts }; } diff --git a/packages/chat/src/server/shared/index.ts b/packages/chat/src/server/shared/index.ts index 1d7af44c8fd..c54241172f5 100644 --- a/packages/chat/src/server/shared/index.ts +++ b/packages/chat/src/server/shared/index.ts @@ -1 +1,6 @@ -export { getSmallModel } from "./small-model"; +export { + getSmallModel, + getSmallModelCandidates, + type SmallModelCandidate, + type SmallModelProviderId, +} from "./small-model"; diff --git a/packages/chat/src/server/shared/small-model/get-small-model.ts b/packages/chat/src/server/shared/small-model/get-small-model.ts index 5cc9aff6ac3..8810b06fe95 100644 --- a/packages/chat/src/server/shared/small-model/get-small-model.ts +++ b/packages/chat/src/server/shared/small-model/get-small-model.ts @@ -1,83 +1,242 @@ import { existsSync, readFileSync } from "node:fs"; -import { homedir, platform } from "node:os"; +import { homedir } from "node:os"; import { join } from "node:path"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createOpenAI } from "@ai-sdk/openai"; +import { createAuthStorage } from "mastracode"; +import { + type ClaudeCredentials, + getCredentialsFromConfig as getAnthropicCredentialsFromConfig, + getCredentialsFromKeychain as getAnthropicCredentialsFromKeychain, + getAnthropicProviderOptions, + isClaudeCredentialExpired, +} from "../../desktop/auth/anthropic"; +import { + getOpenAICredentialsFromAnySource, + isOpenAICredentialExpired, + type OpenAICredentials, +} from "../../desktop/auth/openai"; +import { OPENAI_AUTH_PROVIDER_ID } from "../../desktop/auth/provider-ids"; import { parseAnthropicEnvText } from "../../desktop/chat-service/anthropic-env-config"; const ANTHROPIC_SMALL_MODEL_ID = "claude-haiku-4-5-20251001"; -const OPENAI_SMALL_MODEL_ID = "gpt-4o-mini"; +const OPENAI_API_SMALL_MODEL_ID = "gpt-4o-mini"; +const OPENAI_CODEX_SMALL_MODEL_ID = "gpt-5.1-codex-mini"; +const OPENAI_CODEX_API_ENDPOINT = + "https://chatgpt.com/backend-api/codex/responses"; + +export type SmallModelProviderId = "anthropic" | "openai"; + +export interface SmallModelCandidate { + providerId: SmallModelProviderId; + providerName: string; + credentialKind: "apiKey" | "oauth"; + credentialSource: string; + createModel: () => unknown; +} /** - * Resolves the mastracode auth.json path (same logic as mastracode's - * `getAppDataDir`). We read it directly to avoid importing mastracode, - * which eagerly loads @mastra/fastembed → onnxruntime-node (208 MB native - * binary) and breaks electron-vite bundling. + * FORK NOTE: ported from upstream #3517's `getSmallModel()` but rebuilt + * on top of fork's credential resolvers so it still honors: + * - Anthropic OAuth (claude-code-20250219 / oauth-2025-04-20 headers via + * getAnthropicProviderOptions — upstream lost this when it switched to + * apiKey-only resolution) + * - Anthropic managed env config (~/.superset/chat-anthropic-env.json + * with ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; AUTH_TOKEN is + * routed through the OAuth header path, not apiKey) + * - OpenAI Codex OAuth (custom fetch that rewrites to the Codex + * backend endpoint and refreshes access tokens via mastracode) + * - OpenAI API key in mastracode AuthStorage's `openai-codex` slot + * + * Upstream's version collapsed credentials to apiKey-only. We keep the + * simpler `getSmallModel()` export for upstream-compatible callers + * (runtime.ts title generation) and add `getSmallModelCandidates()` so + * the fork callSmallModel shim can iterate providers in order and + * record attempts properly (restoring provider fallback behavior). */ -function getAuthJsonPath(): string { - const p = platform(); - let base: string; - if (p === "darwin") { - base = join(homedir(), "Library", "Application Support"); - } else if (p === "win32") { - base = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"); - } else { - base = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"); +function buildCandidates(): SmallModelCandidate[] { + const candidates: SmallModelCandidate[] = []; + + const envApiKey = process.env.ANTHROPIC_API_KEY?.trim(); + if (envApiKey) { + candidates.push({ + providerId: "anthropic", + providerName: "Anthropic", + credentialKind: "apiKey", + credentialSource: "env:ANTHROPIC_API_KEY", + createModel: () => + createAnthropic({ apiKey: envApiKey })(ANTHROPIC_SMALL_MODEL_ID), + }); } - return join(base, "mastracode", "auth.json"); -} -type AuthData = Record; + const anthropicStored = resolveAnthropicCredentialsSync(); + if (anthropicStored) { + candidates.push({ + providerId: "anthropic", + providerName: "Anthropic", + credentialKind: anthropicStored.kind === "oauth" ? "oauth" : "apiKey", + credentialSource: anthropicStored.source, + createModel: () => + createAnthropic(getAnthropicProviderOptions(anthropicStored))( + ANTHROPIC_SMALL_MODEL_ID, + ), + }); + } -function readAuthData(): AuthData | null { - const path = getAuthJsonPath(); - if (!existsSync(path)) return null; - try { - return JSON.parse(readFileSync(path, "utf-8")) as AuthData; - } catch { - return null; + const anthropicEnvConfigCred = resolveAnthropicEnvConfigCredential(); + if (anthropicEnvConfigCred) { + candidates.push({ + providerId: "anthropic", + providerName: "Anthropic", + credentialKind: + anthropicEnvConfigCred.kind === "oauth" ? "oauth" : "apiKey", + credentialSource: anthropicEnvConfigCred.source, + createModel: () => + createAnthropic(getAnthropicProviderOptions(anthropicEnvConfigCred))( + ANTHROPIC_SMALL_MODEL_ID, + ), + }); } + + const envOpenAIKey = process.env.OPENAI_API_KEY?.trim(); + if (envOpenAIKey) { + candidates.push({ + providerId: "openai", + providerName: "OpenAI", + credentialKind: "apiKey", + credentialSource: "env:OPENAI_API_KEY", + createModel: () => + createOpenAI({ apiKey: envOpenAIKey }).chat(OPENAI_API_SMALL_MODEL_ID), + }); + } + + const openaiCreds = getOpenAICredentialsFromAnySource(); + if (openaiCreds && !isOpenAICredentialExpired(openaiCreds)) { + candidates.push({ + providerId: "openai", + providerName: "OpenAI", + credentialKind: openaiCreds.kind === "oauth" ? "oauth" : "apiKey", + credentialSource: openaiCreds.source, + createModel: () => + openaiCreds.kind === "oauth" + ? createOpenAICodexOAuthModel(openaiCreds) + : createOpenAI({ apiKey: openaiCreds.apiKey }).chat( + OPENAI_API_SMALL_MODEL_ID, + ), + }); + } + + return candidates; } -function getStoredApiKey( - authData: AuthData | null, - providerId: string, -): string | null { - if (!authData) return null; - const entry = authData[`apikey:${providerId}`]; - if ( - typeof entry === "object" && - entry !== null && - "type" in entry && - entry.type === "api_key" && - "key" in entry && - typeof entry.key === "string" && - entry.key.trim().length > 0 - ) { - return entry.key.trim(); +export function getSmallModelCandidates(): SmallModelCandidate[] { + return buildCandidates(); +} + +/** + * Returns the first viable small-model AI-SDK LanguageModel or null. + * Upstream-compatible surface for simple single-model callers + * (runtime.ts title generation, ai-name.ts workspace naming). + * + * Iterates every candidate and returns the first one whose + * `createModel()` does not throw, so a broken-but-listed credential + * (e.g. stale cached account id) doesn't block the next provider. + * Runtime-level failures (expired OAuth 401, rate limits) still need + * to be handled by the caller — those surface when the returned + * model is actually invoked, not when it's constructed. + */ +export function getSmallModel(): unknown | null { + for (const candidate of buildCandidates()) { + try { + return candidate.createModel(); + } catch { + // Try the next candidate. + } } return null; } -// FORK NOTE: fork stores the OpenAI provider under the Codex CLI slot -// name `openai-codex`, while upstream small-model looks at `openai`. -// Try both so Settings-saved keys route to small-model tasks too. -const OPENAI_STORAGE_SLOTS = ["openai", "openai-codex"]; +// ---- Anthropic credential resolution helpers ------------------------------- + +/** + * Synchronous Anthropic credential resolver. Fork's + * `getCredentialsFromAnySource` is async because it may kick a + * mastracode token refresh. For the small-model candidate list we need + * a sync decision, so we stick to synchronous sources (config file, + * keychain, auth-storage main slot). If the resulting OAuth token is + * actually expired, createAnthropic will 401 and the shim falls + * through to the next candidate. + */ +function resolveAnthropicCredentialsSync(): ClaudeCredentials | null { + // Walk the sync sources in priority order and return the first + // non-expired credential. Unlike getCredentialsFromAnySource() we do + // NOT fall back to a known-expired credential at the end — expired + // OAuth tokens would poison buildCandidates() and block the later + // env-config / OpenAI candidates, which matter for getSmallModel()'s + // direct callers where we can't retry after a 401. + const sources: Array<() => ClaudeCredentials | null> = [ + () => { + try { + return getAnthropicCredentialsFromConfig(); + } catch { + return null; + } + }, + () => { + try { + return getAnthropicCredentialsFromKeychain(); + } catch { + return null; + } + }, + () => resolveAnthropicFromStoreSync(), + ]; + for (const resolve of sources) { + const credential = resolve(); + if (!credential) continue; + if (!isClaudeCredentialExpired(credential)) return credential; + } + return null; +} -function getStoredOpenAIApiKey(authData: AuthData | null): string | null { - for (const slot of OPENAI_STORAGE_SLOTS) { - const key = getStoredApiKey(authData, slot); - if (key) return key; +function resolveAnthropicFromStoreSync(): ClaudeCredentials | null { + try { + const storage = createAuthStorage(); + storage.reload(); + const raw = storage.get("anthropic"); + if (!raw || typeof raw !== "object") return null; + const value = raw as Record; + if ( + value.type === "api_key" && + typeof value.key === "string" && + value.key.trim().length > 0 + ) { + return { + apiKey: value.key.trim(), + source: "auth-storage", + kind: "apiKey", + }; + } + if ( + value.type === "oauth" && + typeof value.access === "string" && + value.access.trim().length > 0 + ) { + return { + apiKey: value.access.trim(), + source: "auth-storage", + kind: "oauth", + expiresAt: + typeof value.expires === "number" ? value.expires : undefined, + }; + } + } catch { + // Fall through to null. } return null; } -// FORK NOTE: fork's ChatService persists ANTHROPIC_API_KEY / -// ANTHROPIC_AUTH_TOKEN into `~/.superset/chat-anthropic-env.json` as a -// managed env config (so a proxy setup can run) and *strips* those keys -// from process.env before launching the chat model. Read that file here -// so small-model tasks can reuse the same credential. -function getAnthropicKeyFromEnvConfig(): string | null { +function resolveAnthropicEnvConfigCredential(): ClaudeCredentials | null { try { const supersetHome = process.env.SUPERSET_HOME_DIR?.trim() || join(homedir(), ".superset"); @@ -89,49 +248,102 @@ function getAnthropicKeyFromEnvConfig(): string | null { if (typeof parsed.envText !== "string") return null; const variables = parseAnthropicEnvText(parsed.envText); const apiKey = variables.ANTHROPIC_API_KEY?.trim(); - if (apiKey) return apiKey; + if (apiKey) { + // `source: "config"` keeps us inside fork's ClaudeCredentials + // union; the actual display label comes from + // SmallModelCandidate.credentialSource below. + return { apiKey, source: "config", kind: "apiKey" }; + } const authToken = variables.ANTHROPIC_AUTH_TOKEN?.trim(); - if (authToken) return authToken; + if (authToken) { + // FORK NOTE: AUTH_TOKEN must flow through the OAuth path + // (authToken + anthropic-beta / x-app headers) — routing it + // through `apiKey` was the original PR #313 regression. + return { apiKey: authToken, source: "config", kind: "oauth" }; + } } catch { - // Swallow; missing / malformed config falls back to other sources. + // Swallow — missing / malformed config falls back to other sources. } return null; } -function resolveAnthropicApiKey(authData: AuthData | null): string | null { - const env = process.env.ANTHROPIC_API_KEY?.trim(); - if (env) return env; - const stored = getStoredApiKey(authData, "anthropic"); - if (stored) return stored; - return getAnthropicKeyFromEnvConfig(); -} +// ---- OpenAI Codex OAuth model ---------------------------------------------- -function resolveOpenAIApiKey(authData: AuthData | null): string | null { - const env = process.env.OPENAI_API_KEY?.trim(); - if (env) return env; - return getStoredOpenAIApiKey(authData); -} +function createOpenAICodexOAuthModel(credentials: OpenAICredentials) { + const authStorage = createAuthStorage(); + const openAIAuthProviderId = + credentials.providerId ?? OPENAI_AUTH_PROVIDER_ID; + const oauthFetchImpl = async ( + url: Parameters[0], + init?: Parameters[1], + ): Promise => { + authStorage.reload(); + const storedCredential = authStorage.get(openAIAuthProviderId); + if (!storedCredential || storedCredential.type !== "oauth") { + throw new Error("Not logged in to OpenAI Codex. Reconnect OpenAI."); + } -/** - * Returns an AI-SDK `LanguageModel` for small-model tasks (branch naming, - * title generation). Tries Anthropic first, falls back to OpenAI. Returns - * `null` if no credentials are available. - * - * Reads credentials from env vars, mastracode's auth.json, and fork's - * managed Anthropic env config. OAuth-only users fall back to `null`. - */ -export function getSmallModel(): unknown | null { - const authData = readAuthData(); + let accessToken = storedCredential.access; + if ( + typeof storedCredential.expires === "number" && + Date.now() >= storedCredential.expires + ) { + const refreshedToken = await authStorage.getApiKey(openAIAuthProviderId); + if (!refreshedToken) { + throw new Error( + "Failed to refresh OpenAI Codex token. Please reconnect OpenAI.", + ); + } + accessToken = refreshedToken; + authStorage.reload(); + } - const anthropicKey = resolveAnthropicApiKey(authData); - if (anthropicKey) { - return createAnthropic({ apiKey: anthropicKey })(ANTHROPIC_SMALL_MODEL_ID); - } + const refreshedCredential = authStorage.get(openAIAuthProviderId); + const accountId = + refreshedCredential && + typeof refreshedCredential === "object" && + "accountId" in refreshedCredential && + typeof refreshedCredential.accountId === "string" && + refreshedCredential.accountId.trim().length > 0 + ? refreshedCredential.accountId.trim() + : credentials.accountId?.trim() || undefined; - const openaiKey = resolveOpenAIApiKey(authData); - if (openaiKey) { - return createOpenAI({ apiKey: openaiKey }).chat(OPENAI_SMALL_MODEL_ID); - } + // biome-ignore-start lint/suspicious/noExplicitAny: fetch signature varies across runtimes (bun vs. node vs. electron) and the cross-package typecheck context loses the DOM Request type overloads. + const baseRequest = new Request(url as any, init as any); + // biome-ignore-end lint/suspicious/noExplicitAny: matching pair + const parsedUrl = new URL(baseRequest.url); + const shouldRewrite = + parsedUrl.pathname.includes("/v1/responses") || + parsedUrl.pathname.includes("/chat/completions"); + const outgoingRequest = new Request( + shouldRewrite ? OPENAI_CODEX_API_ENDPOINT : baseRequest.url, + baseRequest, + ); + const headers = new Headers(outgoingRequest.headers); + headers.delete("authorization"); + headers.set("Authorization", `Bearer ${accessToken}`); + if (accountId) { + headers.set("ChatGPT-Account-Id", accountId); + } - return null; + return fetch( + new Request(outgoingRequest, { + headers, + }), + ); + }; + const bunFetch = globalThis.fetch as typeof fetch & { + preconnect?: typeof globalThis.fetch; + }; + const oauthFetch = Object.assign( + oauthFetchImpl, + typeof bunFetch.preconnect === "function" + ? { preconnect: bunFetch.preconnect.bind(globalThis.fetch) } + : {}, + ) as typeof fetch; + + return createOpenAI({ + apiKey: "oauth-dummy-key", + fetch: oauthFetch, + }).responses(OPENAI_CODEX_SMALL_MODEL_ID); } diff --git a/packages/chat/src/server/shared/small-model/index.ts b/packages/chat/src/server/shared/small-model/index.ts index 43076c04550..d2b53f46e8e 100644 --- a/packages/chat/src/server/shared/small-model/index.ts +++ b/packages/chat/src/server/shared/small-model/index.ts @@ -1 +1,6 @@ -export { getSmallModel } from "./get-small-model"; +export { + getSmallModel, + getSmallModelCandidates, + type SmallModelCandidate, + type SmallModelProviderId, +} from "./get-small-model";