diff --git a/README.md b/README.md index 40e3d6c56..2a54243ae 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ Advanced and source-build guides: | OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers | | Z.AI GLM Coding Plan | `/provider` or OpenAI-compatible env vars | Uses `OPENAI_API_KEY` at `https://api.z.ai/api/coding/paas/v4` and defaults to `glm-5.2` | | AI/ML API | `/provider` or `AIMLAPI_API_KEY` ([setup guide](docs/aimlapi-setup.md)) | Uses `https://api.aimlapi.com/v1`, auto-detects the OpenAI-compatible route from `AIMLAPI_API_KEY`, sends OpenClaude attribution headers, and discovers chat-capable models from the public `/models` catalog | +| ApiSmart | `/provider` or `APISMART_API_KEY` | Uses `https://gw.apismart.ai/v1`, defaults to `DEEPSEEK_V4_FLASH`, and supports optional `APISMART_MODEL` plus authenticated model discovery | | Hicap | `/provider` or OpenAI-compatible env vars | Uses `api-key` auth, discovers models from unauthenticated `/models`, and supports Responses mode for `gpt-` models | | Fireworks AI | `/provider` or env vars | First-class provider with 276 curated models (DeepSeek, Qwen, Llama, Gemma, and more); uses `FIREWORKS_API_KEY` | | LongCat | `/provider` or env vars | Meituan LongCat OpenAI-compatible API at `https://api.longcat.chat/openai/v1`; uses `LONGCAT_API_KEY` and defaults to `LongCat-2.0` | diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index 6d1fd96b7..18363dba8 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -127,6 +127,7 @@ const PRESET_ORDER = [ 'Anthropic', 'Alibaba Coding Plan (China)', 'Alibaba Coding Plan', + 'ApiSmart', 'Atlas Cloud', 'Azure OpenAI', 'Bankr', @@ -692,6 +693,8 @@ test('ProviderManager shows API mode picker for custom OpenAI-compatible provide await waitForFrameOutput(mounted.getOutput, frame => frame.includes('Base URL'), ) + mounted.stdin.write('\x7f'.repeat(64)) + mounted.stdin.write('https://proxy.example/v1') mounted.stdin.write('\r') await waitForFrameOutput(mounted.getOutput, frame => frame.includes('Default model'), diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index d17b0fbfb..ca68e5463 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -43,8 +43,8 @@ import { routeShowsAuthHeader, routeShowsAuthHeaderValue, routeShowsCustomHeaders, + resolveProfileCapabilityRouteId, resolveProfileRoute, - resolveRouteIdFromBaseUrl, } from '../integrations/index.js' import { provisionAimlapiKey, @@ -337,18 +337,6 @@ function getGithubCredentialSourceFromEnv( return 'none' } -function resolveProviderEditorRouteId( - provider: ProviderProfile['provider'], - baseUrl?: string, -): string { - const route = resolveProfileRoute(provider).routeId - if (route !== 'openai') { - return route - } - - return resolveRouteIdFromBaseUrl(baseUrl) ?? route -} - function routeSupportsResponsesModel(routeId: string, model: string): boolean { return openAIShimSupportsApiFormatForModel( getRouteDescriptor(routeId)?.transportConfig.openaiShim, @@ -885,7 +873,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const formSteps = React.useMemo( () => { - const routeId = resolveProviderEditorRouteId(draftProvider, draft.baseUrl) + const routeId = resolveProfileCapabilityRouteId(draftProvider, draft.baseUrl) const showsAuthHeader = routeShowsAuthHeader(routeId) const showsAuthHeaderValue = routeShowsAuthHeaderValue(routeId) const showsCustomHeaders = routeShowsCustomHeaders(routeId) @@ -1659,7 +1647,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { setErrorMessage('Base URL must be a real Anthropic-compatible endpoint.') return } - const routeId = resolveProviderEditorRouteId(provider, nextDraft.baseUrl) + const routeId = resolveProfileCapabilityRouteId(provider, nextDraft.baseUrl) const supportsApiFormat = routeSupportsApiFormatSelection(routeId) const showsAuthHeader = routeShowsAuthHeader(routeId) const showsAuthHeaderValue = routeShowsAuthHeaderValue(routeId) @@ -1768,7 +1756,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { nextDraft: ProviderDraft, provider: ProviderProfile['provider'], ): ProviderDraft { - const routeId = resolveProviderEditorRouteId(provider, nextDraft.baseUrl) + const routeId = resolveProfileCapabilityRouteId(provider, nextDraft.baseUrl) const preferredResponsesMode = nextDraft.apiFormat === 'responses_compat' ? 'responses_compat' : 'responses' const apiFormat = routeSupportsApiFormatSelection(routeId) && @@ -2198,7 +2186,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { Provider type:{' '} {getRouteProviderTypeLabel(resolveProfileRoute(draftProvider).routeId)} - {routeSupportsCustomHeaders(resolveProfileRoute(draftProvider).routeId) ? ( + {routeSupportsCustomHeaders( + resolveProfileCapabilityRouteId(draftProvider, draft.baseUrl), + ) ? ( Advanced: this provider supports custom request headers when you need them. diff --git a/src/components/StartupScreen.test.ts b/src/components/StartupScreen.test.ts index 2ecab962e..8ccdbb6ea 100644 --- a/src/components/StartupScreen.test.ts +++ b/src/components/StartupScreen.test.ts @@ -45,6 +45,9 @@ const ENV_KEYS = [ 'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_MODEL', + 'APISMART_API_KEY', + 'APISMART_MODEL', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'GEMINI_MODEL', 'MISTRAL_MODEL', 'ANTHROPIC_MODEL', @@ -52,6 +55,14 @@ const ENV_KEYS = [ 'NVIDIA_NIM', 'MINIMAX_API_KEY', 'XAI_API_KEY', + 'MIMO_API_KEY', + 'VENICE_API_KEY', + 'NEARAI_API_KEY', + 'FIREWORKS_API_KEY', + 'LONGCAT_API_KEY', + 'CLINE_API_KEY', + 'CLINE_API_MODEL', + 'AIMLAPI_API_KEY', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', @@ -358,6 +369,28 @@ describe('detectProvider — rawModel fallback when URL is generic', () => { // --- Explicit env flags win over URL heuristics --- describe('detectProvider — explicit dedicated-provider env flags', () => { + test('credential-only ApiSmart setup uses route defaults before client initialization', () => { + process.env.APISMART_API_KEY = 'apismart-test-key' + process.env.APISMART_MODEL = 'KIMI_K3' + + expect(detectProvider()).toEqual({ + name: 'ApiSmart', + model: 'KIMI_K3', + baseUrl: 'https://gw.apismart.ai/v1', + isLocal: false, + }) + }) + + test('labels the resolved ApiSmart route when another dedicated key is ambient', () => { + process.env.APISMART_API_KEY = 'apismart-test-key' + process.env.MINIMAX_API_KEY = 'ambient-minimax-key' + + expect(detectProvider()).toMatchObject({ + name: 'ApiSmart', + baseUrl: 'https://gw.apismart.ai/v1', + }) + }) + test('NVIDIA_NIM=1 overrides aggregator URL', () => { setupOpenAIMode('https://openrouter.ai/api/v1', 'some-nim-model') process.env.NVIDIA_NIM = '1' diff --git a/src/components/StartupScreen.ts b/src/components/StartupScreen.ts index be999127d..3473282a8 100644 --- a/src/components/StartupScreen.ts +++ b/src/components/StartupScreen.ts @@ -9,6 +9,7 @@ import { isLocalProviderUrl, resolveProviderRequest } from '../services/api/prov import { getRouteLabel, isMiniMaxBaseUrl, + resolveEnvOnlyProviderRouteId, resolveRouteIdFromBaseUrl, } from '../integrations/routeMetadata.js' import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js' @@ -80,7 +81,11 @@ const LOGO_CLAUDE = [ export function detectProvider(modelOverride?: string): { name: string; model: string; baseUrl: string; isLocal: boolean } { const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true' const useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true' - const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true' + const envOnlyProviderRouteId = resolveEnvOnlyProviderRouteId(process.env) + const useOpenAI = + process.env.CLAUDE_CODE_USE_OPENAI === '1' || + process.env.CLAUDE_CODE_USE_OPENAI === 'true' || + envOnlyProviderRouteId === 'apismart' const useMistral = process.env.CLAUDE_CODE_USE_MISTRAL === '1' || process.env.CLAUDE_CODE_USE_MISTRAL === 'true' if (useGemini) { @@ -103,17 +108,21 @@ export function detectProvider(modelOverride?: string): { name: string; model: s } if (useOpenAI) { - const rawModel = modelOverride || process.env.OPENAI_MODEL || 'gpt-4o' const resolvedRequest = resolveProviderRequest({ - model: rawModel, + model: modelOverride, baseUrl: process.env.OPENAI_BASE_URL, + fallbackModel: + envOnlyProviderRouteId === 'apismart' ? undefined : 'gpt-4o', + processEnv: process.env, }) + const rawModel = resolvedRequest.requestedModel const baseUrl = resolvedRequest.baseUrl const isLocal = isLocalProviderUrl(baseUrl) const routeId = resolveRouteIdFromBaseUrl(baseUrl) let name = 'OpenAI' // Explicit dedicated-provider env flags win. if (process.env.NVIDIA_NIM) name = 'NVIDIA NIM' + else if (routeId === 'apismart') name = getRouteLabel(routeId) ?? name else if (process.env.MINIMAX_API_KEY) name = 'MiniMax' else if ( resolvedRequest.transport === 'codex_responses' || diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index bb56f6c9d..eddcc4b1a 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -110,6 +110,33 @@ afterEach(() => { }) describe('discoverModelsForRoute', () => { + test('drops caller headers when the route disables custom headers', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + let capturedHeaders: Headers | undefined + + setMockFetch(mock((_input: string | URL | Request, init?: RequestInit) => { + capturedHeaders = new Headers(init?.headers) + return Promise.resolve( + new Response( + JSON.stringify({ data: [{ id: 'KIMI_K3' }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as unknown as typeof globalThis.fetch) + + const result = await discoverModelsForRoute('apismart', { + forceRefresh: true, + apiKey: 'apismart-test-key', + headers: { 'X-Proxy-Secret': 'stale-secret' }, + }) + + expect(result?.source).toBe('network') + expect(capturedHeaders?.get('x-proxy-secret')).toBeNull() + expect(capturedHeaders?.get('authorization')).toBe( + 'Bearer apismart-test-key', + ) + }) + test('uses built-in openai-compatible discovery and caches results for dynamic routes', async () => { const { discoverModelsForRoute } = await loadDiscoveryServiceModule() diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 2cd957b74..00d49c09e 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -181,7 +181,8 @@ export function getRouteDiscoveryHeaders( ): Record | undefined { const transportConfig = getRouteDescriptor(routeId)?.transportConfig const acceptsCallerHeaders = - getRouteCatalog(routeId)?.discovery?.requiresAuth !== false + getRouteCatalog(routeId)?.discovery?.requiresAuth !== false && + transportConfig?.openaiShim?.supportsAuthHeaders !== false // Descriptor headers are attribution, not transport plumbing: an `aimlapi` // profile keeps its route id while pointing at a user-controlled proxy, so the // `/models` request must be filtered on the same canonical predicate the diff --git a/src/integrations/index.ts b/src/integrations/index.ts index bf0618c99..e2b5b0084 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -142,6 +142,7 @@ export { isLongcatBaseUrl, normalizeXiaomiMimoBaseUrl, resolveActiveRouteIdFromEnv, + resolveProfileCapabilityRouteId, resolveRouteIdFromBaseUrl, routeSupportsApiFormatSelection, routeSupportsAuthHeaders, diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index f57e7b0d4..d1f787fd4 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -6,14 +6,98 @@ import { getRouteDefaultBaseUrl, getRouteDefaultModel, getRouteProviderTypeLabel, + getUsableRouteConfigEnvValue, + hasUsableRouteCredentialEnvValue, isApismartBaseUrl, isCloudflareBaseUrl, isLongcatBaseUrl, + profileTargetsRoute, resolveActiveRouteIdFromEnv, + resolveProfileCapabilityRouteId, resolveRouteCredentialValue, resolveRouteIdFromBaseUrl, } from './routeMetadata.js' +test('profile capability routing keeps provider identity and endpoint boundaries together', () => { + expect(resolveProfileCapabilityRouteId('apismart')).toBe('apismart') + expect(resolveProfileCapabilityRouteId('openai')).toBe('openai') + expect( + resolveProfileCapabilityRouteId( + 'custom', + 'http://localhost:11434/v1', + ), + ).toBe('ollama') + expect( + resolveProfileCapabilityRouteId( + 'custom', + 'https://gw.apismart.ai/v1', + ), + ).toBe('apismart') + expect( + resolveProfileCapabilityRouteId( + 'custom', + 'https://api.aimlapi.com/v1', + ), + ).toBe('aimlapi') + expect( + resolveProfileCapabilityRouteId( + 'gemini', + 'https://gw.apismart.ai/v1', + ), + ).toBe('gemini') + expect( + resolveProfileCapabilityRouteId( + 'openai', + 'https://gw.apismart.ai/v1', + ), + ).toBe('apismart') + expect( + resolveProfileCapabilityRouteId( + 'apismart', + 'https://proxy.example/v1', + ), + ).toBe('custom') +}) + +test('profile endpoint routing separates credential ownership from editor capabilities', () => { + expect(profileTargetsRoute('apismart', undefined, 'apismart')).toBe(true) + expect(profileTargetsRoute('openai', undefined, 'apismart')).toBe(false) + expect( + profileTargetsRoute('custom', 'https://gw.apismart.ai/v1', 'apismart'), + ).toBe(true) + expect( + profileTargetsRoute('apismart', 'https://proxy.example/v1', 'apismart'), + ).toBe(false) +}) + +test('route credential usability applies OpenAI placeholder rules to dedicated keys', () => { + expect(hasUsableRouteCredentialEnvValue('APISMART_API_KEY', 'SUA_CHAVE')).toBe( + false, + ) + expect( + hasUsableRouteCredentialEnvValue('APISMART_API_KEY', 'apismart-key'), + ).toBe(true) +}) + +test('route config normalization rejects nullish environment sentinels', () => { + expect(getUsableRouteConfigEnvValue(' https://proxy.example/v1 ')).toBe( + 'https://proxy.example/v1', + ) + for (const value of [undefined, '', ' undefined ', 'NULL']) { + expect(getUsableRouteConfigEnvValue(value)).toBeUndefined() + } +}) + +test('ApiSmart env-only intent respects an explicit OpenAI-compatible opt-out', () => { + expect( + resolveActiveRouteIdFromEnv({ + CLAUDE_CODE_USE_OPENAI: '0', + APISMART_API_KEY: 'apismart-key', + APISMART_MODEL: 'KIMI_K3', + }), + ).toBe('anthropic') +}) + test('isCloudflareBaseUrl matches Workers AI host but not the shared AI Gateway', () => { // Workers AI lives on api.cloudflare.com. expect( @@ -476,6 +560,16 @@ test('resolveActiveRouteIdFromEnv treats ApiSmart credential-only env as ApiSmar ).toBe('apismart') }) +test('resolveActiveRouteIdFromEnv treats the generic OpenAI flag as compatible with ApiSmart intent', () => { + expect( + resolveActiveRouteIdFromEnv({ + CLAUDE_CODE_USE_OPENAI: '1', + APISMART_API_KEY: 'apismart-key', + APISMART_MODEL: 'KIMI_K3', + }), + ).toBe('apismart') +}) + test('resolveActiveRouteIdFromEnv ignores placeholder ApiSmart credentials', () => { expect( resolveActiveRouteIdFromEnv({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index bff41a0c9..fb83be734 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -205,7 +205,7 @@ function readFirstNonEmptyEnvValue( ): string | undefined { for (const envVar of envVars) { const value = processEnv[envVar] - if (hasUsableEnvCredentialValue(envVar, value)) { + if (hasUsableRouteCredentialEnvValue(envVar, value)) { return value!.trim() } } @@ -213,7 +213,7 @@ function readFirstNonEmptyEnvValue( return undefined } -function hasUsableEnvCredentialValue( +export function hasUsableRouteCredentialEnvValue( envVar: string, value: string | undefined, ): boolean { @@ -221,11 +221,20 @@ function hasUsableEnvCredentialValue( return false } + if (envVar === 'APISMART_API_KEY') { + const normalized = value.trim().toLowerCase() + return ( + normalized !== '' && + normalized !== 'sua_chave' && + normalized !== 'null' && + normalized !== 'undefined' + ) + } + if ( envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY' || - envVar === 'AIMLAPI_API_KEY' || - envVar === 'APISMART_API_KEY' + envVar === 'AIMLAPI_API_KEY' ) { return hasUsableOpenAICredential(value) } @@ -234,8 +243,8 @@ function hasUsableEnvCredentialValue( function hasAnyUsableOpenAICredential(processEnv: NodeJS.ProcessEnv): boolean { return ( - hasUsableEnvCredentialValue('OPENAI_API_KEYS', processEnv.OPENAI_API_KEYS) || - hasUsableEnvCredentialValue('OPENAI_API_KEY', processEnv.OPENAI_API_KEY) + hasUsableRouteCredentialEnvValue('OPENAI_API_KEYS', processEnv.OPENAI_API_KEYS) || + hasUsableRouteCredentialEnvValue('OPENAI_API_KEY', processEnv.OPENAI_API_KEY) ) } @@ -244,6 +253,32 @@ function hasNonEmptyEnvValue(value: string | undefined): boolean { return Boolean(trimmed && trimmed !== 'undefined' && trimmed !== 'null') } +export function getUsableRouteConfigEnvValue( + value: string | undefined, +): string | undefined { + const trimmed = value?.trim() + if (!trimmed) return undefined + const normalized = trimmed.toLowerCase() + return normalized === 'undefined' || normalized === 'null' + ? undefined + : trimmed +} + +export function getUsableRouteModelEnvValue( + value: string | undefined, +): string | undefined { + return getUsableRouteConfigEnvValue(value) +} + +export function hasExplicitOpenAICompatibleOptOut( + processEnv: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + processEnv.CLAUDE_CODE_USE_OPENAI !== undefined && + !isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI) + ) +} + export function isMiniMaxBaseUrl(value: string | undefined): boolean { const trimmed = value?.trim() if (!trimmed) { @@ -661,7 +696,13 @@ function hasNoExplicitNonOpenAIProvider( !isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL) && !isEnvTruthy(processEnv.CLAUDE_CODE_USE_BEDROCK) && !isEnvTruthy(processEnv.CLAUDE_CODE_USE_VERTEX) && - !isEnvTruthy(processEnv.CLAUDE_CODE_USE_FOUNDRY) + !isEnvTruthy(processEnv.CLAUDE_CODE_USE_FOUNDRY) && + !( + !isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI) && + hasNonEmptyEnvValue(processEnv.ANTHROPIC_BASE_URL) && + (hasNonEmptyEnvValue(processEnv.ANTHROPIC_AUTH_TOKEN) || + hasNonEmptyEnvValue(processEnv.ANTHROPIC_API_KEY)) + ) ) } @@ -816,13 +857,35 @@ export function hasClinePassEnvOnlyProviderIntent( ) } +function hasApismartProviderIntent( + processEnv: NodeJS.ProcessEnv, + hasCredential: boolean, +): boolean { + const explicitRouteId = processEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID?.trim() + return ( + hasCredential && + (!explicitRouteId || explicitRouteId === 'apismart') && + !hasExplicitOpenAICompatibleOptOut(processEnv) && + !hasConflictingOpenAIBaseUrlForRoute(processEnv, isApismartBaseUrl) && + hasNoExplicitNonOpenAIProvider(processEnv) + ) +} + +export function hasConfiguredApismartProviderIntent( + processEnv: NodeJS.ProcessEnv = process.env, +): boolean { + return hasApismartProviderIntent( + processEnv, + Boolean(processEnv.APISMART_API_KEY?.trim()), + ) +} + export function hasApismartEnvOnlyProviderIntent( processEnv: NodeJS.ProcessEnv = process.env, ): boolean { - return ( - hasUsableOpenAICredential(processEnv.APISMART_API_KEY) && - !hasConflictingOpenAIBaseUrlForRoute(processEnv, isApismartBaseUrl) && - hasNoExplicitNonOpenAICompatibleProvider(processEnv) + return hasApismartProviderIntent( + processEnv, + hasUsableOpenAICredential(processEnv.APISMART_API_KEY), ) } @@ -1115,6 +1178,64 @@ function profileRouteHonorsBaseUrlBoundary( return true } +/** + * Reports whether a profile's endpoint belongs to a specific route. A concrete + * endpoint owns credential routing regardless of the editor label; without an + * endpoint, only the declared provider may supply that route's default. + */ +export function profileTargetsRoute( + provider: string, + baseUrl: string | undefined, + targetRouteId: string, +): boolean { + const trimmedBaseUrl = baseUrl?.trim() + if (trimmedBaseUrl) { + return resolveRouteIdFromBaseUrl(trimmedBaseUrl) === targetRouteId + } + return resolveProfileRoute(provider).routeId === targetRouteId +} + +/** + * Resolves the route whose capabilities apply to a saved or edited profile. + * A concrete endpoint refines OpenAI-compatible profiles, while native + * provider families retain their declared transport contract. An + * endpoint-scoped provider retargeted elsewhere becomes a generic route. + */ +export function resolveProfileCapabilityRouteId( + provider: string, + baseUrl?: string, +): string { + const providerRouteId = resolveProfileRoute(provider).routeId + if (providerRouteId === 'custom-anthropic') { + return providerRouteId + } + + const routeIdFromBaseUrl = resolveRouteIdFromBaseUrl(baseUrl) + if (providerRouteId === 'custom') { + return routeIdFromBaseUrl ?? providerRouteId + } + + const providerTransportKind = getTransportKindForRoute(providerRouteId) + if ( + providerTransportKind !== 'openai-compatible' && + providerTransportKind !== 'local' + ) { + return providerRouteId + } + if (routeIdFromBaseUrl) { + return routeIdFromBaseUrl + } + + if ( + baseUrl?.trim() && + !profileRouteHonorsBaseUrlBoundary(providerRouteId, baseUrl) + ) { + return 'custom' + } + + return providerRouteId +} + export function resolveActiveRouteIdFromEnv( processEnv: NodeJS.ProcessEnv = process.env, options?: { diff --git a/src/services/api/agentRouting.test.ts b/src/services/api/agentRouting.test.ts index 736ba4e52..c2f3940e2 100644 --- a/src/services/api/agentRouting.test.ts +++ b/src/services/api/agentRouting.test.ts @@ -10,6 +10,8 @@ import { resolveOutOfProcessTeammateProviderFromCliArgs, shouldEnforceModelAllowlist, } from './agentRouting.js' +import { resolveRouteCredentialValue } from '../../integrations/routeMetadata.js' +import { resolveProviderRequest } from './providerConfig.js' import { getAgentModel } from '../../utils/model/agent.js' import * as agentModelModule from '../../utils/model/agent.js' import type { SettingsJson } from '../../utils/settings/types.js' @@ -731,6 +733,34 @@ describe('applyAgentProviderOverrideToEnv', () => { expect(env.GEMINI_API_KEY).toBe('gemini-key') expect(env.ANTHROPIC_API_KEY).toBe('anthropic-key') }) + + test('replaces parent ApiSmart model and credential for an ApiSmart agent override', () => { + const env: Record = { + CLAUDE_CODE_USE_OPENAI: '1', + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart', + OPENAI_BASE_URL: 'https://gw.apismart.ai/v1', + OPENAI_MODEL: 'KIMI_K3', + OPENAI_API_KEY: 'parent-secret', + APISMART_API_KEY: 'parent-secret', + APISMART_MODEL: 'KIMI_K3', + } + + applyAgentProviderOverrideToEnv( + { + model: 'GLM_5.2', + baseURL: 'https://gw.apismart.ai/v1', + apiKey: 'child-secret', + }, + env, + ) + + expect(resolveProviderRequest({ processEnv: env }).requestedModel).toBe('GLM_5.2') + expect( + resolveRouteCredentialValue({ routeId: 'apismart', processEnv: env }), + ).toBe('child-secret') + expect(env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart') + expect(env.APISMART_MODEL).toBeUndefined() + }) }) describe('shouldEnforceModelAllowlist', () => { diff --git a/src/services/api/agentRouting.ts b/src/services/api/agentRouting.ts index 32b358e36..2e61d5114 100644 --- a/src/services/api/agentRouting.ts +++ b/src/services/api/agentRouting.ts @@ -2,6 +2,11 @@ import type { SettingsJson } from '../../utils/settings/types.js' import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' import { getAgentModel } from '../../utils/model/agent.js' import { isModelAlias } from '../../utils/model/aliases.js' +import { + getRouteCredentialEnvVars, + getRouteDescriptor, + resolveRouteIdFromBaseUrl, +} from '../../integrations/routeMetadata.js' /** * Provider override resolved from agent routing config. @@ -44,6 +49,7 @@ const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [ 'CLAUDE_CODE_USE_MISTRAL', 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'NVIDIA_NIM', 'ANTHROPIC_MODEL', 'ANTHROPIC_BASE_URL', @@ -57,6 +63,8 @@ const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [ 'OPENAI_AUTH_HEADER', 'OPENAI_AUTH_SCHEME', 'OPENAI_AUTH_HEADER_VALUE', + 'APISMART_API_KEY', + 'APISMART_MODEL', ] as const /** @@ -362,4 +370,15 @@ export function applyAgentProviderOverrideToEnv( env.OPENAI_MODEL = providerOverride.model env.OPENAI_BASE_URL = providerOverride.baseURL env.OPENAI_API_KEY = providerOverride.apiKey + + const routeId = resolveRouteIdFromBaseUrl(providerOverride.baseURL) + if (routeId) { + env.CLAUDE_CODE_PROVIDER_ROUTE_ID = routeId + const descriptor = getRouteDescriptor(routeId) + if (descriptor?.setup.dedicatedCredentialsOnly) { + for (const credentialEnvVar of getRouteCredentialEnvVars(routeId)) { + env[credentialEnvVar] = providerOverride.apiKey + } + } + } } diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index c9bc1a00d..f1d7f614d 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -6,6 +6,7 @@ import { registerGateway, } from '../../integrations/index.js' import { publicBuildVersion } from '../../utils/version.js' +import { resolveEnvOnlyProviderRouteId } from '../../integrations/routeMetadata.js' // bun:test keeps mock.module() registrations process-global across test files. // Load and re-register the real module before importing the client so a prior @@ -317,7 +318,7 @@ test('first-party Anthropic requests execute the configured fetch wrapper withou expect(capturedHeaders).toBeDefined() }) -test('routes a custom Anthropic endpoint with ANTHROPIC_AUTH_TOKEN without requiring an API key', async () => { +test('routes a custom Anthropic endpoint with ANTHROPIC_AUTH_TOKEN despite an ambient ApiSmart key', async () => { let capturedUrl: string | undefined let capturedHeaders: Headers | undefined @@ -331,10 +332,13 @@ test('routes a custom Anthropic endpoint with ANTHROPIC_AUTH_TOKEN without requi process.env.ANTHROPIC_API_KEY = 'must-not-forward' process.env.ANTHROPIC_AUTH_TOKEN = 'custom-anthropic-token' process.env.ANTHROPIC_BASE_URL = 'https://anthropic.example/api/v1' + process.env.APISMART_API_KEY = 'ambient-apismart-key' process.env.USER_TYPE = 'ant' process.env.USE_STAGING_OAUTH = '1' process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Tenant: tenant-a\nauthorization: stale-value' + expect(resolveEnvOnlyProviderRouteId(process.env)).toBeNull() + const fetchOverride = (async (input, init) => { capturedUrl = typeof input === 'string' @@ -1536,6 +1540,123 @@ test('strips Anthropic-specific custom headers before sending OpenAI-compatible expect(capturedHeaders?.get('authorization')).toBe('Bearer openai-test-key') }) +test('does not forward ambient custom headers to routes that disable custom headers', async () => { + let capturedHeaders: Headers | undefined + let capturedUrl: string | undefined + + clearEnvForMiniMaxOnlyTest() + process.env.APISMART_API_KEY = 'apismart-test-key' + process.env.APISMART_MODEL = 'KIMI_K3' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Proxy-Secret: stale-secret' + + globalThis.fetch = (async (input, init) => { + capturedUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + capturedHeaders = new Headers(init?.headers) + return new Response( + JSON.stringify({ + id: 'chatcmpl-apismart', + model: 'KIMI_K3', + choices: [ + { + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = (await getAnthropicClient({ + maxRetries: 0, + model: 'KIMI_K3', + })) as unknown as ShimClient + await client.beta.messages.create({ + model: 'KIMI_K3', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + + expect(capturedUrl).toBe('https://gw.apismart.ai/v1/chat/completions') + expect(capturedHeaders?.get('x-proxy-secret')).toBeNull() + expect(capturedHeaders?.get('authorization')).toBe('Bearer apismart-test-key') +}) + +test.each([ + ['OPENAI_BASE_URL', 'null'], + ['OPENAI_BASE_URL', ' UNDEFINED '], + ['OPENAI_API_BASE', 'NULL'], + ['OPENAI_API_BASE', ' undefined '], +] as const)( + 'ApiSmart env-only defaults ignore nullish %s value %s', + async (envVar, sentinel) => { + clearEnvForMiniMaxOnlyTest() + process.env.APISMART_API_KEY = 'apismart-test-key' + process.env.APISMART_MODEL = 'KIMI_K3' + process.env[envVar] = sentinel + + await getAnthropicClient({ maxRetries: 0, model: 'KIMI_K3' }) + + expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1') + }, +) + +test('enforces route header capabilities for an ApiSmart provider override', async () => { + let capturedHeaders: Headers | undefined + + clearEnvForMiniMaxOnlyTest() + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Proxy-Secret: stale-secret' + process.env.OPENAI_AUTH_HEADER = 'X-Parent-Secret' + process.env.OPENAI_AUTH_SCHEME = 'raw' + process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-parent-token' + + globalThis.fetch = (async (_input, init) => { + capturedHeaders = new Headers(init?.headers) + return new Response( + JSON.stringify({ + id: 'chatcmpl-apismart-override', + model: 'KIMI_K3', + choices: [ + { + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = (await getAnthropicClient({ + maxRetries: 0, + providerOverride: { + model: 'KIMI_K3', + baseURL: 'https://gw.apismart.ai/v1', + apiKey: 'child-apismart-key', + }, + })) as unknown as ShimClient + await client.beta.messages.create({ + model: 'unused', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + + expect(capturedHeaders?.get('x-proxy-secret')).toBeNull() + expect(capturedHeaders?.get('x-parent-secret')).toBeNull() + expect(capturedHeaders?.get('authorization')).toBe( + 'Bearer child-apismart-key', + ) +}) + test('strips Anthropic-specific custom headers on providerOverride shim requests too', async () => { let capturedHeaders: Headers | undefined diff --git a/src/services/api/client.ts b/src/services/api/client.ts index d0878e85b..a467c35f5 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -45,9 +45,13 @@ import { getNearaiBaseUrlOverride, getRouteDefaultBaseUrl, getRouteDefaultModel, + getUsableRouteConfigEnvValue, + getUsableRouteModelEnvValue, getXaiBaseUrlOverride, getXiaomiMimoBaseUrlOverride, resolveEnvOnlyProviderRouteId, + resolveRouteIdFromBaseUrl, + routeSupportsCustomHeaders, } from '../../integrations/routeMetadata.js' import { resolveOpenAIShimRuntimeContext } from '../../integrations/runtimeMetadata.js' import { @@ -376,12 +380,12 @@ function applyAimlapiEnvOnlyDefaults(): void { function applyApismartEnvOnlyDefaults(): void { const baseUrlOverride = - process.env.OPENAI_BASE_URL?.trim() || - process.env.OPENAI_API_BASE?.trim() || + getUsableRouteConfigEnvValue(process.env.OPENAI_BASE_URL) || + getUsableRouteConfigEnvValue(process.env.OPENAI_API_BASE) || undefined const modelOverride = - process.env.APISMART_MODEL?.trim() || - process.env.OPENAI_MODEL?.trim() || + getUsableRouteModelEnvValue(process.env.APISMART_MODEL) || + getUsableRouteModelEnvValue(process.env.OPENAI_MODEL) || undefined process.env.CLAUDE_CODE_USE_OPENAI = '1' @@ -489,7 +493,17 @@ export async function getAnthropicClient({ const containerId = process.env.CLAUDE_CODE_CONTAINER_ID const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP - const customHeaders = getCustomHeaders() + const envOnlyProviderRouteId = resolveEnvOnlyProviderRouteId(process.env) + const outboundRouteId = + resolveRouteIdFromBaseUrl( + providerOverride?.baseURL ?? + process.env.OPENAI_BASE_URL ?? + process.env.OPENAI_API_BASE, + ) ?? envOnlyProviderRouteId + const customHeaders = + outboundRouteId && !routeSupportsCustomHeaders(outboundRouteId) + ? {} + : getCustomHeaders() const defaultHeaders: { [key: string]: string } = { 'x-app': 'cli', 'User-Agent': getUserAgent(), @@ -516,7 +530,6 @@ export async function getAnthropicClient({ defaultHeaders['x-anthropic-additional-protection'] = 'true' } - const envOnlyProviderRouteId = resolveEnvOnlyProviderRouteId(process.env) const useMiniMaxEnvOnlyProvider = shouldUseMiniMaxEnvOnlyProvider( model, envOnlyProviderRouteId, diff --git a/src/services/api/credentialPool.test.ts b/src/services/api/credentialPool.test.ts index 67debb755..179503179 100644 --- a/src/services/api/credentialPool.test.ts +++ b/src/services/api/credentialPool.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test' -import { CredentialPool, firstUsableCredential, hasInvalidCredentialPlaceholder, parseCredentialList } from './credentialPool.js' +import { CredentialPool, firstUsableCredential, hasInvalidCredentialPlaceholder, hasUsableOpenAICredential, parseCredentialList } from './credentialPool.js' test('parseCredentialList trims comma-separated keys', () => { expect(parseCredentialList(' key-a, key-b ,,key-c ')).toEqual([ diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 6f561e339..e83234b60 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -522,6 +522,12 @@ class OpenAIShimMessages { runtimeShimContext.routeId === null || getRouteDescriptor(runtimeShimContext.routeId)?.setup .dedicatedCredentialsOnly !== true, + routeSupportsAuthHeaders: + runtimeShimContext.descriptor?.transportConfig.openaiShim + ?.supportsAuthHeaders !== false, + routeSupportsCustomHeaders: + runtimeShimContext.descriptor?.transportConfig.openaiShim + ?.supportsAuthHeaders !== false, getCredentialPool: value => this.getCredentialPool(value), filterAnthropicHeaders, isGeminiMode, resolveRouteCredentialValue, isXaiBaseUrl, isLongcatBaseUrl, parseCredentialList, resolveXaiAccessToken, hasInvalidCredentialPlaceholder, buildOpenAICompatibilityErrorMessage, isAzureStyleBaseUrl, resolveGeminiCredential, COPILOT_HEADERS, getSessionId, getLocalProviderRetryBaseUrls, buildOllamaChatUrl, logForDebugging, redactUrlForDiagnostics, redactSecretValueForDisplay, headersWithRequestUrl, classifyOpenAINetworkFailure, classifyOpenAIHttpFailure, markOpenAIRequestNonReplayable, fetchRequest: (url, init) => fetchWithHeadersDeadline(url, init, { callerSignal: options?.signal, timeoutMs: apiTimeoutMs }), isResponseHeadersTimeout: error => error instanceof ResponseHeadersTimeoutError, requestBodyContainsImages, formatRetryAfterHint, redactUrlsInMessage, sleepMs, shouldAttemptLocalToollessRetry, refreshCopilotTokenOn401, isCopilotTokenExpiredError, convertOllamaStreamingResponse, convertOllamaNonStreamingResponse, logApiCallStart, logApiCallEnd, stableStringifyJson, APIError, GITHUB_429_MAX_RETRIES, GITHUB_429_BASE_DELAY_SEC, GITHUB_429_MAX_DELAY_SEC, request, params, options, requestProcessEnv, fastPath, shimConfig, runtimeShimContext, body, effectiveTransport, useNativeOllamaChat, buildResponsesBody, serializeBody, isLocal, isGithub, isGithubCopilot, isGithubModels, omitTools, }) diff --git a/src/services/api/openaiShim/requestExecutor.test.ts b/src/services/api/openaiShim/requestExecutor.test.ts index 14cb81ec0..1f9d06801 100644 --- a/src/services/api/openaiShim/requestExecutor.test.ts +++ b/src/services/api/openaiShim/requestExecutor.test.ts @@ -585,7 +585,7 @@ test('OPENAI_API_KEYS rejects placeholder values before sending requests', async max_tokens: 32, stream: false, }), - ).rejects.toThrow(/SUA_CHAVE|Authentication failed/) + ).rejects.toThrow(/invalid credential placeholder|Authentication failed/) expect(authorizations).toEqual([]) }) diff --git a/src/services/api/openaiShim/requestExecutor.ts b/src/services/api/openaiShim/requestExecutor.ts index 57d0d82aa..b84d8ff29 100644 --- a/src/services/api/openaiShim/requestExecutor.ts +++ b/src/services/api/openaiShim/requestExecutor.ts @@ -47,6 +47,8 @@ type RequestExecutorContext = { defaultHeaders: Record providerOverride?: { apiKey?: string } routeAcceptsGenericOpenAICredentials: boolean + routeSupportsAuthHeaders: boolean + routeSupportsCustomHeaders: boolean getCredentialPool: (rawCredentials: string) => CredentialPool | null filterAnthropicHeaders: ( headers?: Record, @@ -181,6 +183,8 @@ export async function executeOpenAIRequest( defaultHeaders, providerOverride, routeAcceptsGenericOpenAICredentials, + routeSupportsAuthHeaders, + routeSupportsCustomHeaders, getCredentialPool, filterAnthropicHeaders, isGeminiMode, @@ -240,11 +244,25 @@ export async function executeOpenAIRequest( isGithubCopilot, isGithubModels, } = context + const managedClientHeaders = Object.fromEntries( + Object.entries(defaultHeaders).filter(([name]) => + [ + 'user-agent', + 'x-app', + 'x-client-app', + 'x-claude-code-session-id', + 'x-claude-remote-container-id', + 'x-claude-remote-session-id', + ].includes(name.toLowerCase()), + ), + ) const baseHeaders: Record = { 'Content-Type': 'application/json', ...filterAnthropicHeaders(shimConfig.headers), - ...defaultHeaders, - ...filterAnthropicHeaders(options?.headers), + ...(routeSupportsCustomHeaders ? defaultHeaders : managedClientHeaders), + ...(routeSupportsCustomHeaders + ? filterAnthropicHeaders(options?.headers) + : {}), } const isGemini = isGeminiMode() @@ -331,7 +349,9 @@ export async function executeOpenAIRequest( ?.defaultAuthHeader const configuredAuthHeaderValue = catalogAuthHeader ? undefined - : requestProcessEnv.OPENAI_AUTH_HEADER_VALUE?.trim() + : routeSupportsAuthHeaders + ? requestProcessEnv.OPENAI_AUTH_HEADER_VALUE?.trim() + : undefined if (configuredAuthHeaderValue && /[\r\n]/.test(configuredAuthHeaderValue)) { throw new Error( 'OPENAI_AUTH_HEADER_VALUE must not contain CR/LF characters', @@ -339,7 +359,9 @@ export async function executeOpenAIRequest( } const customAuthHeader = catalogAuthHeader ? undefined - : requestProcessEnv.OPENAI_AUTH_HEADER?.trim() + : routeSupportsAuthHeaders + ? requestProcessEnv.OPENAI_AUTH_HEADER?.trim() + : undefined const hasCustomAuthHeader = Boolean( customAuthHeader && /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(customAuthHeader), ) @@ -354,7 +376,7 @@ export async function executeOpenAIRequest( 401, undefined, buildOpenAICompatibilityErrorMessage( - 'OpenAI API error 401: invalid credential pool placeholder SUA_CHAVE detected', + 'OpenAI API error 401: invalid credential placeholder detected', { category: 'auth_invalid', requestUrl: request.baseUrl, diff --git a/src/services/api/providerConfig.test.ts b/src/services/api/providerConfig.test.ts index 03f4159ef..e9ba6a7ff 100644 --- a/src/services/api/providerConfig.test.ts +++ b/src/services/api/providerConfig.test.ts @@ -340,6 +340,19 @@ test('resolveProviderRequest uses APISMART_MODEL when APISMART_API_KEY is presen expect(request.baseUrl).toBe('https://gw.apismart.ai/v1') }) +test('resolveProviderRequest keeps ApiSmart intent with the generic OpenAI compatibility flag', () => { + const request = resolveProviderRequest({ + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + APISMART_API_KEY: 'apismart-key', + APISMART_MODEL: 'KIMI_K3', + }, + }) + + expect(request.requestedModel).toBe('KIMI_K3') + expect(request.baseUrl).toBe('https://gw.apismart.ai/v1') +}) + test('resolveProviderRequest falls back to OPENAI_MODEL for ApiSmart when APISMART_MODEL is unset', () => { const request = resolveProviderRequest({ processEnv: { @@ -365,6 +378,52 @@ test('resolveProviderRequest treats blank APISMART_MODEL as unset for ApiSmart', expect(request.baseUrl).toBe('https://gw.apismart.ai/v1') }) +test.each(['undefined', 'null', ' NULL '])( + 'resolveProviderRequest treats APISMART_MODEL=%s as unset', + placeholder => { + const request = resolveProviderRequest({ + processEnv: { + APISMART_API_KEY: 'apismart-key', + APISMART_MODEL: placeholder, + }, + }) + + expect(request.requestedModel).toBe('DEEPSEEK_V4_FLASH') + expect(request.baseUrl).toBe('https://gw.apismart.ai/v1') + }, +) + +test.each(['undefined', 'null', ' NULL '])( + 'resolveProviderRequest treats ApiSmart OPENAI_MODEL fallback %s as unset', + placeholder => { + const request = resolveProviderRequest({ + processEnv: { + APISMART_API_KEY: 'apismart-key', + OPENAI_MODEL: placeholder, + }, + }) + + expect(request.requestedModel).toBe('DEEPSEEK_V4_FLASH') + expect(request.baseUrl).toBe('https://gw.apismart.ai/v1') + }, +) + +test.each(['null', ' NULL ', 'undefined'])( + 'resolveProviderRequest treats OPENAI_BASE_URL=%s as unset for ApiSmart', + placeholder => { + const request = resolveProviderRequest({ + processEnv: { + APISMART_API_KEY: 'apismart-key', + APISMART_MODEL: 'KIMI_K3', + OPENAI_BASE_URL: placeholder, + }, + }) + + expect(request.requestedModel).toBe('KIMI_K3') + expect(request.baseUrl).toBe('https://gw.apismart.ai/v1') + }, +) + test('resolveProviderRequest uses the ApiSmart route default when no model env is set', () => { const request = resolveProviderRequest({ processEnv: { @@ -431,6 +490,19 @@ test('resolveProviderRequest ignores ApiSmart model when explicit OPENAI_BASE_UR expect(request.baseUrl).toBe('https://api.openai.com/v1') }) +test('resolveProviderRequest ignores an ambient ApiSmart key for an explicit native provider', () => { + const request = resolveProviderRequest({ + processEnv: { + APISMART_API_KEY: 'apismart-key', + APISMART_MODEL: 'KIMI_K3', + CLAUDE_CODE_USE_BEDROCK: '1', + }, + }) + + expect(request.requestedModel).toBe('codexplan') + expect(request.baseUrl).toBe('https://chatgpt.com/backend-api/codex') +}) + test('resolveProviderRequest resolves the GPT-5.6 family Codex aliases', () => { const sol = resolveProviderRequest({ model: 'gpt-5.6-sol', processEnv: {} }) expect(sol.resolvedModel).toBe('gpt-5.6-sol') diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 5c683dff6..ead96e656 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -26,10 +26,11 @@ import { getCatalogEntriesForRoute } from '../../integrations/registry.js' import { getRouteDefaultBaseUrl, getRouteDefaultModel, + getUsableRouteModelEnvValue, + hasApismartEnvOnlyProviderIntent, isApismartBaseUrl, isClinePassBaseUrl, } from '../../integrations/routeMetadata.js' -import { hasUsableOpenAICredential } from './credentialPool.js' import { openAIShimSupportsApiFormatForModel, resolveOpenAIShimRuntimeContext, @@ -235,13 +236,13 @@ function isPrivateIpv6Address(hostname: string): boolean { } // Reads an env-var-style string intended as a URL or path, rejecting both -// empty strings and the literal string "undefined" that Windows shells can +// empty strings and literal nullish strings that shells and dotenv templates // write when a variable is unset-then-referenced without quotes (issue #336). function asEnvUrl(value: string | undefined): string | undefined { if (!value) return undefined const trimmed = value.trim() if (!trimmed) return undefined - if (trimmed === 'undefined') { + if (trimmed.toLowerCase() === 'undefined' || trimmed.toLowerCase() === 'null') { return undefined } return trimmed @@ -256,11 +257,12 @@ function asNamedEnvUrl( const trimmed = value.trim() if (!trimmed) return undefined - if (trimmed === 'undefined') { + const normalized = trimmed.toLowerCase() + if (normalized === 'undefined' || normalized === 'null') { if (!warnedUndefinedEnvNames.has(envName)) { warnedUndefinedEnvNames.add(envName) logForDebugging( - `[provider-config] Environment variable ${envName} is the literal string "undefined"; ignoring it.`, + `[provider-config] Environment variable ${envName} is the literal string "${trimmed}"; ignoring it.`, { level: 'warn' }, ) } @@ -933,7 +935,7 @@ export function resolveProviderRequest(options?: { const isMistralMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL) const isGeminiMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI) const isClinePassMode = Boolean(processEnv.CLINE_API_KEY?.trim()) - const isApismartMode = hasUsableOpenAICredential(processEnv.APISMART_API_KEY) + const isApismartMode = hasApismartEnvOnlyProviderIntent(processEnv) const explicitBaseUrl = asEnvUrl(options?.baseUrl) const normalizedMistralEnvBaseUrl = asNamedEnvUrl( @@ -1012,8 +1014,8 @@ export function resolveProviderRequest(options?: { ? processEnv.CLINE_API_MODEL?.trim() || processEnv.OPENAI_MODEL?.trim() : effectiveApismartMode - ? processEnv.APISMART_MODEL?.trim() || - processEnv.OPENAI_MODEL?.trim() + ? getUsableRouteModelEnvValue(processEnv.APISMART_MODEL) || + getUsableRouteModelEnvValue(processEnv.OPENAI_MODEL) : processEnv.OPENAI_MODEL?.trim()) || options?.fallbackModel?.trim() || (isGeminiMode ? DEFAULT_GEMINI_MODEL : undefined) || diff --git a/src/utils/model/model.openai-shim-providers.test.ts b/src/utils/model/model.openai-shim-providers.test.ts index 17c50fe60..69618e1d1 100644 --- a/src/utils/model/model.openai-shim-providers.test.ts +++ b/src/utils/model/model.openai-shim-providers.test.ts @@ -75,6 +75,8 @@ const SAVED_ENV = { ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, MIMO_API_KEY: process.env.MIMO_API_KEY, + APISMART_API_KEY: process.env.APISMART_API_KEY, + APISMART_MODEL: process.env.APISMART_MODEL, OPENAI_MODEL: process.env.OPENAI_MODEL, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, CODEX_API_KEY: process.env.CODEX_API_KEY, @@ -131,6 +133,8 @@ beforeEach(async () => { delete process.env.CLAUDE_CODE_USE_FOUNDRY delete process.env.NVIDIA_NIM delete process.env.MINIMAX_API_KEY + delete process.env.APISMART_API_KEY + delete process.env.APISMART_MODEL delete process.env.ANTHROPIC_MODEL delete process.env.MIMO_API_KEY delete process.env.OPENAI_MODEL @@ -340,6 +344,41 @@ test('getDefaultMainLoopModelSetting defaults MiniMax to M3', async () => { expect(getDefaultMainLoopModel()).toBe('MiniMax-M3') }) +test('getDefaultMainLoopModelSetting uses the credential-only ApiSmart model', async () => { + process.env.APISMART_API_KEY = 'apismart-key' + process.env.APISMART_MODEL = 'KIMI_K3' + + const { + getDefaultMainLoopModel, + getDefaultMainLoopModelSetting, + } = await importFreshModelModule() + expect(getDefaultMainLoopModelSetting()).toBe('KIMI_K3') + expect(getDefaultMainLoopModel()).toBe('KIMI_K3') +}) + +test('ApiSmart model selection is consistent across main and helper tiers', async () => { + process.env.APISMART_API_KEY = 'apismart-key' + process.env.APISMART_MODEL = 'KIMI_K3' + + const { + getDefaultHaikuModel, + getDefaultOpusModel, + getDefaultSonnetModel, + getSmallFastModel, + } = await importFreshModelModule() + expect(getDefaultOpusModel()).toBe('KIMI_K3') + expect(getDefaultSonnetModel()).toBe('KIMI_K3') + expect(getDefaultHaikuModel()).toBe('KIMI_K3') + expect(getSmallFastModel()).toBe('KIMI_K3') +}) + +test('getDefaultMainLoopModelSetting uses the ApiSmart descriptor default', async () => { + process.env.APISMART_API_KEY = 'apismart-key' + + const { getDefaultMainLoopModelSetting } = await importFreshModelModule() + expect(getDefaultMainLoopModelSetting()).toBe('DEEPSEEK_V4_FLASH') +}) + test('getDefaultMainLoopModelSetting uses the NVIDIA NIM route model', async () => { process.env.NVIDIA_NIM = '1' process.env.CLAUDE_CODE_USE_OPENAI = '1' diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index ab9bbcae9..b8bf9753d 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -35,7 +35,11 @@ import { type ModelAlias, isModelAlias } from './aliases.js' import { capitalize } from '../stringUtils.js' import { DEFAULT_GEMINI_MODEL } from '../providerProfile.js' import { getAntModelOverrideConfig, resolveAntModel } from './antModels.js' -import { getRouteDefaultModel } from '../../integrations/routeMetadata.js' +import { + getRouteDefaultModel, + getUsableRouteModelEnvValue, + resolveEnvOnlyProviderRouteId, +} from '../../integrations/routeMetadata.js' export type ModelShortName = string export type ModelName = string @@ -45,6 +49,18 @@ function getMiniMaxModelEnv(): string | undefined { return process.env.ANTHROPIC_MODEL || process.env.OPENAI_MODEL } +function getApiSmartModelEnv(): string | undefined { + if (resolveEnvOnlyProviderRouteId(process.env) !== 'apismart') { + return undefined + } + return ( + getUsableRouteModelEnvValue(process.env.APISMART_MODEL) || + getUsableRouteModelEnvValue(process.env.OPENAI_MODEL) || + getRouteDefaultModel('apismart') || + 'DEEPSEEK_V4_FLASH' + ) +} + function normalizeModelSetting(value: unknown): ModelName | ModelAlias | undefined { if (typeof value !== 'string') return undefined const trimmed = value.trim() @@ -56,6 +72,8 @@ export function getSmallFastModel(): ModelName { if (isCustomAnthropicProvider()) { return process.env.ANTHROPIC_MODEL || getDefaultHaikuModel() } + const apiSmartModel = getApiSmartModelEnv() + if (apiSmartModel) return apiSmartModel // For Gemini provider, use a fast model if (getAPIProvider() === 'gemini') { return process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite' @@ -195,6 +213,8 @@ export function getDefaultOpusModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) { return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL } + const apiSmartModel = getApiSmartModelEnv() + if (apiSmartModel) return apiSmartModel // Gemini provider if (getAPIProvider() === 'gemini') { return process.env.GEMINI_MODEL || 'gemini-2.5-pro' @@ -245,6 +265,8 @@ export function getDefaultSonnetModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) { return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL } + const apiSmartModel = getApiSmartModelEnv() + if (apiSmartModel) return apiSmartModel // Gemini provider if (getAPIProvider() === 'gemini') { return process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL @@ -293,6 +315,8 @@ export function getDefaultHaikuModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL } + const apiSmartModel = getApiSmartModelEnv() + if (apiSmartModel) return apiSmartModel // Mistral provider if (getAPIProvider() === 'mistral') { return process.env.MISTRAL_MODEL || 'ministral-3b-latest' @@ -379,6 +403,8 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias { if (isCustomAnthropicProvider()) { return process.env.ANTHROPIC_MODEL || getDefaultSonnetModel() } + const apiSmartModel = getApiSmartModelEnv() + if (apiSmartModel) return apiSmartModel // GitHub Copilot provider: check settings.model first, then env, then default if (getAPIProvider() === 'github') { const settings = getSettings_DEPRECATED() || {} diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index c911f527a..628f0faf2 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -13,6 +13,7 @@ import { applyModelFlagFromArgs, VALID_PROVIDERS, } from './providerFlag.js' +import { resolveEnvOnlyProviderRouteId } from '../integrations/routeMetadata.js' const ENV_KEYS = [ 'CLAUDE_CODE_USE_OPENAI', @@ -22,6 +23,7 @@ const ENV_KEYS = [ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_API_KEY', @@ -73,6 +75,7 @@ const RESET_KEYS = [ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_API_KEY', @@ -191,6 +194,16 @@ describe('applyProviderFlag - anthropic', () => { expect(result.error).toBeUndefined() expect(process.env.ANTHROPIC_API_KEY).toBe('first-party-key') }) + + test('explicit Anthropic selection suppresses an ambient ApiSmart key', () => { + process.env.ANTHROPIC_API_KEY = 'first-party-key' + process.env.APISMART_API_KEY = 'ambient-apismart-key' + + applyProviderFlag('anthropic', []) + + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('anthropic') + expect(resolveEnvOnlyProviderRouteId(process.env)).toBeNull() + }) }) describe('applyProviderFlag - custom Anthropic-compatible', () => { @@ -1019,6 +1032,41 @@ describe('applyProviderFlag - apismart', () => { expect(process.env.OPENAI_MODEL).toBe('KIMI_K3') }) + test('ApiSmart-specific model replaces a stale generic OpenAI model', () => { + process.env.APISMART_API_KEY = 'apismart-secret-key' + process.env.APISMART_MODEL = 'KIMI_K3' + process.env.OPENAI_MODEL = 'gpt-4o' + + applyProviderFlag('apismart', []) + + expect(process.env.OPENAI_MODEL).toBe('KIMI_K3') + }) + + test.each(['undefined', 'null', ' NULL '])( + 'ignores stale generic model sentinel %s for ApiSmart', + placeholder => { + process.env.APISMART_API_KEY = 'apismart-secret-key' + process.env.OPENAI_MODEL = placeholder + + applyProviderFlag('apismart', []) + + expect(process.env.OPENAI_MODEL).toBe('DEEPSEEK_V4_FLASH') + }, + ) + + test.each(['undefined', 'null', ' NULL '])( + 'ignores stale generic base URL sentinel %s for ApiSmart', + placeholder => { + process.env.APISMART_API_KEY = 'apismart-secret-key' + process.env.OPENAI_BASE_URL = placeholder + + applyProviderFlag('apismart', []) + + expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1') + expect(process.env.OPENAI_API_KEY).toBe('apismart-secret-key') + }, + ) + test('makes an explicit --model override APISMART_MODEL', () => { process.env.APISMART_API_KEY = 'apismart-secret-key' process.env.APISMART_MODEL = 'KIMI_K3' @@ -1029,6 +1077,16 @@ describe('applyProviderFlag - apismart', () => { expect(process.env.APISMART_MODEL).toBe('GLM_5.2') }) + test('standalone --model overrides credential-only ApiSmart model selection', () => { + process.env.APISMART_API_KEY = 'apismart-secret-key' + process.env.APISMART_MODEL = 'KIMI_K3' + + applyModelFlagFromArgs(['--model', 'GLM_5.2']) + + expect(process.env.OPENAI_MODEL).toBe('GLM_5.2') + expect(process.env.APISMART_MODEL).toBe('GLM_5.2') + }) + test('dedicated key overrides a lingering OPENAI_API_KEY from another provider', () => { process.env.OPENAI_API_KEY = 'existing-openai-key' process.env.APISMART_API_KEY = 'apismart-secret-key' @@ -1046,14 +1104,54 @@ describe('applyProviderFlag - apismart', () => { expect(process.env.OPENAI_API_KEY).toBeUndefined() }) + test.each(['SUA_CHAVE', 'null', 'undefined', ' NULL '])( + 'does not mirror placeholder ApiSmart credential %s', + placeholder => { + process.env.APISMART_API_KEY = placeholder + + applyProviderFlag('apismart', []) + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + }, + ) + test('clears a copied ApiSmart key from OPENAI_API_KEY when switching to another provider', () => { process.env.APISMART_API_KEY = 'apismart-secret-key' process.env.OPENAI_API_KEY = 'apismart-secret-key' + process.env.OPENAI_BASE_URL = 'https://gw.apismart.ai/v1' applyProviderFlag('openai', []) expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1') + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('openai') + expect(resolveEnvOnlyProviderRouteId(process.env)).toBeNull() }) + + test.each([ + ['openai', 'https://api.openai.com/v1'], + ['github', 'https://api.githubcopilot.com'], + ['ollama', 'http://localhost:11434/v1'], + ['nvidia-nim', 'https://integrate.api.nvidia.com/v1'], + ['bankr', 'https://llm.bankr.bot/v1'], + ['xai', 'https://api.x.ai/v1'], + ['xiaomi-mimo', 'https://api.xiaomimimo.com/v1'], + ['venice', 'https://api.venice.ai/api/v1'], + ])( + 'explicit %s selection replaces a stale ApiSmart endpoint', + (provider, expectedBaseUrl) => { + process.env.APISMART_API_KEY = 'apismart-secret-key' + process.env.OPENAI_API_KEY = 'apismart-secret-key' + process.env.OPENAI_BASE_URL = 'https://gw.apismart.ai/v1' + process.env.OPENAI_MODEL = 'KIMI_K3' + + applyProviderFlag(provider, []) + + expect(process.env.OPENAI_BASE_URL).toBe(expectedBaseUrl) + expect(process.env.OPENAI_API_KEY).not.toBe('apismart-secret-key') + expect(process.env.OPENAI_MODEL).not.toBe('KIMI_K3') + }, + ) }) describe('applyProviderFlag - xai', () => { diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 0529de354..9b1c6b7a7 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -21,6 +21,7 @@ import { getAllVendors, getGateway, getVendor, + getTransportKindForRoute, isCloudflareBaseUrl, isLongcatBaseUrl, routeSupportsApiFormatSelection, @@ -29,7 +30,13 @@ import { resolveRouteIdFromBaseUrl, } from '../integrations/index.js' import { PRESET_VENDOR_MAP } from '../integrations/compatibility.js' -import { isApismartBaseUrl } from '../integrations/routeMetadata.js' +import { + hasApismartEnvOnlyProviderIntent, + getUsableRouteConfigEnvValue, + getUsableRouteModelEnvValue, + hasUsableRouteCredentialEnvValue, + isApismartBaseUrl, +} from '../integrations/routeMetadata.js' import { isFirstPartyAnthropicBaseUrlForEnv } from './anthropicBaseUrl.js' const PREFERRED_PROVIDER_ORDER = [ @@ -170,8 +177,7 @@ function getRouteDefaults(provider: string): { } function normalizeBaseUrlEnv(value: string | undefined): string | undefined { - const trimmed = value?.trim() - return trimmed && trimmed !== 'undefined' ? trimmed : undefined + return getUsableRouteConfigEnvValue(value) } function getConfiguredOpenAIBaseUrl(): string | undefined { @@ -193,7 +199,6 @@ function shouldReplaceStaleKnownBaseUrl(provider: string): boolean { const targetRouteId = resolveProfileRoute(provider).routeId return ( - targetRouteId !== 'openai' && targetRouteId !== 'custom' && targetRouteId !== 'unknown-fallback' && currentRouteId !== targetRouteId @@ -231,6 +236,25 @@ function applyOpenAIBaseUrlDefault(provider: string, baseUrl?: string): void { } } +function replaceStaleKnownBaseUrlForExplicitProvider( + provider: string, + baseUrl: string | undefined, +): boolean { + const routeId = resolveProfileRoute(provider).routeId + const transportKind = getTransportKindForRoute(routeId) + if ( + (transportKind !== 'openai-compatible' && transportKind !== 'local') || + !baseUrl || + isPlaceholderBaseUrl(baseUrl) || + !shouldReplaceStaleKnownBaseUrl(provider) + ) { + return false + } + + process.env.OPENAI_BASE_URL = baseUrl.trim() + return true +} + function clearUnsupportedOpenAIShimSettings(routeId: string): void { if (!routeSupportsApiFormatSelection(routeId)) { delete process.env.OPENAI_API_FORMAT @@ -273,6 +297,7 @@ export function applyModelFlagFromArgs(args: string[]): void { const useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true' + const useApismartEnvOnly = hasApismartEnvOnlyProviderIntent(process.env) if (useGemini) { process.env.GEMINI_MODEL = model @@ -280,6 +305,12 @@ export function applyModelFlagFromArgs(args: string[]): void { process.env.MISTRAL_MODEL = model } else if (useOpenAI || useGithub) { process.env.OPENAI_MODEL = model + if (useApismartEnvOnly) { + process.env.APISMART_MODEL = model + } + } else if (useApismartEnvOnly) { + process.env.OPENAI_MODEL = model + process.env.APISMART_MODEL = model } else { process.env.ANTHROPIC_MODEL = model } @@ -305,6 +336,12 @@ export function applyProviderFlag( } } + // Record explicit CLI provider intent separately from compatibility flags. + // In particular, built-in Anthropic has no CLAUDE_CODE_USE_* flag, while an + // OpenAI-compatible flag alone does not identify a concrete gateway. + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = + resolveProfileRoute(provider).routeId + const opengatewayApiKey = process.env.OPENGATEWAY_API_KEY?.trim() const copiedOpenAIKeyProvider = process.env.OPENAI_API_KEY !== undefined && @@ -366,6 +403,19 @@ export function applyProviderFlag( const model = parseModelFlag(args) const { defaultBaseUrl, defaultModel } = getRouteDefaults(provider) + // A CLI provider selection is a route transition, not just a flag change. + // Replace endpoints owned by the previously selected known route for every + // OpenAI-shim provider, while preserving unknown user-managed proxy URLs. + const replacedKnownProviderEndpoint = + replaceStaleKnownBaseUrlForExplicitProvider(provider, defaultBaseUrl) + if (replacedKnownProviderEndpoint && !model) { + if (defaultModel) { + process.env.OPENAI_MODEL = defaultModel + } else { + delete process.env.OPENAI_MODEL + } + } + // Azure-style routing changes both request paths and authentication. It is // only meaningful for an explicit OpenAI/Azure configuration, so never let // it follow a provider switch to another OpenAI-compatible endpoint. @@ -428,6 +478,10 @@ export function applyProviderFlag( case 'openai': process.env.CLAUDE_CODE_USE_OPENAI = '1' + applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://api.openai.com/v1', + ) if (model) process.env.OPENAI_MODEL = model break @@ -607,10 +661,19 @@ export function applyProviderFlag( provider, defaultBaseUrl ?? 'https://gw.apismart.ai/v1', ) - process.env.OPENAI_MODEL ??= - process.env.APISMART_MODEL?.trim() || - defaultModel || - 'DEEPSEEK_V4_FLASH' + { + const apismartModel = getUsableRouteModelEnvValue( + process.env.APISMART_MODEL, + ) + if (apismartModel) { + process.env.OPENAI_MODEL = apismartModel + } else { + process.env.OPENAI_MODEL = + getUsableRouteModelEnvValue(process.env.OPENAI_MODEL) ?? + defaultModel ?? + 'DEEPSEEK_V4_FLASH' + } + } if (model) { process.env.OPENAI_MODEL = model process.env.APISMART_MODEL = model @@ -620,7 +683,10 @@ export function applyProviderFlag( // and clear any stale generic key so another provider's credential is // never forwarded to ApiSmart. if ( - process.env.APISMART_API_KEY && + hasUsableRouteCredentialEnvValue( + 'APISMART_API_KEY', + process.env.APISMART_API_KEY, + ) && isApismartBaseUrl(getConfiguredOpenAIBaseUrl()) ) { process.env.OPENAI_API_KEY = process.env.APISMART_API_KEY diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index a619b66c1..6d34dc039 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -35,6 +35,7 @@ import { PROFILE_FILE_NAME, redactSecretValueForDisplay, saveProfileFile, + sanitizeOpenAICredentialPool, sanitizeProviderConfigValue, hasInvalidOpenAICredentialPool, selectAutoProfile, @@ -278,6 +279,47 @@ test('openai launch preserves persisted ApiSmart dedicated credentials across re assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'apismart') }) +test('openai launch withholds a persisted mirrored ApiSmart key after live endpoint retargeting', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://gw.apismart.ai/v1', + OPENAI_MODEL: 'DEEPSEEK_V4_FLASH', + OPENAI_API_KEY: 'apismart-secret-key', + APISMART_API_KEY: 'apismart-secret-key', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example/v1', + }, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example/v1') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.APISMART_API_KEY, undefined) +}) + +test('openai launch keeps a live generic key when retargeting a persisted ApiSmart profile', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://gw.apismart.ai/v1', + OPENAI_MODEL: 'DEEPSEEK_V4_FLASH', + OPENAI_API_KEY: 'apismart-secret-key', + APISMART_API_KEY: 'apismart-secret-key', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example/v1', + OPENAI_API_KEY: 'proxy-key', + }, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example/v1') + assert.equal(env.OPENAI_API_KEY, 'proxy-key') + assert.equal(env.APISMART_API_KEY, undefined) +}) + test('buildApismartProfileEnv prefers APISMART_MODEL over OPENAI_MODEL', () => { const env = buildApismartProfileEnv({ apiKey: 'apismart-secret-key', @@ -300,6 +342,12 @@ test('buildApismartProfileEnv refuses to copy the dedicated credential to a cust assert.equal(env, null) }) +for (const placeholder of ['SUA_CHAVE', 'null', 'undefined', ' NULL ']) { + test(`buildApismartProfileEnv rejects placeholder credential ${placeholder}`, () => { + assert.equal(buildApismartProfileEnv({ apiKey: placeholder }), null) + }) +} + test('openai launch carries APISMART_API_KEY only when the route resolves to apismart', async () => { const offRoute = await buildLaunchEnv({ profile: 'openai', @@ -966,6 +1014,30 @@ test('applyStartupEnvFromProfile applies valid startup env (issue #1651)', async assert.equal(processEnv.OPENGATEWAY_API_KEY, 'test-key') }) +test('invalid ambient ApiSmart placeholder does not suppress a saved profile', async () => { + const processEnv: NodeJS.ProcessEnv = { + APISMART_API_KEY: ' SUA_CHAVE ', + } + const warnings: string[] = [] + + const error = await applyStartupEnvFromProfile({ + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEY: 'saved-openai-key', + }), + processEnv, + onValidationError: message => warnings.push(message), + }) + + assert.equal(error, null) + assert.deepEqual(warnings, []) + assert.equal(processEnv.OPENAI_BASE_URL, 'https://api.openai.com/v1') + assert.equal(processEnv.OPENAI_MODEL, 'gpt-4o') + assert.equal(processEnv.OPENAI_API_KEY, 'saved-openai-key') + assert.equal(processEnv.APISMART_API_KEY, undefined) +}) + test('buildStartupEnvFromProfile preserves explicit OpenAI-compatible env without a saved profile', async () => { const env = await buildStartupEnvFromProfile({ persisted: null, @@ -2728,6 +2800,7 @@ test('openai launch preserves invalid live pooled credentials for launch validat 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', diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index c2f497890..8eb4ca0d9 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -25,6 +25,9 @@ import { getErrnoCode } from './errors.js' import { getRouteDefaultBaseUrl, getRouteDefaultModel, + getRouteCredentialEnvVars, + hasExplicitOpenAICompatibleOptOut, + hasUsableRouteCredentialEnvValue, isApismartBaseUrl, isLongcatBaseUrl, normalizeXiaomiMimoBaseUrl, @@ -644,7 +647,7 @@ export function buildApismartProfileEnv(options: { }): ProfileEnv | null { const processEnv = options.processEnv ?? process.env const key = sanitizeApiKey(options.apiKey ?? processEnv.APISMART_API_KEY) - if (!key) { + if (!key || !hasUsableRouteCredentialEnvValue('APISMART_API_KEY', key)) { return null } @@ -1316,15 +1319,6 @@ function hasExplicitNonOpenAIProviderSelection( ) } -function hasExplicitOpenAICompatibleOptOut( - processEnv: NodeJS.ProcessEnv = process.env, -): boolean { - return ( - processEnv.CLAUDE_CODE_USE_OPENAI !== undefined && - !isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI) - ) -} - function hasConcreteProviderSelection( processEnv: NodeJS.ProcessEnv = process.env, ): boolean { @@ -1401,7 +1395,10 @@ function hasConcreteProviderSelection( sanitizeApiKey(processEnv.FIREWORKS_API_KEY) !== undefined || sanitizeApiKey(processEnv.NEARAI_API_KEY) !== undefined || sanitizeApiKey(processEnv.LONGCAT_API_KEY) !== undefined || - sanitizeApiKey(processEnv.APISMART_API_KEY) !== undefined + hasUsableRouteCredentialEnvValue( + 'APISMART_API_KEY', + processEnv.APISMART_API_KEY, + ) ) } @@ -2015,10 +2012,9 @@ export async function buildLaunchEnv(options: { } else { delete env.OPENAI_AUTH_HEADER_VALUE } - const openAICredential = resolveOpenAICredentialEnvOverride( - processEnv, - persistedEnv, - ) + const liveOpenAICredential = resolveOpenAICredentialEnvSelection(processEnv) + const openAICredential = + liveOpenAICredential || resolveOpenAICredentialEnvSelection(persistedEnv) if (openAICredential) { env[openAICredential.envVar] = openAICredential.value } @@ -2045,6 +2041,30 @@ export async function buildLaunchEnv(options: { const effectiveOpenAIRouteId = resolvedOpenAIRouteId || (shouldUsePersistedOpenAIRouteId ? persistedOpenAIRouteId : undefined) + const persistedCredentialRouteId = + resolveRouteIdFromBaseUrl(persistedOpenAIBaseUrl) || persistedOpenAIRouteId + const persistedOpenAICredential = + resolveOpenAICredentialEnvSelection(persistedEnv) + const persistedGenericCredentialMirrorsDedicatedRoute = + !liveOpenAICredential && + persistedOpenAICredential?.envVar === 'OPENAI_API_KEY' && + !!persistedCredentialRouteId && + getRouteCredentialEnvVars(persistedCredentialRouteId) + .filter( + envVar => + envVar !== 'OPENAI_API_KEY' && envVar !== 'OPENAI_API_KEYS', + ) + .some( + envVar => + sanitizeApiKey(persistedEnv[envVar]) === + persistedOpenAICredential.value, + ) + if ( + persistedGenericCredentialMirrorsDedicatedRoute && + effectiveOpenAIRouteId !== persistedCredentialRouteId + ) { + delete env.OPENAI_API_KEY + } if (resolvedOpenAIRouteId && resolvedOpenAIRouteId !== 'openai') { env.CLAUDE_CODE_PROVIDER_ROUTE_ID = resolvedOpenAIRouteId } else if (shouldUsePersistedOpenAIRouteId && persistedOpenAIRouteId) { diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index b73035235..d2f9bd00b 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -830,6 +830,24 @@ describe('applyProviderProfileToProcessEnv', () => { expect(getFreshAPIProvider()).toBe('openai') }) + test.each(['SUA_CHAVE', 'null', 'undefined', ' NULL '])( + 'rejects an ApiSmart profile with invalid credential placeholder %p', + async apiKey => { + const { addProviderProfile } = await importFreshProviderProfileModules() + + const saved = addProviderProfile({ + provider: 'apismart', + name: 'ApiSmart invalid', + baseUrl: 'https://gw.apismart.ai/v1', + model: 'KIMI_K3', + apiKey, + }) + + expect(saved).toBeNull() + expect(mockConfigState.providerProfiles).toEqual([]) + }, + ) + test('apismart profile clears a stale route-specific model before applying its saved model', async () => { const { applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() @@ -857,12 +875,46 @@ describe('applyProviderProfileToProcessEnv', () => { await importFreshProviderProfileModules() applyProviderProfileToProcessEnv( - buildApismartProfile({ baseUrl: 'https://proxy.example/v1' }), + buildApismartProfile({ + baseUrl: 'https://proxy.example/v1', + authHeader: 'X-Proxy-Key', + authScheme: 'raw', + authHeaderValue: 'proxy-secret', + customHeaders: { 'X-Team': 'devtools' }, + }), ) expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example/v1') expect(process.env.OPENAI_API_KEY).toBeUndefined() expect(process.env.APISMART_API_KEY).toBeUndefined() + expect(process.env.OPENAI_AUTH_HEADER).toBe('X-Proxy-Key') + expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw') + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe('proxy-secret') + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe('X-Team: devtools') + }) + + test('custom profile targeting ApiSmart mirrors its key into the endpoint credential', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'custom', + baseUrl: 'https://gw.apismart.ai/v1', + model: 'KIMI_K3', + apiKey: 'apismart-custom-profile-key', + apiFormat: 'responses', + authHeader: 'X-Api-Key', + authScheme: 'raw', + authHeaderValue: 'unsupported-custom-auth', + }), + ) + + expect(process.env.OPENAI_API_KEY).toBe('apismart-custom-profile-key') + expect(process.env.APISMART_API_KEY).toBe('apismart-custom-profile-key') + expect(process.env.OPENAI_API_FORMAT).toBeUndefined() + expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined() + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBeUndefined() }) test('cloudflare profile applies OpenAI-compatible env with CLOUDFLARE_API_TOKEN mirror', async () => { @@ -3189,6 +3241,46 @@ describe('setActiveProviderProfile', () => { } }) + test('removes stale startup persistence when an active profile has no usable startup credential', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) + process.chdir(tempDir) + process.env.CLAUDE_CONFIG_DIR = configDir + + try { + const { setActiveProviderProfile } = + await importFreshProviderProfileModules() + const { createProfileFile, saveProfileFile } = await import( + `./providerProfile.js?ts=${Date.now()}-${Math.random()}` + ) + saveProfileFile( + createProfileFile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEY: 'stale-openai-key', + }), + { configDir }, + ) + const apismartProfile = buildApismartProfile({ + id: 'apismart_keyless', + apiKey: undefined, + }) + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [apismartProfile], + })) + + expect( + setActiveProviderProfile('apismart_keyless', { configDir })?.id, + ).toBe('apismart_keyless') + expect(existsSync(join(configDir, '.openclaude-profile.json'))).toBe(false) + } finally { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + rmSync(configDir, { recursive: true, force: true }) + } + }) + test('retargeted ApiSmart profiles persist without their dedicated credential', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) @@ -3201,6 +3293,10 @@ describe('setActiveProviderProfile', () => { const apismartProfile = buildApismartProfile({ id: 'apismart_proxy', baseUrl: 'https://proxy.example/v1', + authHeader: 'X-Proxy-Key', + authScheme: 'raw', + authHeaderValue: 'proxy-secret', + customHeaders: { 'X-Team': 'devtools' }, }) saveMockGlobalConfig(current => ({ @@ -3218,6 +3314,10 @@ describe('setActiveProviderProfile', () => { expect(persisted.env).toEqual({ OPENAI_BASE_URL: 'https://proxy.example/v1', OPENAI_MODEL: 'DEEPSEEK_V4_FLASH', + OPENAI_AUTH_HEADER: 'X-Proxy-Key', + OPENAI_AUTH_SCHEME: 'raw', + OPENAI_AUTH_HEADER_VALUE: 'proxy-secret', + ANTHROPIC_CUSTOM_HEADERS: 'X-Team: devtools', }) } finally { process.chdir(originalCwd) @@ -3226,6 +3326,46 @@ describe('setActiveProviderProfile', () => { } }) + test('persists credentials by concrete ApiSmart endpoint before provider label', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) + process.chdir(tempDir) + process.env.CLAUDE_CONFIG_DIR = configDir + + try { + const { setActiveProviderProfile } = + await importFreshProviderProfileModules() + const profile = buildAtlasCloudProfile({ + id: 'atlas_label_apismart_endpoint', + baseUrl: 'https://gw.apismart.ai/v1', + model: 'KIMI_K3', + apiKey: 'endpoint-owned-key', + }) + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [profile], + })) + + expect( + setActiveProviderProfile(profile.id, { configDir })?.id, + ).toBe(profile.id) + const persisted = JSON.parse( + readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + ) + expect(persisted.env).toMatchObject({ + OPENAI_BASE_URL: 'https://gw.apismart.ai/v1', + OPENAI_MODEL: 'KIMI_K3', + OPENAI_API_KEY: 'endpoint-owned-key', + APISMART_API_KEY: 'endpoint-owned-key', + }) + expect(persisted.env.ATLAS_CLOUD_API_KEY).toBeUndefined() + } finally { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + rmSync(configDir, { recursive: true, force: true }) + } + }) + test('persists Xiaomi MiMo profiles using a legacy-compatible openai startup profile', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 703cf4226..30947a5be 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -45,6 +45,7 @@ import { routeSupportsApiFormatSelection, routeSupportsAuthHeaders, routeSupportsCustomHeaders, + resolveProfileCapabilityRouteId, resolveProfileRoute, resolveRouteIdFromBaseUrl, type ResolvedProfileRoute, @@ -54,10 +55,11 @@ import { getRouteDefaultBaseUrl, isCloudflareBaseUrl, isClinePassBaseUrl, - isApismartBaseUrl, isFireworksBaseUrl, isLongcatBaseUrl, isNearaiBaseUrl, + hasUsableRouteCredentialEnvValue, + profileTargetsRoute, isXaiBaseUrl, isXiaomiMimoBaseUrl, resolveEnvOnlyProviderRouteId, @@ -153,8 +155,7 @@ function isClinePassProfile(profile: ProviderProfile): boolean { } function isApismartProfile(profile: ProviderProfile): boolean { - const baseUrl = profile.baseUrl?.trim() - return !baseUrl || isApismartBaseUrl(baseUrl) + return profileTargetsRoute(profile.provider, profile.baseUrl, 'apismart') } function deriveGithubEnterpriseUrl(baseUrl: string | undefined): string | undefined { @@ -221,36 +222,6 @@ function normalizeBaseUrl(value: string): string { return trimValue(value).replace(/\/+$/, '') } -function resolveProfileCapabilityRouteId( - provider: string, - baseUrl?: string, -): string { - const providerRouteId = resolveProfileRoute(provider).routeId - if (providerRouteId === 'custom-anthropic') { - return providerRouteId - } - - const routeIdFromBaseUrl = resolveRouteIdFromBaseUrl(baseUrl) - if (routeIdFromBaseUrl) { - return routeIdFromBaseUrl - } - - // Cloudflare and LongCat profiles retargeted away from their dedicated - // endpoints run generically at runtime. Mirror that boundary here so - // capability-driven surfaces are not stripped based on stale route ids. - if ( - (providerRouteId === 'cloudflare' || providerRouteId === 'longcat') && - baseUrl && - !(providerRouteId === 'cloudflare' - ? isCloudflareBaseUrl(baseUrl) - : isLongcatBaseUrl(baseUrl)) - ) { - return 'custom' - } - - return providerRouteId -} - function normalizeProfileModelLookupKey(model: string | undefined): string { // Strip a trailing [1m] tag along with any ?query suffix: the tag is a // client-side context opt-in, not part of the model's identity, so a saved @@ -331,6 +302,7 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null { const provider = trimValue(profile.provider) const baseUrl = normalizeBaseUrl(profile.baseUrl) const model = trimValue(profile.model) + const apiKey = trimOrUndefined(profile.apiKey) const apiFormat = parseOpenAICompatibleApiFormat(profile.apiFormat) const azureStyle = profile.azureStyle === true const authHeader = sanitizeAuthHeader(profile.authHeader) @@ -346,6 +318,13 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null { if (!id || !name || !baseUrl || !model || !provider) { return null } + if ( + apiKey && + profileTargetsRoute(provider, baseUrl, 'apismart') && + !hasUsableRouteCredentialEnvValue('APISMART_API_KEY', apiKey) + ) { + return null + } const maxContextLength = typeof profile.maxContextLength === 'number' && @@ -361,7 +340,7 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null { provider, baseUrl, model, - apiKey: trimOrUndefined(profile.apiKey), + apiKey, } if (supportsApiFormat && apiFormat) { sanitized.apiFormat = apiFormat @@ -996,8 +975,14 @@ export function applyProviderProfileToProcessEnv( const withholdRetargetedApismartCredential = route.routeId === 'apismart' && !isApismartProfile(profile) - if (profile.apiKey && !withholdRetargetedApismartCredential) { - openAIProfileEnv.OPENAI_API_KEY = profile.apiKey + const profileApiKey = + isApismartProfile(profile) && + profile.apiKey && + !hasUsableRouteCredentialEnvValue('APISMART_API_KEY', profile.apiKey) + ? undefined + : profile.apiKey + if (profileApiKey && !withholdRetargetedApismartCredential) { + openAIProfileEnv.OPENAI_API_KEY = profileApiKey if (route.vendorId === 'minimax' || normalizedProfileBaseUrl.toLowerCase().includes('minimax')) { openAIProfileEnv.MINIMAX_API_KEY = profile.apiKey } @@ -1031,7 +1016,7 @@ export function applyProviderProfileToProcessEnv( openAIProfileEnv.ATLAS_CLOUD_API_KEY = profile.apiKey } if (isApismartProfile(profile)) { - openAIProfileEnv.APISMART_API_KEY = profile.apiKey + openAIProfileEnv.APISMART_API_KEY = profileApiKey } if (isClinePassProfile(profile)) { openAIProfileEnv.CLINE_API_KEY = profile.apiKey @@ -1590,6 +1575,23 @@ function buildStartupProfileFromActiveProfile( })), } case 'openai': { + // A concrete endpoint owns persistence just as it owns live credential + // routing. Handle dedicated ApiSmart endpoints before provider-label + // special cases (Atlas, NVIDIA, MiniMax, etc.), otherwise a retargeted + // profile works live but persists the wrong provider's credential. + if (isApismartProfile(activeProfile)) { + const env = + buildApismartProfileEnv({ + model: getPrimaryModel(activeProfile.model), + baseUrl: activeProfile.baseUrl, + apiKey: activeProfile.apiKey, + processEnv: process.env, + }) ?? null + return env + ? { profile: 'openai', env: applySupportedProfileCustomHeaders(activeProfile, env) } + : null + } + if (route.gatewayId === 'nvidia-nim') { const env = buildNvidiaNimProfileEnv({ @@ -1655,19 +1657,6 @@ function buildStartupProfileFromActiveProfile( : null } - if (route.routeId === 'apismart' && isApismartProfile(activeProfile)) { - const env = - buildApismartProfileEnv({ - model: getPrimaryModel(activeProfile.model), - baseUrl: activeProfile.baseUrl, - apiKey: activeProfile.apiKey, - processEnv: process.env, - }) ?? null - return env - ? { profile: 'openai', env: applySupportedProfileCustomHeaders(activeProfile, env) } - : null - } - if (route.vendorId === 'nearai') { const env = buildOpenAICompatibleStartupEnv(activeProfile) return env ? { profile: 'openai', env } : null @@ -1754,6 +1743,8 @@ export function setActiveProviderProfile( if (startupProfile) { const file = createProfileFile(startupProfile.profile, startupProfile.env) saveProfileFile(file, options) + } else { + deleteProfileFile(options) } return activeProfile diff --git a/src/utils/providerStartupOverrides.test.ts b/src/utils/providerStartupOverrides.test.ts index 18fd38613..554899a5e 100644 --- a/src/utils/providerStartupOverrides.test.ts +++ b/src/utils/providerStartupOverrides.test.ts @@ -23,6 +23,9 @@ describe('clearStartupProviderOverrides', () => { MINIMAX_API_KEY: 'sk-minimax', VENICE_API_KEY: 'sk-venice', LONGCAT_API_KEY: 'sk-longcat', + APISMART_API_KEY: 'sk-apismart', + APISMART_MODEL: 'KIMI_K3', + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart', ANTHROPIC_AUTH_TOKEN: 'stale-proxy-token', KEEP_ME: '1', }, @@ -47,6 +50,9 @@ describe('clearStartupProviderOverrides', () => { MINIMAX_API_KEY: undefined, VENICE_API_KEY: undefined, LONGCAT_API_KEY: undefined, + APISMART_API_KEY: undefined, + APISMART_MODEL: undefined, + CLAUDE_CODE_PROVIDER_ROUTE_ID: undefined, ANTHROPIC_AUTH_TOKEN: undefined, }), }), diff --git a/src/utils/providerStartupOverrides.ts b/src/utils/providerStartupOverrides.ts index 06409dad9..4c79046a0 100644 --- a/src/utils/providerStartupOverrides.ts +++ b/src/utils/providerStartupOverrides.ts @@ -9,6 +9,7 @@ export const STARTUP_PROVIDER_OVERRIDE_ENV_KEYS = [ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_MODEL', @@ -42,6 +43,8 @@ export const STARTUP_PROVIDER_OVERRIDE_ENV_KEYS = [ 'NVIDIA_NIM', 'VENICE_API_KEY', 'LONGCAT_API_KEY', + 'APISMART_API_KEY', + 'APISMART_MODEL', ] as const type GlobalConfigWithEnv = { diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 451c7a612..1cc744402 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -195,6 +195,43 @@ test('non-canonical ApiSmart host falls back to generic OpenAI validation', asyn await expect(getProviderValidationError(process.env)).resolves.toBeNull() }) +test.each(['SUA_CHAVE', 'null', 'undefined', ' NULL '])( + 'ApiSmart validation rejects placeholder dedicated credential %s', + async placeholder => { + await expect( + getProviderValidationError({ + APISMART_API_KEY: placeholder, + APISMART_MODEL: 'KIMI_K3', + }), + ).resolves.toBe('ApiSmart auth is required. Set APISMART_API_KEY.') + }, +) + +test.each(['SUA_CHAVE', 'null', 'undefined', ' NULL '])( + 'ApiSmart compatibility mode rejects placeholder dedicated credential %s', + async placeholder => { + await expect( + getProviderValidationError({ + CLAUDE_CODE_USE_OPENAI: '1', + APISMART_API_KEY: placeholder, + APISMART_MODEL: 'KIMI_K3', + }), + ).resolves.toBe('ApiSmart auth is required. Set APISMART_API_KEY.') + }, +) + +test('ApiSmart placeholder does not override a runnable explicit OpenAI setup', async () => { + await expect( + getProviderValidationError({ + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_API_KEY: 'sk-valid-openai-key', + OPENAI_MODEL: 'gpt-4o', + APISMART_API_KEY: 'SUA_CHAVE', + APISMART_MODEL: 'KIMI_K3', + }), + ).resolves.toBeNull() +}) + test('codex auth error redacts descriptor-declared provider secret values used as model text', async () => { const providerSecret = 'ogw-provider-secret' process.env.CLAUDE_CODE_USE_OPENAI = '1' diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index a643d3419..08bb210da 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -15,6 +15,8 @@ import { getRouteCredentialValue, getRouteDescriptor, getRouteDefaultModel, + hasConfiguredApismartProviderIntent, + hasUsableRouteCredentialEnvValue, isApismartBaseUrl, isCloudflareBaseUrl, isLongcatBaseUrl, @@ -30,7 +32,6 @@ 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 { @@ -126,26 +127,10 @@ 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 hasUsableOpenAICredential(value) - } - - return value.trim() !== '' -} - function hasOpenAICredential(env: NodeJS.ProcessEnv): boolean { return ( - hasUsableCredentialEnvValue(env, 'OPENAI_API_KEYS') || - hasUsableCredentialEnvValue(env, 'OPENAI_API_KEY') + hasUsableRouteCredentialEnvValue('OPENAI_API_KEYS', env.OPENAI_API_KEYS) || + hasUsableRouteCredentialEnvValue('OPENAI_API_KEY', env.OPENAI_API_KEY) ) } @@ -256,6 +241,37 @@ function getRuntimeValidationTarget( return enabledTarget } + const activeRouteId = resolveActiveRouteIdFromEnv(env) + if ( + activeRouteId === 'openai' && + !hasOpenAICredential(env) && + hasConfiguredApismartProviderIntent(env) + ) { + return validationTargets.find( + target => target.descriptor.id === 'apismart', + ) + } + if (activeRouteId) { + const activeRouteTarget = validationTargets.find( + target => target.descriptor.id === activeRouteId, + ) + const activeRouting = activeRouteTarget + ? getValidationRouting(activeRouteTarget) + : undefined + if ( + activeRouteTarget && + !(useOpenAI && activeRouting?.skipWhenUseOpenAI) + ) { + return activeRouteTarget + } + } + + if (hasConfiguredApismartProviderIntent(env)) { + return validationTargets.find( + target => target.descriptor.id === 'apismart', + ) + } + if (!useOpenAI) { return undefined } @@ -264,6 +280,7 @@ function getRuntimeValidationTarget( model: env.OPENAI_MODEL, baseUrl: env.OPENAI_BASE_URL, fallbackModel: getRouteDefaultModel('openai'), + processEnv: env, }) const baseUrlMatchedTarget = validationTargets.find(target => { @@ -360,7 +377,9 @@ function getCredentialEnvValidationError( } if ( - credentialEnvVars.some(envVar => hasUsableCredentialEnvValue(env, envVar)) + credentialEnvVars.some(envVar => + hasUsableRouteCredentialEnvValue(envVar, env[envVar]), + ) ) { return null } @@ -535,6 +554,7 @@ export async function getProviderValidationError( model: env.OPENAI_MODEL, baseUrl: env.OPENAI_BASE_URL, fallbackModel: getRouteDefaultModel('openai'), + processEnv: env, }) const genericRouteValidation = getGenericRouteCredentialValidationError( env, diff --git a/web/src/data/configuration.ts b/web/src/data/configuration.ts index 673664307..bbe6a9d10 100644 --- a/web/src/data/configuration.ts +++ b/web/src/data/configuration.ts @@ -78,6 +78,8 @@ export const envVars: EnvVar[] = [ { name: 'GEMINI_API_KEY', description: 'Gemini API key (the preset reads this, not GOOGLE_API_KEY).' }, { name: 'XAI_API_KEY', description: "xAI Grok key (or sign in with 'openclaude auth xai login')." }, { name: 'AIMLAPI_API_KEY', description: 'AI/ML API key.' }, + { name: 'APISMART_API_KEY', description: 'ApiSmart gateway key; selects the ApiSmart route when no conflicting endpoint is configured.' }, + { name: 'APISMART_MODEL', description: 'Optional ApiSmart model override; defaults to DEEPSEEK_V4_FLASH.' }, { name: 'CLOUDFLARE_API_TOKEN', description: 'Cloudflare Workers AI token.' }, { name: 'NVIDIA_API_KEY', description: 'NVIDIA NIM key.' }, { name: 'NEARAI_API_KEY', description: 'NEAR AI unified gateway key.' },