diff --git a/docs/src/content/docs/en/configuration/profiles.md b/docs/src/content/docs/en/configuration/profiles.md index 0f5aa03f..9554bb1a 100644 --- a/docs/src/content/docs/en/configuration/profiles.md +++ b/docs/src/content/docs/en/configuration/profiles.md @@ -93,7 +93,7 @@ The OpenCode CLI must be available as `opencode` in the CCR Desktop process envi | Environment variables | Injected into Codex CLI or ChatGPT. Claude Code-specific model discovery variables are not passed to Codex. | | Bot | Applies only to the ChatGPT app entry. | -After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr-app "Codex - Work"`. Use the play button to open ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display: an existing valid ChatGPT token is shown as the real ChatGPT account, while a profile without credentials uses a tokenless ChatGPT-shaped workspace identity so the desktop renderer keeps model selection available without storing a real user login. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported. +After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr-app "Codex - Work"`. Use the play button to open ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display. When the default Codex home has a valid, unexpired ChatGPT login, CCR bridges that token in memory without copying it into the isolated profile. Without a valid token, CCR reports a local non-OpenAI compatibility identity so current ChatGPT builds do not enter a repeated authentication and attestation loop. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported. Model and public plugin listings are not synthesized by the middleware. The native Codex app-server reads the generated `model_catalog_json` and handles `model/list` plus public `plugin/list` requests unchanged. This lets Codex refresh the official public [`openai/plugins`](https://github.com/openai/plugins) Git marketplace over the network. In a virtual workspace, only account-private marketplace requests are answered with an explicit empty result because the native service requires real ChatGPT authentication for those sections; they are never replaced with local plugins. Any downloaded Git checkout is owned only by Codex as its normal last-known-good data, not used by CCR as a replacement catalog. diff --git a/docs/src/content/docs/zh/configuration/profile.md b/docs/src/content/docs/zh/configuration/profile.md index a7fd2ff0..f0a2bf22 100644 --- a/docs/src/content/docs/zh/configuration/profile.md +++ b/docs/src/content/docs/zh/configuration/profile.md @@ -93,7 +93,7 @@ CCR Desktop 进程环境中必须能够执行 `opencode`。如果 CLI 安装在 | 环境变量 | 注入 Codex CLI 或 ChatGPT。Claude Code 专用的模型发现变量不会传给 Codex。 | | Bot | 只在 ChatGPT App 入口生效。 | -保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr-app "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:隔离目录已有有效 ChatGPT token 时显示真实账号;没有凭据时使用无 token、ChatGPT 形态的虚拟工作区身份,让桌面端在不保存真实用户登录的情况下仍可使用模型选择。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。 +保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr-app "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:默认 Codex Home 已有有效且未过期的 ChatGPT 登录时,CCR 只在内存中桥接 token,不会把它复制进隔离配置;没有有效 token 时则返回本地非 OpenAI 兼容身份,避免新版 ChatGPT 进入循环鉴权和 attestation。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。 模型和公共插件列表不再由中间层合成。原生 Codex app-server 读取生成的 `model_catalog_json`,并原样处理 `model/list` 与公共 `plugin/list` 请求,因此 Codex 可以自行联网刷新官方公开 [`openai/plugins`](https://github.com/openai/plugins) Git marketplace。虚拟 workspace 中,只有必须使用真实 ChatGPT 鉴权的账号私有 marketplace 请求会得到明确空结果,绝不会用本地插件替代。下载后的 Git checkout 只作为 Codex 自己的常规 last-known-good 数据,CCR 不会拿它替代远端目录。 diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts index b517816b..ed1f59ec 100644 --- a/packages/core/src/agents/codex/app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -317,10 +317,13 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co ...(process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG?.trim() ? { CCR_CODEX_CLI_MIDDLEWARE_LOG: process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG.trim() } : {}), + ...codexSharedChatGptAuthEnv(), CCR_CODEX_MODEL_PROVIDER: providerId, CCR_CODEX_PROFILE: providerId, CCR_CODEX_REMOTE_FRONTEND_MODE: remoteFrontendMode, + CCR_BUNDLED_CODEX_CLI_PATH: realCliPath, CCR_REAL_CODEX_CLI_PATH: realCliPath, + CODEXL_BUNDLED_CODEX_CLI_PATH: realCliPath, CODEXL_CODEX_CORE_MODE: remoteFrontendMode, CODEXL_CODEX_MODEL_PROVIDER: providerId, CODEXL_CODEX_PROFILE: providerId, @@ -329,6 +332,25 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co }; } +function codexSharedChatGptAuthEnv(): Record { + const configured = [ + process.env.CCR_CODEX_CHATGPT_AUTH_FILE, + process.env.CODEXL_CODEX_CHATGPT_AUTH_FILE + ].map((value) => value?.trim()).find((value) => value && isFile(resolveUserPath(value))); + const authFile = configured ? resolveUserPath(configured) : path.join(os.homedir(), ".codex", "auth.json"); + if (!isFile(authFile)) { + return {}; + } + return { + CCR_CODEX_CHATGPT_AUTH_FILE: authFile, + CODEXL_CODEX_CHATGPT_AUTH_FILE: authFile + }; +} + +export function codexSharedChatGptAuthEnvForTest(): Record { + return codexSharedChatGptAuthEnv(); +} + function codexAppAgentEnv( spec: CodexCompatibleAppSpec, launcher: string, diff --git a/packages/core/src/agents/codex/cli-middleware-runtime.ts b/packages/core/src/agents/codex/cli-middleware-runtime.ts index 2f697840..6fe07558 100644 --- a/packages/core/src/agents/codex/cli-middleware-runtime.ts +++ b/packages/core/src/agents/codex/cli-middleware-runtime.ts @@ -37,6 +37,20 @@ const ACCOUNT_REMOTE_PLUGIN_MARKETPLACE_KINDS = new Set([ "shared-with-me", "workspace-directory" ]); +const SIGNED_CODEX_SUPERVISOR_SOURCE = [ + 'const { spawn } = require("node:child_process");', + "const executable = process.argv[1];", + "const args = process.argv.slice(2);", + 'const child = spawn(executable, args, { env: process.env, stdio: "inherit" });', + 'for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {', + " process.once(signal, () => child.kill(signal));", + "}", + 'child.once("error", (error) => {', + ' console.error("Failed to launch supervised Codex CLI: " + error.message);', + " process.exit(1);", + "});", + 'child.once("exit", (code) => process.exit(code == null ? 1 : code));' +].join("\n"); let BOT_BRIDGE_INSTANCE = null; function claudeCodeUtcTimezoneEnvOverride() { @@ -299,7 +313,8 @@ async function runCodexCliMiddleware(args) { } const cleanupAuthBootstrap = createEphemeralCodexApiKeyBootstrap(runtimeAgent); - const child = spawnAgentCli(realCli, realArgs, { + const launch = supervisedCodexAppServerLaunch(runtimeAgent, realCli, realArgs); + const child = spawnAgentCli(launch.command, launch.args, { env: childEnvForAgent(runtimeAgent), stdio: ["pipe", "pipe", "inherit"] }); @@ -343,6 +358,35 @@ async function runCodexCliMiddleware(args) { process.exitCode = exit.exitCode; } +function supervisedCodexAppServerLaunch(runtimeAgent, realCli, realArgs) { + if (runtimeAgent !== "codex" || process.platform !== "darwin") { + return { command: realCli, args: realArgs }; + } + const configured = nonEmptyEnv("CCR_SIGNED_CODEX_SUPERVISOR_NODE_PATH") || + nonEmptyEnv("CODEXL_SIGNED_CODEX_SUPERVISOR_NODE_PATH"); + const bundled = path.join(path.dirname(realCli), "cua_node", "bin", "node"); + const node = [configured, bundled].find(isExecutableFile); + if (!node) { + return { command: realCli, args: realArgs }; + } + // Browser's native-pipe authorizer validates the connecting process and its + // parent/grandparent. Keep the middleware while placing ChatGPT's signed Node + // runtime between it and the main app-server. + return { + command: node, + args: ["-e", SIGNED_CODEX_SUPERVISOR_SOURCE, realCli, ...realArgs] + }; +} + +function isExecutableFile(file) { + if (!file) return false; + try { + return fs.statSync(file).isFile(); + } catch { + return false; + } +} + function createEphemeralCodexApiKeyBootstrap(runtimeAgent) { if (runtimeAgent !== "codex") return () => {}; const scope = nonEmptyEnv("CCR_PROFILE_SCOPE"); @@ -4552,14 +4596,30 @@ function loadChatGptAuth() { planType: "", workspaceName }; - const value = readJsonFile(path.join(codexRuntimeHome(), "auth.json")); - if (!value || !isPlainObject(value)) return fallback; - if (typeof value.auth_mode === "string" && value.auth_mode !== "chatgpt") return fallback; - if (!isPlainObject(value.tokens)) return fallback; + const seen = new Set(); + for (const authFile of [ + path.join(codexRuntimeHome(), "auth.json"), + nonEmptyEnv("CCR_CODEX_CHATGPT_AUTH_FILE"), + nonEmptyEnv("CODEXL_CODEX_CHATGPT_AUTH_FILE") + ]) { + if (!authFile || seen.has(authFile)) continue; + seen.add(authFile); + const auth = chatGptAuthFromFile(authFile, workspaceName); + if (auth) return auth; + } + return fallback; +} + +function chatGptAuthFromFile(authFile, workspaceName) { + const value = readJsonFile(authFile); + if (!value || !isPlainObject(value)) return undefined; + if (typeof value.auth_mode === "string" && value.auth_mode !== "chatgpt") return undefined; + if (!isPlainObject(value.tokens)) return undefined; const authToken = stringValue(value.tokens.access_token); const idToken = stringValue(value.tokens.id_token); const claims = jwtPayloadClaims(authToken) || jwtPayloadClaims(idToken) || {}; + if (!usableChatGptAuthToken(authToken, claims)) return undefined; const profileClaims = isPlainObject(claims["https://api.openai.com/profile"]) ? claims["https://api.openai.com/profile"] : {}; @@ -4574,6 +4634,12 @@ function loadChatGptAuth() { }; } +function usableChatGptAuthToken(token, claims) { + if (!token) return false; + const expiresAt = Number(claims && claims.exp); + return !Number.isFinite(expiresAt) || expiresAt > Math.floor(Date.now() / 1000) + 30; +} + function jwtPayloadClaims(token) { if (!token) return undefined; const payload = String(token).split(".")[1]; @@ -4589,6 +4655,7 @@ function jwtPayloadClaims(token) { } function codexAppAccountRead(auth) { + if (!auth.authToken) return mockAccountRead(); return { account: { type: "chatgpt", @@ -4600,6 +4667,7 @@ function codexAppAccountRead(auth) { } function codexAppAuthStatus(auth, includeToken) { + if (!auth.authToken) return mockAuthStatus(includeToken); const result = { authMethod: "chatgpt", requiresOpenaiAuth: true @@ -7010,10 +7078,21 @@ function codexRuntimeRealCli(agent) { nonEmptyEnv("ZCODE_CLI_PATH") || "zcode"; } - return nonEmptyEnv("CCR_REAL_CODEX_CLI_PATH") || - nonEmptyEnv("CODEXL_REAL_CODEX_CLI_PATH") || - nonEmptyEnv("CODEX_CLI_PATH") || - "codex"; + for (const candidate of [ + nonEmptyEnv("CCR_REAL_CODEX_CLI_PATH"), + nonEmptyEnv("CODEXL_REAL_CODEX_CLI_PATH"), + nonEmptyEnv("CCR_BUNDLED_CODEX_CLI_PATH"), + nonEmptyEnv("CODEXL_BUNDLED_CODEX_CLI_PATH"), + nonEmptyEnv("CODEX_CLI_PATH") + ]) { + if (candidate && !codexCliPathIsMiddleware(candidate)) return candidate; + } + return "codex"; +} + +function codexCliPathIsMiddleware(value) { + const name = path.basename(String(value || "")).toLowerCase().replace(/\.cmd$/i, ""); + return name === "ccr-codex-cli-middleware.js" || name.startsWith("ccr-codex-cli-stdio-"); } function codexRuntimeHome() { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 09b04c09..fa308acb 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -9,7 +9,7 @@ import { removeOpenCodeProviderAccountConfig } from "@ccr/core/agents/local-prov import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, ROUTER_SCRIPT_MAX_TIMEOUT_MS, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "@ccr/core/contracts/app"; import { createDefaultAppConfig } from "@ccr/core/config/default-config"; import { maxRequestLogBodyBytes } from "@ccr/core/observability/request-log-limits"; -import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; +import { findProviderPresetByBaseUrl, primaryProviderPresetEndpoint, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; import type { AppConfig, ApiKeyConfig, @@ -305,10 +305,11 @@ export async function loadAppConfig(): Promise { } }); const shouldPersistApiKeys = loadedApiKeys.length === 0 || hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0; + const shouldRepairProviderCapabilities = hasUnsupportedNvidiaCapabilities(value.Providers); if (shouldPersistApiKeys) { await replacePersistedApiKeys(apiKeys); } - if (loadedRawConfig.source !== "sqlite" || shouldPersistApiKeys) { + if (loadedRawConfig.source !== "sqlite" || shouldPersistApiKeys || shouldRepairProviderCapabilities) { await writeSanitizedConfig(config); } return config; @@ -348,6 +349,7 @@ export async function saveAppConfig(config: AppConfig): Promise { function withSingleEnabledGlobalProfiles(config: AppConfig): AppConfig { return { ...config, + Providers: config.Providers.map(normalizeProviderPresetCapabilities), profile: { ...config.profile, profiles: enforceSingleEnabledGlobalProfilePerAgent(config.profile.profiles) @@ -355,6 +357,57 @@ function withSingleEnabledGlobalProfiles(config: AppConfig): AppConfig { }; } +function normalizeProviderPresetCapabilities(provider: GatewayProviderConfig): GatewayProviderConfig { + const preset = findProviderPresetByBaseUrl(providerBaseUrl(provider)); + if (preset?.id !== "nvidia") { + return provider; + } + + const chatCapability = provider.capabilities?.find((capability) => + capability.type === "openai_chat_completions" + ); + const presetBaseUrl = primaryProviderPresetEndpoint(preset)?.baseUrl ?? providerBaseUrl(provider); + return { + ...provider, + capabilities: [{ + baseUrl: chatCapability?.baseUrl || presetBaseUrl, + endpoint: chatCapability?.endpoint, + source: chatCapability?.source ?? "preset", + type: "openai_chat_completions" + }] + }; +} + +export function normalizeProviderPresetCapabilitiesForTest( + provider: GatewayProviderConfig +): GatewayProviderConfig { + return normalizeProviderPresetCapabilities(provider); +} + +function hasUnsupportedNvidiaCapabilities(value: unknown): boolean { + if (!Array.isArray(value)) { + return false; + } + return value.some((item) => { + if (!isObject(item)) { + return false; + } + const baseUrl = readString(item.api_base_url) || readString(item.baseUrl) || readString(item.baseurl); + if (!baseUrl || findProviderPresetByBaseUrl(baseUrl)?.id !== "nvidia" || !Array.isArray(item.capabilities)) { + return false; + } + return item.capabilities.some((capability) => { + if (!isObject(capability)) { + return false; + } + const protocol = parseProviderCapabilityProtocol( + readString(capability.type) || readString(capability.protocol) + ); + return Boolean(protocol && protocol !== "openai_chat_completions"); + }); + }); +} + function assertProviderApiKeysAreSafe(config: AppConfig): void { for (const provider of config.Providers ?? []) { const apiKey = providerApiKey(provider); @@ -1144,8 +1197,10 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { type: readString(item.type) }; return removeOpenCodeProviderAccountConfig( - normalizeGrokProviderMediaCapabilities( - normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider)) + normalizeProviderPresetCapabilities( + normalizeGrokProviderMediaCapabilities( + normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider)) + ) ) ); }) diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 53ef9e1e..6ca87f33 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -1778,6 +1778,10 @@ function codexMiddlewareShellScript( ` CCR_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`, "fi", "export CCR_REAL_CODEX_CLI_PATH", + "if [ -z \"${CCR_BUNDLED_CODEX_CLI_PATH:-}\" ]; then", + " CCR_BUNDLED_CODEX_CLI_PATH=$CCR_REAL_CODEX_CLI_PATH", + "fi", + "export CCR_BUNDLED_CODEX_CLI_PATH", `export CCR_CODEX_PROFILE=${shellQuote(values.providerId)}`, `export CCR_CODEX_MODEL=${shellQuote(values.model)}`, `export CCR_CODEX_MODEL_CATALOG_FILE=${shellQuote(values.modelCatalogFile)}`, @@ -1789,6 +1793,10 @@ function codexMiddlewareShellScript( " CODEXL_REAL_CODEX_CLI_PATH=$CCR_REAL_CODEX_CLI_PATH", "fi", "export CODEXL_REAL_CODEX_CLI_PATH", + "if [ -z \"${CODEXL_BUNDLED_CODEX_CLI_PATH:-}\" ]; then", + " CODEXL_BUNDLED_CODEX_CLI_PATH=$CCR_BUNDLED_CODEX_CLI_PATH", + "fi", + "export CODEXL_BUNDLED_CODEX_CLI_PATH", `export CODEXL_CODEX_PROFILE=${shellQuote(values.providerId)}`, `export CODEXL_CODEX_MODEL_CATALOG_FILE=${shellQuote(values.modelCatalogFile)}`, `export CODEXL_CODEX_MODEL_PROVIDER=${shellQuote(values.providerId)}`, @@ -1803,6 +1811,7 @@ function codexMiddlewareShellScript( ...shellProfileSurfaceExports(surface), ...botEnvExports, ...shellCodexlProfileSurfaceExports(), + ...(profile.agent === "codex" ? codexNativeHelperBypassShellLines() : []), ...nodeRuntimeShellExecLines(runtimeFile), "" ].join("\n"); @@ -1828,6 +1837,16 @@ function cmdCodexlProfileSurfaceExports(): string[] { ]; } +function codexNativeHelperBypassShellLines(): string[] { + return [ + "# Browser's native-pipe authorizer requires helper processes to bypass the CCR middleware.", + "if [ \"${1:-}\" = 'sandbox' ] || { [ \"${1:-}\" = 'app-server' ] && [ \"${2:-}\" = '--listen' ] && [ \"${3:-}\" = 'stdio://' ]; }; then", + " unset CODEX_CLI_PATH", + " exec \"$CCR_BUNDLED_CODEX_CLI_PATH\" \"$@\"", + "fi" + ]; +} + function nodeRuntimeCmdExecLines(runtimeFile: string): string[] { const quotedRuntime = cmdQuote(runtimeFile); const quotedHost = cmdQuote(process.execPath); @@ -1890,6 +1909,7 @@ function codexMiddlewareCmdScript( : [ cmdSetLine("CODEX_HOME", resolvedCodexHome), `if not defined CCR_REAL_CODEX_CLI_PATH ${cmdSetLine("CCR_REAL_CODEX_CLI_PATH", codexCli)}`, + "if not defined CCR_BUNDLED_CODEX_CLI_PATH set \"CCR_BUNDLED_CODEX_CLI_PATH=%CCR_REAL_CODEX_CLI_PATH%\"", cmdSetLine("CCR_CODEX_PROFILE", values.providerId), cmdSetLine("CCR_CODEX_MODEL", values.model), cmdSetLine("CCR_CODEX_MODEL_CATALOG_FILE", values.modelCatalogFile), @@ -1898,6 +1918,7 @@ function codexMiddlewareCmdScript( cmdSetLine("CCR_PROFILE_SCOPE", normalizeProfileScope(profile.scope)), cmdSetLine("CCR_CODEX_REMOTE_FRONTEND_MODE", remoteFrontendMode), "if not defined CODEXL_REAL_CODEX_CLI_PATH set \"CODEXL_REAL_CODEX_CLI_PATH=%CCR_REAL_CODEX_CLI_PATH%\"", + "if not defined CODEXL_BUNDLED_CODEX_CLI_PATH set \"CODEXL_BUNDLED_CODEX_CLI_PATH=%CCR_BUNDLED_CODEX_CLI_PATH%\"", cmdSetLine("CODEXL_CODEX_PROFILE", values.providerId), cmdSetLine("CODEXL_CODEX_MODEL_CATALOG_FILE", values.modelCatalogFile), cmdSetLine("CODEXL_CODEX_MODEL_PROVIDER", values.providerId), diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index fefbeb71..2ade2ef5 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -12,7 +12,7 @@ import type { GatewayProviderCapabilityProtocol, GatewayProviderProtocol } from "@ccr/core/contracts/app"; -import { providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index"; +import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index"; import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; import { compactProviderUrl, @@ -228,7 +228,7 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest) } const parsed = parseProviderUrl(request.baseUrl); - const protocols = uniqueProtocols(request.protocols ?? []); + const protocols = providerProbeProtocolsForBaseUrl(request.baseUrl, request.protocols ?? []); const typedModels = uniqueStrings(request.models ?? []); const modelProbe = mode !== "models" || request.skipModelDiscovery ? { models: [] } @@ -264,7 +264,7 @@ function providerProbeCacheKey(request: GatewayProviderProbeRequest): string { mode: request.mode ?? "protocols", models: uniqueStrings(request.models ?? []), providerPluginsHash: hashSensitiveValue(JSON.stringify(request.providerPlugins ?? [])), - protocols: uniqueProtocols(request.protocols ?? []), + protocols: providerProbeProtocolsForBaseUrl(request.baseUrl, request.protocols ?? []), skipModelDiscovery: request.skipModelDiscovery === true }); } @@ -347,7 +347,12 @@ function providerProbeCapabilities( candidate: GatewayProviderProbeCandidate, probe: GatewayProviderProbeResult ): GatewayProviderCapability[] { - const detectedCapabilities = mergeProviderCapabilities(probe.capabilities ?? []); + const allowedProtocols = new Set(providerProbeProtocolsForBaseUrl( + candidate.baseUrl, + (probe.capabilities ?? []).map((capability) => capability.type) + )); + const detectedCapabilities = mergeProviderCapabilities(probe.capabilities ?? []) + .filter((capability) => allowedProtocols.has(capability.type)); const presetCapabilities = providerProbePresetCapabilities(candidate); return mergeProviderCapabilities(detectedCapabilities, presetCapabilities); } @@ -357,13 +362,26 @@ function providerProbePresetCapabilities(candidate: GatewayProviderProbeCandidat return []; } - return uniqueProtocols(candidate.declaredProtocols ?? []).map((type) => ({ + return providerProbeProtocolsForBaseUrl(candidate.baseUrl, candidate.declaredProtocols ?? []).map((type) => ({ baseUrl: providerProbeCandidateBaseUrlForProtocol(candidate.baseUrl, type), source: "preset" as const, type })); } +function providerProbeProtocolsForBaseUrl( + baseUrl: string, + requestedProtocols: GatewayProviderCapabilityProtocol[] +): GatewayProviderCapabilityProtocol[] { + if (findProviderPresetByBaseUrl(baseUrl)?.id === "nvidia") { + // NVIDIA NIM's official OpenAI-compatible endpoint only exposes Chat + // Completions. Never probe /responses: an auth-only response can otherwise + // be mistaken for protocol support and persisted as a false capability. + return ["openai_chat_completions"]; + } + return uniqueProtocols(requestedProtocols); +} + function providerProbeCandidateBaseUrlForProtocol(baseUrl: string, protocol: GatewayProviderCapabilityProtocol): string { try { return providerBaseUrlForCapability(parseProviderBaseUrl(baseUrl), protocol); diff --git a/packages/core/src/providers/runtime-topology.ts b/packages/core/src/providers/runtime-topology.ts index c803ad24..55624b99 100644 --- a/packages/core/src/providers/runtime-topology.ts +++ b/packages/core/src/providers/runtime-topology.ts @@ -240,9 +240,16 @@ function lockedProviderPresetProtocols( ].filter((value): value is string => Boolean(value?.trim())); for (const baseUrl of baseUrls) { - if (findProviderPresetByBaseUrl(baseUrl)?.id === "gemini") { + const presetId = findProviderPresetByBaseUrl(baseUrl)?.id; + if (presetId === "gemini") { return ["gemini_generate_content", "gemini_interactions"]; } + if (presetId === "nvidia") { + // NVIDIA NIM exposes OpenAI Chat Completions at this official endpoint, + // but not the Responses API. Ignore stale or auth-only probe results that + // would otherwise route Codex directly to a non-existent /responses path. + return ["openai_chat_completions"]; + } } return []; diff --git a/packages/core/test/integration/agents/codex-cli-middleware-runtime.test.mjs b/packages/core/test/integration/agents/codex-cli-middleware-runtime.test.mjs index 5bb245ab..6e1c0c5f 100644 --- a/packages/core/test/integration/agents/codex-cli-middleware-runtime.test.mjs +++ b/packages/core/test/integration/agents/codex-cli-middleware-runtime.test.mjs @@ -13,6 +13,73 @@ test("generated Codex CLI middleware runtime is valid JavaScript", () => { execFileSync(process.execPath, ["--check", file], { stdio: "pipe" }); }); +test("Codex app-server uses ChatGPT's bundled Node as a signed supervisor", { skip: process.platform !== "darwin" }, () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-signed-supervisor-")); + const runtimeFile = writeRuntimeScript(dir); + const resourcesDir = path.join(dir, "ChatGPT.app", "Contents", "Resources"); + const fakeCodex = path.join(resourcesDir, "codex"); + const bundledNode = path.join(resourcesDir, "cua_node", "bin", "node"); + const observedFile = path.join(dir, "supervisor-args.txt"); + mkdirSync(path.dirname(bundledNode), { recursive: true }); + writeFileSync(fakeCodex, [ + "#!/bin/sh", + "IFS= read -r request", + "printf '%s\\n' '{\"id\":1,\"result\":{\"supervised\":true}}'", + "" + ].join("\n")); + writeFileSync(bundledNode, [ + "#!/bin/sh", + "printf '%s\\n' \"$@\" > \"$CCR_FAKE_SUPERVISOR_ARGS\"", + "exec \"$CCR_TEST_NODE\" \"$@\"", + "" + ].join("\n")); + chmodSync(fakeCodex, 0o700); + chmodSync(bundledNode, 0o700); + + const result = spawnSync(process.execPath, [runtimeFile, "app-server"], { + encoding: "utf8", + env: { + ...process.env, + CCR_CODEX_REMOTE_FRONTEND_MODE: "app", + CCR_FAKE_SUPERVISOR_ARGS: observedFile, + CCR_PROFILE_SCOPE: "global", + CCR_REAL_CODEX_CLI_PATH: fakeCodex, + CCR_TEST_NODE: process.execPath + }, + input: JSON.stringify({ id: 1, method: "probe/supervisor", params: {} }) + "\n" + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout.trim()).result, { supervised: true }); + const observed = readFileSync(observedFile, "utf8"); + assert.match(observed, /^-e\n/); + assert.ok(observed.includes(fakeCodex)); + assert.ok(observed.includes("app-server")); +}); + +test("Codex runtime ignores middleware recursion and uses the bundled real CLI fallback", { skip: process.platform === "win32" }, () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-real-cli-fallback-")); + const runtimeFile = writeRuntimeScript(dir); + const fakeCodex = path.join(dir, "real-codex"); + writeFileSync(fakeCodex, "#!/bin/sh\nprintf 'real-codex\\n'\n"); + chmodSync(fakeCodex, 0o700); + + const result = spawnSync(process.execPath, [runtimeFile, "--version"], { + encoding: "utf8", + env: { + ...process.env, + CCR_BUNDLED_CODEX_CLI_PATH: fakeCodex, + CCR_CODEX_PROFILE: "claude-code-router", + CCR_REAL_CODEX_CLI_PATH: "", + CODEXL_REAL_CODEX_CLI_PATH: "", + CODEX_CLI_PATH: path.join(dir, "ccr-codex-cli-stdio-profile") + } + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "real-codex\n"); +}); + test("Codex CLI middleware launches Windows cmd shims", { skip: process.platform !== "win32" }, () => { const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-windows-cmd-")); const runtimeFile = writeRuntimeScript(dir); @@ -70,7 +137,7 @@ test("Windows direct profile dispatch strips the profile command arguments", { s assert.deepEqual(JSON.parse(result.stdout), ["--version"]); }); -test("Codex app-server exposes a ChatGPT-shaped workspace identity without credentials", { skip: process.platform === "win32" }, () => { +test("Codex app-server uses a local non-OpenAI identity without credentials", { skip: process.platform === "win32" }, () => { const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-virtual-auth-")); const runtimeFile = writeRuntimeScript(dir); const fakeCodex = path.join(dir, "fake-codex"); @@ -101,9 +168,11 @@ test("Codex app-server exposes a ChatGPT-shaped workspace identity without crede env: { ...process.env, CCR_CODEX_REMOTE_FRONTEND_MODE: "app", + CCR_CODEX_CHATGPT_AUTH_FILE: "", CCR_PROFILE_SCOPE: "ccr", CCR_REAL_CODEX_CLI_PATH: fakeCodex, CODEX_HOME: codexHome, + CODEXL_CODEX_CHATGPT_AUTH_FILE: "", CODEXL_CODEX_WORKSPACE_NAME: "CCR Workspace" }, input: [ @@ -119,17 +188,18 @@ test("Codex app-server exposes a ChatGPT-shaped workspace identity without crede const responses = result.stdout.trim().split(/\r?\n/).map((line) => JSON.parse(line)); assert.deepEqual(responses[0].result, { sawBootstrap: true }); assert.deepEqual(responses[1].result, { - authMethod: "chatgpt", - authToken: null, - requiresOpenaiAuth: true + authMethod: "amazonBedrock", + authToken: "ccr-local-profile", + requiresOpenaiAuth: false }); assert.deepEqual(responses[2].result, { - authMethod: "chatgpt", - requiresOpenaiAuth: true + authMethod: "amazonBedrock", + authToken: null, + requiresOpenaiAuth: false }); assert.deepEqual(responses[3].result, { - account: { type: "chatgpt", email: "CCR Workspace", planType: "unknown" }, - requiresOpenaiAuth: true + account: { type: "amazonBedrock", credentialSource: "codexManaged" }, + requiresOpenaiAuth: false }); assert.equal(existsSync(path.join(codexHome, "auth.json")), false); }); @@ -192,6 +262,64 @@ test("Codex app-server reads but never overwrites an existing ChatGPT auth file" assert.deepEqual(JSON.parse(readFileSync(authFile, "utf8")), existingAuth); }); +test("Codex app-server bridges shared ChatGPT auth into an isolated profile without copying it", { skip: process.platform === "win32" }, () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-shared-auth-")); + const runtimeFile = writeRuntimeScript(dir); + const fakeCodex = path.join(dir, "fake-codex"); + const codexHome = path.join(dir, "codex-home"); + const sharedAuthFile = path.join(dir, "shared-auth.json"); + const token = "header.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2ZpbGUiOnsiZW1haWwiOiJzaGFyZWRAZXhhbXBsZS5jb20ifSwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7ImNoYXRncHRfcGxhbl90eXBlIjoicHJvIn19.signature"; + const sharedAuth = { + auth_mode: "chatgpt", + tokens: { access_token: token, id_token: token, refresh_token: "shared-refresh" } + }; + mkdirSync(codexHome, { recursive: true }); + writeFileSync(sharedAuthFile, JSON.stringify(sharedAuth)); + writeFileSync(fakeCodex, [ + "#!/usr/bin/env node", + "const readline = require('node:readline');", + "const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });", + "input.on('line', (line) => {", + " const request = JSON.parse(line);", + " process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + '\\n');", + "});", + "" + ].join("\n")); + chmodSync(fakeCodex, 0o700); + + const result = spawnSync(process.execPath, [runtimeFile, "app-server"], { + encoding: "utf8", + env: { + ...process.env, + CCR_CODEX_CHATGPT_AUTH_FILE: sharedAuthFile, + CCR_CODEX_REMOTE_FRONTEND_MODE: "app", + CCR_PROFILE_SCOPE: "ccr", + CCR_REAL_CODEX_CLI_PATH: fakeCodex, + CODEX_HOME: codexHome, + CODEXL_CODEX_CHATGPT_AUTH_FILE: "" + }, + input: [ + JSON.stringify({ id: 1, method: "getAuthStatus", params: { includeToken: true, refreshToken: false } }), + JSON.stringify({ id: 2, method: "account/read", params: {} }), + "" + ].join("\n") + }); + + assert.equal(result.status, 0, result.stderr); + const responses = result.stdout.trim().split(/\r?\n/).map((line) => JSON.parse(line)); + assert.deepEqual(responses[0].result, { + authMethod: "chatgpt", + authToken: token, + requiresOpenaiAuth: true + }); + assert.deepEqual(responses[1].result, { + account: { type: "chatgpt", email: "shared@example.com", planType: "pro" }, + requiresOpenaiAuth: true + }); + assert.equal(existsSync(path.join(codexHome, "auth.json")), false); + assert.deepEqual(JSON.parse(readFileSync(sharedAuthFile, "utf8")), sharedAuth); +}); + test("Codex app-server delegates public Git marketplaces and leaves account-private marketplaces empty", { skip: process.platform === "win32" }, () => { const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-official-plugins-")); const runtimeFile = writeRuntimeScript(dir); diff --git a/packages/core/test/integration/profiles/profile-service.test.mjs b/packages/core/test/integration/profiles/profile-service.test.mjs index c896ec14..739f09c4 100644 --- a/packages/core/test/integration/profiles/profile-service.test.mjs +++ b/packages/core/test/integration/profiles/profile-service.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { existsSync, lstatSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -218,6 +219,92 @@ test("profile service overwrites generated bin files without creating backups", assert.deepEqual(backupEntries, []); }); +test("Codex profile launcher bypasses middleware for Browser and Computer Use helpers", { skip: process.platform === "win32" || !process.env.CCR_INTERNAL_HOME_DIR }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-browser-helper-bypass-")); + const profileId = "browser-helper-bypass-test"; + try { + const fakeCodex = path.join(root, "real-codex"); + writeFileSync(fakeCodex, [ + "#!/bin/sh", + "printf '%s|%s|%s|cli_path=%s\\n' \"$1\" \"$2\" \"$3\" \"${CODEX_CLI_PATH:-}\"", + "" + ].join("\n")); + chmodSync(fakeCodex, 0o700); + + const config = createDefaultAppConfig({ + generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") + }); + config.APIKEY = "ccr-browser-helper-test"; + config.APIKEYS = [{ + createdAt: "2026-01-01T00:00:00.000Z", + id: `profile:${profileId}`, + key: config.APIKEY, + name: "Profile: Browser Helper Bypass" + }]; + config.Providers = [{ + api_base_url: "https://example.test/v1", + api_key: "provider-key", + models: ["model"], + name: "Provider" + }]; + config.profile.profiles = [{ + agent: "codex", + cliMiddleware: true, + codexCliPath: fakeCodex, + codexHome: "", + configFile: "", + configFormat: "separate_profile_files", + enabled: true, + env: {}, + id: profileId, + model: "Provider/model", + name: "Browser Helper Bypass", + providerId: "claude-code-router", + providerName: "Claude Code Router", + scope: "ccr", + showAllSessions: false, + surface: "app" + }]; + + const applied = await applyProfileConfig(config); + assert.equal(applied.clients[0].ok, true); + const launcher = path.join(CONFIGDIR, "bin", `ccr-codex-cli-stdio-${profileId}`); + const content = readFileSync(launcher, "utf8"); + assert.ok(content.includes("CCR_BUNDLED_CODEX_CLI_PATH")); + assert.ok(content.includes("app-server' ] && [ \"${2:-}\" = '--listen'")); + + const result = spawnSync(launcher, ["app-server", "--listen", "stdio://"], { + encoding: "utf8", + env: { + ...process.env, + CCR_BUNDLED_CODEX_CLI_PATH: "", + CCR_REAL_CODEX_CLI_PATH: "", + CODEXL_BUNDLED_CODEX_CLI_PATH: "", + CODEXL_REAL_CODEX_CLI_PATH: "", + CODEX_CLI_PATH: launcher + } + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "app-server|--listen|stdio://|cli_path=\n"); + + const sandboxResult = spawnSync(launcher, ["sandbox"], { + encoding: "utf8", + env: { + ...process.env, + CCR_BUNDLED_CODEX_CLI_PATH: "", + CCR_REAL_CODEX_CLI_PATH: "", + CODEXL_BUNDLED_CODEX_CLI_PATH: "", + CODEXL_REAL_CODEX_CLI_PATH: "", + CODEX_CLI_PATH: launcher + } + }); + assert.equal(sandboxResult.status, 0, sandboxResult.stderr); + assert.equal(sandboxResult.stdout, "sandbox|||cli_path=\n"); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + test("profile service injects ToolHub MCP into Codex config", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "codex-toolhub-test"; const config = createDefaultAppConfig({ diff --git a/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs b/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs index 1b089b47..2d65c1ff 100644 --- a/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs +++ b/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs @@ -5,11 +5,35 @@ import path from "node:path"; import test from "node:test"; import { codexDesktopAppName, + codexSharedChatGptAuthEnvForTest, findInstalledCodexAppExecutable, removeLegacyCodexVirtualAuthMarker, writeCodexCompatibleAppModelCatalog } from "@ccr/core/agents/codex/app-launch.ts"; +test("ChatGPT app launch bridges an existing shared Codex login without copying it", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-chatgpt-shared-auth-")); + const authFile = path.join(root, "auth.json"); + const previousCcr = process.env.CCR_CODEX_CHATGPT_AUTH_FILE; + const previousCodexl = process.env.CODEXL_CODEX_CHATGPT_AUTH_FILE; + try { + writeFileSync(authFile, JSON.stringify({ auth_mode: "chatgpt", tokens: { access_token: "token" } })); + process.env.CCR_CODEX_CHATGPT_AUTH_FILE = authFile; + delete process.env.CODEXL_CODEX_CHATGPT_AUTH_FILE; + + assert.deepEqual(codexSharedChatGptAuthEnvForTest(), { + CCR_CODEX_CHATGPT_AUTH_FILE: authFile, + CODEXL_CODEX_CHATGPT_AUTH_FILE: authFile + }); + } finally { + if (previousCcr === undefined) delete process.env.CCR_CODEX_CHATGPT_AUTH_FILE; + else process.env.CCR_CODEX_CHATGPT_AUTH_FILE = previousCcr; + if (previousCodexl === undefined) delete process.env.CODEXL_CODEX_CHATGPT_AUTH_FILE; + else process.env.CODEXL_CODEX_CHATGPT_AUTH_FILE = previousCodexl; + rmSync(root, { force: true, recursive: true }); + } +}); + test("ChatGPT model catalog write includes patch bridge capabilities", () => { const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-app-catalog-")); try { diff --git a/packages/core/test/unit/providers/provider-preset-utils.test.mjs b/packages/core/test/unit/providers/provider-preset-utils.test.mjs index 059ef3b8..2a107887 100644 --- a/packages/core/test/unit/providers/provider-preset-utils.test.mjs +++ b/packages/core/test/unit/providers/provider-preset-utils.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { + normalizeProviderPresetCapabilitiesForTest +} from "@ccr/core/config/config.ts"; import { findProviderPresetByBaseUrlInList, findProviderPresetByIdentityInList, @@ -21,6 +24,10 @@ import { import { providerPresets } from "@ccr/core/providers/presets/index.ts"; +import { + normalizedProviderCapabilities, + providerCapabilityForClientProtocol +} from "@ccr/core/providers/runtime-topology.ts"; import { qiniuAiProviderPreset } from "@ccr/core/providers/presets/qiniu-ai/index.ts"; @@ -120,6 +127,62 @@ test("NVIDIA preset exposes the hosted NIM OpenAI-compatible endpoint", () => { assert.equal(providerPresetMatchesBaseUrl(nvidiaProviderPreset, "https://build.nvidia.com/models"), false); }); +test("NVIDIA preset ignores stale Responses detection and converts Codex requests to Chat Completions", () => { + const provider = { + api_base_url: "https://integrate.api.nvidia.com/v1", + capabilities: [ + { + baseUrl: "https://integrate.api.nvidia.com/v1", + source: "detected", + type: "openai_chat_completions" + }, + { + baseUrl: "https://integrate.api.nvidia.com/v1", + source: "detected", + type: "openai_responses" + } + ], + models: ["z-ai/glm-5.2"], + name: "NVIDIA" + }; + + assert.deepEqual(normalizedProviderCapabilities(provider), [ + { + baseUrl: "https://integrate.api.nvidia.com/v1", + source: "detected", + type: "openai_chat_completions" + } + ]); + assert.equal( + providerCapabilityForClientProtocol(provider, "openai_responses")?.type, + "openai_chat_completions" + ); +}); + +test("NVIDIA config normalization removes a previously persisted Responses capability", () => { + const provider = normalizeProviderPresetCapabilitiesForTest({ + api_base_url: "https://integrate.api.nvidia.com/v1", + capabilities: [ + { + baseUrl: "https://integrate.api.nvidia.com/v1", + source: "detected", + type: "openai_responses" + } + ], + models: ["z-ai/glm-5.2"], + name: "NVIDIA" + }); + + assert.deepEqual(provider.capabilities, [ + { + baseUrl: "https://integrate.api.nvidia.com/v1", + endpoint: undefined, + source: "preset", + type: "openai_chat_completions" + } + ]); +}); + test("provider identity safety does not block branded third-party endpoints", () => { assert.equal( providerIdentitySafetyIssueInList(presets, { diff --git a/packages/core/test/unit/providers/provider-probe.test.mjs b/packages/core/test/unit/providers/provider-probe.test.mjs index ce49518b..290dd113 100644 --- a/packages/core/test/unit/providers/provider-probe.test.mjs +++ b/packages/core/test/unit/providers/provider-probe.test.mjs @@ -47,6 +47,41 @@ test("protocol support probe keeps auth-only fallback for unhinted endpoints", ( ); }); +test("NVIDIA probe never requests or persists the Responses protocol", async (t) => { + const previousFetch = globalThis.fetch; + const paths = []; + + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + paths.push(url.pathname); + return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { + headers: { "content-type": "application/json" }, + status: 401 + }); + }; + t.after(() => { + globalThis.fetch = previousFetch; + }); + + const probe = await probeGatewayProvider({ + baseUrl: "https://integrate.api.nvidia.com/v1", + forceRefresh: true, + mode: "protocols", + protocols: ["openai_responses", "openai_chat_completions"] + }); + + assert.deepEqual(paths, ["/v1/chat/completions"]); + assert.deepEqual( + probe.protocols.map(({ protocol, status, supported }) => ({ protocol, status, supported })), + [{ protocol: "openai_chat_completions", status: 401, supported: true }] + ); + assert.deepEqual( + probe.capabilities?.map(({ type }) => type), + ["openai_chat_completions"] + ); + assert.equal(probe.detectedProtocol, "openai_chat_completions"); +}); + test("protocol support probe treats HTTP 400 validation as protocol support", () => { const message = "HTTP 400: * GenerateContentRequest.contents: contents is not specified";