From 92438816d55175cf8f43a90a00f22e423d3cea73 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 17 Jun 2026 20:35:14 -0700 Subject: [PATCH 01/14] feat(api): rotate OpenAI credential pools --- .env.example | 2 + docs/advanced-setup.md | 3 +- scripts/provider-bootstrap.ts | 6 +- scripts/provider-launch.ts | 16 +- scripts/provider-recommend.ts | 14 +- scripts/system-check.test.ts | 50 +++- scripts/system-check.ts | 6 +- src/commands/cache-probe/cache-probe.test.ts | 43 +++ src/commands/cache-probe/cache-probe.ts | 27 +- .../onboard-github/onboard-github.test.ts | 3 + .../onboard-github/onboard-github.tsx | 3 + src/commands/provider/provider.tsx | 14 +- src/components/ProviderManager.tsx | 3 + .../integrationArtifacts.generated.ts | 1 + src/integrations/providerUiMetadata.ts | 16 +- src/integrations/routeMetadata.test.ts | 46 ++++ src/integrations/routeMetadata.ts | 45 +++- src/integrations/vendors/openai.ts | 16 +- src/services/api/credentialPool.test.ts | 55 ++++ src/services/api/credentialPool.ts | 104 ++++++++ .../api/errors.openaiCompatibility.test.ts | 16 ++ src/services/api/errors.ts | 2 +- src/services/api/openaiShim.test.ts | 214 +++++++++++++++ src/services/api/openaiShim.ts | 250 ++++++++++++++---- src/services/api/providerConfig.local.test.ts | 12 + src/services/api/providerConfig.ts | 1 + src/utils/envFile.test.ts | 24 ++ src/utils/envFile.ts | 1 + src/utils/providerAutoDetect.test.ts | 9 + src/utils/providerAutoDetect.ts | 21 +- src/utils/providerProfile.test.ts | 115 +++++++- src/utils/providerProfile.ts | 74 +++++- src/utils/providerProfiles.test.ts | 11 + src/utils/providerStartupOverrides.test.ts | 4 + src/utils/providerStartupOverrides.ts | 1 + src/utils/providerValidation.test.ts | 52 +++- src/utils/providerValidation.ts | 37 ++- src/utils/swarm/spawnUtils.test.ts | 10 + src/utils/swarm/spawnUtils.ts | 1 + 39 files changed, 1214 insertions(+), 114 deletions(-) create mode 100644 src/commands/cache-probe/cache-probe.test.ts create mode 100644 src/services/api/credentialPool.test.ts create mode 100644 src/services/api/credentialPool.ts diff --git a/.env.example b/.env.example index d10eaa8233..db29915c74 100644 --- a/.env.example +++ b/.env.example @@ -175,6 +175,8 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # ----------------------------------------------------------------------------- # CLAUDE_CODE_USE_OPENAI=1 # OPENAI_API_KEY=sk-your-key-here +# Optional: rotate across multiple OpenAI-compatible API keys on auth/quota/rate-limit failures. +# OPENAI_API_KEYS=sk-key-a,sk-key-b # OPENAI_MODEL=gpt-4o # For DeepSeek, set: # OPENAI_BASE_URL=https://api.deepseek.com/v1 diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 664e55f324..83da29ce56 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -324,7 +324,8 @@ The **OpenClaude VS Code extension** can store the key in Secret Storage and set | Variable | Required | Description | |----------|----------|-------------| | `CLAUDE_CODE_USE_OPENAI` | OpenAI-compatible only | Set to `1` to enable the OpenAI-compatible provider path | -| `OPENAI_API_KEY` | OpenAI-compatible cloud routes* | Your API key (`*` not needed for local models like Ollama, LM Studio, Atomic Chat, or other local OpenAI-compatible proxies) | +| `OPENAI_API_KEYS` | One of `OPENAI_API_KEYS` or `OPENAI_API_KEY` for non-local OpenAI-compatible cloud routes* | Comma-separated OpenAI-compatible API key pool. Takes precedence over `OPENAI_API_KEY` and rotates to the next key on auth, quota, or rate-limit failures (`*` not needed for local models like Ollama, LM Studio, Atomic Chat, or other local OpenAI-compatible proxies). | +| `OPENAI_API_KEY` | Required only when `OPENAI_API_KEYS` is unset or empty for non-local OpenAI-compatible cloud routes* | Your API key (`*` not needed for local models like Ollama, LM Studio, Atomic Chat, or other local OpenAI-compatible proxies). A comma-separated list also enables key rotation. | | `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` | | `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` | | `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` | diff --git a/scripts/provider-bootstrap.ts b/scripts/provider-bootstrap.ts index 404bcb32cc..0a5da747ce 100644 --- a/scripts/provider-bootstrap.ts +++ b/scripts/provider-bootstrap.ts @@ -167,12 +167,14 @@ async function main(): Promise { goal, model: argModel || null, baseUrl: argBaseUrl || null, - apiKey: argApiKey || process.env.OPENAI_API_KEY || null, + apiKey: argApiKey || null, processEnv: process.env, }) if (!builtEnv) { - console.error('OpenAI profile requires a real API key. Use --api-key or set OPENAI_API_KEY.') + console.error( + 'OpenAI profile requires real credential(s). Use --api-key or set OPENAI_API_KEYS or OPENAI_API_KEY.', + ) process.exit(1) } diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index 81d4596bfe..18f2cadccd 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -9,7 +9,9 @@ import { } from '../src/utils/providerRecommendation.ts' import { buildLaunchEnv, + hasInvalidOpenAICredentialPool, loadProfileFile, + sanitizeOpenAICredentialPool, selectAutoProfile, type ProfileFile, type ProviderProfile, @@ -227,8 +229,18 @@ async function main(): Promise { process.exit(1) } - if (profile === 'openai' && (!env.OPENAI_API_KEY || env.OPENAI_API_KEY === 'SUA_CHAVE')) { - console.error('OPENAI_API_KEY is required for openai profile and cannot be SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key ') + if ( + profile === 'openai' && + (hasInvalidOpenAICredentialPool(env.OPENAI_API_KEYS) || + hasInvalidOpenAICredentialPool(env.OPENAI_API_KEY) || + !( + sanitizeOpenAICredentialPool(env.OPENAI_API_KEYS) || + sanitizeOpenAICredentialPool(env.OPENAI_API_KEY) + )) + ) { + console.error( + 'OPENAI_API_KEYS or OPENAI_API_KEY is required for openai profile and cannot include SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key ', + ) process.exit(1) } diff --git a/scripts/provider-recommend.ts b/scripts/provider-recommend.ts index 8dc23835b3..20c49947a0 100644 --- a/scripts/provider-recommend.ts +++ b/scripts/provider-recommend.ts @@ -15,7 +15,7 @@ import { buildOpenAIProfileEnv, createProfileFile, saveProfileFile, - sanitizeApiKey, + sanitizeOpenAICredentialPool, type ProfileFile, type ProviderProfile, } from '../src/utils/providerProfile.ts' @@ -140,12 +140,11 @@ async function maybeApplyProfile( env = buildOpenAIProfileEnv({ goal, model: model || getGoalDefaultOpenAIModel(goal), - apiKey: process.env.OPENAI_API_KEY, processEnv: process.env, }) if (!env) { - console.error('Cannot apply an OpenAI profile without OPENAI_API_KEY.') + console.error('Cannot apply an OpenAI profile without OPENAI_API_KEYS or OPENAI_API_KEY.') return false } } @@ -186,7 +185,10 @@ async function main(): Promise { })) const recommendedOllama = selectRecommendedOllamaModel(rankedModels) - const openAIConfigured = Boolean(sanitizeApiKey(process.env.OPENAI_API_KEY)) + const openAIConfigured = Boolean( + sanitizeOpenAICredentialPool(process.env.OPENAI_API_KEYS) || + sanitizeOpenAICredentialPool(process.env.OPENAI_API_KEY), + ) let recommendedProfile: ProviderProfile let recommendedModel: string @@ -252,10 +254,10 @@ async function main(): Promise { if (!recommendedOllama && !openAIConfigured) { console.log( - '\nNo local Ollama model was detected and OPENAI_API_KEY is unset.', + '\nNo local Ollama model was detected and OPENAI_API_KEYS / OPENAI_API_KEY are unset.', ) console.log( - 'Next steps: `ollama pull qwen2.5-coder:7b` or set OPENAI_API_KEY.', + 'Next steps: `ollama pull qwen2.5-coder:7b` or set OPENAI_API_KEYS or OPENAI_API_KEY.', ) } } diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index 169b75709c..73a357bb4d 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -23,6 +23,7 @@ const ENV_KEYS = [ 'MISTRAL_MODEL', 'OPENAI_MODEL', 'OPENAI_BASE_URL', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENGATEWAY_API_KEY', 'GITHUB_TOKEN', @@ -183,15 +184,58 @@ describe('system-check provider diagnostics', () => { const results = checkOpenAIEnv() const summary = serializeSafeEnvSummary() - const credentialResult = results.find(result => result.label === 'OPENAI_API_KEY') + const credentialResult = results.find( + result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY', + ) expect(credentialResult).toEqual({ ok: false, - label: 'OPENAI_API_KEY', - detail: 'Missing key for non-local provider URL. Set OPENAI_API_KEY.', + label: 'OPENAI_API_KEYS or OPENAI_API_KEY', + detail: + 'Missing key for non-local provider URL. Set OPENAI_API_KEYS or OPENAI_API_KEY.', }) expect(summary.PROVIDER_API_KEY_SET).toBe(false) }) + + test('falls back to OPENAI_API_KEY when OPENAI_API_KEYS is delimiter-only', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-4o' + process.env.OPENAI_API_KEYS = ', ,' + process.env.OPENAI_API_KEY = 'sk-openai-single' + + const results = checkOpenAIEnv() + const summary = serializeSafeEnvSummary() + const credentialResult = results.find( + result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY', + ) + + expect(credentialResult).toEqual({ + ok: true, + label: 'OPENAI_API_KEYS or OPENAI_API_KEY', + detail: 'Configured.', + }) + expect(summary.PROVIDER_API_KEY_SET).toBe(true) + }) + + test('rejects placeholder values inside OPENAI_API_KEYS pools', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-4o' + process.env.OPENAI_API_KEYS = 'sk-openai-a,SUA_CHAVE' + delete process.env.OPENAI_API_KEY + + const results = checkOpenAIEnv() + const credentialResult = results.find( + result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY', + ) + + expect(credentialResult).toEqual({ + ok: false, + label: 'OPENAI_API_KEYS or OPENAI_API_KEY', + detail: 'Placeholder value detected: SUA_CHAVE.', + }) + }) }) describe('checkNodeVersion', () => { diff --git a/scripts/system-check.ts b/scripts/system-check.ts index 6f39a8d7df..6de0696581 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -331,6 +331,10 @@ function getOpenAICompatibleCredentialContext(baseUrl: string): { } } +function hasPlaceholderCredential(value: string | undefined): boolean { + return (value ?? '').split(',').some(part => part.trim() === 'SUA_CHAVE') +} + function currentBaseUrl(): string { if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) { return process.env.GEMINI_BASE_URL ?? GEMINI_DEFAULT_BASE_URL @@ -495,7 +499,7 @@ export function checkOpenAIEnv(): CheckResult[] { const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN const hasGithubRouteCredential = credentialContext.routeId === 'github' && Boolean(githubToken?.trim()) - if (key === 'SUA_CHAVE' || providerCredential === 'SUA_CHAVE') { + if (hasPlaceholderCredential(key) || hasPlaceholderCredential(providerCredential)) { results.push(fail(credentialLabel, 'Placeholder value detected: SUA_CHAVE.')) } else if ( !providerCredential && diff --git a/src/commands/cache-probe/cache-probe.test.ts b/src/commands/cache-probe/cache-probe.test.ts new file mode 100644 index 0000000000..168f3dcd34 --- /dev/null +++ b/src/commands/cache-probe/cache-probe.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'bun:test' + +import { call, resolveCacheProbeApiKey } from './cache-probe.js' + +test('resolveCacheProbeApiKey prefers the first usable OPENAI_API_KEYS entry', () => { + expect( + resolveCacheProbeApiKey({ + OPENAI_API_KEYS: 'key-a,key-b', + OPENAI_API_KEY: 'single-key', + } as NodeJS.ProcessEnv), + ).toBe('key-a') +}) + +test('resolveCacheProbeApiKey falls back to comma-separated OPENAI_API_KEY', () => { + expect( + resolveCacheProbeApiKey({ + OPENAI_API_KEY: 'key-a,key-b', + } as NodeJS.ProcessEnv), + ).toBe('key-a') +}) + +test('cache-probe no-key guidance mentions pooled OpenAI credentials', async () => { + const originalEnv = { ...process.env } + try { + for (const key of Object.keys(process.env)) { + delete process.env[key] + } + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + + const result = await call('', {} as any) + + expect(result.type).toBe('text') + if (result.type !== 'text') throw new Error('expected text result') + expect(result.value).toContain('OPENAI_API_KEYS or OPENAI_API_KEY') + } finally { + for (const key of Object.keys(process.env)) { + delete process.env[key] + } + Object.assign(process.env, originalEnv) + } +}) \ No newline at end of file diff --git a/src/commands/cache-probe/cache-probe.ts b/src/commands/cache-probe/cache-probe.ts index e3aa910009..2ff03687eb 100644 --- a/src/commands/cache-probe/cache-probe.ts +++ b/src/commands/cache-probe/cache-probe.ts @@ -1,4 +1,5 @@ import { getSessionId } from '../../bootstrap/state.js' +import { parseCredentialList } from '../../services/api/credentialPool.js' import { resolveProviderRequest } from '../../services/api/providerConfig.js' import type { LocalCommandCall } from '../../types/command.js' import { logForDebugging } from '../../utils/debug.js' @@ -39,6 +40,22 @@ function getModelFamily(model: string | undefined): string { .replace(/-preview$/, '') } +function firstUsableCredential(value: string | undefined): string | undefined { + return parseCredentialList(value).find( + credential => credential !== 'SUA_CHAVE', + ) +} + +export function resolveCacheProbeApiKey( + env: NodeJS.ProcessEnv = process.env, +): string { + return ( + firstUsableCredential(env.OPENAI_API_KEYS) ?? + firstUsableCredential(env.OPENAI_API_KEY) ?? + '' + ) +} + function getField(obj: unknown, path: string): unknown { return path .split('.') @@ -187,13 +204,13 @@ export const call: LocalCommandCall = async (args) => { const isGithub = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) // Resolve API key the same way the OpenAI shim does - let apiKey = process.env.OPENAI_API_KEY ?? '' + let apiKey = resolveCacheProbeApiKey(process.env) if (!apiKey && isGithub) { hydrateGithubModelsTokenFromSecureStorage() apiKey = - process.env.OPENAI_API_KEY ?? - process.env.GITHUB_TOKEN ?? - process.env.GH_TOKEN ?? + resolveCacheProbeApiKey(process.env) || + process.env.GITHUB_TOKEN || + process.env.GH_TOKEN || '' } @@ -203,7 +220,7 @@ export const call: LocalCommandCall = async (args) => { value: 'No API key found. Make sure you are in an active OpenAI-compatible or GitHub Copilot session.\n' + 'For GitHub Copilot: run /onboard-github first.\n' + - 'For OpenAI-compatible: set OPENAI_API_KEY.', + 'For OpenAI-compatible: set OPENAI_API_KEYS or OPENAI_API_KEY.', } } diff --git a/src/commands/onboard-github/onboard-github.test.ts b/src/commands/onboard-github/onboard-github.test.ts index 3fce86c5b5..4177433caa 100644 --- a/src/commands/onboard-github/onboard-github.test.ts +++ b/src/commands/onboard-github/onboard-github.test.ts @@ -86,6 +86,7 @@ describe('onboarding auth precedence cleanup', () => { const env: NodeJS.ProcessEnv = { CLAUDE_CODE_USE_OPENAI: '1', OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEYS: 'sk-stale-pool-a,sk-stale-pool-b', OPENAI_API_KEY: 'sk-stale-openai-key', OPENAI_ORG: 'org-old', OPENAI_PROJECT: 'project-old', @@ -103,6 +104,7 @@ describe('onboarding auth precedence cleanup', () => { expect(env.CLAUDE_CODE_USE_GITHUB).toBe('1') expect(env.OPENAI_MODEL).toBe('github:copilot') + expect(env.OPENAI_API_KEYS).toBeUndefined() expect(env.OPENAI_API_KEY).toBeUndefined() expect(env.OPENAI_ORG).toBeUndefined() expect(env.OPENAI_PROJECT).toBeUndefined() @@ -119,6 +121,7 @@ describe('onboarding auth precedence cleanup', () => { const settingsEnv = buildGithubOnboardingSettingsEnv('github:copilot') expect(settingsEnv.CLAUDE_CODE_USE_GITHUB).toBe('1') expect(settingsEnv.OPENAI_MODEL).toBe('github:copilot') + expect(settingsEnv.OPENAI_API_KEYS).toBeUndefined() expect(settingsEnv.OPENAI_API_KEY).toBeUndefined() expect(settingsEnv.OPENAI_ORG).toBeUndefined() expect(settingsEnv.OPENAI_PROJECT).toBeUndefined() diff --git a/src/commands/onboard-github/onboard-github.tsx b/src/commands/onboard-github/onboard-github.tsx index 589a52f774..2a3bdc8440 100644 --- a/src/commands/onboard-github/onboard-github.tsx +++ b/src/commands/onboard-github/onboard-github.tsx @@ -44,6 +44,7 @@ const PROVIDER_SPECIFIC_KEYS = new Set([ 'CLAUDE_CODE_USE_FOUNDRY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENAI_MODEL', 'GITHUB_COPILOT_KEY', @@ -103,6 +104,7 @@ export function buildGithubOnboardingSettingsEnv( CLAUDE_CODE_USE_GITHUB: '1', OPENAI_MODEL: model, GITHUB_ENTERPRISE_URL: gheUrl, + OPENAI_API_KEYS: undefined, OPENAI_API_KEY: undefined, OPENAI_ORG: undefined, OPENAI_PROJECT: undefined, @@ -130,6 +132,7 @@ export function applyGithubOnboardingProcessEnv( delete env.GITHUB_ENTERPRISE_URL } + delete env.OPENAI_API_KEYS delete env.OPENAI_API_KEY delete env.OPENAI_ORG delete env.OPENAI_PROJECT diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index 3aa9ac6463..55ed18cf36 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -50,6 +50,7 @@ import { maskSecretForDisplay, redactSecretValueForDisplay, sanitizeApiKey, + sanitizeOpenAICredentialPool, sanitizeProviderConfigValue, saveProfileFile, type ProfileEnv, @@ -1370,28 +1371,29 @@ export function ProviderWizard({ case 'openai-key': { const openAIMetadata = getProviderPresetUiMetadata('openai') + const currentOpenAIApiKey = openAIMetadata.apiKey return ( { - const candidate = value.trim() || process.env.OPENAI_API_KEY || '' - return sanitizeApiKey(candidate) + const candidate = value.trim() || currentOpenAIApiKey + return sanitizeOpenAICredentialPool(candidate) ? null : 'Enter a real API key. Placeholder values like SUA_CHAVE are not valid.' }} onSubmit={value => { - const apiKey = value.trim() || process.env.OPENAI_API_KEY || '' + const apiKey = value.trim() || currentOpenAIApiKey setStep({ name: 'openai-base', apiKey, diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 115c65fb39..2b527a4df8 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -1325,6 +1325,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { env: { CLAUDE_CODE_USE_GITHUB: '1', OPENAI_MODEL: GITHUB_PROVIDER_DEFAULT_MODEL, + OPENAI_API_KEYS: undefined as any, OPENAI_API_KEY: undefined as any, OPENAI_ORG: undefined as any, OPENAI_PROJECT: undefined as any, @@ -1344,6 +1345,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { process.env.CLAUDE_CODE_USE_GITHUB = '1' process.env.OPENAI_MODEL = GITHUB_PROVIDER_DEFAULT_MODEL + delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_API_KEY delete process.env.OPENAI_ORG delete process.env.OPENAI_PROJECT @@ -1394,6 +1396,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER] delete process.env.OPENAI_MODEL + delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_API_KEY delete process.env.OPENAI_ORG delete process.env.OPENAI_PROJECT diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 585ae5d3ac..100c5e018e 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -348,6 +348,7 @@ export const PROVIDER_PRESET_MANIFEST = [ "vendorId": "openai", "description": "OpenAI API with API key", "apiKeyEnvVars": [ + "OPENAI_API_KEYS", "OPENAI_API_KEY" ] }, diff --git a/src/integrations/providerUiMetadata.ts b/src/integrations/providerUiMetadata.ts index 428b44a9f9..ba57a81c23 100644 --- a/src/integrations/providerUiMetadata.ts +++ b/src/integrations/providerUiMetadata.ts @@ -27,14 +27,26 @@ function readFirstEnvValue( ): string { for (const envVar of envVars ?? []) { const value = processEnv[envVar]?.trim() - if (value) { - return value + if (hasUsableEnvValue(envVar, value)) { + return value ?? '' } } return '' } +function hasUsableEnvValue(envVar: string, value: string | undefined): boolean { + if (!value) { + return false + } + + if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { + return value.split(',').some(part => part.trim() !== '') + } + + return true +} + export type ProviderPresetUiMetadata = { apiKey: string authMode: AuthMode diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index efcffe9105..3e255005b9 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -7,6 +7,7 @@ import { getRouteDefaultModel, getRouteProviderTypeLabel, resolveActiveRouteIdFromEnv, + resolveRouteCredentialValue, resolveRouteIdFromBaseUrl, } from './routeMetadata.js' @@ -32,24 +33,33 @@ test('getRouteProviderTypeLabel falls back safely for unknown routes', () => { }) test('getRouteCredentialEnvVars keeps descriptor env vars and openai fallback for openai-compatible routes', () => { + expect(getRouteCredentialEnvVars('custom')).toEqual([ + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ]) expect(getRouteCredentialEnvVars('openrouter')).toEqual([ 'OPENROUTER_API_KEY', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', ]) expect(getRouteCredentialEnvVars('deepseek')).toEqual([ 'DEEPSEEK_API_KEY', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', ]) expect(getRouteCredentialEnvVars('hicap')).toEqual([ 'HICAP_API_KEY', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', ]) expect(getRouteCredentialEnvVars('venice')).toEqual([ 'VENICE_API_KEY', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', ]) expect(getRouteCredentialEnvVars('xiaomi-mimo')).toEqual([ 'MIMO_API_KEY', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', ]) }) @@ -84,6 +94,33 @@ test('getRouteCredentialValue reads the first configured route credential', () = ).toBe('sk-openai-fallback') }) +test('route credential discovery reads OPENAI_API_KEYS before singular fallback', () => { + expect( + getRouteCredentialValue('openai', { + OPENAI_API_KEYS: 'sk-openai-a,sk-openai-b', + OPENAI_API_KEY: 'sk-openai-single', + }), + ).toBe('sk-openai-a,sk-openai-b') + expect( + resolveRouteCredentialValue({ + baseUrl: 'https://api.openai.com/v1', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_API_KEYS: 'sk-openai-a,sk-openai-b', + }, + }), + ).toBe('sk-openai-a,sk-openai-b') +}) + +test('route credential discovery ignores delimiter-only OPENAI_API_KEYS before singular fallback', () => { + expect( + getRouteCredentialValue('openai', { + OPENAI_API_KEYS: ', ,', + OPENAI_API_KEY: 'sk-openai-single', + }), + ).toBe('sk-openai-single') +}) + test('Venice route metadata uses official OpenAI-compatible defaults', () => { expect(getRouteDefaultBaseUrl('venice')).toBe('https://api.venice.ai/api/v1') expect(getRouteDefaultModel('venice')).toBe('venice-uncensored') @@ -228,6 +265,15 @@ test('resolveActiveRouteIdFromEnv does not infer MiniMax with OpenAI credentials ).toBe('anthropic') }) +test('resolveActiveRouteIdFromEnv does not infer MiniMax with pooled OpenAI credentials', () => { + expect( + resolveActiveRouteIdFromEnv({ + MINIMAX_API_KEY: 'minimax-key', + OPENAI_API_KEYS: 'openai-key-a,openai-key-b', + }), + ).toBe('anthropic') +}) + test('resolveActiveRouteIdFromEnv infers Near AI with NEARAI_API_KEY and stale OPENAI_API_KEY', () => { expect( resolveActiveRouteIdFromEnv({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index cdeed11d4e..f90f07c542 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -196,15 +196,37 @@ function readFirstNonEmptyEnvValue( envVars: readonly string[], ): string | undefined { for (const envVar of envVars) { - const value = processEnv[envVar]?.trim() - if (value) { - return value + const value = processEnv[envVar] + if (hasUsableEnvCredentialValue(envVar, value)) { + return value!.trim() } } return undefined } +function hasUsableEnvCredentialValue( + envVar: string, + value: string | undefined, +): boolean { + if (typeof value !== 'string') { + return false + } + + if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { + return value.split(',').some(part => part.trim() !== '') + } + + return value.trim() !== '' +} + +function hasAnyUsableOpenAICredential(processEnv: NodeJS.ProcessEnv): boolean { + return ( + hasUsableEnvCredentialValue('OPENAI_API_KEYS', processEnv.OPENAI_API_KEYS) || + hasUsableEnvCredentialValue('OPENAI_API_KEY', processEnv.OPENAI_API_KEY) + ) +} + function hasNonEmptyEnvValue(value: string | undefined): boolean { const trimmed = value?.trim().toLowerCase() return Boolean(trimmed && trimmed !== 'undefined' && trimmed !== 'null') @@ -473,7 +495,7 @@ export function hasMiniMaxEnvOnlyProviderIntent( hasMiniMaxCredential && !hasConflictingOpenAIBaseUrlForRoute(processEnv, isMiniMaxBaseUrl) && (hasExplicitMiniMaxIntent || - (!hasNonEmptyEnvValue(processEnv.OPENAI_API_KEY) && + (!hasAnyUsableOpenAICredential(processEnv) && !hasNonEmptyEnvValue(processEnv.XAI_API_KEY) && hasNoExplicitNonOpenAICompatibleProvider(processEnv))) ) @@ -484,7 +506,7 @@ export function hasVeniceEnvOnlyProviderIntent( ): boolean { return ( hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) && - !hasNonEmptyEnvValue(processEnv.OPENAI_API_KEY) && + !hasAnyUsableOpenAICredential(processEnv) && !hasNonEmptyEnvValue(processEnv.XAI_API_KEY) && !hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) && !hasConflictingOpenAIBaseUrlForRoute(processEnv, isVeniceBaseUrl) && @@ -497,7 +519,7 @@ export function hasXiaomiMimoEnvOnlyProviderIntent( ): boolean { return ( hasNonEmptyEnvValue(processEnv.MIMO_API_KEY) && - !hasNonEmptyEnvValue(processEnv.OPENAI_API_KEY) && + !hasAnyUsableOpenAICredential(processEnv) && !hasNonEmptyEnvValue(processEnv.XAI_API_KEY) && !hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) && !hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) && @@ -582,7 +604,7 @@ export function getRouteCredentialEnvVars( routeId: string, ): string[] { if (routeId === 'custom') { - return ['OPENAI_API_KEY'] + return ['OPENAI_API_KEYS', 'OPENAI_API_KEY'] } const descriptor = getRouteDescriptor(routeId) @@ -595,9 +617,14 @@ export function getRouteCredentialEnvVars( (descriptor.transportConfig.kind === 'openai-compatible' || descriptor.transportConfig.kind === 'local') && !descriptor.setup.dedicatedCredentialsOnly && - !envVars.includes('OPENAI_API_KEY') + !envVars.includes('OPENAI_API_KEYS') ) { - envVars.push('OPENAI_API_KEY') + const openAIFallbackIndex = envVars.indexOf('OPENAI_API_KEY') + if (openAIFallbackIndex === -1) { + envVars.push('OPENAI_API_KEYS', 'OPENAI_API_KEY') + } else { + envVars.splice(openAIFallbackIndex, 0, 'OPENAI_API_KEYS') + } } return uniqueEnvVars(envVars) diff --git a/src/integrations/vendors/openai.ts b/src/integrations/vendors/openai.ts index cd516a74ea..0d942e2c73 100644 --- a/src/integrations/vendors/openai.ts +++ b/src/integrations/vendors/openai.ts @@ -6,11 +6,11 @@ export default defineVendor({ classification: 'openai-compatible', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.4', - requiredEnvVars: ['OPENAI_API_KEY'], + requiredEnvVars: ['OPENAI_API_KEYS', 'OPENAI_API_KEY'], setup: { requiresAuth: true, authMode: 'api-key', - credentialEnvVars: ['OPENAI_API_KEY'], + credentialEnvVars: ['OPENAI_API_KEYS', 'OPENAI_API_KEY'], }, transportConfig: { kind: 'openai-compatible', @@ -22,7 +22,7 @@ export default defineVendor({ preset: { id: 'openai', description: 'OpenAI API with API key', - apiKeyEnvVars: ['OPENAI_API_KEY'], + apiKeyEnvVars: ['OPENAI_API_KEYS', 'OPENAI_API_KEY'], }, validation: { kind: 'credential-env', @@ -30,11 +30,17 @@ export default defineVendor({ matchDefaultBaseUrl: true, fallbackWhenUseOpenAI: true, }, - credentialEnvVars: ['OPENAI_API_KEY'], + credentialEnvVars: ['OPENAI_API_KEYS', 'OPENAI_API_KEY'], allowLocalBaseUrlWithoutCredential: true, missingCredentialMessage: - 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', + 'OPENAI_API_KEYS or OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', invalidCredentialValues: [ + { + envVar: 'OPENAI_API_KEYS', + value: 'SUA_CHAVE', + message: + 'Invalid OPENAI_API_KEYS: placeholder value SUA_CHAVE detected. Set real key(s) or unset for local providers.', + }, { envVar: 'OPENAI_API_KEY', value: 'SUA_CHAVE', diff --git a/src/services/api/credentialPool.test.ts b/src/services/api/credentialPool.test.ts new file mode 100644 index 0000000000..405d5beee9 --- /dev/null +++ b/src/services/api/credentialPool.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'bun:test' +import { CredentialPool, parseCredentialList } from './credentialPool.js' + +test('parseCredentialList trims comma-separated keys', () => { + expect(parseCredentialList(' key-a, key-b ,,key-c ')).toEqual([ + 'key-a', + 'key-b', + 'key-c', + ]) +}) + +test('CredentialPool rotates through healthy credentials', () => { + const pool = new CredentialPool(['key-a', 'key-b']) + + expect(pool.next()?.value).toBe('key-a') + expect(pool.next()?.value).toBe('key-b') + expect(pool.next()?.value).toBe('key-a') +}) + +test('CredentialPool permanently skips auth-failed credentials', () => { + const pool = new CredentialPool(['key-a', 'key-b']) + const first = pool.next() + + pool.reportFailure(first, 'auth', 30_000) + + expect(pool.next()?.value).toBe('key-b') + expect(pool.next()?.value).toBe('key-b') +}) + +test('CredentialPool cools down recoverable failures', () => { + let now = 1_000 + const pool = new CredentialPool(['key-a', 'key-b'], () => now) + const first = pool.next() + + pool.reportFailure(first, 'cooldown', 30_000) + + expect(pool.next()?.value).toBe('key-b') + + now += 30_001 + expect(pool.next()?.value).toBe('key-a') +}) + +test('CredentialPool falls back to least-recently failed credential when all are cooling down', () => { + let now = 1_000 + const pool = new CredentialPool(['key-a', 'key-b'], () => now) + const first = pool.next() + + pool.reportFailure(first, 'cooldown', 30_000) + + now += 1_000 + const second = pool.next() + pool.reportFailure(second, 'cooldown', 30_000) + + expect(pool.next()).toEqual({ value: 'key-a', index: 0 }) +}) diff --git a/src/services/api/credentialPool.ts b/src/services/api/credentialPool.ts new file mode 100644 index 0000000000..8c72adf658 --- /dev/null +++ b/src/services/api/credentialPool.ts @@ -0,0 +1,104 @@ +export type CredentialPoolFailureKind = 'auth' | 'cooldown' + +export type CredentialLease = { + value: string + index: number +} + +type CredentialState = { + value: string + disabled: boolean + cooldownUntil: number + lastFailureAt: number +} + +export class CredentialPool { + private credentials: CredentialState[] + private cursor = 0 + private readonly now: () => number + + constructor(credentials: string[], now: () => number = Date.now) { + this.credentials = credentials.map(value => ({ + value, + disabled: false, + cooldownUntil: 0, + lastFailureAt: 0, + })) + this.now = now + } + + get size(): number { + return this.credentials.length + } + + next(): CredentialLease | null { + if (this.credentials.length === 0) { + return null + } + + const now = this.now() + for (let offset = 0; offset < this.credentials.length; offset++) { + const index = (this.cursor + offset) % this.credentials.length + const candidate = this.credentials[index] + if (!candidate || candidate.disabled || candidate.cooldownUntil > now) { + continue + } + this.cursor = (index + 1) % this.credentials.length + return { value: candidate.value, index } + } + + let leastRecentlyFailedIndex = -1 + let leastRecentlyFailedAt = Number.POSITIVE_INFINITY + for (let index = 0; index < this.credentials.length; index++) { + const candidate = this.credentials[index] + if (!candidate || candidate.disabled) { + continue + } + if (candidate.lastFailureAt < leastRecentlyFailedAt) { + leastRecentlyFailedAt = candidate.lastFailureAt + leastRecentlyFailedIndex = index + } + } + + if (leastRecentlyFailedIndex === -1) { + return null + } + + const fallback = this.credentials[leastRecentlyFailedIndex] + this.cursor = (leastRecentlyFailedIndex + 1) % this.credentials.length + return { value: fallback.value, index: leastRecentlyFailedIndex } + } + + reportSuccess(lease: CredentialLease | null): void { + if (!lease) return + const credential = this.credentials[lease.index] + if (!credential || credential.value !== lease.value) return + credential.cooldownUntil = 0 + } + + reportFailure( + lease: CredentialLease | null, + kind: CredentialPoolFailureKind, + cooldownMs: number, + ): void { + if (!lease) return + const credential = this.credentials[lease.index] + if (!credential || credential.value !== lease.value) return + + const now = this.now() + credential.lastFailureAt = now + if (kind === 'auth') { + credential.disabled = true + credential.cooldownUntil = 0 + } else { + credential.cooldownUntil = now + Math.max(0, cooldownMs) + } + } +} + +export function parseCredentialList(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map(item => item.trim()) + .filter(Boolean) +} diff --git a/src/services/api/errors.openaiCompatibility.test.ts b/src/services/api/errors.openaiCompatibility.test.ts index 8c830d9017..7d037fe30d 100644 --- a/src/services/api/errors.openaiCompatibility.test.ts +++ b/src/services/api/errors.openaiCompatibility.test.ts @@ -98,6 +98,22 @@ test('endpoint_not_found without a host falls back to the Ollama-aware message', expect(text).toContain('Ollama') }) +test('auth_invalid guidance names singular and pooled OpenAI credentials', () => { + const error = APIError.generate( + 401, + undefined, + 'OpenAI API error 401: Unauthorized [openai_category=auth_invalid,host=api.openai.com] Hint: Authentication failed.', + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'gpt-4o') + const text = getFirstText(message) + + expect(text).toContain('Authentication failed') + expect(text).toContain('OPENAI_API_KEYS') + expect(text).toContain('OPENAI_API_KEY') +}) + test('maps tool_call_incompatible category markers to model/tool guidance', () => { const error = APIError.generate( 400, diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index d15448f2c5..b93289d761 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -112,7 +112,7 @@ function mapOpenAICompatibilityFailureToAssistantMessage(options: { case 'auth_invalid': return createAssistantAPIErrorMessage({ - content: `${API_ERROR_MESSAGE_PREFIX}: Authentication failed for your OpenAI-compatible provider. Verify OPENAI_API_KEY and endpoint-specific auth requirements.`, + content: `${API_ERROR_MESSAGE_PREFIX}: Authentication failed for your OpenAI-compatible provider. Verify OPENAI_API_KEYS or OPENAI_API_KEY and endpoint-specific auth requirements.`, error: 'authentication_failed', }) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index b65667cbb1..975c6f5d81 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -12,6 +12,7 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, OPENAI_MODEL: process.env.OPENAI_MODEL, OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT, OPENAI_AUTH_HEADER: process.env.OPENAI_AUTH_HEADER, @@ -151,6 +152,7 @@ beforeEach(async () => { process.env.OPENAI_BASE_URL = 'http://example.test/v1' delete process.env.OPENAI_API_BASE process.env.OPENAI_API_KEY = 'test-key' + delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_MODEL delete process.env.OPENAI_API_FORMAT delete process.env.OPENAI_AUTH_HEADER @@ -192,6 +194,7 @@ afterEach(() => { restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE) restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) + restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT) restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER) @@ -2639,6 +2642,36 @@ test('gitlawb opengateway provider flag prefers OPENGATEWAY_API_KEY over generic expect(captured.authorization).toBe('Bearer fake-ogw-key') }) +test('gitlawb opengateway provider flag prefers OPENGATEWAY_API_KEY over generic OPENAI_API_KEYS pool', async () => { + process.env.OPENGATEWAY_BASE_URL = 'http://localhost:8181/v1' + process.env.OPENGATEWAY_API_KEY = 'fake-ogw-key' + process.env.OPENAI_API_KEYS = 'fake-openai-pool-a,fake-openai-pool-b' + delete process.env.OPENAI_API_KEY + + const result = applyProviderFlag('gitlawb-opengateway', []) + expect(result.error).toBeUndefined() + + const captured = await captureChatCompletionRequest() + + expect(captured.url).toBe('http://localhost:8181/v1/chat/completions') + expect(captured.authorization).toBe('Bearer fake-ogw-key') +}) + +test('gitlawb opengateway provider flag uses generic OPENAI_API_KEYS pool before generic OPENAI_API_KEY fallback', async () => { + process.env.OPENGATEWAY_BASE_URL = 'http://localhost:8181/v1' + process.env.OPENAI_API_KEYS = 'fake-openai-pool-a,fake-openai-pool-b' + process.env.OPENAI_API_KEY = 'fake-generic-openai-key' + delete process.env.OPENGATEWAY_API_KEY + + const result = applyProviderFlag('gitlawb-opengateway', []) + expect(result.error).toBeUndefined() + + const captured = await captureChatCompletionRequest() + + expect(captured.url).toBe('http://localhost:8181/v1/chat/completions') + expect(captured.authorization).toBe('Bearer fake-openai-pool-a') +}) + test('gitlawb opengateway stored provider profile key becomes bearer auth', async () => { delete process.env.OPENAI_API_KEY delete process.env.OPENGATEWAY_API_KEY @@ -2669,6 +2702,187 @@ test('openai route still sends OPENAI_API_KEY as bearer auth', async () => { expect(captured.authorization).toBe('Bearer fake-openai-key') }) +test('OPENAI_API_KEYS rotates to the next key on rate-limit failure', async () => { + const authorizations: Array = [] + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + process.env.OPENAI_API_KEY = 'single-key-should-not-win' + + globalThis.fetch = (async (_input, init) => { + const headers = init?.headers as Record | undefined + authorizations.push(headers?.Authorization ?? headers?.authorization ?? null) + + if (authorizations.length === 1) { + return new Response(JSON.stringify({ error: { message: 'rate limited' } }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return makeChatCompletionResponse('gpt-5.5') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(authorizations).toEqual(['Bearer key-a', 'Bearer key-b']) +}) + +test('comma-separated OPENAI_API_KEY rotates to the next key on rate-limit failure', async () => { + const authorizations: Array = [] + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENAI_API_KEY = 'key-a,key-b' + delete process.env.OPENAI_API_KEYS + + globalThis.fetch = (async (_input, init) => { + const headers = init?.headers as Record | undefined + authorizations.push(headers?.Authorization ?? headers?.authorization ?? null) + + if (authorizations.length === 1) { + return new Response(JSON.stringify({ error: { message: 'rate limited' } }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return makeChatCompletionResponse('gpt-5.5') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(authorizations).toEqual(['Bearer key-a', 'Bearer key-b']) +}) + +test('OPENAI_API_KEYS preserves cooldown state across client requests', async () => { + const authorizations: Array = [] + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + + globalThis.fetch = (async (_input, init) => { + const headers = init?.headers as Record | undefined + authorizations.push(headers?.Authorization ?? headers?.authorization ?? null) + + if (authorizations.length === 1) { + return new Response(JSON.stringify({ error: { message: 'rate limited' } }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return makeChatCompletionResponse('gpt-5.5') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + for (let i = 0; i < 2; i++) { + await client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + } + + expect(authorizations).toEqual([ + 'Bearer key-a', + 'Bearer key-b', + 'Bearer key-b', + ]) +}) + +test('OPENAI_API_KEYS rotates Azure api-key auth on auth failure', async () => { + const apiKeys: Array = [] + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://example.openai.azure.com/openai/deployments/test/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENAI_API_KEYS = 'azure-key-a,azure-key-b' + + globalThis.fetch = (async (_input, init) => { + const headers = init?.headers as Record | undefined + apiKeys.push(headers?.['api-key'] ?? null) + + if (apiKeys.length === 1) { + return new Response(JSON.stringify({ error: { message: 'unauthorized' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return makeChatCompletionResponse('gpt-5.5') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(apiKeys).toEqual(['azure-key-a', 'azure-key-b']) +}) + +test('OPENAI_API_KEYS does not reuse auth-disabled credentials across client requests', async () => { + const authorizations: Array = [] + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + + globalThis.fetch = (async (_input, init) => { + const headers = init?.headers as Record | undefined + authorizations.push(headers?.Authorization ?? headers?.authorization ?? null) + + return new Response(JSON.stringify({ error: { message: 'unauthorized' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect( + client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + await expect( + client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello again' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + expect(authorizations).toEqual(['Bearer key-a', 'Bearer key-b']) +}) + test('does not use BNKR_API_KEY for non-Bankr OpenAI-compatible routes', async () => { let capturedAuthorization: string | null = null diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index cad58834f0..629adf2381 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -11,6 +11,7 @@ * Environment variables: * CLAUDE_CODE_USE_OPENAI=1 — enable this provider * OPENAI_API_KEY=sk-... — API key (optional for local models) + * OPENAI_API_KEYS=sk-a,sk-b — optional comma-separated key pool for rotation * OPENAI_AUTH_HEADER=api-key — optional custom auth header name * OPENAI_AUTH_HEADER_VALUE=... — optional custom auth header value * OPENAI_AUTH_SCHEME=bearer|raw — auth scheme for Authorization/custom header handling @@ -95,10 +96,16 @@ import { getStreamStats, } from '../../utils/streamingOptimizer.js' import { stableStringifyJson } from '../../utils/stableStringify.js' +import { + CredentialPool, + type CredentialLease, + parseCredentialList, +} from './credentialPool.js' const GITHUB_429_MAX_RETRIES = 3 const GITHUB_429_BASE_DELAY_SEC = 1 const GITHUB_429_MAX_DELAY_SEC = 32 +const CREDENTIAL_POOL_COOLDOWN_MS = 30_000 const GEMINI_API_HOST = 'generativelanguage.googleapis.com' const COPILOT_HEADERS: Record = { 'User-Agent': 'GitHubCopilotChat/0.26.7', @@ -2188,6 +2195,8 @@ class OpenAIShimMessages { private defaultHeaders: Record private reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' private providerOverride?: { model: string; baseURL: string; apiKey: string } + private credentialPool?: CredentialPool + private credentialPoolRaw?: string constructor(defaultHeaders: Record, reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh', providerOverride?: { model: string; baseURL: string; apiKey: string }) { this.defaultHeaders = filterAnthropicHeaders(defaultHeaders) @@ -2195,6 +2204,22 @@ class OpenAIShimMessages { this.providerOverride = providerOverride } + private getCredentialPool(raw: string): CredentialPool | null { + const credentials = parseCredentialList(raw) + if (credentials.length === 0) { + this.credentialPool = undefined + this.credentialPoolRaw = undefined + return null + } + + if (!this.credentialPool || this.credentialPoolRaw !== raw) { + this.credentialPool = new CredentialPool(credentials) + this.credentialPoolRaw = raw + } + + return this.credentialPool + } + create( params: ShimCreateParams, options?: { signal?: AbortSignal; headers?: Record }, @@ -2824,7 +2849,7 @@ class OpenAIShimMessages { return geminiBody } - const headers: Record = { + const baseHeaders: Record = { 'Content-Type': 'application/json', ...filterAnthropicHeaders(shimConfig.headers), ...this.defaultHeaders, @@ -2842,19 +2867,67 @@ class OpenAIShimMessages { // sent as a Bearer to api.x.ai/v1 — same surface as an API key. const isXaiRoute = runtimeShimContext.routeId === 'xai' || isXaiBaseUrl(request.baseUrl) + const openAIApiKeysPoolRaw = + parseCredentialList(process.env.OPENAI_API_KEYS).length > 0 + ? process.env.OPENAI_API_KEYS + : undefined + const openAIApiKeyRaw = process.env.OPENAI_API_KEY?.trim() + const openAIApiKeyValues = parseCredentialList(openAIApiKeyRaw) + const openAIApiKey = openAIApiKeyValues[0] + const openAIApiKeyRawUsable = + openAIApiKeyValues.length > 0 ? openAIApiKeyRaw : undefined const xaiOAuthToken = isXaiRoute && !this.providerOverride?.apiKey && !routeCredential && - !process.env.OPENAI_API_KEY + !openAIApiKeysPoolRaw && + !openAIApiKey ? await resolveXaiAccessToken() : undefined - const apiKey = + const openAIApiKeyIsCopiedProviderKey = + Boolean( + openAIApiKeyRawUsable && + [ + process.env.OPENGATEWAY_API_KEY, + process.env.NVIDIA_API_KEY, + process.env.BNKR_API_KEY, + process.env.XAI_API_KEY, + process.env.MIMO_API_KEY, + process.env.VENICE_API_KEY, + process.env.MINIMAX_API_KEY, + process.env.ATLAS_CLOUD_API_KEY, + process.env.NEARAI_API_KEY, + process.env.FIREWORKS_API_KEY, + ].some(value => value?.trim() === openAIApiKeyRawUsable), + ) + const routeCredentialIsCopiedProviderKey = + Boolean( + routeCredential && + openAIApiKeyRawUsable && + routeCredential === openAIApiKeyRawUsable && + openAIApiKeyIsCopiedProviderKey, + ) + const routeCredentialIsProviderSpecific = + Boolean( + routeCredential && + (!openAIApiKeyRawUsable || + routeCredential !== openAIApiKeyRawUsable || + routeCredentialIsCopiedProviderKey), + ) + const routeCredentialIsGenericOpenAIFallback = + Boolean( + !routeCredentialIsProviderSpecific && + routeCredential && + openAIApiKeyRawUsable && + routeCredential === openAIApiKeyRawUsable, + ) + const apiKeyRaw = this.providerOverride?.apiKey ?? + (openAIApiKeyIsCopiedProviderKey ? openAIApiKeyRawUsable : undefined) ?? + (routeCredentialIsGenericOpenAIFallback ? undefined : routeCredential) ?? + openAIApiKeysPoolRaw ?? routeCredential ?? - process.env.OPENAI_API_KEY ?? - xaiOAuthToken ?? - '' + (openAIApiKeyRawUsable || xaiOAuthToken || '') // A catalog-level auth header is part of the selected model's transport // contract. Ignore global custom auth left behind by another route so it // cannot replace that model-specific header or credential. @@ -2874,9 +2947,9 @@ class OpenAIShimMessages { customAuthHeader && /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(customAuthHeader), ) - const authValue = hasCustomAuthHeader - ? configuredAuthHeaderValue || apiKey - : apiKey + const explicitCustomAuthHeaderValue = hasCustomAuthHeader + ? configuredAuthHeaderValue + : '' // Detect Azure endpoints by hostname (not raw URL) to prevent bypass via // path segments like https://evil.com/cognitiveservices.azure.com/ let isAzure = isEnvTruthy(process.env.OPENAI_AZURE_STYLE) @@ -2901,56 +2974,74 @@ class OpenAIShimMessages { request.baseUrl.toLowerCase().includes('bankr') } catch { /* malformed URL — not Bankr */ } - if (authValue) { - if (hasCustomAuthHeader && customAuthHeader) { - const defaultCustomAuthScheme = - customAuthHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw' - const customAuthScheme = - process.env.OPENAI_AUTH_SCHEME === 'raw' || - process.env.OPENAI_AUTH_SCHEME === 'bearer' - ? process.env.OPENAI_AUTH_SCHEME - : defaultCustomAuthScheme - headers[customAuthHeader] = - customAuthScheme === 'bearer' - ? `Bearer ${authValue}` - : authValue - } else if (isAzure) { - // Azure uses api-key header instead of Bearer token - headers['api-key'] = authValue - } else if (isBankr) { - // Bankr uses X-API-Key header instead of Bearer token - headers['X-API-Key'] = authValue - } else if (shimConfig.defaultAuthHeader?.name) { - headers[shimConfig.defaultAuthHeader.name] = - shimConfig.defaultAuthHeader.scheme === 'bearer' - ? `Bearer ${authValue}` - : authValue - } else { - headers.Authorization = `Bearer ${authValue}` - } - } else if (isGemini) { - const geminiCredential = await resolveGeminiCredential(process.env) - if (geminiCredential.kind !== 'none') { - headers.Authorization = `Bearer ${geminiCredential.credential}` - if (geminiCredential.kind !== 'api-key' && 'projectId' in geminiCredential && geminiCredential.projectId) { - headers['x-goog-user-project'] = geminiCredential.projectId + const credentialPool = explicitCustomAuthHeaderValue + ? null + : this.getCredentialPool(apiKeyRaw) + const singleAuthValue = + explicitCustomAuthHeaderValue || parseCredentialList(apiKeyRaw)[0] || apiKeyRaw + + const buildHeadersForAttempt = async ( + credentialLease: CredentialLease | null, + ): Promise> => { + const headers: Record = { ...baseHeaders } + const authValue = + explicitCustomAuthHeaderValue || + credentialLease?.value || + (credentialPool ? '' : singleAuthValue) + + if (authValue) { + if (hasCustomAuthHeader && customAuthHeader) { + const defaultCustomAuthScheme = + customAuthHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw' + const customAuthScheme = + process.env.OPENAI_AUTH_SCHEME === 'raw' || + process.env.OPENAI_AUTH_SCHEME === 'bearer' + ? process.env.OPENAI_AUTH_SCHEME + : defaultCustomAuthScheme + headers[customAuthHeader] = + customAuthScheme === 'bearer' + ? `Bearer ${authValue}` + : authValue + } else if (isAzure) { + // Azure uses api-key header instead of Bearer token + headers['api-key'] = authValue + } else if (isBankr) { + // Bankr uses X-API-Key header instead of Bearer token + headers['X-API-Key'] = authValue + } else if (shimConfig.defaultAuthHeader?.name) { + headers[shimConfig.defaultAuthHeader.name] = + shimConfig.defaultAuthHeader.scheme === 'bearer' + ? `Bearer ${authValue}` + : authValue + } else { + headers.Authorization = `Bearer ${authValue}` + } + } else if (isGemini) { + const geminiCredential = await resolveGeminiCredential(process.env) + if (geminiCredential.kind !== 'none') { + headers.Authorization = `Bearer ${geminiCredential.credential}` + if (geminiCredential.kind !== 'api-key' && 'projectId' in geminiCredential && geminiCredential.projectId) { + headers['x-goog-user-project'] = geminiCredential.projectId + } } } - } - if (isGithubCopilot) { - Object.assign(headers, COPILOT_HEADERS) - } else if (isGithubModels) { - headers['Accept'] = 'application/vnd.github+json' - headers['X-GitHub-Api-Version'] = '2022-11-28' - } + if (isGithubCopilot) { + Object.assign(headers, COPILOT_HEADERS) + } else if (isGithubModels) { + headers['Accept'] = 'application/vnd.github+json' + headers['X-GitHub-Api-Version'] = '2022-11-28' + } - // xAI / Grok prompt caching. Pinning the session id via x-grok-conv-id - // routes follow-up requests to the same backend so xAI can reuse the - // cached system prompt and conversation history. Mirrors the Hermes - // implementation (RELEASE_v0.8.0 PR #5604). - if (isXaiRoute) { - headers['x-grok-conv-id'] ??= getSessionId() + // xAI / Grok prompt caching. Pinning the session id via x-grok-conv-id + // routes follow-up requests to the same backend so xAI can reuse the + // cached system prompt and conversation history. Mirrors the Hermes + // implementation (RELEASE_v0.8.0 PR #5604). + if (isXaiRoute) { + headers['x-grok-conv-id'] ??= getSessionId() + } + + return headers } const buildChatCompletionsUrl = (baseUrl: string): string => { @@ -3063,7 +3154,7 @@ class OpenAIShimMessages { serializedBody = serializeBody() } - const buildFetchInit = () => ({ + const buildFetchInit = (headers: Record) => ({ method: 'POST' as const, headers, body: serializedBody, @@ -3073,7 +3164,10 @@ class OpenAIShimMessages { const maxSelfHealAttempts = isLocal ? localRetryBaseUrls.length + 1 : 0 - const maxAttempts = (isGithub ? GITHUB_429_MAX_RETRIES : 1) + maxSelfHealAttempts + const credentialPoolAttempts = credentialPool?.size ?? 1 + const maxAttempts = + Math.max(isGithub ? GITHUB_429_MAX_RETRIES : 1, credentialPoolAttempts) + + maxSelfHealAttempts const throwClassifiedTransportError = ( error: unknown, @@ -3157,10 +3251,26 @@ class OpenAIShimMessages { : 'openai' const { correlationId, startTime } = logApiCallStart(provider, request.resolvedModel) for (let attempt = 0; attempt < maxAttempts; attempt++) { + const credentialLease = credentialPool?.next() ?? null + if (credentialPool && !credentialLease) { + throw APIError.generate( + 401, + undefined, + buildOpenAICompatibilityErrorMessage( + 'OpenAI API error 401: credential pool exhausted after authentication failures', + { + category: 'auth_invalid', + requestUrl, + }, + ), + new Headers(), + ) + } + const headers = await buildHeadersForAttempt(credentialLease) try { response = await fetchWithProxyRetry( requestUrl, - buildFetchInit(), + buildFetchInit(headers), ) } catch (error) { const isAbortError = @@ -3197,6 +3307,7 @@ class OpenAIShimMessages { if (!response) continue if (response.ok) { + credentialPool?.reportSuccess(credentialLease) let tokensIn = 0 let tokensOut = 0 // Skip clone() for streaming responses - it blocks until full body is received, @@ -3318,6 +3429,29 @@ class OpenAIShimMessages { hasImages: bodyContainsImages(), }) + const credentialFailureKind = + response.status === 401 + ? 'auth' + : response.status === 402 || + response.status === 403 || + response.status === 429 + ? 'cooldown' + : null + if (credentialPool && credentialPool.size > 1 && credentialFailureKind) { + credentialPool.reportFailure( + credentialLease, + credentialFailureKind, + CREDENTIAL_POOL_COOLDOWN_MS, + ) + if (attempt < maxAttempts - 1) { + logForDebugging( + `[OpenAIShim] credential pool retry status=${response.status} method=POST url=${redactUrlForDiagnostics(requestUrl)} model=${request.resolvedModel}`, + { level: 'warn' }, + ) + continue + } + } + if ( isLocal && failure.category === 'endpoint_not_found' && diff --git a/src/services/api/providerConfig.local.test.ts b/src/services/api/providerConfig.local.test.ts index 6493b242bd..ac676a9811 100644 --- a/src/services/api/providerConfig.local.test.ts +++ b/src/services/api/providerConfig.local.test.ts @@ -12,6 +12,7 @@ import { const originalEnv = { CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_AUTH_HEADER: process.env.OPENAI_AUTH_HEADER, OPENAI_AUTH_SCHEME: process.env.OPENAI_AUTH_SCHEME, @@ -37,6 +38,7 @@ afterEach(() => { try { restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) + restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER) restoreEnv('OPENAI_AUTH_SCHEME', originalEnv.OPENAI_AUTH_SCHEME) @@ -136,9 +138,19 @@ test('partitions local openai-compatible model cache scope by credentials and he process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Route: second' const thirdScope = getAdditionalModelOptionsCacheScope() + delete process.env.OPENAI_API_KEY + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Route: first' + process.env.OPENAI_API_KEYS = 'first-a,first-b' + const pooledScope = getAdditionalModelOptionsCacheScope() + + process.env.OPENAI_API_KEYS = 'second-a,second-b' + const secondPooledScope = getAdditionalModelOptionsCacheScope() + expect(firstScope).not.toBe(secondScope) expect(firstScope).not.toBe(thirdScope) + expect(pooledScope).not.toBe(secondPooledScope) expect(firstScope?.startsWith('openai:http://localhost:1234/v1:')).toBe(true) + expect(pooledScope?.startsWith('openai:http://localhost:1234/v1:')).toBe(true) }) test('uses responses transport when OpenAI-compatible API format requests responses', () => { diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 94ee943786..d424357c81 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -929,6 +929,7 @@ export function getAdditionalModelOptionsCacheScope(): string | null { } const partition = hashCacheScopePartition({ + apiKeys: normalizeCacheScopeHeaderValue(process.env.OPENAI_API_KEYS), apiKey: normalizeCacheScopeHeaderValue(process.env.OPENAI_API_KEY), authHeader: normalizeCacheScopeHeaderValue(process.env.OPENAI_AUTH_HEADER).toLowerCase(), authScheme: normalizeCacheScopeHeaderValue(process.env.OPENAI_AUTH_SCHEME).toLowerCase(), diff --git a/src/utils/envFile.test.ts b/src/utils/envFile.test.ts index 8f751352a3..a7d9095215 100644 --- a/src/utils/envFile.test.ts +++ b/src/utils/envFile.test.ts @@ -17,6 +17,7 @@ const TEST_ENV_KEYS = [ 'AZURE_OPENAI_API_VERSION', 'CODEX_AUTH_JSON_PATH', 'CODEX_HOME', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', @@ -222,6 +223,29 @@ describe('loadEnvFile', () => { expect(process.env.OPENAI_MODEL).toBe('from-file') }) + it('loads and reapplies OpenAI credential pools from provider env files', () => { + const filePath = writeTempEnvFile([ + 'CLAUDE_CODE_USE_OPENAI=1', + 'OPENAI_BASE_URL=https://api.openai.com/v1', + 'OPENAI_MODEL=gpt-4o', + 'OPENAI_API_KEYS=key-a,key-b', + ].join('\n')) + + const loaded = loadEnvFile(filePath) + rememberLoadedEnvFileValues(loaded) + process.env.OPENAI_API_KEYS = 'settings-key' + + reapplyRememberedEnvFileValues() + + expect(process.env.OPENAI_API_KEYS).toBe('key-a,key-b') + expect(loaded).toEqual({ + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEYS: 'key-a,key-b', + }) + }) + it('loads documented Azure OpenAI API version values', () => { const filePath = writeTempEnvFile( 'AZURE_OPENAI_API_VERSION=2024-12-01-preview', diff --git a/src/utils/envFile.ts b/src/utils/envFile.ts index f80c41a9b9..2734e41e67 100644 --- a/src/utils/envFile.ts +++ b/src/utils/envFile.ts @@ -91,6 +91,7 @@ const ALLOWED_ENV_FILE_KEYS = new Set([ 'OPENROUTER_API_KEY', 'OPENAI_API_BASE', 'OPENAI_API_FORMAT', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENAI_AUTH_HEADER', 'OPENAI_AUTH_HEADER_VALUE', diff --git a/src/utils/providerAutoDetect.test.ts b/src/utils/providerAutoDetect.test.ts index fd653b24f2..3eb6f91b3f 100644 --- a/src/utils/providerAutoDetect.test.ts +++ b/src/utils/providerAutoDetect.test.ts @@ -76,6 +76,15 @@ describe('detectProviderFromEnv — priority order', () => { ).toEqual({ kind: 'openai', source: 'OPENAI_API_KEYS set' }) }) + test('delimiter-only OPENAI_API_KEYS does not block lower-priority providers', () => { + expect( + scan({ + OPENAI_API_KEYS: ', ,', + GEMINI_API_KEY: 'gemini-key', + }), + ).toEqual({ kind: 'gemini', source: 'GEMINI_API_KEY set' }) + }) + test('OPENAI_API_KEY reports baseUrl when set', () => { expect( scan({ diff --git a/src/utils/providerAutoDetect.ts b/src/utils/providerAutoDetect.ts index 274af2e7b9..1a646e6019 100644 --- a/src/utils/providerAutoDetect.ts +++ b/src/utils/providerAutoDetect.ts @@ -69,6 +69,12 @@ function envHasNonEmpty(env: EnvLike, key: string): boolean { return typeof value === 'string' && value.trim().length > 0 } +function envHasUsableOpenAICredential(env: EnvLike, key: string): boolean { + const value = env[key] + if (typeof value !== 'string') return false + return value.split(',').some(part => part.trim().length > 0) +} + function firstSet(env: EnvLike, keys: readonly string[]): string | undefined { for (const key of keys) { if (envHasNonEmpty(env, key)) return key @@ -76,6 +82,16 @@ function firstSet(env: EnvLike, keys: readonly string[]): string | undefined { return undefined } +function firstOpenAICredentialSet( + env: EnvLike, + keys: readonly string[], +): string | undefined { + for (const key of keys) { + if (envHasUsableOpenAICredential(env, key)) return key + } + return undefined +} + function defaultHasCodexAuthFile(): boolean { const paths = [ process.env.CODEX_AUTH_PATH, @@ -146,7 +162,10 @@ export function detectProviderFromEnv( } } - const openaiKey = firstSet(env, ['OPENAI_API_KEYS', 'OPENAI_API_KEY']) + const openaiKey = firstOpenAICredentialSet(env, [ + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ]) if (openaiKey) { return { kind: 'openai', diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 3aca9be0f4..7c645cd6f6 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -5,7 +5,10 @@ import { join } from 'node:path' import test, { afterEach, beforeEach } from 'node:test' import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js' -import { resolveActiveRouteIdFromEnv } from '../integrations/routeMetadata.js' +import { + resolveActiveRouteIdFromEnv, + resolveRouteCredentialValue, +} from '../integrations/routeMetadata.js' import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js' import { getProviderValidationError } from './providerValidation.js' import { @@ -13,6 +16,7 @@ import { applyStartupEnvFromProfile, buildStartupEnvFromProfile, buildAtomicChatProfileEnv, + buildCompatibilityProcessEnv, buildCodexProfileEnv, buildGeminiProfileEnv, buildLaunchEnv, @@ -132,6 +136,33 @@ test('openai launch ignores mismatched persisted ollama env', async () => { assert.equal(env.CHATGPT_ACCOUNT_ID, undefined) }) +test('openai profile switch clears stale plural credential pool before applying saved key', () => { + const env = buildCompatibilityProcessEnv({ + compatibilityMode: 'openai', + profileEnv: { + OPENAI_API_KEY: 'profile-key', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-5.4', + }, + processEnv: { + OPENAI_API_KEYS: 'stale-a,stale-b', + OPENAI_API_KEY: 'shell-key', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + }, + }) + + assert.equal(env.OPENAI_API_KEYS, undefined) + assert.equal(env.OPENAI_API_KEY, 'profile-key') + assert.equal( + resolveRouteCredentialValue({ + routeId: 'openai', + processEnv: env, + }), + 'profile-key', + ) +}) + test('anthropic launch preserves unmanaged process env values', async () => { const env = await buildLaunchEnv({ profile: 'anthropic', @@ -1297,6 +1328,38 @@ test('legacy openai saved profiles still deserialize and rebuild startup env', a } }) +test('legacy openai saved profiles preserve OPENAI_API_KEYS during startup rebuild', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + + try { + saveProfileFile( + profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEYS: 'key-a,key-b', + }), + { cwd: tempDir }, + ) + + const persisted = loadProfileFile({ cwd: tempDir }) + assert.notEqual(persisted, null) + assert.equal(persisted?.profile, 'openai') + + const env = await buildStartupEnvFromProfile({ + persisted, + processEnv: {}, + }) + + assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1') + assert.equal(env.OPENAI_BASE_URL, 'https://api.openai.com/v1') + assert.equal(env.OPENAI_MODEL, 'gpt-4o') + assert.equal(env.OPENAI_API_KEYS, 'key-a,key-b') + assert.equal(env.OPENAI_API_KEY, undefined) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) + test('legacy anthropic saved profiles still deserialize and rebuild startup env', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) @@ -1725,6 +1788,56 @@ test('openai profiles ignore poisoned shell model and base url values', () => { }) }) +test('openai profiles accept OPENAI_API_KEYS without OPENAI_API_KEY', () => { + const env = buildOpenAIProfileEnv({ + goal: 'balanced', + model: 'gpt-5.4', + processEnv: { + OPENAI_API_KEYS: 'key-a, key-b', + }, + }) + + assert.equal(env?.OPENAI_MODEL, 'gpt-5.4') + assert.equal(env?.OPENAI_API_KEYS, 'key-a,key-b') + assert.equal(env?.OPENAI_API_KEY, undefined) +}) + +test('openai profiles reject placeholder values inside pooled credentials', () => { + const env = buildOpenAIProfileEnv({ + goal: 'balanced', + model: 'gpt-5.4', + apiKey: 'key-a,SUA_CHAVE', + processEnv: {}, + }) + + assert.equal(env, null) +}) + +test('openai profiles reject invalid OPENAI_API_KEYS instead of falling back', () => { + const env = buildOpenAIProfileEnv({ + goal: 'balanced', + model: 'gpt-5.4', + processEnv: { + OPENAI_API_KEYS: 'key-a,SUA_CHAVE', + OPENAI_API_KEY: 'key-single', + }, + }) + + assert.equal(env, null) +}) + +test('openai profiles store comma-separated explicit keys as a pool', () => { + const env = buildOpenAIProfileEnv({ + goal: 'balanced', + model: 'gpt-5.4', + apiKey: 'key-a, key-b', + processEnv: {}, + }) + + assert.equal(env?.OPENAI_API_KEYS, 'key-a,key-b') + assert.equal(env?.OPENAI_API_KEY, undefined) +}) + test('openai profiles normalize multi-model profile values to the primary model', () => { const env = buildOpenAIProfileEnv({ goal: 'balanced', diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 7e314671c5..11e57e38aa 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -10,6 +10,7 @@ import { resolveProviderRequest, } from '../services/api/providerConfig.js' import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js' +import { parseCredentialList } from '../services/api/credentialPool.js' import { getGoalDefaultOpenAIModel, normalizeRecommendationGoal, @@ -71,6 +72,7 @@ const PROFILE_ENV_KEYS = [ 'OPENAI_AUTH_HEADER', 'OPENAI_AUTH_SCHEME', 'OPENAI_AUTH_HEADER_VALUE', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'GITHUB_COPILOT_KEY', 'GITHUB_ENTERPRISE_URL', @@ -149,6 +151,7 @@ export type ProfileEnv = { OPENAI_AUTH_HEADER?: string OPENAI_AUTH_SCHEME?: 'bearer' | 'raw' OPENAI_AUTH_HEADER_VALUE?: string + OPENAI_API_KEYS?: string OPENAI_API_KEY?: string GITHUB_COPILOT_KEY?: string GITHUB_ENTERPRISE_URL?: string @@ -656,6 +659,39 @@ export function buildGeminiProfileEnv(options: { return env } +export function sanitizeOpenAICredentialPool( + value: string | null | undefined, +): string | undefined { + const resolved = resolveOpenAICredentialPool(value) + return resolved.kind === 'usable' ? resolved.value : undefined +} + +export function hasInvalidOpenAICredentialPool( + value: string | null | undefined, +): boolean { + return resolveOpenAICredentialPool(value).kind === 'invalid' +} + +function resolveOpenAICredentialPool( + value: string | null | undefined, +): + | { kind: 'empty' } + | { kind: 'invalid' } + | { kind: 'usable'; value: string; count: number } { + const credentials = parseCredentialList(value ?? undefined) + if (credentials.length === 0) { + return { kind: 'empty' } + } + if (credentials.some(credential => credential === 'SUA_CHAVE')) { + return { kind: 'invalid' } + } + return { + kind: 'usable', + value: credentials.join(','), + count: credentials.length, + } +} + export function buildOpenAIProfileEnv(options: { goal: RecommendationGoal model?: string | null @@ -669,7 +705,36 @@ export function buildOpenAIProfileEnv(options: { processEnv?: NodeJS.ProcessEnv }): ProfileEnv | null { const processEnv = options.processEnv ?? process.env - const key = sanitizeApiKey(options.apiKey ?? processEnv.OPENAI_API_KEY) + const explicitCredential = resolveOpenAICredentialPool(options.apiKey) + const envOpenAIKeysCredential = resolveOpenAICredentialPool( + processEnv.OPENAI_API_KEYS, + ) + const envOpenAIKeyCredential = resolveOpenAICredentialPool( + processEnv.OPENAI_API_KEY, + ) + if ( + explicitCredential.kind === 'invalid' || + envOpenAIKeysCredential.kind === 'invalid' || + envOpenAIKeyCredential.kind === 'invalid' + ) { + return null + } + const key = + explicitCredential.kind === 'usable' + ? explicitCredential.value + : envOpenAIKeysCredential.kind === 'usable' + ? envOpenAIKeysCredential.value + : envOpenAIKeyCredential.kind === 'usable' + ? envOpenAIKeyCredential.value + : undefined + const keyEnvVar = + explicitCredential.kind === 'usable' && explicitCredential.count > 1 + ? 'OPENAI_API_KEYS' + : explicitCredential.kind === 'usable' + ? 'OPENAI_API_KEY' + : envOpenAIKeysCredential.kind === 'usable' + ? 'OPENAI_API_KEYS' + : 'OPENAI_API_KEY' const authHeaderValue = sanitizeApiKey( options.authHeaderValue ?? processEnv.OPENAI_AUTH_HEADER_VALUE, ) @@ -679,6 +744,7 @@ export function buildOpenAIProfileEnv(options: { const defaultModel = getGoalDefaultOpenAIModel(options.goal) const secretSource: SecretValueSource = { + OPENAI_API_KEYS: key, OPENAI_API_KEY: key, OPENAI_AUTH_HEADER_VALUE: authHeaderValue, } @@ -716,7 +782,7 @@ export function buildOpenAIProfileEnv(options: { ...(options.authHeader ? { OPENAI_AUTH_HEADER: options.authHeader } : {}), ...(options.authScheme ? { OPENAI_AUTH_SCHEME: options.authScheme } : {}), ...(authHeaderValue ? { OPENAI_AUTH_HEADER_VALUE: authHeaderValue } : {}), - ...(key ? { OPENAI_API_KEY: key } : {}), + ...(key ? { [keyEnvVar]: key } : {}), ...(options.maxContextLength ? { CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: JSON.stringify({ @@ -1646,6 +1712,10 @@ export async function buildLaunchEnv(options: { delete env.OPENAI_AUTH_HEADER_VALUE } const openAIKey = processEnv.OPENAI_API_KEY || persistedEnv.OPENAI_API_KEY + const openAIKeys = processEnv.OPENAI_API_KEYS || persistedEnv.OPENAI_API_KEYS + if (openAIKeys) { + env.OPENAI_API_KEYS = openAIKeys + } if (openAIKey) { env.OPENAI_API_KEY = openAIKey } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 90abe16624..16a5e789d3 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -32,6 +32,7 @@ const RESTORED_KEYS = [ 'OPENAI_AUTH_HEADER', 'OPENAI_AUTH_SCHEME', 'OPENAI_AUTH_HEADER_VALUE', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'GITHUB_COPILOT_KEY', 'GITHUB_ENTERPRISE_URL', @@ -1556,6 +1557,16 @@ describe('persistActiveProviderProfileModel', () => { }) describe('getProviderPresetDefaults', () => { + test('openai preset skips delimiter-only pooled keys before singular fallback', async () => { + const { getProviderPresetDefaults } = await importFreshProviderProfileModules() + process.env.OPENAI_API_KEYS = ', ,' + process.env.OPENAI_API_KEY = 'openai-single-key' + + const defaults = getProviderPresetDefaults('openai') + + expect(defaults.apiKey).toBe('openai-single-key') + }) + test('ollama preset defaults to a local Ollama model', async () => { const { getProviderPresetDefaults } = await importFreshProviderProfileModules() delete process.env.OPENAI_MODEL diff --git a/src/utils/providerStartupOverrides.test.ts b/src/utils/providerStartupOverrides.test.ts index 6db9f6e9fa..8082de7096 100644 --- a/src/utils/providerStartupOverrides.test.ts +++ b/src/utils/providerStartupOverrides.test.ts @@ -13,6 +13,8 @@ describe('clearStartupProviderOverrides', () => { CLAUDE_CODE_USE_OPENAI: '1', OPENAI_BASE_URL: 'https://api.minimax.io/v1', OPENAI_MODEL: 'minimax-m2.7', + OPENAI_API_KEYS: 'pool-a,pool-b', + OPENAI_API_KEY: 'single-key', MINIMAX_API_KEY: 'sk-minimax', VENICE_API_KEY: 'sk-venice', KEEP_ME: '1', @@ -33,6 +35,8 @@ describe('clearStartupProviderOverrides', () => { CLAUDE_CODE_USE_OPENAI: undefined, OPENAI_BASE_URL: undefined, OPENAI_MODEL: undefined, + OPENAI_API_KEYS: undefined, + OPENAI_API_KEY: undefined, MINIMAX_API_KEY: undefined, VENICE_API_KEY: undefined, }), diff --git a/src/utils/providerStartupOverrides.ts b/src/utils/providerStartupOverrides.ts index 542b72c38e..b39b0e1254 100644 --- a/src/utils/providerStartupOverrides.ts +++ b/src/utils/providerStartupOverrides.ts @@ -12,6 +12,7 @@ export const STARTUP_PROVIDER_OVERRIDE_ENV_KEYS = [ 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_MODEL', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENAI_ORG', 'OPENAI_PROJECT', diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index fac45b365c..8a3b70befa 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -12,6 +12,7 @@ import { const ENV_KEYS = [ 'CLAUDE_CODE_USE_OPENAI', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', @@ -130,7 +131,7 @@ test('openai missing key error includes recovery guidance and config locations', const message = await getProviderValidationError(process.env) expect(message).not.toBeNull() expect(message!).toContain( - 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', + 'OPENAI_API_KEYS or OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', ) expect(message!).toContain( 'set CLAUDE_CODE_USE_OPENAI=0 in your shell environment', @@ -303,10 +304,53 @@ test('openai validation does not accept unrelated minimax credentials', async () const error = await getProviderValidationError(process.env) expect(error).not.toBeNull() expect(error!).toContain( - 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', + 'OPENAI_API_KEYS or OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', ) }) +test('openai validation accepts OPENAI_API_KEYS without OPENAI_API_KEY', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_API_KEYS = 'sk-openai-a,sk-openai-b' + delete process.env.OPENAI_API_KEY + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('openai validation rejects placeholder values in OPENAI_API_KEYS', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_API_KEYS = 'sk-openai-a,SUA_CHAVE' + delete process.env.OPENAI_API_KEY + + const error = await getProviderValidationError(process.env) + expect(error).toBe( + 'Invalid OPENAI_API_KEYS: placeholder value SUA_CHAVE detected. Set real key(s) or unset for local providers.', + ) +}) + +test('openai validation rejects delimiter-only OPENAI_API_KEYS', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_API_KEYS = ', ,' + delete process.env.OPENAI_API_KEY + + const error = await getProviderValidationError(process.env) + expect(error).not.toBeNull() + expect(error!).toContain( + 'OPENAI_API_KEYS or OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', + ) +}) + +test('openai validation accepts OPENAI_API_KEY when OPENAI_API_KEYS is delimiter-only', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_API_KEYS = ', ,' + process.env.OPENAI_API_KEY = 'sk-openai-single' + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + test('openrouter validation accepts OPENROUTER_API_KEY without OPENAI_API_KEY', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' @@ -464,7 +508,7 @@ test('github validation is skipped when openai mode is also active', async () => const error = await getProviderValidationError(process.env) expect(error).not.toBeNull() expect(error!).toContain( - 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', + 'OPENAI_API_KEYS or OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', ) }) @@ -499,7 +543,7 @@ test('non-Ollama remote provider still requires OPENAI_API_KEY', async () => { const message = await getProviderValidationError(process.env) expect(message).toContain( - 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', + 'OPENAI_API_KEYS or OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', ) }) diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 9c84fcc282..20b4d2a11d 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -106,7 +106,7 @@ function getOpenAIMissingKeyMessage(): string { const profilePath = resolve(process.cwd(), PROFILE_FILE_NAME) return [ - 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', + 'OPENAI_API_KEYS or OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.', `To recover, run /provider and switch provider, or set CLAUDE_CODE_USE_OPENAI=0 in your shell environment.`, `Saved startup settings can come from ${globalConfigPath} or ${profilePath}.`, ].join('\n') @@ -119,6 +119,29 @@ function hasNonEmptyEnvValue( return typeof env[envVar] === 'string' && env[envVar]!.trim() !== '' } +function hasUsableCredentialEnvValue( + env: NodeJS.ProcessEnv, + envVar: string, +): boolean { + const value = env[envVar] + if (typeof value !== 'string') { + return false + } + + if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { + return value.split(',').some(part => part.trim() !== '') + } + + return value.trim() !== '' +} + +function hasOpenAICredential(env: NodeJS.ProcessEnv): boolean { + return Boolean( + getRouteCredentialValue('openai', env) || + hasUsableCredentialEnvValue(env, 'OPENAI_API_KEY'), + ) +} + function normalizeBaseUrl(baseUrl: string | undefined): string | undefined { if (!baseUrl) { return undefined @@ -275,7 +298,9 @@ function getCredentialEnvValidationError( request?: ReturnType, ): string | null { for (const invalidValue of validation.invalidCredentialValues ?? []) { - if (env[invalidValue.envVar]?.trim() === invalidValue.value) { + const envValue = env[invalidValue.envVar] + const envValues = (envValue ?? '').split(',').map(value => value.trim()) + if (envValues.includes(invalidValue.value)) { return invalidValue.message } } @@ -290,7 +315,9 @@ function getCredentialEnvValidationError( } if ( - validation.credentialEnvVars.some(envVar => hasNonEmptyEnvValue(env, envVar)) + validation.credentialEnvVars.some(envVar => + hasUsableCredentialEnvValue(env, envVar), + ) ) { return null } @@ -523,7 +550,7 @@ export async function getProviderValidationError( if ( validationTarget.kind === 'vendor' && validationTarget.descriptor.id === 'openai' && - !env.OPENAI_API_KEY && + !hasOpenAICredential(env) && !isLocalProviderUrl(request.baseUrl) && !isLikelyOllamaEndpoint(request.baseUrl) ) { @@ -542,7 +569,7 @@ export async function getProviderValidationError( } if ( - !env.OPENAI_API_KEY && + !hasOpenAICredential(env) && !isLocalProviderUrl(request.baseUrl) && !isLikelyOllamaEndpoint(request.baseUrl) ) { diff --git a/src/utils/swarm/spawnUtils.test.ts b/src/utils/swarm/spawnUtils.test.ts index 98a4de27ac..1a48b60771 100644 --- a/src/utils/swarm/spawnUtils.test.ts +++ b/src/utils/swarm/spawnUtils.test.ts @@ -32,6 +32,16 @@ test('buildInheritedEnvVars marks spawned teammates as host-managed for provider expect(envVars).toContain('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1') }) +test('buildInheritedEnvVars forwards pooled OpenAI credentials', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + + const envVars = buildInheritedEnvVars() + + expect(envVars).toContain('CLAUDE_CODE_USE_OPENAI=1') + expect(envVars).toContain('OPENAI_API_KEYS=key-a\\,key-b') +}) + test('buildInheritedEnvVars forwards PATH for source-built teammate tool lookups', () => { process.env.PATH = '/custom/bin:/usr/bin' diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index 6ea17005ad..fed53bdb9c 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -107,6 +107,7 @@ const TEAMMATE_ENV_VARS = [ 'CLAUDE_CODE_USE_OPENAI', 'GITHUB_TOKEN', 'GH_TOKEN', + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', From 355d4c15ff58c1cfe42d7e9937388283bbfa2fde Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 18 Jun 2026 14:13:36 -0700 Subject: [PATCH 02/14] fix(api): align pooled credential discovery --- src/commands/cache-probe/cache-probe.ts | 7 +-- src/commands/model/model.test.tsx | 66 ++++++++++++++++++++ src/commands/model/model.tsx | 13 ++-- src/integrations/discoveryService.test.ts | 33 ++++++++++ src/integrations/discoveryService.ts | 32 ++++++---- src/services/api/credentialPool.ts | 6 ++ src/services/api/openaiShim.test.ts | 40 ++++++++++++ src/services/api/openaiShim.ts | 6 +- src/utils/model/openaiModelDiscovery.test.ts | 35 +++++++++++ src/utils/model/openaiModelDiscovery.ts | 5 +- 10 files changed, 214 insertions(+), 29 deletions(-) diff --git a/src/commands/cache-probe/cache-probe.ts b/src/commands/cache-probe/cache-probe.ts index 2ff03687eb..2455aad26f 100644 --- a/src/commands/cache-probe/cache-probe.ts +++ b/src/commands/cache-probe/cache-probe.ts @@ -1,5 +1,5 @@ import { getSessionId } from '../../bootstrap/state.js' -import { parseCredentialList } from '../../services/api/credentialPool.js' +import { firstUsableCredential } from '../../services/api/credentialPool.js' import { resolveProviderRequest } from '../../services/api/providerConfig.js' import type { LocalCommandCall } from '../../types/command.js' import { logForDebugging } from '../../utils/debug.js' @@ -40,11 +40,6 @@ function getModelFamily(model: string | undefined): string { .replace(/-preview$/, '') } -function firstUsableCredential(value: string | undefined): string | undefined { - return parseCredentialList(value).find( - credential => credential !== 'SUA_CHAVE', - ) -} export function resolveCacheProbeApiKey( env: NodeJS.ProcessEnv = process.env, diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index 6f43107703..af05c757ea 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -29,6 +29,7 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, OPENAI_MODEL: process.env.OPENAI_MODEL, ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS, @@ -209,6 +210,7 @@ afterEach(() => { restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE) restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) + restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS) @@ -2193,6 +2195,70 @@ test('descriptor model options ignore active profile when route does not match', ]) }) +test('/model refresh passes first pooled OpenAI credential to descriptor discovery', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + delete process.env.OPENROUTER_API_KEY + process.env.OPENAI_MODEL = 'gpt-5.5' + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_GITHUB + delete process.env.CLAUDE_CODE_USE_MISTRAL + delete process.env.CLAUDE_CODE_USE_BEDROCK + delete process.env.CLAUDE_CODE_USE_VERTEX + delete process.env.CLAUDE_CODE_USE_FOUNDRY + delete process.env.OPENAI_API_BASE + + const discoverModelsForRoute = mock(async () => ({ + routeId: 'openrouter', + models: [{ id: 'gpt-5.5', apiName: 'gpt-5.5' }], + stale: false, + error: null, + source: 'network', + })) + + mock.module('../../integrations/discoveryCache.js', () => ({ + clearDiscoveryCache: mock(async () => {}), + getCachedModels: mock(async () => ({ + models: [{ id: 'cached-gpt', apiName: 'gpt-5.5' }], + updatedAt: Date.now(), + error: null, + })), + isCacheStale: mock(async () => false), + parseDurationString: (value: number | string) => + typeof value === 'number' ? value : 86_400_000, + })) + + mock.module('../../integrations/discoveryService.js', () => ({ + getDiscoveryCacheKey: ( + routeId: string, + options?: { apiKey?: string; baseUrl?: string; headers?: Record }, + ) => `${routeId}|${options?.baseUrl ?? ''}|${options?.apiKey ?? ''}|${JSON.stringify(options?.headers ?? {})}`, + discoverModelsForRoute, + probeRouteReadiness: mock(async () => null), + })) + + mockProviderProfiles({ + getActiveOpenAIModelOptionsCache: () => [], + getActiveProviderProfile: () => undefined, + getProfileModelOptions: () => [], + setActiveOpenAIModelOptionsCache: () => {}, + }) + + const { call } = await importFreshModelModule( + 'descriptor-refresh-openai-pooled-key', + ) + await call(() => {}, {} as never, 'refresh') + + expect(discoverModelsForRoute).toHaveBeenCalledWith('openrouter', { + apiKey: 'key-a', + baseUrl: 'https://openrouter.ai/api/v1', + headers: undefined, + forceRefresh: true, + }) +}) + test('/model refresh clears descriptor cache and reports updates', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 465212258f..84e543ab1d 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -33,6 +33,7 @@ import { getAdditionalModelOptionsCacheScope, resolveProviderRequest, } from '../../services/api/providerConfig.js' +import { firstUsableCredential } from '../../services/api/credentialPool.js' import type { ProviderProfile } from '../../utils/config.js' import type { AppState } from '../../state/AppState.js' import { useAppState, useSetAppState } from '../../state/AppState.js' @@ -327,11 +328,13 @@ function getOpenAIDiscoveryRequestOptions(routeId?: string | null): { }) return { - apiKey: resolveRouteCredentialValue({ - routeId, - baseUrl: request.baseUrl, - processEnv: process.env, - }), + apiKey: firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl: request.baseUrl, + processEnv: process.env, + }), + ), baseUrl: request.baseUrl, headers: parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS), } diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 7e9b41e197..176d012201 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -15,6 +15,7 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, OPENAI_MODEL: process.env.OPENAI_MODEL, ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS, CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, @@ -55,6 +56,7 @@ function clearProviderEnv(): void { delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_API_BASE delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_MODEL delete process.env.ANTHROPIC_CUSTOM_HEADERS delete process.env.CLAUDE_CODE_USE_OPENAI @@ -87,6 +89,7 @@ afterEach(() => { restoreEnvValue('OPENAI_BASE_URL') restoreEnvValue('OPENAI_API_BASE') restoreEnvValue('OPENAI_API_KEY') + restoreEnvValue('OPENAI_API_KEYS') restoreEnvValue('OPENAI_MODEL') restoreEnvValue('ANTHROPIC_CUSTOM_HEADERS') restoreEnvValue('CLAUDE_CODE_USE_OPENAI') @@ -442,6 +445,36 @@ describe('discoverModelsForRoute', () => { expect(result?.source).toBe('network') }) + test('refreshStartupDiscoveryForActiveRoute sends first pooled OpenAI credential', async () => { + const { refreshStartupDiscoveryForActiveRoute } = + await loadDiscoveryServiceModule() + + const startupEnv: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://custom.example/v1', + OPENAI_API_KEYS: 'key-a,key-b', + } + + setMockFetch(mock((_input, init) => { + expect(init?.headers).toEqual({ Authorization: 'Bearer key-a' }) + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ id: 'gpt-5.5' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as unknown as typeof globalThis.fetch) + + const result = await refreshStartupDiscoveryForActiveRoute({ + processEnv: startupEnv, + }) + + expect(result?.routeId).toBe('custom') + expect(result?.source).toBe('network') + }) + test('refreshStartupDiscoveryForActiveRoute discovers custom route with hybrid startup discovery', async () => { const { refreshStartupDiscoveryForActiveRoute } = await loadDiscoveryServiceModule() diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index c0c0ba0f00..2234c87b32 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -29,6 +29,7 @@ import { probeAtomicChatReadiness, probeOllamaGenerationReadiness, } from '../utils/providerDiscovery.js' +import { firstUsableCredential } from '../services/api/credentialPool.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' @@ -155,14 +156,17 @@ function getRouteDiscoveryApiKey( return undefined } - if (options?.apiKey?.trim()) { - return options.apiKey.trim() + const optionCredential = firstUsableCredential(options?.apiKey) + if (optionCredential) { + return optionCredential } - return resolveRouteCredentialValue({ - routeId, - processEnv: process.env, - }) + return firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + processEnv: process.env, + }), + ) } function getRouteDiscoveryHeaders( @@ -446,13 +450,15 @@ export async function refreshStartupDiscoveryForActiveRoute( options?.headers ?? parseCustomHeadersEnv(processEnv.ANTHROPIC_CUSTOM_HEADERS), apiKey: - options?.apiKey ?? - resolveRouteCredentialValue({ - routeId, - baseUrl, - processEnv, - activeProfileProvider: options?.activeProfileProvider, - }), + firstUsableCredential(options?.apiKey) ?? + firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl, + processEnv, + activeProfileProvider: options?.activeProfileProvider, + }), + ), }) } diff --git a/src/services/api/credentialPool.ts b/src/services/api/credentialPool.ts index 8c72adf658..fa05a67c90 100644 --- a/src/services/api/credentialPool.ts +++ b/src/services/api/credentialPool.ts @@ -102,3 +102,9 @@ export function parseCredentialList(value: string | undefined): string[] { .map(item => item.trim()) .filter(Boolean) } + +export function firstUsableCredential(value: string | undefined): string | undefined { + return parseCredentialList(value).find( + credential => credential !== 'SUA_CHAVE', + ) +} diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 975c6f5d81..0c2b9a361c 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2883,6 +2883,46 @@ test('OPENAI_API_KEYS does not reuse auth-disabled credentials across client req expect(authorizations).toEqual(['Bearer key-a', 'Bearer key-b']) }) +test('OPENAI_API_KEYS permanently evicts 403 auth failures', async () => { + const authorizations: Array = [] + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + + globalThis.fetch = (async (_input, init) => { + const headers = init?.headers as Record | undefined + authorizations.push(headers?.Authorization ?? headers?.authorization ?? null) + + return new Response(JSON.stringify({ error: { message: 'forbidden' } }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect( + client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + await expect( + client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello again' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + expect(authorizations).toEqual(['Bearer key-a', 'Bearer key-b']) +}) test('does not use BNKR_API_KEY for non-Bankr OpenAI-compatible routes', async () => { let capturedAuthorization: string | null = null diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 629adf2381..743818717c 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -3430,11 +3430,9 @@ class OpenAIShimMessages { }) const credentialFailureKind = - response.status === 401 + failure.category === 'auth_invalid' && !failure.retryable ? 'auth' - : response.status === 402 || - response.status === 403 || - response.status === 429 + : failure.retryable || response.status === 402 || response.status === 429 ? 'cooldown' : null if (credentialPool && credentialPool.size > 1 && credentialFailureKind) { diff --git a/src/utils/model/openaiModelDiscovery.test.ts b/src/utils/model/openaiModelDiscovery.test.ts index 507c8bcedb..ca78f9becb 100644 --- a/src/utils/model/openaiModelDiscovery.test.ts +++ b/src/utils/model/openaiModelDiscovery.test.ts @@ -11,6 +11,8 @@ const originalEnv = { process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, OPENAI_MODEL: process.env.OPENAI_MODEL, } @@ -36,6 +38,8 @@ afterEach(() => { ) restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) + restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) + restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) } finally { releaseSharedMutationLock() @@ -60,3 +64,34 @@ test('skips legacy OpenAI-compatible model discovery when nonessential traffic i await expect(discoverOpenAICompatibleModelOptions()).resolves.toEqual([]) expect(getSpy).not.toHaveBeenCalled() }) + +test('legacy OpenAI-compatible model discovery uses the first pooled credential', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://custom.example/v1' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + process.env.OPENAI_MODEL = 'gpt-5.5' + + const getSpy = mock(async (_url: string, options?: { headers?: Record }) => { + expect(options?.headers).toEqual({ Authorization: 'Bearer key-a' }) + return { + data: { + data: [{ id: 'gpt-5.5' }], + }, + } + }) + axios.get = getSpy as typeof axios.get + + const { discoverOpenAICompatibleModelOptions } = await import( + `./openaiModelDiscovery.js?pooled=${Date.now()}-${Math.random()}` + ) + + await expect(discoverOpenAICompatibleModelOptions()).resolves.toEqual([ + { + value: 'gpt-5.5', + label: 'gpt-5.5', + description: 'Discovered from OpenAI-compatible endpoint', + }, + ]) + expect(getSpy).toHaveBeenCalled() +}) diff --git a/src/utils/model/openaiModelDiscovery.ts b/src/utils/model/openaiModelDiscovery.ts index 1990f064df..1801fd506f 100644 --- a/src/utils/model/openaiModelDiscovery.ts +++ b/src/utils/model/openaiModelDiscovery.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { firstUsableCredential } from '../../services/api/credentialPool.js' import { logForDebugging } from '../debug.js' import { isEssentialTrafficOnly } from '../privacyLevel.js' import type { ModelOption } from './modelOptions.js' @@ -49,7 +50,9 @@ function isBankrBaseUrl(baseUrl: string): boolean { } function getOpenAIAuthHeaders(baseUrl: string): Record { - const apiKey = process.env.OPENAI_API_KEY?.trim() + const apiKey = + firstUsableCredential(process.env.OPENAI_API_KEYS) ?? + firstUsableCredential(process.env.OPENAI_API_KEY) if (!apiKey) { return {} } From 19df6f12c4177c9bd99b1ef9b9b0e3c9fa10497c Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 18 Jun 2026 14:29:35 -0700 Subject: [PATCH 03/14] fix(cache-probe): preserve GitHub credential precedence --- src/commands/cache-probe/cache-probe.test.ts | 27 ++++++++++++++-- src/commands/cache-probe/cache-probe.ts | 34 +++++++++++++++----- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/commands/cache-probe/cache-probe.test.ts b/src/commands/cache-probe/cache-probe.test.ts index 168f3dcd34..8c1210f966 100644 --- a/src/commands/cache-probe/cache-probe.test.ts +++ b/src/commands/cache-probe/cache-probe.test.ts @@ -1,6 +1,14 @@ import { expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' -import { call, resolveCacheProbeApiKey } from './cache-probe.js' +import { + call, + resolveCacheProbeApiKey, + resolveCacheProbeRequestApiKey, +} from './cache-probe.js' test('resolveCacheProbeApiKey prefers the first usable OPENAI_API_KEYS entry', () => { expect( @@ -19,7 +27,21 @@ test('resolveCacheProbeApiKey falls back to comma-separated OPENAI_API_KEY', () ).toBe('key-a') }) +test('resolveCacheProbeRequestApiKey prefers GitHub credentials in GitHub mode', () => { + expect( + resolveCacheProbeRequestApiKey( + { + CLAUDE_CODE_USE_GITHUB: '1', + OPENAI_API_KEYS: 'openai-key-a,openai-key-b', + GITHUB_TOKEN: 'github-token', + } as NodeJS.ProcessEnv, + { isGithub: true }, + ), + ).toBe('github-token') +}) + test('cache-probe no-key guidance mentions pooled OpenAI credentials', async () => { + await acquireSharedMutationLock('commands/cache-probe/cache-probe.test.ts') const originalEnv = { ...process.env } try { for (const key of Object.keys(process.env)) { @@ -39,5 +61,6 @@ test('cache-probe no-key guidance mentions pooled OpenAI credentials', async () delete process.env[key] } Object.assign(process.env, originalEnv) + releaseSharedMutationLock() } -}) \ No newline at end of file +}) diff --git a/src/commands/cache-probe/cache-probe.ts b/src/commands/cache-probe/cache-probe.ts index 2455aad26f..2528e027e8 100644 --- a/src/commands/cache-probe/cache-probe.ts +++ b/src/commands/cache-probe/cache-probe.ts @@ -51,6 +51,29 @@ export function resolveCacheProbeApiKey( ) } +function resolveGithubCacheProbeApiKey(env: NodeJS.ProcessEnv): string { + return ( + env.GITHUB_COPILOT_KEY?.trim() || + env.GITHUB_TOKEN?.trim() || + env.GH_TOKEN?.trim() || + '' + ) +} + +export function resolveCacheProbeRequestApiKey( + env: NodeJS.ProcessEnv = process.env, + options?: { isGithub?: boolean }, +): string { + if (options?.isGithub) { + const githubKey = resolveGithubCacheProbeApiKey(env) + if (githubKey) { + return githubKey + } + } + + return resolveCacheProbeApiKey(env) +} + function getField(obj: unknown, path: string): unknown { return path .split('.') @@ -198,16 +221,11 @@ export const call: LocalCommandCall = async (args) => { const request = resolveProviderRequest({ model: modelStr }) const isGithub = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) - // Resolve API key the same way the OpenAI shim does - let apiKey = resolveCacheProbeApiKey(process.env) - if (!apiKey && isGithub) { + // Resolve API key the same way the active provider path does. + if (isGithub) { hydrateGithubModelsTokenFromSecureStorage() - apiKey = - resolveCacheProbeApiKey(process.env) || - process.env.GITHUB_TOKEN || - process.env.GH_TOKEN || - '' } + const apiKey = resolveCacheProbeRequestApiKey(process.env, { isGithub }) if (!apiKey) { return { From f24a7209c1b7cab150112699c36d8d05de43204d Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 18 Jun 2026 15:25:11 -0700 Subject: [PATCH 04/14] fix(provider): honor pooled OpenAI fallbacks --- docs/advanced-setup.md | 2 +- src/commands/provider/provider.test.tsx | 15 +++++++++ src/commands/provider/provider.tsx | 2 +- src/integrations/gateways/custom.ts | 2 +- .../gateways/gitlawb-opengateway.ts | 10 +++--- src/integrations/gateways/hicap.ts | 4 +-- src/integrations/gateways/opencode-go.ts | 4 +-- src/integrations/gateways/opencode.ts | 4 +-- .../integrationArtifacts.generated.ts | 1 + src/integrations/vendors/venice.ts | 4 +-- src/integrations/vendors/xiaomi-mimo.ts | 4 +-- src/services/api/openaiShim.test.ts | 31 +++++++++++++++++++ src/services/api/openaiShim.ts | 2 +- src/utils/providerProfile.test.ts | 26 ++++++++++++++++ src/utils/providerProfile.ts | 8 +++-- src/utils/providerProfiles.test.ts | 9 ++++++ src/utils/providerValidation.test.ts | 21 +++++++++++++ 17 files changed, 128 insertions(+), 21 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 83da29ce56..c8bd8ab5e2 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -433,7 +433,7 @@ bun run dev:profile # codex profile (uses CODEX_API_KEY or ~/.codex/auth.json) bun run dev:codex -# OpenAI profile (uses the saved OpenAI profile, or OPENAI_API_KEY from your shell) +# OpenAI profile (uses the saved OpenAI profile, or OPENAI_API_KEYS / OPENAI_API_KEY from your shell) bun run dev:openai # Gemini profile (uses the saved Gemini profile, or GEMINI_API_KEY / GOOGLE_API_KEY from your shell) diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx index 05bf2563d6..a4fb53392d 100644 --- a/src/commands/provider/provider.test.tsx +++ b/src/commands/provider/provider.test.tsx @@ -330,6 +330,21 @@ test('buildProfileSaveMessage maps provider fields without echoing secrets', () expect(message).not.toContain('sk-secret-12345678') }) +test('buildProfileSaveMessage reports pooled OpenAI credentials as configured', () => { + const message = buildProfileSaveMessage( + 'openai', + { + OPENAI_API_KEYS: 'sk-secret-a,sk-secret-b', + OPENAI_MODEL: 'gpt-4o', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + }, + 'D:/codings/Opensource/openclaude/.openclaude-profile.json', + ) + + expect(message).toContain('Saved OpenAI profile.') + expect(message).toContain('Credentials: configured') + expect(message).not.toContain('sk-secret-a') +}) test('buildProfileSaveMessage labels local openai-compatible profiles consistently', () => { const message = buildProfileSaveMessage( 'openai', diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index 55ed18cf36..37d5a13015 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -478,7 +478,7 @@ function buildSavedProfileSummary( env, ), credentialLabel: - maskSecretForDisplay(env.OPENAI_API_KEY) !== undefined + maskSecretForDisplay(env.OPENAI_API_KEYS ?? env.OPENAI_API_KEY) !== undefined ? 'configured' : undefined, } diff --git a/src/integrations/gateways/custom.ts b/src/integrations/gateways/custom.ts index 2ada4e8e88..551933335b 100644 --- a/src/integrations/gateways/custom.ts +++ b/src/integrations/gateways/custom.ts @@ -44,7 +44,7 @@ export default defineGateway({ description: 'Any OpenAI-compatible provider', label: 'Custom', name: 'Custom OpenAI-compatible', - apiKeyEnvVars: ['OPENAI_API_KEY'], + apiKeyEnvVars: ['OPENAI_API_KEYS', 'OPENAI_API_KEY'], baseUrlEnvVars: ['OPENAI_BASE_URL', 'OPENAI_API_BASE'], modelEnvVars: ['OPENAI_MODEL'], fallbackBaseUrl: 'http://localhost:11434/v1', diff --git a/src/integrations/gateways/gitlawb-opengateway.ts b/src/integrations/gateways/gitlawb-opengateway.ts index 0d921c52a0..68869e6058 100644 --- a/src/integrations/gateways/gitlawb-opengateway.ts +++ b/src/integrations/gateways/gitlawb-opengateway.ts @@ -11,17 +11,17 @@ export default defineGateway({ setup: { requiresAuth: true, authMode: 'api-key', - credentialEnvVars: ['OPENGATEWAY_API_KEY', 'OPENAI_API_KEY'], + credentialEnvVars: ['OPENGATEWAY_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], }, validation: { kind: 'credential-env', // OPENGATEWAY_API_KEY first so users who set both don't get their generic - // OpenAI key sent to opengateway by accident. OPENAI_API_KEY kept as a - // fallback because that's where existing openclaude configs already hold it. - credentialEnvVars: ['OPENGATEWAY_API_KEY', 'OPENAI_API_KEY'], + // OpenAI key sent to opengateway by accident. OPENAI_API_KEYS / OPENAI_API_KEY kept as + // fallbacks because existing openclaude configs may already hold generic credentials there. + credentialEnvVars: ['OPENGATEWAY_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], missingCredentialMessage: 'OPENGATEWAY_API_KEY is required to use Gitlawb Opengateway.\n' + - 'Mint a free API key at https://gitlawb.com/opengateway/keys and set it as OPENGATEWAY_API_KEY (or OPENAI_API_KEY when OPENAI_BASE_URL points at opengateway).', + 'Mint a free API key at https://gitlawb.com/opengateway/keys and set it as OPENGATEWAY_API_KEY (or OPENAI_API_KEYS / OPENAI_API_KEY when OPENAI_BASE_URL points at opengateway).', routing: { matchBaseUrlHosts: ['opengateway.gitlawb.com', 'opengateway.fly.dev'], }, diff --git a/src/integrations/gateways/hicap.ts b/src/integrations/gateways/hicap.ts index e87de23c54..024c184073 100644 --- a/src/integrations/gateways/hicap.ts +++ b/src/integrations/gateways/hicap.ts @@ -45,9 +45,9 @@ export default defineGateway({ matchDefaultBaseUrl: true, matchBaseUrlHosts: ['api.hicap.ai'], }, - credentialEnvVars: ['HICAP_API_KEY', 'OPENAI_API_KEY'], + credentialEnvVars: ['HICAP_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], missingCredentialMessage: - 'Set HICAP_API_KEY or OPENAI_API_KEY for the Hicap provider.', + 'Set HICAP_API_KEY or OPENAI_API_KEYS / OPENAI_API_KEY for the Hicap provider.', }, catalog: { source: 'hybrid', diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index ffb3837114..fefc30c99a 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -65,9 +65,9 @@ export default defineGateway({ routing: { matchDefaultBaseUrl: true, }, - credentialEnvVars: ['OPENCODE_API_KEY', 'OPENAI_API_KEY'], + credentialEnvVars: ['OPENCODE_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], missingCredentialMessage: - 'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai', + 'OPENCODE_API_KEY or OPENAI_API_KEYS / OPENAI_API_KEY is required. Get your API key from https://opencode.ai', }, transportConfig: { kind: 'openai-compatible', diff --git a/src/integrations/gateways/opencode.ts b/src/integrations/gateways/opencode.ts index 30155ca59e..9238fd7c93 100644 --- a/src/integrations/gateways/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -98,9 +98,9 @@ export default defineGateway({ routing: { matchDefaultBaseUrl: true, }, - credentialEnvVars: ['OPENCODE_API_KEY', 'OPENAI_API_KEY'], + credentialEnvVars: ['OPENCODE_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], missingCredentialMessage: - 'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai', + 'OPENCODE_API_KEY or OPENAI_API_KEYS / OPENAI_API_KEY is required. Get your API key from https://opencode.ai', }, catalog: { source: 'static', diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 100c5e018e..d58c9347fb 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -472,6 +472,7 @@ export const PROVIDER_PRESET_MANIFEST = [ "label": "Custom", "name": "Custom OpenAI-compatible", "apiKeyEnvVars": [ + "OPENAI_API_KEYS", "OPENAI_API_KEY" ], "baseUrlEnvVars": [ diff --git a/src/integrations/vendors/venice.ts b/src/integrations/vendors/venice.ts index 8c4d1396a6..87430bc552 100644 --- a/src/integrations/vendors/venice.ts +++ b/src/integrations/vendors/venice.ts @@ -31,9 +31,9 @@ export default defineVendor({ matchDefaultBaseUrl: true, matchBaseUrlHosts: ['api.venice.ai'], }, - credentialEnvVars: ['VENICE_API_KEY', 'OPENAI_API_KEY'], + credentialEnvVars: ['VENICE_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], missingCredentialMessage: - 'Venice auth is required. Set VENICE_API_KEY or OPENAI_API_KEY.', + 'Venice auth is required. Set VENICE_API_KEY or OPENAI_API_KEYS / OPENAI_API_KEY.', }, catalog: { source: 'static', diff --git a/src/integrations/vendors/xiaomi-mimo.ts b/src/integrations/vendors/xiaomi-mimo.ts index cf8e5d9009..6d9c0b6d9a 100644 --- a/src/integrations/vendors/xiaomi-mimo.ts +++ b/src/integrations/vendors/xiaomi-mimo.ts @@ -66,9 +66,9 @@ export default defineVendor({ matchDefaultBaseUrl: true, matchBaseUrlHosts: ['api.xiaomimimo.com', 'api.mimo-v2.com'], }, - credentialEnvVars: ['MIMO_API_KEY', 'OPENAI_API_KEY'], + credentialEnvVars: ['MIMO_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], missingCredentialMessage: - 'Xiaomi MiMo auth is required. Set MIMO_API_KEY or OPENAI_API_KEY.', + 'Xiaomi MiMo auth is required. Set MIMO_API_KEY or OPENAI_API_KEYS / OPENAI_API_KEY.', }, catalog, usage: { supported: false }, diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 0c2b9a361c..733eb0a117 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2770,6 +2770,37 @@ test('comma-separated OPENAI_API_KEY rotates to the next key on rate-limit failu expect(authorizations).toEqual(['Bearer key-a', 'Bearer key-b']) }) +test('OPENAI_API_KEYS does not rotate through pool on provider 5xx outage', async () => { + const authorizations: Array = [] + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + + globalThis.fetch = (async (_input, init) => { + const headers = init?.headers as Record | undefined + authorizations.push(headers?.Authorization ?? headers?.authorization ?? null) + + return new Response(JSON.stringify({ error: { message: 'server error' } }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect( + client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + expect(authorizations).toEqual(['Bearer key-a']) +}) test('OPENAI_API_KEYS preserves cooldown state across client requests', async () => { const authorizations: Array = [] diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 743818717c..380ebe2af6 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -3432,7 +3432,7 @@ class OpenAIShimMessages { const credentialFailureKind = failure.category === 'auth_invalid' && !failure.retryable ? 'auth' - : failure.retryable || response.status === 402 || response.status === 429 + : response.status === 402 || response.status === 429 ? 'cooldown' : null if (credentialPool && credentialPool.size > 1 && credentialFailureKind) { diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 7c645cd6f6..1d350d2468 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -1360,6 +1360,32 @@ test('legacy openai saved profiles preserve OPENAI_API_KEYS during startup rebui } }) +test('legacy openai saved profiles ignore delimiter-only shell OPENAI_API_KEYS during startup rebuild', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + + try { + saveProfileFile( + profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEYS: 'saved-a,saved-b', + }), + { cwd: tempDir }, + ) + + const persisted = loadProfileFile({ cwd: tempDir }) + assert.notEqual(persisted, null) + + const env = await buildStartupEnvFromProfile({ + persisted, + processEnv: { OPENAI_API_KEYS: ', ,' }, + }) + + assert.equal(env.OPENAI_API_KEYS, 'saved-a,saved-b') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) test('legacy anthropic saved profiles still deserialize and rebuild startup env', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 11e57e38aa..171f39d278 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -1711,8 +1711,12 @@ export async function buildLaunchEnv(options: { } else { delete env.OPENAI_AUTH_HEADER_VALUE } - const openAIKey = processEnv.OPENAI_API_KEY || persistedEnv.OPENAI_API_KEY - const openAIKeys = processEnv.OPENAI_API_KEYS || persistedEnv.OPENAI_API_KEYS + const openAIKey = + sanitizeOpenAICredentialPool(processEnv.OPENAI_API_KEY) || + sanitizeOpenAICredentialPool(persistedEnv.OPENAI_API_KEY) + const openAIKeys = + sanitizeOpenAICredentialPool(processEnv.OPENAI_API_KEYS) || + sanitizeOpenAICredentialPool(persistedEnv.OPENAI_API_KEYS) if (openAIKeys) { env.OPENAI_API_KEYS = openAIKeys } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 16a5e789d3..2f34399873 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -1567,6 +1567,15 @@ describe('getProviderPresetDefaults', () => { expect(defaults.apiKey).toBe('openai-single-key') }) + test('custom preset reads pooled OpenAI credentials', async () => { + const { getProviderPresetDefaults } = await importFreshProviderProfileModules() + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + + const defaults = getProviderPresetDefaults('custom') + + expect(defaults.apiKey).toBe('key-a,key-b') + }) test('ollama preset defaults to a local Ollama model', async () => { const { getProviderPresetDefaults } = await importFreshProviderProfileModules() delete process.env.OPENAI_MODEL diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 8a3b70befa..6c0f86366a 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -434,6 +434,27 @@ test('opengateway validation accepts OPENAI_API_KEY as fallback', async () => { await expect(getProviderValidationError(process.env)).resolves.toBeNull() }) +test.each([ + ['opengateway', 'https://opengateway.gitlawb.com/v1', 'mimo-v2.5-pro'], + ['hicap', 'https://api.hicap.ai/v1', 'claude-opus-4.7'], + ['venice', 'https://api.venice.ai/api/v1', 'venice-uncensored'], + ['xiaomi mimo', 'https://api.xiaomimimo.com/v1', 'mimo-v2.5-pro'], + ['opencode', 'https://opencode.ai/zen/v1', 'gpt-5.4'], + ['opencode go', 'https://opencode.ai/zen/go/v1', 'glm-5.1'], +])('%s validation accepts OPENAI_API_KEYS fallback', async (_name, baseUrl, model) => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = baseUrl + process.env.OPENAI_MODEL = model + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + delete process.env.OPENGATEWAY_API_KEY + delete process.env.HICAP_API_KEY + delete process.env.VENICE_API_KEY + delete process.env.MIMO_API_KEY + delete process.env.OPENCODE_API_KEY + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) test('opengateway validation still requires a key on the model-specific path', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://opengateway.gitlawb.com/v1/xiaomi-mimo' From 15479b900e1d6c187363c4f36a779143701a2920 Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 18 Jun 2026 15:48:09 -0700 Subject: [PATCH 05/14] fix(provider): validate pooled profile credential labels --- src/commands/provider/provider.test.tsx | 15 +++++++++++++++ src/commands/provider/provider.tsx | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx index a4fb53392d..018853c76c 100644 --- a/src/commands/provider/provider.test.tsx +++ b/src/commands/provider/provider.test.tsx @@ -344,6 +344,21 @@ test('buildProfileSaveMessage reports pooled OpenAI credentials as configured', expect(message).toContain('Saved OpenAI profile.') expect(message).toContain('Credentials: configured') expect(message).not.toContain('sk-secret-a') + expect(message).not.toContain('sk-secret-b') +}) +test('buildProfileSaveMessage ignores delimiter-only pooled OpenAI credentials', () => { + const message = buildProfileSaveMessage( + 'openai', + { + OPENAI_API_KEYS: ', ,', + OPENAI_MODEL: 'gpt-4o', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + }, + 'D:/codings/Opensource/openclaude/.openclaude-profile.json', + ) + + expect(message).toContain('Saved OpenAI profile.') + expect(message).not.toContain('Credentials: configured') }) test('buildProfileSaveMessage labels local openai-compatible profiles consistently', () => { const message = buildProfileSaveMessage( diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index 37d5a13015..7c9feb9270 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -478,7 +478,10 @@ function buildSavedProfileSummary( env, ), credentialLabel: - maskSecretForDisplay(env.OPENAI_API_KEYS ?? env.OPENAI_API_KEY) !== undefined + maskSecretForDisplay( + sanitizeOpenAICredentialPool(env.OPENAI_API_KEYS) ?? + sanitizeOpenAICredentialPool(env.OPENAI_API_KEY), + ) !== undefined ? 'configured' : undefined, } From 178af767e2d36d102b7a5eded762f5cdbd64cd33 Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 18 Jun 2026 18:03:48 -0700 Subject: [PATCH 06/14] fix(api): harden OpenAI credential pool handling Reject placeholder values in pooled OpenAI credentials before requests, discovery, diagnostics, and profile generation can use them. Normalize pooled credentials to a single usable key for model discovery, runtime cache partitions, cache probing, and NVIDIA NIM cache lookups. Preserve documented profile precedence by letting live shell credentials override saved pools, carrying OpenCode fallback pools through launch, and redacting individual pool members in profile display. Add regression coverage for pooled credential validation, profile launch/rebuild behavior, discovery/cache callers, diagnostics, provider autodetect, and shim failover semantics. --- scripts/provider-recommend.ts | 11 +- src/commands/cache-probe/cache-probe.test.ts | 8 ++ src/commands/cache-probe/cache-probe.ts | 9 +- src/commands/provider/provider.test.tsx | 16 +++ src/integrations/discoveryService.test.ts | 41 +++++++ src/integrations/discoveryService.ts | 27 +++-- src/integrations/providerUiMetadata.ts | 9 +- src/integrations/runtimeMetadata.test.ts | 35 +++++- src/integrations/runtimeMetadata.ts | 13 +- src/services/api/bootstrap.ts | 6 +- src/services/api/credentialPool.test.ts | 7 +- src/services/api/credentialPool.ts | 12 +- src/services/api/openaiShim.test.ts | 27 +++++ src/services/api/openaiShim.ts | 15 +++ src/utils/diagnostics/issueReport.test.ts | 25 ++++ src/utils/diagnostics/issueReport.ts | 12 +- src/utils/model/nvidiaNimModels.test.ts | 89 ++++++++++++-- src/utils/model/nvidiaNimModels.ts | 63 +++++----- src/utils/model/openaiModelDiscovery.test.ts | 29 +++++ src/utils/model/openaiModelDiscovery.ts | 9 +- src/utils/opencodeProfile.test.ts | 42 +++++++ src/utils/providerAutoDetect.test.ts | 8 ++ src/utils/providerAutoDetect.ts | 9 +- src/utils/providerProfile.test.ts | 86 +++++++++++++- src/utils/providerProfile.ts | 118 ++++++++++++++----- src/utils/providerProfiles.test.ts | 9 ++ src/utils/providerSecrets.test.ts | 13 ++ src/utils/providerSecrets.ts | 5 + 28 files changed, 647 insertions(+), 106 deletions(-) diff --git a/scripts/provider-recommend.ts b/scripts/provider-recommend.ts index 20c49947a0..6c11fba48a 100644 --- a/scripts/provider-recommend.ts +++ b/scripts/provider-recommend.ts @@ -185,10 +185,13 @@ async function main(): Promise { })) const recommendedOllama = selectRecommendedOllamaModel(rankedModels) - const openAIConfigured = Boolean( - sanitizeOpenAICredentialPool(process.env.OPENAI_API_KEYS) || - sanitizeOpenAICredentialPool(process.env.OPENAI_API_KEY), - ) + const openAIConfigured = + !hasInvalidOpenAICredentialPool(process.env.OPENAI_API_KEYS) && + !hasInvalidOpenAICredentialPool(process.env.OPENAI_API_KEY) && + Boolean( + sanitizeOpenAICredentialPool(process.env.OPENAI_API_KEYS) || + sanitizeOpenAICredentialPool(process.env.OPENAI_API_KEY), + ) let recommendedProfile: ProviderProfile let recommendedModel: string diff --git a/src/commands/cache-probe/cache-probe.test.ts b/src/commands/cache-probe/cache-probe.test.ts index 8c1210f966..c8d9c142a8 100644 --- a/src/commands/cache-probe/cache-probe.test.ts +++ b/src/commands/cache-probe/cache-probe.test.ts @@ -19,6 +19,14 @@ test('resolveCacheProbeApiKey prefers the first usable OPENAI_API_KEYS entry', ( ).toBe('key-a') }) +test('resolveCacheProbeApiKey rejects placeholder values inside credential pools', () => { + expect( + resolveCacheProbeApiKey({ + OPENAI_API_KEYS: 'key-a,SUA_CHAVE', + OPENAI_API_KEY: 'key-single', + } as NodeJS.ProcessEnv), + ).toBe('') +}) test('resolveCacheProbeApiKey falls back to comma-separated OPENAI_API_KEY', () => { expect( resolveCacheProbeApiKey({ diff --git a/src/commands/cache-probe/cache-probe.ts b/src/commands/cache-probe/cache-probe.ts index 2528e027e8..cadb05b115 100644 --- a/src/commands/cache-probe/cache-probe.ts +++ b/src/commands/cache-probe/cache-probe.ts @@ -1,5 +1,5 @@ import { getSessionId } from '../../bootstrap/state.js' -import { firstUsableCredential } from '../../services/api/credentialPool.js' +import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../../services/api/credentialPool.js' import { resolveProviderRequest } from '../../services/api/providerConfig.js' import type { LocalCommandCall } from '../../types/command.js' import { logForDebugging } from '../../utils/debug.js' @@ -44,6 +44,13 @@ function getModelFamily(model: string | undefined): string { export function resolveCacheProbeApiKey( env: NodeJS.ProcessEnv = process.env, ): string { + if (hasInvalidCredentialPlaceholder(env.OPENAI_API_KEYS)) { + return '' + } + if (hasInvalidCredentialPlaceholder(env.OPENAI_API_KEY)) { + return '' + } + return ( firstUsableCredential(env.OPENAI_API_KEYS) ?? firstUsableCredential(env.OPENAI_API_KEY) ?? diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx index 018853c76c..f10f738441 100644 --- a/src/commands/provider/provider.test.tsx +++ b/src/commands/provider/provider.test.tsx @@ -346,6 +346,22 @@ test('buildProfileSaveMessage reports pooled OpenAI credentials as configured', expect(message).not.toContain('sk-secret-a') expect(message).not.toContain('sk-secret-b') }) +test('buildProfileSaveMessage redacts individual pooled OpenAI credentials in display fields', () => { + const message = buildProfileSaveMessage( + 'openai', + { + OPENAI_API_KEYS: 'lowercasecredentialaaaaaaaaaaaa,lowercasecredentialbbbbbbbbbbbb', + OPENAI_MODEL: 'lowercasecredentialaaaaaaaaaaaa', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + }, + 'D:/codings/Opensource/openclaude/.openclaude-profile.json', + ) + + expect(message).toContain('Saved OpenAI profile.') + expect(message).not.toContain('lowercasecredentialaaaaaaaaaaaa') + expect(message).not.toContain('lowercasecredentialbbbbbbbbbbbb') + expect(message).toContain('Model: low...aaa') +}) test('buildProfileSaveMessage ignores delimiter-only pooled OpenAI credentials', () => { const message = buildProfileSaveMessage( 'openai', diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 176d012201..cf1dbd0123 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -445,6 +445,47 @@ describe('discoverModelsForRoute', () => { expect(result?.source).toBe('network') }) + test('openai-compatible discovery does not use invalid pooled credentials', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + + registerGateway({ + id: 'discovery-invalid-pool-test', + label: 'Invalid Pool Test', + defaultBaseUrl: 'https://invalid-pool.example/v1', + defaultModel: 'gpt-5.5', + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['OPENAI_API_KEYS', 'OPENAI_API_KEY'], + }, + transportConfig: { kind: 'openai-compatible' }, + catalog: { + source: 'dynamic', + discovery: { kind: 'openai-compatible' }, + }, + }) + + setMockFetch(mock((_input, init) => { + expect(init?.headers ?? {}).toEqual({}) + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ id: 'gpt-5.5' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as unknown as typeof globalThis.fetch) + + const result = await discoverModelsForRoute('discovery-invalid-pool-test', { + apiKey: 'key-a,SUA_CHAVE', + forceRefresh: true, + }) + + expect(result?.routeId).toBe('discovery-invalid-pool-test') + expect(result?.source).toBe('network') + }) + test('refreshStartupDiscoveryForActiveRoute sends first pooled OpenAI credential', async () => { const { refreshStartupDiscoveryForActiveRoute } = await loadDiscoveryServiceModule() diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 2234c87b32..bca55da02f 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -29,7 +29,7 @@ import { probeAtomicChatReadiness, probeOllamaGenerationReadiness, } from '../utils/providerDiscovery.js' -import { firstUsableCredential } from '../services/api/credentialPool.js' +import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../services/api/credentialPool.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' @@ -156,6 +156,10 @@ function getRouteDiscoveryApiKey( return undefined } + if (hasInvalidCredentialPlaceholder(options?.apiKey)) { + return undefined + } + const optionCredential = firstUsableCredential(options?.apiKey) if (optionCredential) { return optionCredential @@ -449,16 +453,17 @@ export async function refreshStartupDiscoveryForActiveRoute( headers: options?.headers ?? parseCustomHeadersEnv(processEnv.ANTHROPIC_CUSTOM_HEADERS), - apiKey: - firstUsableCredential(options?.apiKey) ?? - firstUsableCredential( - resolveRouteCredentialValue({ - routeId, - baseUrl, - processEnv, - activeProfileProvider: options?.activeProfileProvider, - }), - ), + apiKey: hasInvalidCredentialPlaceholder(options?.apiKey) + ? undefined + : firstUsableCredential(options?.apiKey) ?? + firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl, + processEnv, + activeProfileProvider: options?.activeProfileProvider, + }), + ), }) } diff --git a/src/integrations/providerUiMetadata.ts b/src/integrations/providerUiMetadata.ts index ba57a81c23..dabc28bff3 100644 --- a/src/integrations/providerUiMetadata.ts +++ b/src/integrations/providerUiMetadata.ts @@ -41,7 +41,14 @@ function hasUsableEnvValue(envVar: string, value: string | undefined): boolean { } if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { - return value.split(',').some(part => part.trim() !== '') + const credentials = value + .split(',') + .map(part => part.trim()) + .filter(Boolean) + return ( + credentials.length > 0 && + credentials.every(credential => credential !== 'SUA_CHAVE') + ) } return true diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index cdd5958852..189af06bf9 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -70,7 +70,6 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(1_000_000) }) }) - it('uses built-in Z.AI GLM-5.2 runtime limits', () => { const limits = resolveModelRuntimeLimits({ model: 'glm-5.2', @@ -128,6 +127,40 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(131_072) } }) + + + it('uses pooled OpenAI fallback credentials when reading discovered runtime limits', async () => { + await withTempConfigDir(async () => { + const baseUrl = 'http://localhost:4000/v1' + await setCachedModels( + getDiscoveryCacheKey('custom', { + baseUrl, + apiKey: 'key-a', + }), + { + models: [ + { + id: 'pooled-litellm-proxy', + apiName: 'pooled-litellm-proxy', + label: 'pooled-litellm-proxy', + contextWindow: 2_000_000, + }, + ], + }, + ) + + expect( + resolveModelRuntimeLimits({ + model: 'pooled-litellm-proxy', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + OPENAI_API_KEYS: 'key-a,key-b', + }, + }).contextWindow, + ).toBe(2_000_000) + }) + }) }) describe('resolveOpenAIShimRuntimeContext - Z.AI GLM-5.2', () => { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 123a64f50c..bc8cf8a707 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -25,6 +25,7 @@ import { type RouteDescriptor, } from './routeMetadata.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' +import { firstUsableCredential } from '../services/api/credentialPool.js' function normalizeModelApiName( value: string | undefined, @@ -395,11 +396,13 @@ function findCachedCatalogEntryForApiName( const baseUrl = runtimeEnv.OPENAI_BASE_URL ?? runtimeEnv.OPENAI_API_BASE const cacheKey = getDiscoveryCacheKey(routeId, { baseUrl, - apiKey: resolveRouteCredentialValue({ - routeId, - baseUrl, - processEnv: runtimeEnv, - }), + apiKey: firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl, + processEnv: runtimeEnv, + }), + ), headers: parseCustomHeadersEnv(runtimeEnv.ANTHROPIC_CUSTOM_HEADERS), }) const cached = getCachedModelsSync(cacheKey, getDiscoveryCacheTtlMs(routeId)) diff --git a/src/services/api/bootstrap.ts b/src/services/api/bootstrap.ts index cd462b65c7..ddce085ef4 100644 --- a/src/services/api/bootstrap.ts +++ b/src/services/api/bootstrap.ts @@ -6,6 +6,7 @@ import { } from '../../integrations/discoveryService.js' import { getGateway, getVendor } from '../../integrations/index.js' import { resolveRouteCredentialValue } from '../../integrations/routeMetadata.js' +import { firstUsableCredential, hasInvalidCredentialPlaceholder } from './credentialPool.js' import { getAnthropicApiKey, getClaudeAIOAuthTokens, @@ -159,11 +160,14 @@ async function fetchLocalOpenAIModelOptions(): Promise { expect(parseCredentialList(' key-a, key-b ,,key-c ')).toEqual([ @@ -9,6 +9,11 @@ test('parseCredentialList trims comma-separated keys', () => { ]) }) +test('firstUsableCredential rejects pools containing placeholder credentials', () => { + expect(firstUsableCredential('key-a,key-b')).toBe('key-a') + expect(firstUsableCredential('key-a,SUA_CHAVE')).toBeUndefined() + expect(hasInvalidCredentialPlaceholder('key-a,SUA_CHAVE')).toBe(true) +}) test('CredentialPool rotates through healthy credentials', () => { const pool = new CredentialPool(['key-a', 'key-b']) diff --git a/src/services/api/credentialPool.ts b/src/services/api/credentialPool.ts index fa05a67c90..b4a3497f0a 100644 --- a/src/services/api/credentialPool.ts +++ b/src/services/api/credentialPool.ts @@ -103,8 +103,14 @@ export function parseCredentialList(value: string | undefined): string[] { .filter(Boolean) } +export function hasInvalidCredentialPlaceholder(value: string | undefined): boolean { + return parseCredentialList(value).some(credential => credential === 'SUA_CHAVE') +} + export function firstUsableCredential(value: string | undefined): string | undefined { - return parseCredentialList(value).find( - credential => credential !== 'SUA_CHAVE', - ) + if (hasInvalidCredentialPlaceholder(value)) { + return undefined + } + + return parseCredentialList(value)[0] } diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 733eb0a117..e86d8efa0d 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2702,6 +2702,33 @@ test('openai route still sends OPENAI_API_KEY as bearer auth', async () => { expect(captured.authorization).toBe('Bearer fake-openai-key') }) +test('OPENAI_API_KEYS rejects placeholder values before sending requests', async () => { + const authorizations: Array = [] + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENAI_API_KEYS = 'key-a,SUA_CHAVE' + process.env.OPENAI_API_KEY = 'single-key-should-not-hide-invalid-pool' + + globalThis.fetch = (async (_input, init) => { + const headers = init?.headers as Record | undefined + authorizations.push(headers?.Authorization ?? headers?.authorization ?? null) + return makeChatCompletionResponse('gpt-5.5') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect( + client.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow(/SUA_CHAVE|Authentication failed/) + + expect(authorizations).toEqual([]) +}) test('OPENAI_API_KEYS rotates to the next key on rate-limit failure', async () => { const authorizations: Array = [] diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 380ebe2af6..7202097c69 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -99,6 +99,7 @@ import { stableStringifyJson } from '../../utils/stableStringify.js' import { CredentialPool, type CredentialLease, + hasInvalidCredentialPlaceholder, parseCredentialList, } from './credentialPool.js' @@ -2950,6 +2951,20 @@ class OpenAIShimMessages { const explicitCustomAuthHeaderValue = hasCustomAuthHeader ? configuredAuthHeaderValue : '' + if (!explicitCustomAuthHeaderValue && hasInvalidCredentialPlaceholder(apiKeyRaw)) { + throw APIError.generate( + 401, + undefined, + buildOpenAICompatibilityErrorMessage( + 'OpenAI API error 401: invalid credential pool placeholder SUA_CHAVE detected', + { + category: 'auth_invalid', + requestUrl: request.baseUrl, + }, + ), + new Headers(), + ) + } // Detect Azure endpoints by hostname (not raw URL) to prevent bypass via // path segments like https://evil.com/cognitiveservices.azure.com/ let isAzure = isEnvTruthy(process.env.OPENAI_AZURE_STYLE) diff --git a/src/utils/diagnostics/issueReport.test.ts b/src/utils/diagnostics/issueReport.test.ts index f37fbb4b20..a468bbbf2a 100644 --- a/src/utils/diagnostics/issueReport.test.ts +++ b/src/utils/diagnostics/issueReport.test.ts @@ -69,6 +69,31 @@ describe('diagnostic issue report', () => { expect(serialized).not.toContain('server.js') }) + test('does not report delimiter-only OpenAI credential pools as present', async () => { + const report = await buildIssueReport({ + env: { + ...baseEnv, + OPENAI_API_KEYS: ', ,', + OPENAI_API_KEY: undefined, + }, + cwd: '/home/alice/private/openclaude', + now: new Date('2026-06-15T10:30:00.000Z'), + checks: { + buildArtifactsPresent: true, + ripgrep: { available: true, detail: 'system rg' }, + }, + settings: { + sourcesPresent: [], + validationErrors: [], + }, + mcpServers: {}, + errors: [], + }) + + expect(report.provider.credential.present).toBe(false) + expect(report.provider.credential.sources).toEqual([]) + }) + test('formats markdown suitable for a GitHub issue', async () => { const report = await buildIssueReport({ env: baseEnv, diff --git a/src/utils/diagnostics/issueReport.ts b/src/utils/diagnostics/issueReport.ts index a6f76b0d65..77aa6239fa 100644 --- a/src/utils/diagnostics/issueReport.ts +++ b/src/utils/diagnostics/issueReport.ts @@ -12,6 +12,7 @@ import { getRouteCredentialEnvVars, } from '../../integrations/routeMetadata.js' import { resolveModelRuntimeLimits } from '../../integrations/runtimeMetadata.js' +import { parseCredentialList } from '../../services/api/credentialPool.js' import type { CapabilityFlags, ModelCatalogEntry } from '../../integrations/descriptors.js' import type { ScopedMcpServerConfig } from '../../services/mcp/types.js' import { getClaudeCodeMcpConfigs } from '../../services/mcp/config.js' @@ -272,10 +273,19 @@ function normalizeRouteMatchBaseUrl( } } +function hasDiagnosticCredentialValue(name: string, value: string | undefined): boolean { + if (name === 'OPENAI_API_KEYS' || name === 'OPENAI_API_KEY') { + return parseCredentialList(value).length > 0 + } + return Boolean(value?.trim()) +} + function getCredentialSummary(routeId: string, env: NodeJS.ProcessEnv) { const descriptor = getRouteDescriptor(routeId) const envVars = getRouteCredentialEnvVars(routeId) - const sources = envVars.filter(name => Boolean(env[name]?.trim())) + const sources = envVars.filter(name => + hasDiagnosticCredentialValue(name, env[name]), + ) const requiresAuth = descriptor?.setup.requiresAuth ?? routeId !== 'custom' return { diff --git a/src/utils/model/nvidiaNimModels.test.ts b/src/utils/model/nvidiaNimModels.test.ts index e7a98a9638..fd1ecdcc2c 100644 --- a/src/utils/model/nvidiaNimModels.test.ts +++ b/src/utils/model/nvidiaNimModels.test.ts @@ -2,18 +2,52 @@ // fallback path in `getDiscoveredNvidiaNimModelIds()`. The cache key it // builds must match the partition the descriptor picker // (`getOpenAIDiscoveryRequestOptions` in src/commands/model/model.tsx) -// writes to — same `(baseUrl, apiKey, headers)` triple — or the inline -// validator looks in a different partition than the picker just wrote. -// -// Previous rounds of #1177 closed the apiKey-mismatch case. This test -// pins the remaining headers-mismatch case flagged in jatmn's -// 2026-05-21 re-review. - -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +// writes to: same `(baseUrl, apiKey, headers)` triple, including reducing +// pooled OpenAI fallback credentials to the single key used for discovery. + +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { getDiscoveryCacheKey } from '../../integrations/discoveryService.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { parseCustomHeadersEnv } from '../providerCustomHeaders.js' const ROUTE = 'nvidia-nim' +const originalEnv = { + ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + OPENCLAUDE_CONFIG_DIR: process.env.OPENCLAUDE_CONFIG_DIR, + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, + CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, + CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, + CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL, + CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, + CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, + NVIDIA_API_KEY: process.env.NVIDIA_API_KEY, + NVIDIA_NIM: process.env.NVIDIA_NIM, + OPENAI_API_BASE: process.env.OPENAI_API_BASE, + OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_MODEL: process.env.OPENAI_MODEL, +} +let tempDir = '' + +function restoreEnv(): void { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +} function withEnv( patch: Record, @@ -44,12 +78,24 @@ function withEnv( } describe('nvidia-nim discovery cache key parity', () => { - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('nvidiaNimModels.test.ts') + mock.restore() + tempDir = mkdtempSync(join(tmpdir(), 'openclaude-nvidia-nim-cache-test-')) + process.env.CLAUDE_CONFIG_DIR = tempDir + process.env.OPENCLAUDE_CONFIG_DIR = tempDir delete process.env.ANTHROPIC_CUSTOM_HEADERS }) afterEach(() => { - delete process.env.ANTHROPIC_CUSTOM_HEADERS + try { + mock.restore() + restoreEnv() + rmSync(tempDir, { recursive: true, force: true }) + tempDir = '' + } finally { + releaseSharedMutationLock() + } }) test('custom headers move the partition off the no-headers default', () => { @@ -83,7 +129,6 @@ describe('nvidia-nim discovery cache key parity', () => { ), }) - // Mirror what `getDiscoveredNvidiaNimModelIds()` now does. const inlineKey = getDiscoveryCacheKey(ROUTE, { baseUrl, apiKey, @@ -97,6 +142,28 @@ describe('nvidia-nim discovery cache key parity', () => { ) }) + test('inline validator reads the picker cache partition for pooled OpenAI fallback keys', async () => { + const nonce = `${Date.now()}-${Math.random()}` + const { getNvidiaNimDiscoveryCacheKeyForEnv } = await import( + `./nvidiaNimModels.js?nvidiaPooledModels=${nonce}` + ) + + const baseUrl = 'https://integrate.api.nvidia.com/v1' + const processEnv: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + OPENAI_MODEL: 'nvidia/test-chat', + OPENAI_API_KEYS: 'key-a,key-b', + } + const pickerKey = getDiscoveryCacheKey(ROUTE, { + baseUrl, + apiKey: 'key-a', + headers: parseCustomHeadersEnv(processEnv.ANTHROPIC_CUSTOM_HEADERS), + }) + + expect(getNvidiaNimDiscoveryCacheKeyForEnv(processEnv)).toBe(pickerKey) + }) + test('absent ANTHROPIC_CUSTOM_HEADERS leaves picker and inline keys identical', () => { const baseUrl = 'https://integrate.api.nvidia.com/v1' const apiKey = 'nvapi-test' diff --git a/src/utils/model/nvidiaNimModels.ts b/src/utils/model/nvidiaNimModels.ts index 661166ba0b..2ee1ba90f0 100644 --- a/src/utils/model/nvidiaNimModels.ts +++ b/src/utils/model/nvidiaNimModels.ts @@ -9,6 +9,7 @@ import { isEnvTruthy } from '../envUtils.js' import { getCachedModels } from '../../integrations/discoveryCache.js' import { getDiscoveryCacheKey } from '../../integrations/discoveryService.js' import { resolveRouteCredentialValue } from '../../integrations/routeMetadata.js' +import { firstUsableCredential } from '../../services/api/credentialPool.js' import { resolveProviderRequest } from '../../services/api/providerConfig.js' import { parseCustomHeadersEnv } from '../providerCustomHeaders.js' @@ -188,38 +189,40 @@ export function getCachedNvidiaNimModelOptions(): ModelOption[] { * silently mis-partitioned the cache for users authenticating via * `OPENAI_API_KEY`. */ +export function getNvidiaNimDiscoveryCacheKeyForEnv( + processEnv: NodeJS.ProcessEnv = process.env, +): string { + // Mirror the picker side (`getOpenAIDiscoveryRequestOptions` in + // src/commands/model/model.tsx): pull baseUrl from the resolved provider + // request and let `resolveRouteCredentialValue` walk the route's full + // credential list. Falling back to undefined keeps the cache key shape + // identical to the picker's even when the env var is missing; both sides + // will hash the same `apiKeyHash: ''` partition in that case. + const request = resolveProviderRequest({ + model: processEnv.OPENAI_MODEL, + baseUrl: processEnv.OPENAI_BASE_URL, + processEnv, + }) + const routeCredential = resolveRouteCredentialValue({ + routeId: 'nvidia-nim', + baseUrl: request.baseUrl, + processEnv, + }) + const apiKey = firstUsableCredential(routeCredential) + + // Include custom headers in the cache key so this read lands in the + // same partition the picker (`getOpenAIDiscoveryRequestOptions` in + // src/commands/model/model.tsx) writes to. + return getDiscoveryCacheKey('nvidia-nim', { + baseUrl: request.baseUrl, + apiKey, + headers: parseCustomHeadersEnv(processEnv.ANTHROPIC_CUSTOM_HEADERS), + }) +} + export async function getDiscoveredNvidiaNimModelIds(): Promise { try { - // Mirror the picker side (`getOpenAIDiscoveryRequestOptions` in - // src/commands/model/model.tsx): pull baseUrl from the resolved provider - // request and let `resolveRouteCredentialValue` walk the route's full - // credential list. Falling back to undefined keeps the cache key shape - // identical to the picker's even when the env var is missing — both - // sides will hash the same `apiKeyHash: ''` partition in that case. - const request = resolveProviderRequest({ - model: process.env.OPENAI_MODEL, - baseUrl: process.env.OPENAI_BASE_URL, - }) - const apiKey = resolveRouteCredentialValue({ - routeId: 'nvidia-nim', - baseUrl: request.baseUrl, - processEnv: process.env, - }) - - // Include custom headers in the cache key so this read lands in the - // same partition the picker (`getOpenAIDiscoveryRequestOptions` in - // src/commands/model/model.tsx) writes to. The picker passes - // `headers: parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS)` - // into `getDiscoveryCacheKey`; without it, two users sharing a - // baseUrl/apiKey but differing in `ANTHROPIC_CUSTOM_HEADERS` end up - // on different cache partitions and the inline `/model ` - // validator misses the discovered ids the picker just persisted - // (jatmn re-review on #1177). - const cacheKey = getDiscoveryCacheKey('nvidia-nim', { - baseUrl: request.baseUrl, - apiKey, - headers: parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS), - }) + const cacheKey = getNvidiaNimDiscoveryCacheKeyForEnv(process.env) const cached = await getCachedModels(cacheKey, Number.MAX_SAFE_INTEGER, { includeStale: true, }) diff --git a/src/utils/model/openaiModelDiscovery.test.ts b/src/utils/model/openaiModelDiscovery.test.ts index ca78f9becb..8cb093f1b0 100644 --- a/src/utils/model/openaiModelDiscovery.test.ts +++ b/src/utils/model/openaiModelDiscovery.test.ts @@ -65,6 +65,35 @@ test('skips legacy OpenAI-compatible model discovery when nonessential traffic i expect(getSpy).not.toHaveBeenCalled() }) +test('legacy OpenAI-compatible model discovery rejects placeholder values inside pools', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://custom.example/v1' + process.env.OPENAI_API_KEYS = 'key-a,SUA_CHAVE' + process.env.OPENAI_API_KEY = 'key-single' + process.env.OPENAI_MODEL = 'gpt-5.5' + + const getSpy = mock(async (_url: string, options?: { headers?: Record }) => { + expect(options?.headers).toEqual({}) + return { + data: { + data: [{ id: 'gpt-5.5' }], + }, + } + }) + axios.get = getSpy as typeof axios.get + + const { discoverOpenAICompatibleModelOptions } = await import( + `./openaiModelDiscovery.js?invalid-pooled=${Date.now()}-${Math.random()}` + ) + + await expect(discoverOpenAICompatibleModelOptions()).resolves.toEqual([ + { + description: 'Discovered from OpenAI-compatible endpoint', + label: 'gpt-5.5', + value: 'gpt-5.5', + }, + ]) +}) test('legacy OpenAI-compatible model discovery uses the first pooled credential', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://custom.example/v1' diff --git a/src/utils/model/openaiModelDiscovery.ts b/src/utils/model/openaiModelDiscovery.ts index 1801fd506f..ab6a838cf8 100644 --- a/src/utils/model/openaiModelDiscovery.ts +++ b/src/utils/model/openaiModelDiscovery.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { firstUsableCredential } from '../../services/api/credentialPool.js' +import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../../services/api/credentialPool.js' import { logForDebugging } from '../debug.js' import { isEssentialTrafficOnly } from '../privacyLevel.js' import type { ModelOption } from './modelOptions.js' @@ -50,6 +50,13 @@ function isBankrBaseUrl(baseUrl: string): boolean { } function getOpenAIAuthHeaders(baseUrl: string): Record { + if ( + hasInvalidCredentialPlaceholder(process.env.OPENAI_API_KEYS) || + hasInvalidCredentialPlaceholder(process.env.OPENAI_API_KEY) + ) { + return {} + } + const apiKey = firstUsableCredential(process.env.OPENAI_API_KEYS) ?? firstUsableCredential(process.env.OPENAI_API_KEY) diff --git a/src/utils/opencodeProfile.test.ts b/src/utils/opencodeProfile.test.ts index cb3e688a74..9f32745df5 100644 --- a/src/utils/opencodeProfile.test.ts +++ b/src/utils/opencodeProfile.test.ts @@ -133,6 +133,48 @@ test('opencode profile prefers OPENCODE_API_KEY from process env over persisted' assert.equal(env.OPENAI_API_KEY, 'sk-process-key') }) +test('opencode profile preserves generic pooled OpenAI fallback credentials', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', {}), + goal: 'balanced', + processEnv: { + OPENAI_API_KEYS: 'live-a,live-b', + }, + }) + + assert.equal(env.OPENAI_API_KEYS, 'live-a,live-b') + assert.equal(env.OPENAI_API_KEY, undefined) +}) + +test('opencode profile preserves invalid generic pools for launch validation', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', {}), + goal: 'balanced', + processEnv: { + OPENAI_API_KEYS: 'live-a,SUA_CHAVE', + OPENAI_API_KEY: 'live-single', + }, + }) + + assert.equal(env.OPENAI_API_KEYS, 'live-a,SUA_CHAVE') + assert.equal(env.OPENAI_API_KEY, undefined) +}) +test('opencode profile lets dedicated OpenCode credentials override generic pools', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', {}), + goal: 'balanced', + processEnv: { + OPENCODE_API_KEY: 'opencode-live', + OPENAI_API_KEYS: 'live-a,live-b', + }, + }) + + assert.equal(env.OPENAI_API_KEY, 'opencode-live') + assert.equal(env.OPENAI_API_KEYS, undefined) +}) test('opencode profile handles empty api key gracefully', async () => { const env = await buildLaunchEnv({ profile: 'opencode', diff --git a/src/utils/providerAutoDetect.test.ts b/src/utils/providerAutoDetect.test.ts index 3eb6f91b3f..6663b073b5 100644 --- a/src/utils/providerAutoDetect.test.ts +++ b/src/utils/providerAutoDetect.test.ts @@ -85,6 +85,14 @@ describe('detectProviderFromEnv — priority order', () => { ).toEqual({ kind: 'gemini', source: 'GEMINI_API_KEY set' }) }) + test('placeholder OPENAI_API_KEYS does not block lower-priority providers', () => { + expect( + scan({ + OPENAI_API_KEYS: 'sk-a,SUA_CHAVE', + GEMINI_API_KEY: 'gemini-key', + }), + ).toEqual({ kind: 'gemini', source: 'GEMINI_API_KEY set' }) + }) test('OPENAI_API_KEY reports baseUrl when set', () => { expect( scan({ diff --git a/src/utils/providerAutoDetect.ts b/src/utils/providerAutoDetect.ts index 1a646e6019..40fac44294 100644 --- a/src/utils/providerAutoDetect.ts +++ b/src/utils/providerAutoDetect.ts @@ -72,7 +72,14 @@ function envHasNonEmpty(env: EnvLike, key: string): boolean { function envHasUsableOpenAICredential(env: EnvLike, key: string): boolean { const value = env[key] if (typeof value !== 'string') return false - return value.split(',').some(part => part.trim().length > 0) + const credentials = value + .split(',') + .map(part => part.trim()) + .filter(Boolean) + return ( + credentials.length > 0 && + credentials.every(credential => credential !== 'SUA_CHAVE') + ) } function firstSet(env: EnvLike, keys: readonly string[]): string | undefined { diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 1d350d2468..579979b7d5 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -34,6 +34,7 @@ import { redactSecretValueForDisplay, saveProfileFile, sanitizeProviderConfigValue, + hasInvalidOpenAICredentialPool, selectAutoProfile, type ProfileFile, } from './providerProfile.js' @@ -744,6 +745,7 @@ test('xai OAuth profile ignores ambient OPENAI_API_KEY and preserves marker', as }), goal: 'balanced', processEnv: { + OPENAI_API_KEYS: 'sk-openai-pool-a,SUA_CHAVE', OPENAI_API_KEY: 'sk-openai-shell-key', }, }) @@ -752,9 +754,10 @@ test('xai OAuth profile ignores ambient OPENAI_API_KEY and preserves marker', as assert.equal(env.XAI_CREDENTIAL_SOURCE, 'oauth') // OpenAI key is NOT promoted into the xAI bearer surface. assert.equal(env.XAI_API_KEY, undefined) - // openaiShim short-circuits on OPENAI_API_KEY before checking the - // stored OAuth token, so it must also be cleared from the resulting - // process env (clearManagedProfileEnv + no promotion in profileEnv). + // openaiShim short-circuits on OPENAI_API_KEYS / OPENAI_API_KEY before + // checking the stored OAuth token, so both must be cleared from the + // resulting process env (clearManagedProfileEnv + no promotion in profileEnv). + assert.equal(env.OPENAI_API_KEYS, undefined) assert.equal(env.OPENAI_API_KEY, undefined) // Base URL and model still come through. assert.equal(env.OPENAI_BASE_URL, 'https://api.x.ai/v1') @@ -1360,6 +1363,33 @@ test('legacy openai saved profiles preserve OPENAI_API_KEYS during startup rebui } }) +test('legacy openai saved profiles let live singular keys override saved pools during startup rebuild', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + + try { + saveProfileFile( + profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEYS: 'saved-a,saved-b', + }), + { cwd: tempDir }, + ) + + const persisted = loadProfileFile({ cwd: tempDir }) + assert.notEqual(persisted, null) + + const env = await buildStartupEnvFromProfile({ + persisted, + processEnv: { OPENAI_API_KEY: 'shell-single' }, + }) + + assert.equal(env.OPENAI_API_KEY, 'shell-single') + assert.equal(env.OPENAI_API_KEYS, undefined) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) test('legacy openai saved profiles ignore delimiter-only shell OPENAI_API_KEYS during startup rebuild', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) @@ -1828,6 +1858,20 @@ test('openai profiles accept OPENAI_API_KEYS without OPENAI_API_KEY', () => { assert.equal(env?.OPENAI_API_KEY, undefined) }) +test('openai profiles let explicit credentials replace invalid shell pools', () => { + const env = buildOpenAIProfileEnv({ + goal: 'balanced', + model: 'gpt-5.4', + apiKey: 'real-key', + processEnv: { + OPENAI_API_KEYS: 'SUA_CHAVE', + OPENAI_API_KEY: 'SUA_CHAVE', + }, + }) + + assert.equal(env?.OPENAI_API_KEY, 'real-key') + assert.equal(env?.OPENAI_API_KEYS, undefined) +}) test('openai profiles reject placeholder values inside pooled credentials', () => { const env = buildOpenAIProfileEnv({ goal: 'balanced', @@ -1880,6 +1924,42 @@ test('openai profiles normalize multi-model profile values to the primary model' }) }) +test('openai launch preserves invalid live pooled credentials for launch validation', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEYS: 'saved-a,saved-b', + }), + goal: 'balanced', + processEnv: { + OPENAI_API_KEYS: 'key-a,SUA_CHAVE', + OPENAI_API_KEY: 'shell-single', + }, + }) + + assert.equal(env.OPENAI_API_KEYS, 'key-a,SUA_CHAVE') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(hasInvalidOpenAICredentialPool(env.OPENAI_API_KEYS), true) +}) +test('openai launch lets a live singular key override a saved pool', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEYS: 'saved-a,saved-b', + }), + goal: 'balanced', + processEnv: { + OPENAI_API_KEY: 'shell-single', + }, + }) + + assert.equal(env.OPENAI_API_KEY, 'shell-single') + assert.equal(env.OPENAI_API_KEYS, undefined) +}) test('startup env ignores poisoned persisted openai model and base url', async () => { const env = await buildStartupEnvFromProfile({ persisted: profile('openai', { diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 171f39d278..a7da18c598 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -691,6 +691,57 @@ function resolveOpenAICredentialPool( count: credentials.length, } } +type OpenAICredentialEnvSelection = + | { + kind: 'usable' + envVar: 'OPENAI_API_KEYS' | 'OPENAI_API_KEY' + value: string + } + | { + kind: 'invalid' + envVar: 'OPENAI_API_KEYS' | 'OPENAI_API_KEY' + value: string + } + +function resolveOpenAICredentialEnvSelection( + env: SecretValueSource, +): OpenAICredentialEnvSelection | undefined { + const pooled = resolveOpenAICredentialPool(env.OPENAI_API_KEYS) + if (pooled.kind === 'invalid') { + return { + kind: 'invalid', + envVar: 'OPENAI_API_KEYS', + value: env.OPENAI_API_KEYS ?? '', + } + } + if (pooled.kind === 'usable') { + return { kind: 'usable', envVar: 'OPENAI_API_KEYS', value: pooled.value } + } + + const single = resolveOpenAICredentialPool(env.OPENAI_API_KEY) + if (single.kind === 'invalid') { + return { + kind: 'invalid', + envVar: 'OPENAI_API_KEY', + value: env.OPENAI_API_KEY ?? '', + } + } + if (single.kind === 'usable') { + return { kind: 'usable', envVar: 'OPENAI_API_KEY', value: single.value } + } + + return undefined +} + +function resolveOpenAICredentialEnvOverride( + primary: SecretValueSource, + fallback: SecretValueSource, +): OpenAICredentialEnvSelection | undefined { + return ( + resolveOpenAICredentialEnvSelection(primary) || + resolveOpenAICredentialEnvSelection(fallback) + ) +} export function buildOpenAIProfileEnv(options: { goal: RecommendationGoal @@ -706,6 +757,10 @@ export function buildOpenAIProfileEnv(options: { }): ProfileEnv | null { const processEnv = options.processEnv ?? process.env const explicitCredential = resolveOpenAICredentialPool(options.apiKey) + if (explicitCredential.kind === 'invalid') { + return null + } + const envOpenAIKeysCredential = resolveOpenAICredentialPool( processEnv.OPENAI_API_KEYS, ) @@ -713,28 +768,24 @@ export function buildOpenAIProfileEnv(options: { processEnv.OPENAI_API_KEY, ) if ( - explicitCredential.kind === 'invalid' || - envOpenAIKeysCredential.kind === 'invalid' || - envOpenAIKeyCredential.kind === 'invalid' + explicitCredential.kind !== 'usable' && + (envOpenAIKeysCredential.kind === 'invalid' || + envOpenAIKeyCredential.kind === 'invalid') ) { return null } + + const envCredential = resolveOpenAICredentialEnvSelection(processEnv) const key = explicitCredential.kind === 'usable' ? explicitCredential.value - : envOpenAIKeysCredential.kind === 'usable' - ? envOpenAIKeysCredential.value - : envOpenAIKeyCredential.kind === 'usable' - ? envOpenAIKeyCredential.value - : undefined + : envCredential?.value const keyEnvVar = explicitCredential.kind === 'usable' && explicitCredential.count > 1 ? 'OPENAI_API_KEYS' : explicitCredential.kind === 'usable' ? 'OPENAI_API_KEY' - : envOpenAIKeysCredential.kind === 'usable' - ? 'OPENAI_API_KEYS' - : 'OPENAI_API_KEY' + : envCredential?.envVar ?? 'OPENAI_API_KEY' const authHeaderValue = sanitizeApiKey( options.authHeaderValue ?? processEnv.OPENAI_AUTH_HEADER_VALUE, ) @@ -1541,17 +1592,18 @@ export async function buildLaunchEnv(options: { env.XAI_CREDENTIAL_SOURCE = 'oauth' } - // For OAuth profiles, also clear any ambient OPENAI_API_KEY from + // For OAuth profiles, also clear any ambient OpenAI credentials from // the returned compatibility env. openaiShim's resolver checks - // process.env.OPENAI_API_KEY before falling back to the stored - // OAuth token; leaving the shell key there would short-circuit - // OAuth and send the wrong bearer to api.x.ai/v1. + // process.env.OPENAI_API_KEYS / OPENAI_API_KEY before falling back + // to the stored OAuth token; leaving shell credentials there would + // short-circuit OAuth and send the wrong bearer to api.x.ai/v1. const result = buildCompatibilityProcessEnv({ processEnv, compatibilityMode: 'openai', profileEnv: env, }) if (isOAuthProfile && !env.XAI_API_KEY) { + delete result.OPENAI_API_KEYS delete result.OPENAI_API_KEY } return result @@ -1561,21 +1613,30 @@ export async function buildLaunchEnv(options: { const opencodeKey = sanitizeApiKey(processEnv.OPENCODE_API_KEY) || sanitizeApiKey(persistedEnv.OPENCODE_API_KEY) + const openAICredential = resolveOpenAICredentialEnvOverride( + processEnv, + persistedEnv, + ) const opencodeBaseUrl = sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL) || sanitizeProviderConfigValue(persistedEnv.OPENAI_BASE_URL) || DEFAULT_OPENCODE_BASE_URL const opencodeModel = shellOpenAIModel || persistedOpenAIModel || 'gpt-5.4' + const profileEnv: ProfileEnv = { + OPENAI_BASE_URL: opencodeBaseUrl, + OPENAI_MODEL: opencodeModel, + } + if (opencodeKey) { + profileEnv.OPENAI_API_KEY = opencodeKey + } else if (openAICredential) { + profileEnv[openAICredential.envVar] = openAICredential.value + } return buildCompatibilityProcessEnv({ processEnv, compatibilityMode: 'openai', - profileEnv: { - OPENAI_BASE_URL: opencodeBaseUrl, - OPENAI_MODEL: opencodeModel, - ...(opencodeKey ? { OPENAI_API_KEY: opencodeKey } : {}), - }, + profileEnv, }) } @@ -1711,17 +1772,12 @@ export async function buildLaunchEnv(options: { } else { delete env.OPENAI_AUTH_HEADER_VALUE } - const openAIKey = - sanitizeOpenAICredentialPool(processEnv.OPENAI_API_KEY) || - sanitizeOpenAICredentialPool(persistedEnv.OPENAI_API_KEY) - const openAIKeys = - sanitizeOpenAICredentialPool(processEnv.OPENAI_API_KEYS) || - sanitizeOpenAICredentialPool(persistedEnv.OPENAI_API_KEYS) - if (openAIKeys) { - env.OPENAI_API_KEYS = openAIKeys - } - if (openAIKey) { - env.OPENAI_API_KEY = openAIKey + const openAICredential = resolveOpenAICredentialEnvOverride( + processEnv, + persistedEnv, + ) + if (openAICredential) { + env[openAICredential.envVar] = openAICredential.value } // Dedicated vendor credentials ride alongside the generic OpenAI env in // persisted profiles (e.g. ATLAS_CLOUD_API_KEY). Carry them over — a live diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 2f34399873..8a5f3940b5 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -1567,6 +1567,15 @@ describe('getProviderPresetDefaults', () => { expect(defaults.apiKey).toBe('openai-single-key') }) + test('openai preset skips placeholder pooled keys before singular fallback', async () => { + const { getProviderPresetDefaults } = await importFreshProviderProfileModules() + process.env.OPENAI_API_KEYS = 'key-a,SUA_CHAVE' + process.env.OPENAI_API_KEY = 'openai-single-key' + + const defaults = getProviderPresetDefaults('openai') + + expect(defaults.apiKey).toBe('openai-single-key') + }) test('custom preset reads pooled OpenAI credentials', async () => { const { getProviderPresetDefaults } = await importFreshProviderProfileModules() process.env.OPENAI_API_KEYS = 'key-a,key-b' diff --git a/src/utils/providerSecrets.test.ts b/src/utils/providerSecrets.test.ts index 642174e8b2..c0901f2e5b 100644 --- a/src/utils/providerSecrets.test.ts +++ b/src/utils/providerSecrets.test.ts @@ -218,6 +218,19 @@ describe('redactSecretValueForDisplay', () => { expect(redactSecretValueForDisplay(token, { GEMINI_ACCESS_TOKEN: token })).toBe('gem...ken') }) + test('redacts individual members of pooled OpenAI credentials', () => { + const pool = 'lowercasecredentialaaaaaaaaaaaa,lowercasecredentialbbbbbbbbbbbb' + expect( + redactSecretValueForDisplay('lowercasecredentialaaaaaaaaaaaa', { + OPENAI_API_KEYS: pool, + }), + ).toBe('low...aaa') + expect( + sanitizeProviderConfigValue('lowercasecredentialbbbbbbbbbbbb', { + OPENAI_API_KEYS: pool, + }), + ).toBeUndefined() + }) test('redacts configured provider secrets after trimming source env values', () => { const providerSecret = 'ogw-provider-secret' expect( diff --git a/src/utils/providerSecrets.ts b/src/utils/providerSecrets.ts index b92c295c6f..fa390f24bb 100644 --- a/src/utils/providerSecrets.ts +++ b/src/utils/providerSecrets.ts @@ -10,6 +10,7 @@ import { // credential paths that either predate descriptors or are accepted by // provider-specific auth helpers outside setup.credentialEnvVars. const FALLBACK_SECRET_ENV_KEYS: readonly string[] = [ + 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENAI_AUTH_HEADER_VALUE', 'CODEX_API_KEY', @@ -188,6 +189,10 @@ function collectSecretValues( const value = sanitizeApiKey(source[key])?.trim() if (value) { values.add(value) + for (const part of value.split(',')) { + const trimmedPart = sanitizeApiKey(part)?.trim() + if (trimmedPart) values.add(trimmedPart) + } } } } From eca3c1bc3cdf50b430f1345c6bea1fc6dd62e6af Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 18 Jun 2026 18:41:29 -0700 Subject: [PATCH 07/14] fix(provider): cover pooled key recommendation path Import the pooled OpenAI credential validator in provider-recommend and split invalid credentials from unset credentials in user guidance. Add a script-level regression that runs the OpenAI recommendation path with OPENAI_API_KEYS so the ts-nocheck script cannot regress with runtime ReferenceErrors. Scrub pooled OpenAI keys before xAI OAuth profile env construction and loosen the invalid-pool discovery test to assert auth header absence instead of exact header shape. --- scripts/provider-recommend.test.ts | 85 +++++++++++++++++++++++ scripts/provider-recommend.ts | 37 +++++++--- src/integrations/discoveryService.test.ts | 4 +- src/utils/providerProfile.ts | 17 ++++- 4 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 scripts/provider-recommend.test.ts diff --git a/scripts/provider-recommend.test.ts b/scripts/provider-recommend.test.ts new file mode 100644 index 0000000000..e6f8c4d1e9 --- /dev/null +++ b/scripts/provider-recommend.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test' +import { getOpenAIConfigurationState } from './provider-recommend.ts' + +function envWithOpenAI( + patch: Record, +): NodeJS.ProcessEnv { + const env = { ...process.env, ...patch } + for (const key of ['OPENAI_API_KEYS', 'OPENAI_API_KEY']) { + if (patch[key] === undefined) { + delete env[key] + } + } + return env +} + +async function readStream(stream: ReadableStream): Promise { + return await new Response(stream).text() +} + +describe('provider-recommend OpenAI credential detection', () => { + test('detects valid pooled OpenAI credentials', () => { + expect( + getOpenAIConfigurationState( + envWithOpenAI({ + OPENAI_API_KEYS: 'key-a,key-b', + OPENAI_API_KEY: undefined, + }), + ), + ).toEqual({ configured: true, invalid: false }) + }) + + test('distinguishes placeholder pools from unset credentials', () => { + expect( + getOpenAIConfigurationState( + envWithOpenAI({ + OPENAI_API_KEYS: 'key-a,SUA_CHAVE', + OPENAI_API_KEY: undefined, + }), + ), + ).toEqual({ configured: false, invalid: true }) + + expect( + getOpenAIConfigurationState( + envWithOpenAI({ + OPENAI_API_KEYS: undefined, + OPENAI_API_KEY: undefined, + }), + ), + ).toEqual({ configured: false, invalid: false }) + }) + + test('profile:recommend openai path runs with pooled credentials', async () => { + const env = envWithOpenAI({ + OPENAI_API_KEYS: 'key-a,key-b', + OPENAI_API_KEY: undefined, + NO_COLOR: '1', + }) + const proc = Bun.spawn( + [ + process.execPath, + 'run', + 'scripts/provider-recommend.ts', + '--provider', + 'openai', + '--json', + ], + { + cwd: process.cwd(), + env, + stdout: 'pipe', + stderr: 'pipe', + }, + ) + + const [stdout, stderr, exitCode] = await Promise.all([ + readStream(proc.stdout), + readStream(proc.stderr), + proc.exited, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(JSON.parse(stdout).openAIConfigured).toBe(true) + }) +}) \ No newline at end of file diff --git a/scripts/provider-recommend.ts b/scripts/provider-recommend.ts index 6c11fba48a..ccdceff5b3 100644 --- a/scripts/provider-recommend.ts +++ b/scripts/provider-recommend.ts @@ -14,6 +14,7 @@ import { buildOllamaProfileEnv, buildOpenAIProfileEnv, createProfileFile, + hasInvalidOpenAICredentialPool, saveProfileFile, sanitizeOpenAICredentialPool, type ProfileFile, @@ -35,6 +36,22 @@ type CliOptions = { baseUrl: string | null } +export function getOpenAIConfigurationState( + env: NodeJS.ProcessEnv = process.env, +): { configured: boolean; invalid: boolean } { + const invalid = + hasInvalidOpenAICredentialPool(env.OPENAI_API_KEYS) || + hasInvalidOpenAICredentialPool(env.OPENAI_API_KEY) + const configured = + !invalid && + Boolean( + sanitizeOpenAICredentialPool(env.OPENAI_API_KEYS) || + sanitizeOpenAICredentialPool(env.OPENAI_API_KEY), + ) + + return { configured, invalid } +} + function parseOptions(argv: string[]): CliOptions { const options: CliOptions = { apply: false, @@ -185,13 +202,8 @@ async function main(): Promise { })) const recommendedOllama = selectRecommendedOllamaModel(rankedModels) - const openAIConfigured = - !hasInvalidOpenAICredentialPool(process.env.OPENAI_API_KEYS) && - !hasInvalidOpenAICredentialPool(process.env.OPENAI_API_KEY) && - Boolean( - sanitizeOpenAICredentialPool(process.env.OPENAI_API_KEYS) || - sanitizeOpenAICredentialPool(process.env.OPENAI_API_KEY), - ) + const openAIConfiguration = getOpenAIConfigurationState(process.env) + const openAIConfigured = openAIConfiguration.configured let recommendedProfile: ProviderProfile let recommendedModel: string @@ -257,14 +269,19 @@ async function main(): Promise { if (!recommendedOllama && !openAIConfigured) { console.log( - '\nNo local Ollama model was detected and OPENAI_API_KEYS / OPENAI_API_KEY are unset.', + ` +No local Ollama model was detected and OPENAI_API_KEYS / OPENAI_API_KEY ${ + openAIConfiguration.invalid ? 'are invalid' : 'are unset' + }.`, ) console.log( - 'Next steps: `ollama pull qwen2.5-coder:7b` or set OPENAI_API_KEYS or OPENAI_API_KEY.', + 'Next steps: `ollama pull qwen2.5-coder:7b` or set valid OPENAI_API_KEYS or OPENAI_API_KEY.', ) } } -await main() +if (import.meta.main) { + await main() +} export {} diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index cf1dbd0123..540df2104e 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -466,7 +466,9 @@ describe('discoverModelsForRoute', () => { }) setMockFetch(mock((_input, init) => { - expect(init?.headers ?? {}).toEqual({}) + const headers = new Headers(init?.headers) + expect(headers.get('authorization')).toBeNull() + expect(headers.get('api-key')).toBeNull() return Promise.resolve( new Response( JSON.stringify({ diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index a7da18c598..242408785f 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -1578,7 +1578,12 @@ export async function buildLaunchEnv(options: { // it won't be promoted into XAI_API_KEY / OPENAI_API_KEY for // this profile's env. processEnv: isOAuthProfile - ? { ...processEnv, OPENAI_API_KEY: undefined, XAI_API_KEY: undefined } + ? { + ...processEnv, + OPENAI_API_KEYS: undefined, + OPENAI_API_KEY: undefined, + XAI_API_KEY: undefined, + } : processEnv, }) const customHeaders = shellCustomHeaders || persistedCustomHeaders @@ -1597,8 +1602,16 @@ export async function buildLaunchEnv(options: { // process.env.OPENAI_API_KEYS / OPENAI_API_KEY before falling back // to the stored OAuth token; leaving shell credentials there would // short-circuit OAuth and send the wrong bearer to api.x.ai/v1. + const compatibilityProcessEnv = + isOAuthProfile && !env.XAI_API_KEY + ? { + ...processEnv, + OPENAI_API_KEYS: undefined, + OPENAI_API_KEY: undefined, + } + : processEnv const result = buildCompatibilityProcessEnv({ - processEnv, + processEnv: compatibilityProcessEnv, compatibilityMode: 'openai', profileEnv: env, }) From c24d7ed1dfb97c39ce79c1be511710b657fae675 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sun, 21 Jun 2026 17:51:20 -0700 Subject: [PATCH 08/14] fix(tests): stabilize rebased provider checks --- src/utils/model/openaiModelDiscovery.test.ts | 38 ++++++++++++++++++++ src/utils/statusRedaction.test.ts | 2 ++ 2 files changed, 40 insertions(+) diff --git a/src/utils/model/openaiModelDiscovery.test.ts b/src/utils/model/openaiModelDiscovery.test.ts index 8cb093f1b0..908836575b 100644 --- a/src/utils/model/openaiModelDiscovery.test.ts +++ b/src/utils/model/openaiModelDiscovery.test.ts @@ -24,8 +24,44 @@ function restoreEnv(key: string, value: string | undefined): void { } } +function clearCompetingProviderEnv(): void { + for (const key of [ + 'ANTHROPIC_BASE_URL', + 'CHATGPT_ACCOUNT_ID', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_USE_VERTEX', + 'CODEX_ACCOUNT_ID', + 'CODEX_API_KEY', + 'CODEX_CREDENTIAL_SOURCE', + 'GEMINI_API_KEY', + 'GITHUB_COPILOT_KEY', + 'GITHUB_ENTERPRISE_URL', + 'MIMO_API_KEY', + 'MINIMAX_API_KEY', + 'MISTRAL_API_KEY', + 'NVIDIA_API_KEY', + 'NVIDIA_NIM', + 'OPENAI_API_BASE', + 'OPENAI_API_FORMAT', + 'OPENAI_AUTH_HEADER', + 'OPENAI_AUTH_HEADER_VALUE', + 'OPENAI_AUTH_SCHEME', + 'XAI_API_KEY', + 'XAI_CREDENTIAL_SOURCE', + ]) { + delete process.env[key] + } +} + beforeEach(async () => { await acquireSharedMutationLock('utils/model/openaiModelDiscovery.test.ts') + mock.restore() + mock.module('./providers.js', () => ({ getAPIProvider: () => 'openai' })) + clearCompetingProviderEnv() }) afterEach(() => { @@ -66,6 +102,7 @@ test('skips legacy OpenAI-compatible model discovery when nonessential traffic i }) test('legacy OpenAI-compatible model discovery rejects placeholder values inside pools', async () => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://custom.example/v1' process.env.OPENAI_API_KEYS = 'key-a,SUA_CHAVE' @@ -95,6 +132,7 @@ test('legacy OpenAI-compatible model discovery rejects placeholder values inside ]) }) test('legacy OpenAI-compatible model discovery uses the first pooled credential', async () => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://custom.example/v1' process.env.OPENAI_API_KEYS = 'key-a,key-b' diff --git a/src/utils/statusRedaction.test.ts b/src/utils/statusRedaction.test.ts index 3064b4b05e..bfb7fc9b73 100644 --- a/src/utils/statusRedaction.test.ts +++ b/src/utils/statusRedaction.test.ts @@ -112,12 +112,14 @@ describe('redactPathForStatus', () => { }) test('shortens POSIX home directory paths to ~', () => { + process.env.HOME = REAL_HOMEDIR const result = redactPathForStatus(`${REAL_HOMEDIR}/secrets/client.key`) expect(result).toBe('~/secrets/client.key') expect(result).not.toContain(REAL_HOMEDIR) }) test('handles the home directory exactly', () => { + process.env.HOME = REAL_HOMEDIR expect(redactPathForStatus(REAL_HOMEDIR)).toBe('~') }) From 10af8dbddb9a6a34fcbc16697fc481f7cac5cf0d Mon Sep 17 00:00:00 2001 From: jatmn Date: Sun, 21 Jun 2026 19:48:53 -0700 Subject: [PATCH 09/14] fix(provider): address pooled credential review findings --- scripts/system-check.ts | 9 +++++- src/integrations/routeMetadata.test.ts | 20 ++++++++++++ src/integrations/routeMetadata.ts | 10 ++++-- src/utils/model/openaiModelDiscovery.test.ts | 33 ++++++++++++++++++++ src/utils/model/openaiModelDiscovery.ts | 17 ++++------ src/utils/providerValidation.ts | 2 ++ 6 files changed, 77 insertions(+), 14 deletions(-) diff --git a/scripts/system-check.ts b/scripts/system-check.ts index 6de0696581..e5501533ff 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -499,7 +499,14 @@ export function checkOpenAIEnv(): CheckResult[] { const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN const hasGithubRouteCredential = credentialContext.routeId === 'github' && Boolean(githubToken?.trim()) - if (hasPlaceholderCredential(key) || hasPlaceholderCredential(providerCredential)) { + const hasPlaceholderProviderCredential = credentialContext.envVars.some(envVar => + hasPlaceholderCredential(process.env[envVar]), + ) + if ( + hasPlaceholderCredential(key) || + hasPlaceholderCredential(providerCredential) || + hasPlaceholderProviderCredential + ) { results.push(fail(credentialLabel, 'Placeholder value detected: SUA_CHAVE.')) } else if ( !providerCredential && diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 3e255005b9..29ffd8ec71 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -121,6 +121,25 @@ test('route credential discovery ignores delimiter-only OPENAI_API_KEYS before s ).toBe('sk-openai-single') }) +test('route credential discovery ignores placeholder OpenAI credentials', () => { + expect( + getRouteCredentialValue('openai', { + OPENAI_API_KEYS: 'SUA_CHAVE', + OPENAI_API_KEY: 'sk-openai-single', + }), + ).toBe('sk-openai-single') + expect( + resolveRouteCredentialValue({ + baseUrl: 'https://api.openai.com/v1', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_API_KEYS: 'SUA_CHAVE', + OPENAI_API_KEY: 'SUA_CHAVE', + }, + }), + ).toBeUndefined() +}) + test('Venice route metadata uses official OpenAI-compatible defaults', () => { expect(getRouteDefaultBaseUrl('venice')).toBe('https://api.venice.ai/api/v1') expect(getRouteDefaultModel('venice')).toBe('venice-uncensored') @@ -169,6 +188,7 @@ test('resolveActiveRouteIdFromEnv treats Venice credential-only env as Venice', }), ).toBe('venice') }) + test('resolveActiveRouteIdFromEnv treats xAI credential-only env as xAI', () => { expect( resolveActiveRouteIdFromEnv({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index f90f07c542..34001e1893 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -214,9 +214,15 @@ function hasUsableEnvCredentialValue( } if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { - return value.split(',').some(part => part.trim() !== '') + const credentials = value + .split(',') + .map(part => part.trim()) + .filter(Boolean) + return ( + credentials.length > 0 && + credentials.every(credential => credential !== 'SUA_CHAVE') + ) } - return value.trim() !== '' } diff --git a/src/utils/model/openaiModelDiscovery.test.ts b/src/utils/model/openaiModelDiscovery.test.ts index 908836575b..85973687ec 100644 --- a/src/utils/model/openaiModelDiscovery.test.ts +++ b/src/utils/model/openaiModelDiscovery.test.ts @@ -131,6 +131,7 @@ test('legacy OpenAI-compatible model discovery rejects placeholder values inside }, ]) }) + test('legacy OpenAI-compatible model discovery uses the first pooled credential', async () => { delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC process.env.CLAUDE_CODE_USE_OPENAI = '1' @@ -162,3 +163,35 @@ test('legacy OpenAI-compatible model discovery uses the first pooled credential' ]) expect(getSpy).toHaveBeenCalled() }) + +test('legacy OpenAI-compatible model discovery ignores placeholder singular key when pool is usable', async () => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://custom.example/v1' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + process.env.OPENAI_API_KEY = 'SUA_CHAVE' + process.env.OPENAI_MODEL = 'gpt-5.5' + + const getSpy = mock(async (_url: string, options?: { headers?: Record }) => { + expect(options?.headers).toEqual({ Authorization: 'Bearer key-a' }) + return { + data: { + data: [{ id: 'gpt-5.5' }], + }, + } + }) + axios.get = getSpy as typeof axios.get + + const { discoverOpenAICompatibleModelOptions } = await import( + `./openaiModelDiscovery.js?pooled-singular-placeholder=${Date.now()}-${Math.random()}` + ) + + await expect(discoverOpenAICompatibleModelOptions()).resolves.toEqual([ + { + value: 'gpt-5.5', + label: 'gpt-5.5', + description: 'Discovered from OpenAI-compatible endpoint', + }, + ]) + expect(getSpy).toHaveBeenCalled() +}) diff --git a/src/utils/model/openaiModelDiscovery.ts b/src/utils/model/openaiModelDiscovery.ts index ab6a838cf8..2a94c9e74f 100644 --- a/src/utils/model/openaiModelDiscovery.ts +++ b/src/utils/model/openaiModelDiscovery.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../../services/api/credentialPool.js' +import { firstUsableCredential, parseCredentialList } from '../../services/api/credentialPool.js' import { logForDebugging } from '../debug.js' import { isEssentialTrafficOnly } from '../privacyLevel.js' import type { ModelOption } from './modelOptions.js' @@ -50,16 +50,11 @@ function isBankrBaseUrl(baseUrl: string): boolean { } function getOpenAIAuthHeaders(baseUrl: string): Record { - if ( - hasInvalidCredentialPlaceholder(process.env.OPENAI_API_KEYS) || - hasInvalidCredentialPlaceholder(process.env.OPENAI_API_KEY) - ) { - return {} - } - - const apiKey = - firstUsableCredential(process.env.OPENAI_API_KEYS) ?? - firstUsableCredential(process.env.OPENAI_API_KEY) + const hasPooledCredentials = + parseCredentialList(process.env.OPENAI_API_KEYS).length > 0 + const apiKey = hasPooledCredentials + ? firstUsableCredential(process.env.OPENAI_API_KEYS) + : firstUsableCredential(process.env.OPENAI_API_KEY) if (!apiKey) { return {} } diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 20b4d2a11d..42dde08dcb 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -548,6 +548,8 @@ export async function getProviderValidationError( if (descriptorValidationError) { if ( + descriptorValidationError === + validationTarget.descriptor.validation?.missingCredentialMessage && validationTarget.kind === 'vendor' && validationTarget.descriptor.id === 'openai' && !hasOpenAICredential(env) && From 09855d737e65776314ab63c93e94cf1d7367c4c8 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sun, 21 Jun 2026 20:11:06 -0700 Subject: [PATCH 10/14] test(api): cover opencode go credential failover --- src/services/api/openaiShim.test.ts | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index e86d8efa0d..d13b3ecd23 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2533,6 +2533,67 @@ test.each([ expect(capturedBody).not.toHaveProperty('store') }) +test('opencode go messages endpoint rotates raw x-api-key credentials after rate-limit failure', async () => { + const capturedUrls: string[] = [] + const capturedKeys: Array = [] + + process.env.OPENAI_BASE_URL = 'https://opencode.ai/zen/go/v1' + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_API_KEYS + process.env.OPENAI_MODEL = 'minimax-m3' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENCODE_API_KEY = 'fake-opencode-a,fake-opencode-b' + + globalThis.fetch = (async (input, init) => { + const headers = new Headers(init?.headers) + capturedUrls.push(String(input)) + capturedKeys.push(headers.get('x-api-key')) + + if (capturedKeys.length === 1) { + return new Response(JSON.stringify({ error: { message: 'rate limited' } }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response( + JSON.stringify({ + id: 'msg_opencode_go_retry', + type: 'message', + role: 'assistant', + model: 'minimax-m3', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'minimax-m3', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(capturedUrls).toEqual([ + 'https://opencode.ai/zen/go/v1/messages', + 'https://opencode.ai/zen/go/v1/messages', + ]) + expect(capturedKeys).toEqual(['fake-opencode-a', 'fake-opencode-b']) +}) + test('gitlawb opengateway provider flag sends OPENGATEWAY_API_KEY as bearer auth despite stale generic base URL', async () => { process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' process.env.OPENAI_MODEL = 'gpt-5.5' From c2e3bd3b387196de3dc686c5b68e83665b31b262 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sun, 21 Jun 2026 20:26:09 -0700 Subject: [PATCH 11/14] fix(provider): share OpenAI credential usability checks --- src/integrations/providerUiMetadata.ts | 10 ++-------- src/integrations/routeMetadata.ts | 10 ++-------- src/services/api/credentialPool.ts | 8 ++++++++ src/utils/providerAutoDetect.ts | 12 ++---------- src/utils/providerValidation.ts | 9 +++++---- 5 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/integrations/providerUiMetadata.ts b/src/integrations/providerUiMetadata.ts index dabc28bff3..85a14f6bff 100644 --- a/src/integrations/providerUiMetadata.ts +++ b/src/integrations/providerUiMetadata.ts @@ -1,3 +1,4 @@ +import { hasUsableOpenAICredential } from '../services/api/credentialPool.js' import type { AuthMode, PresetBadge } from './descriptors.js' import type { ProviderPresetManifestEntry } from './descriptors.js' import { routeForPreset } from './compatibility.js' @@ -41,14 +42,7 @@ function hasUsableEnvValue(envVar: string, value: string | undefined): boolean { } if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { - const credentials = value - .split(',') - .map(part => part.trim()) - .filter(Boolean) - return ( - credentials.length > 0 && - credentials.every(credential => credential !== 'SUA_CHAVE') - ) + return hasUsableOpenAICredential(value) } return true diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index 34001e1893..03af1c62e4 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -12,6 +12,7 @@ import { getVendor, resolveProfileRoute, } from './index.js' +import { hasUsableOpenAICredential } from '../services/api/credentialPool.js' import { isEnvTruthy } from '../utils/envUtils.js' export type RouteDescriptor = GatewayDescriptor | VendorDescriptor @@ -214,14 +215,7 @@ function hasUsableEnvCredentialValue( } if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { - const credentials = value - .split(',') - .map(part => part.trim()) - .filter(Boolean) - return ( - credentials.length > 0 && - credentials.every(credential => credential !== 'SUA_CHAVE') - ) + return hasUsableOpenAICredential(value) } return value.trim() !== '' } diff --git a/src/services/api/credentialPool.ts b/src/services/api/credentialPool.ts index b4a3497f0a..d818c0015e 100644 --- a/src/services/api/credentialPool.ts +++ b/src/services/api/credentialPool.ts @@ -107,6 +107,14 @@ export function hasInvalidCredentialPlaceholder(value: string | undefined): bool return parseCredentialList(value).some(credential => credential === 'SUA_CHAVE') } +export function hasUsableOpenAICredential(value: string | undefined): boolean { + const credentials = parseCredentialList(value) + return ( + credentials.length > 0 && + credentials.every(credential => credential !== 'SUA_CHAVE') + ) +} + export function firstUsableCredential(value: string | undefined): string | undefined { if (hasInvalidCredentialPlaceholder(value)) { return undefined diff --git a/src/utils/providerAutoDetect.ts b/src/utils/providerAutoDetect.ts index 40fac44294..328f5f8ead 100644 --- a/src/utils/providerAutoDetect.ts +++ b/src/utils/providerAutoDetect.ts @@ -34,6 +34,7 @@ import { existsSync } from 'fs' import { homedir } from 'os' +import { hasUsableOpenAICredential } from '../services/api/credentialPool.js' import { join } from 'path' export type DetectedProviderKind = @@ -70,16 +71,7 @@ function envHasNonEmpty(env: EnvLike, key: string): boolean { } function envHasUsableOpenAICredential(env: EnvLike, key: string): boolean { - const value = env[key] - if (typeof value !== 'string') return false - const credentials = value - .split(',') - .map(part => part.trim()) - .filter(Boolean) - return ( - credentials.length > 0 && - credentials.every(credential => credential !== 'SUA_CHAVE') - ) + return hasUsableOpenAICredential(env[key]) } function firstSet(env: EnvLike, keys: readonly string[]): string | undefined { diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 42dde08dcb..f606d3619a 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -27,6 +27,7 @@ import { resolveProviderRequest, shouldUseCodexTransport, } from '../services/api/providerConfig.js' +import { hasUsableOpenAICredential } from '../services/api/credentialPool.js' import { getGlobalClaudeFile } from './env.js' import { isBareMode } from './envUtils.js' import { @@ -129,16 +130,16 @@ function hasUsableCredentialEnvValue( } if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { - return value.split(',').some(part => part.trim() !== '') + return hasUsableOpenAICredential(value) } return value.trim() !== '' } function hasOpenAICredential(env: NodeJS.ProcessEnv): boolean { - return Boolean( - getRouteCredentialValue('openai', env) || - hasUsableCredentialEnvValue(env, 'OPENAI_API_KEY'), + return ( + hasUsableCredentialEnvValue(env, 'OPENAI_API_KEYS') || + hasUsableCredentialEnvValue(env, 'OPENAI_API_KEY') ) } From f58207396305f1c8c608caded54e63a57a4e342a Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 22 Jun 2026 06:30:29 -0700 Subject: [PATCH 12/14] fix(provider): respect pooled credential precedence --- scripts/provider-launch.test.ts | 22 +++++++++++++ scripts/provider-launch.ts | 23 +++++++------- scripts/provider-recommend.test.ts | 11 +++++++ scripts/provider-recommend.ts | 14 ++------- scripts/system-check.test.ts | 21 +++++++++++++ scripts/system-check.ts | 23 ++++++++++---- src/commands/cache-probe/cache-probe.test.ts | 10 ++++++ src/commands/cache-probe/cache-probe.ts | 15 +++------ src/utils/providerProfile.ts | 24 ++++++++++++++ src/utils/providerValidation.test.ts | 10 ++++++ src/utils/providerValidation.ts | 33 +++++++++++++++++--- 11 files changed, 162 insertions(+), 44 deletions(-) create mode 100644 scripts/provider-launch.test.ts diff --git a/scripts/provider-launch.test.ts b/scripts/provider-launch.test.ts new file mode 100644 index 0000000000..783174aba6 --- /dev/null +++ b/scripts/provider-launch.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { hasUsableOpenAILaunchCredential } from './provider-launch.ts' + +describe('provider-launch OpenAI credential validation', () => { + test('accepts valid OPENAI_API_KEYS before placeholder OPENAI_API_KEY fallback', () => { + expect( + hasUsableOpenAILaunchCredential({ + OPENAI_API_KEYS: 'sk-openai-a,sk-openai-b', + OPENAI_API_KEY: 'SUA_CHAVE', + } as NodeJS.ProcessEnv), + ).toBe(true) + }) + + test('rejects placeholder OPENAI_API_KEYS before singular fallback', () => { + expect( + hasUsableOpenAILaunchCredential({ + OPENAI_API_KEYS: 'sk-openai-a,SUA_CHAVE', + OPENAI_API_KEY: 'sk-openai-single', + } as NodeJS.ProcessEnv), + ).toBe(false) + }) +}) diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index 18f2cadccd..f11ad800ff 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -9,9 +9,8 @@ import { } from '../src/utils/providerRecommendation.ts' import { buildLaunchEnv, - hasInvalidOpenAICredentialPool, loadProfileFile, - sanitizeOpenAICredentialPool, + resolveOpenAICredentialEnvState, selectAutoProfile, type ProfileFile, type ProviderProfile, @@ -154,6 +153,12 @@ function hasUsableGeminiLaunchAuth(env: NodeJS.ProcessEnv): boolean { ) } +export function hasUsableOpenAILaunchCredential( + env: NodeJS.ProcessEnv, +): boolean { + return resolveOpenAICredentialEnvState(env).configured +} + async function main(): Promise { const options = parseLaunchOptions(process.argv.slice(2)) const requestedProfile = options.requestedProfile @@ -229,15 +234,7 @@ async function main(): Promise { process.exit(1) } - if ( - profile === 'openai' && - (hasInvalidOpenAICredentialPool(env.OPENAI_API_KEYS) || - hasInvalidOpenAICredentialPool(env.OPENAI_API_KEY) || - !( - sanitizeOpenAICredentialPool(env.OPENAI_API_KEYS) || - sanitizeOpenAICredentialPool(env.OPENAI_API_KEY) - )) - ) { + if (profile === 'openai' && !hasUsableOpenAILaunchCredential(env)) { console.error( 'OPENAI_API_KEYS or OPENAI_API_KEY is required for openai profile and cannot include SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key ', ) @@ -277,6 +274,8 @@ async function main(): Promise { process.exit(devCode) } -await main() +if (import.meta.main) { + await main() +} export {} diff --git a/scripts/provider-recommend.test.ts b/scripts/provider-recommend.test.ts index e6f8c4d1e9..b42f49018e 100644 --- a/scripts/provider-recommend.test.ts +++ b/scripts/provider-recommend.test.ts @@ -29,6 +29,17 @@ describe('provider-recommend OpenAI credential detection', () => { ).toEqual({ configured: true, invalid: false }) }) + test('ignores placeholder OPENAI_API_KEY when OPENAI_API_KEYS is usable', () => { + expect( + getOpenAIConfigurationState( + envWithOpenAI({ + OPENAI_API_KEYS: 'key-a,key-b', + OPENAI_API_KEY: 'SUA_CHAVE', + }), + ), + ).toEqual({ configured: true, invalid: false }) + }) + test('distinguishes placeholder pools from unset credentials', () => { expect( getOpenAIConfigurationState( diff --git a/scripts/provider-recommend.ts b/scripts/provider-recommend.ts index ccdceff5b3..5cb3dbf7ff 100644 --- a/scripts/provider-recommend.ts +++ b/scripts/provider-recommend.ts @@ -14,9 +14,8 @@ import { buildOllamaProfileEnv, buildOpenAIProfileEnv, createProfileFile, - hasInvalidOpenAICredentialPool, saveProfileFile, - sanitizeOpenAICredentialPool, + resolveOpenAICredentialEnvState, type ProfileFile, type ProviderProfile, } from '../src/utils/providerProfile.ts' @@ -39,16 +38,7 @@ type CliOptions = { export function getOpenAIConfigurationState( env: NodeJS.ProcessEnv = process.env, ): { configured: boolean; invalid: boolean } { - const invalid = - hasInvalidOpenAICredentialPool(env.OPENAI_API_KEYS) || - hasInvalidOpenAICredentialPool(env.OPENAI_API_KEY) - const configured = - !invalid && - Boolean( - sanitizeOpenAICredentialPool(env.OPENAI_API_KEYS) || - sanitizeOpenAICredentialPool(env.OPENAI_API_KEY), - ) - + const { configured, invalid } = resolveOpenAICredentialEnvState(env) return { configured, invalid } } diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index 73a357bb4d..9cd242a225 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -218,6 +218,27 @@ describe('system-check provider diagnostics', () => { expect(summary.PROVIDER_API_KEY_SET).toBe(true) }) + test('accepts valid OPENAI_API_KEYS before placeholder OPENAI_API_KEY fallback', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-4o' + process.env.OPENAI_API_KEYS = 'sk-openai-a,sk-openai-b' + process.env.OPENAI_API_KEY = 'SUA_CHAVE' + + const results = checkOpenAIEnv() + const summary = serializeSafeEnvSummary() + const credentialResult = results.find( + result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY', + ) + + expect(credentialResult).toEqual({ + ok: true, + label: 'OPENAI_API_KEYS or OPENAI_API_KEY', + detail: 'Configured.', + }) + expect(summary.PROVIDER_API_KEY_SET).toBe(true) + }) + test('rejects placeholder values inside OPENAI_API_KEYS pools', () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' diff --git a/scripts/system-check.ts b/scripts/system-check.ts index e5501533ff..8eb7aec3f5 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -17,7 +17,10 @@ import { getLocalOpenAICompatibleProviderLabel, probeOllamaGenerationReadiness, } from '../src/utils/providerDiscovery.js' -import { DEFAULT_GEMINI_MODEL } from '../src/utils/providerProfile.js' +import { + DEFAULT_GEMINI_MODEL, + resolveOpenAICredentialEnvState, +} from '../src/utils/providerProfile.js' import { redactSecretValueForDisplay, redactSecretSubstringsForDisplay, @@ -489,7 +492,6 @@ export function checkOpenAIEnv(): CheckResult[] { return results } - const key = process.env.OPENAI_API_KEY const credentialContext = getOpenAICompatibleCredentialContext(request.baseUrl) const providerCredential = credentialContext.value const credentialLabel = @@ -499,11 +501,20 @@ export function checkOpenAIEnv(): CheckResult[] { const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN const hasGithubRouteCredential = credentialContext.routeId === 'github' && Boolean(githubToken?.trim()) - const hasPlaceholderProviderCredential = credentialContext.envVars.some(envVar => - hasPlaceholderCredential(process.env[envVar]), - ) + const openAIState = resolveOpenAICredentialEnvState(process.env) + const hasOpenAIFallback = + credentialContext.envVars.includes('OPENAI_API_KEYS') || + credentialContext.envVars.includes('OPENAI_API_KEY') + const hasPlaceholderProviderCredential = credentialContext.envVars.some(envVar => { + if ( + hasOpenAIFallback && + (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') + ) { + return openAIState.invalid && openAIState.envVar === envVar + } + return hasPlaceholderCredential(process.env[envVar]) + }) if ( - hasPlaceholderCredential(key) || hasPlaceholderCredential(providerCredential) || hasPlaceholderProviderCredential ) { diff --git a/src/commands/cache-probe/cache-probe.test.ts b/src/commands/cache-probe/cache-probe.test.ts index c8d9c142a8..15443fc544 100644 --- a/src/commands/cache-probe/cache-probe.test.ts +++ b/src/commands/cache-probe/cache-probe.test.ts @@ -19,6 +19,15 @@ test('resolveCacheProbeApiKey prefers the first usable OPENAI_API_KEYS entry', ( ).toBe('key-a') }) +test('resolveCacheProbeApiKey ignores placeholder OPENAI_API_KEY when OPENAI_API_KEYS is usable', () => { + expect( + resolveCacheProbeApiKey({ + OPENAI_API_KEYS: 'key-a,key-b', + OPENAI_API_KEY: 'SUA_CHAVE', + } as NodeJS.ProcessEnv), + ).toBe('key-a') +}) + test('resolveCacheProbeApiKey rejects placeholder values inside credential pools', () => { expect( resolveCacheProbeApiKey({ @@ -27,6 +36,7 @@ test('resolveCacheProbeApiKey rejects placeholder values inside credential pools } as NodeJS.ProcessEnv), ).toBe('') }) + test('resolveCacheProbeApiKey falls back to comma-separated OPENAI_API_KEY', () => { expect( resolveCacheProbeApiKey({ diff --git a/src/commands/cache-probe/cache-probe.ts b/src/commands/cache-probe/cache-probe.ts index cadb05b115..0484676933 100644 --- a/src/commands/cache-probe/cache-probe.ts +++ b/src/commands/cache-probe/cache-probe.ts @@ -1,5 +1,6 @@ import { getSessionId } from '../../bootstrap/state.js' -import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../../services/api/credentialPool.js' +import { firstUsableCredential } from '../../services/api/credentialPool.js' +import { resolveOpenAICredentialEnvState } from '../../utils/providerProfile.js' import { resolveProviderRequest } from '../../services/api/providerConfig.js' import type { LocalCommandCall } from '../../types/command.js' import { logForDebugging } from '../../utils/debug.js' @@ -44,18 +45,12 @@ function getModelFamily(model: string | undefined): string { export function resolveCacheProbeApiKey( env: NodeJS.ProcessEnv = process.env, ): string { - if (hasInvalidCredentialPlaceholder(env.OPENAI_API_KEYS)) { - return '' - } - if (hasInvalidCredentialPlaceholder(env.OPENAI_API_KEY)) { + const credentialState = resolveOpenAICredentialEnvState(env) + if (!credentialState.configured || !credentialState.envVar) { return '' } - return ( - firstUsableCredential(env.OPENAI_API_KEYS) ?? - firstUsableCredential(env.OPENAI_API_KEY) ?? - '' - ) + return firstUsableCredential(env[credentialState.envVar]) ?? '' } function resolveGithubCacheProbeApiKey(env: NodeJS.ProcessEnv): string { diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 242408785f..af73d25a6f 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -672,6 +672,30 @@ export function hasInvalidOpenAICredentialPool( return resolveOpenAICredentialPool(value).kind === 'invalid' } +export function resolveOpenAICredentialEnvState(env: Record): { + configured: boolean + invalid: boolean + envVar?: 'OPENAI_API_KEYS' | 'OPENAI_API_KEY' +} { + const plural = resolveOpenAICredentialPool(env.OPENAI_API_KEYS) + if (plural.kind === 'usable') { + return { configured: true, invalid: false, envVar: 'OPENAI_API_KEYS' } + } + if (plural.kind === 'invalid') { + return { configured: false, invalid: true, envVar: 'OPENAI_API_KEYS' } + } + + const singular = resolveOpenAICredentialPool(env.OPENAI_API_KEY) + if (singular.kind === 'usable') { + return { configured: true, invalid: false, envVar: 'OPENAI_API_KEY' } + } + if (singular.kind === 'invalid') { + return { configured: false, invalid: true, envVar: 'OPENAI_API_KEY' } + } + + return { configured: false, invalid: false } +} + function resolveOpenAICredentialPool( value: string | null | undefined, ): diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 6c0f86366a..ad3a8244e8 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -317,6 +317,15 @@ test('openai validation accepts OPENAI_API_KEYS without OPENAI_API_KEY', async ( await expect(getProviderValidationError(process.env)).resolves.toBeNull() }) +test('openai validation accepts valid OPENAI_API_KEYS before placeholder OPENAI_API_KEY fallback', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_API_KEYS = 'sk-openai-a,sk-openai-b' + process.env.OPENAI_API_KEY = 'SUA_CHAVE' + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + test('openai validation rejects placeholder values in OPENAI_API_KEYS', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' @@ -455,6 +464,7 @@ test.each([ await expect(getProviderValidationError(process.env)).resolves.toBeNull() }) + test('opengateway validation still requires a key on the model-specific path', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://opengateway.gitlawb.com/v1/xiaomi-mimo' diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index f606d3619a..5916423d00 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -40,7 +40,10 @@ async function defaultHasStoredXaiOAuthCredentials(): Promise { const stored = await readXaiCredentialsAsync() return Boolean(stored?.accessToken && stored?.refreshToken) } -import { PROFILE_FILE_NAME } from './providerProfile.js' +import { + PROFILE_FILE_NAME, + resolveOpenAICredentialEnvState, +} from './providerProfile.js' import { redactSecretValueForDisplay, type SecretValueSource, @@ -298,7 +301,31 @@ function getCredentialEnvValidationError( env: NodeJS.ProcessEnv, request?: ReturnType, ): string | null { + const credentialEnvVars = validation.credentialEnvVars + const usesOpenAIFallback = + credentialEnvVars.includes('OPENAI_API_KEYS') || + credentialEnvVars.includes('OPENAI_API_KEY') + + if (usesOpenAIFallback) { + const openAIState = resolveOpenAICredentialEnvState(env) + if (openAIState.invalid) { + return ( + validation.invalidCredentialValues?.find( + invalidValue => invalidValue.envVar === openAIState.envVar, + )?.message ?? null + ) + } + } + for (const invalidValue of validation.invalidCredentialValues ?? []) { + if ( + usesOpenAIFallback && + (invalidValue.envVar === 'OPENAI_API_KEYS' || + invalidValue.envVar === 'OPENAI_API_KEY') + ) { + continue + } + const envValue = env[invalidValue.envVar] const envValues = (envValue ?? '').split(',').map(value => value.trim()) if (envValues.includes(invalidValue.value)) { @@ -316,9 +343,7 @@ function getCredentialEnvValidationError( } if ( - validation.credentialEnvVars.some(envVar => - hasUsableCredentialEnvValue(env, envVar), - ) + credentialEnvVars.some(envVar => hasUsableCredentialEnvValue(env, envVar)) ) { return null } From c349902ffa080d3603a1e5397cfbeeb08f212d8a Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 22 Jun 2026 06:52:38 -0700 Subject: [PATCH 13/14] fix(model): preserve pooled discovery credential precedence --- src/integrations/routeMetadata.test.ts | 9 +++++++++ src/utils/model/openaiModelDiscovery.ts | 11 +++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 29ffd8ec71..88eb4c972d 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -140,6 +140,15 @@ test('route credential discovery ignores placeholder OpenAI credentials', () => ).toBeUndefined() }) +test('route credential discovery ignores mixed placeholder OpenAI pools before singular fallback', () => { + expect( + getRouteCredentialValue('openai', { + OPENAI_API_KEYS: 'sk-openai-a,SUA_CHAVE', + OPENAI_API_KEY: 'sk-openai-single', + }), + ).toBe('sk-openai-single') +}) + test('Venice route metadata uses official OpenAI-compatible defaults', () => { expect(getRouteDefaultBaseUrl('venice')).toBe('https://api.venice.ai/api/v1') expect(getRouteDefaultModel('venice')).toBe('venice-uncensored') diff --git a/src/utils/model/openaiModelDiscovery.ts b/src/utils/model/openaiModelDiscovery.ts index 2a94c9e74f..99bbedf435 100644 --- a/src/utils/model/openaiModelDiscovery.ts +++ b/src/utils/model/openaiModelDiscovery.ts @@ -50,11 +50,14 @@ function isBankrBaseUrl(baseUrl: string): boolean { } function getOpenAIAuthHeaders(baseUrl: string): Record { - const hasPooledCredentials = + const pooledCredential = firstUsableCredential(process.env.OPENAI_API_KEYS) + const hasPooledCredentialSource = parseCredentialList(process.env.OPENAI_API_KEYS).length > 0 - const apiKey = hasPooledCredentials - ? firstUsableCredential(process.env.OPENAI_API_KEYS) - : firstUsableCredential(process.env.OPENAI_API_KEY) + const apiKey = + pooledCredential ?? + (hasPooledCredentialSource + ? undefined + : firstUsableCredential(process.env.OPENAI_API_KEY)) if (!apiKey) { return {} } From ecdf8858a77ccf2dd5968235ee6f3980fa0846c4 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 22 Jun 2026 07:25:26 -0700 Subject: [PATCH 14/14] fix(model): fall back from unusable pooled discovery keys --- src/utils/model/openaiModelDiscovery.test.ts | 8 ++++---- src/utils/model/openaiModelDiscovery.ts | 11 +++-------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/utils/model/openaiModelDiscovery.test.ts b/src/utils/model/openaiModelDiscovery.test.ts index 85973687ec..5399812e81 100644 --- a/src/utils/model/openaiModelDiscovery.test.ts +++ b/src/utils/model/openaiModelDiscovery.test.ts @@ -101,16 +101,16 @@ test('skips legacy OpenAI-compatible model discovery when nonessential traffic i expect(getSpy).not.toHaveBeenCalled() }) -test('legacy OpenAI-compatible model discovery rejects placeholder values inside pools', async () => { +test('legacy OpenAI-compatible model discovery falls back to singular key for unusable pooled credentials', async () => { delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://custom.example/v1' - process.env.OPENAI_API_KEYS = 'key-a,SUA_CHAVE' - process.env.OPENAI_API_KEY = 'key-single' + process.env.OPENAI_API_KEYS = 'sk-openai-a,SUA_CHAVE' + process.env.OPENAI_API_KEY = 'sk-openai-single' process.env.OPENAI_MODEL = 'gpt-5.5' const getSpy = mock(async (_url: string, options?: { headers?: Record }) => { - expect(options?.headers).toEqual({}) + expect(options?.headers).toEqual({ Authorization: 'Bearer sk-openai-single' }) return { data: { data: [{ id: 'gpt-5.5' }], diff --git a/src/utils/model/openaiModelDiscovery.ts b/src/utils/model/openaiModelDiscovery.ts index 99bbedf435..1801fd506f 100644 --- a/src/utils/model/openaiModelDiscovery.ts +++ b/src/utils/model/openaiModelDiscovery.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { firstUsableCredential, parseCredentialList } from '../../services/api/credentialPool.js' +import { firstUsableCredential } from '../../services/api/credentialPool.js' import { logForDebugging } from '../debug.js' import { isEssentialTrafficOnly } from '../privacyLevel.js' import type { ModelOption } from './modelOptions.js' @@ -50,14 +50,9 @@ function isBankrBaseUrl(baseUrl: string): boolean { } function getOpenAIAuthHeaders(baseUrl: string): Record { - const pooledCredential = firstUsableCredential(process.env.OPENAI_API_KEYS) - const hasPooledCredentialSource = - parseCredentialList(process.env.OPENAI_API_KEYS).length > 0 const apiKey = - pooledCredential ?? - (hasPooledCredentialSource - ? undefined - : firstUsableCredential(process.env.OPENAI_API_KEY)) + firstUsableCredential(process.env.OPENAI_API_KEYS) ?? + firstUsableCredential(process.env.OPENAI_API_KEY) if (!apiKey) { return {} }