diff --git a/.env.example b/.env.example index 019303a440..ba1871816e 100644 --- a/.env.example +++ b/.env.example @@ -216,6 +216,11 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # OPENAI_BASE_URL=https://api.aimlapi.com/v1 # OPENAI_MODEL=gpt-4o +# For API Route, the dedicated key enables the OpenAI-compatible route: +# API_ROUTE_API_KEY=your-api-route-key-here +# OPENAI_BASE_URL=https://global.api-route.com/v1 +# OPENAI_MODEL=claude-sonnet-4-6 + # For LLMTR, prefer its dedicated key. Raw env setup must also set # OPENAI_BASE_URL and OPENAI_MODEL. OPENAI_API_KEY remains supported: # LLMTR_API_KEY=your-llmtr-key-here diff --git a/README.md b/README.md index 7c8c67c310..eb93f8c4bc 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,7 @@ Advanced and source-build guides: | LLMTR | `/provider` or OpenAI-compatible env vars | Multi-model gateway at `https://llmtr.com/v1`; `/provider` and `--provider llmtr` default to `deepseek/deepseek-v4-flash`, while raw env setup must set `OPENAI_BASE_URL=https://llmtr.com/v1` and `OPENAI_MODEL`; accepts `LLMTR_API_KEY` or `OPENAI_API_KEY` after the route is selected and discovers tool-capable Chat Completions models from the public catalog | | Command Code | `/provider` or OpenAI-compatible env vars | Hybrid OpenAI-compatible gateway at `https://api.commandcode.ai/provider/v1`; `/provider` and `--provider commandcode` default to `deepseek/deepseek-v4-flash`, while raw env setup must set `OPENAI_BASE_URL=https://api.commandcode.ai/provider/v1` and `OPENAI_MODEL`; requires `CMD_API_KEY`, `COMMANDCODE_API_KEY`, or the official `COMMAND_CODE_API_KEY` after the route is selected and discovers Chat Completions models from the public catalog. Claude models are unsupported by this Chat Completions integration | | 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 | +| API Route | `/provider` or `API_ROUTE_API_KEY` | Uses `https://global.api-route.com/v1`, defaults to `claude-sonnet-4-6`, and supports optional `API_ROUTE_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/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index 9520a85e06..7ed084485e 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -58,6 +58,7 @@ const EXPECTED_PRESETS = [ 'opencode', 'opencode-go', 'clinepass', + 'api-route', ] as const satisfies readonly ProviderPreset[] describe('compatibility mappings', () => { diff --git a/src/integrations/gateways/api-route.test.ts b/src/integrations/gateways/api-route.test.ts new file mode 100644 index 0000000000..98b4824f50 --- /dev/null +++ b/src/integrations/gateways/api-route.test.ts @@ -0,0 +1,174 @@ +import { expect, test } from 'bun:test' + +import { routeForPreset } from '../compatibility.js' +import { getProviderPresetUiMetadata } from '../providerUiMetadata.js' +import { + resolveActiveRouteIdFromEnv, + resolveRouteCredentialValue, + resolveRouteIdFromBaseUrl, +} from '../routeMetadata.js' +import gateway, { mapApiRouteModel } from './api-route.js' + +test('API Route uses a dedicated hybrid OpenAI-compatible gateway contract', () => { + expect(gateway.defaultBaseUrl).toBe('https://global.api-route.com/v1') + expect(gateway.defaultModel).toBe('claude-sonnet-4-6') + expect(gateway.setup.credentialEnvVars).toEqual(['API_ROUTE_API_KEY']) + expect(gateway.setup.dedicatedCredentialsOnly).toBe(false) + expect(gateway.preset?.apiKeyEnvVars).toEqual(['API_ROUTE_API_KEY']) + expect(gateway.validation).toMatchObject({ + kind: 'credential-env', + routing: { matchDefaultBaseUrl: true }, + credentialEnvVars: ['API_ROUTE_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], + }) + expect(gateway.transportConfig).toEqual({ + kind: 'openai-compatible', + openaiShim: { + supportsApiFormatSelection: false, + supportsAuthHeaders: false, + maxTokensField: 'max_tokens', + }, + }) + expect(gateway.catalog?.source).toBe('hybrid') + expect(gateway.catalog?.discovery?.requiresAuth).toBe(true) + expect(gateway.catalog?.models?.map(model => model.apiName)).toEqual([ + 'claude-sonnet-4-6', + ]) +}) + +test('API Route preset uses the existing generic profile path', () => { + expect(routeForPreset('api-route')).toEqual({ + vendorId: 'openai', + gatewayId: 'api-route', + routeId: 'api-route', + }) + expect( + getProviderPresetUiMetadata('api-route', { + API_ROUTE_API_KEY: 'test-api-route-key', + }), + ).toMatchObject({ + apiKey: 'test-api-route-key', + baseUrl: 'https://global.api-route.com/v1', + model: 'claude-sonnet-4-6', + provider: 'api-route', + routeId: 'api-route', + }) + expect( + getProviderPresetUiMetadata('api-route', { + API_ROUTE_API_KEY: 'test-api-route-key', + API_ROUTE_MODEL: 'custom-api-route-model', + OPENAI_MODEL: 'custom-openai-model', + }), + ).toMatchObject({ + model: 'custom-api-route-model', + }) + expect( + getProviderPresetUiMetadata('api-route', { + API_ROUTE_API_KEY: 'test-api-route-key', + OPENAI_MODEL: 'custom-openai-model', + }), + ).toMatchObject({ + model: 'custom-openai-model', + }) +}) + +test('API Route profile credentials use the generic fallback only on the canonical inference URL', () => { + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: 'https://global.api-route.com/v1', + processEnv: { OPENAI_API_KEY: 'saved-profile-key' }, + }), + ).toBe('saved-profile-key') + + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: 'https://proxy.example.com/v1', + processEnv: { OPENAI_API_KEY: 'saved-profile-key' }, + }), + ).toBeUndefined() + + const env = { API_ROUTE_API_KEY: 'secret-key' } + + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: 'https://global.api-route.com/v1', + processEnv: env, + }), + ).toBe('secret-key') + + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: 'http://global.api-route.com/v1', + processEnv: env, + }), + ).toBeUndefined() + + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: 'https://global.api-route.com/v1?tenant=proxy', + processEnv: env, + }), + ).toBeUndefined() + + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: 'https://proxy.example.com/v1', + processEnv: env, + }), + ).toBeUndefined() +}) + +test('API Route discovery maps chat models and drops non-chat IDs', () => { + expect( + mapApiRouteModel({ + id: 'claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + context_length: 200000, + }), + ).toEqual({ + id: 'claude-sonnet-4-6', + apiName: 'claude-sonnet-4-6', + label: 'Claude Sonnet 4.6', + contextWindow: 200000, + }) + + expect( + mapApiRouteModel({ + id: 'gpt-4o-mini', + owned_by: 'openai', + }), + ).toEqual({ + id: 'gpt-4o-mini', + apiName: 'gpt-4o-mini', + label: 'gpt-4o-mini (openai)', + }) + + expect(mapApiRouteModel({ id: 'text-embedding-3-small' })).toBeNull() + expect(mapApiRouteModel({ id: 'whisper-1' })).toBeNull() + expect(mapApiRouteModel({ id: 'dall-e-3' })).toBeNull() + expect(mapApiRouteModel({ id: 'gpt-image-1' })).toBeNull() + expect(mapApiRouteModel({ id: 'sora-turbo' })).toBeNull() + expect(mapApiRouteModel({ id: 'veo-2' })).toBeNull() + expect(mapApiRouteModel(null)).toBeNull() + expect(mapApiRouteModel({})).toBeNull() +}) + +test('API Route route resolution detects endpoints and environment intent', () => { + expect(resolveRouteIdFromBaseUrl('https://global.api-route.com/v1')).toBe( + 'api-route', + ) + expect( + resolveRouteIdFromBaseUrl('https://global.api-route.com/v1/chat/completions'), + ).toBe('api-route') + + expect( + resolveActiveRouteIdFromEnv({ + API_ROUTE_API_KEY: 'test-key', + }), + ).toBe('api-route') +}) diff --git a/src/integrations/gateways/api-route.ts b/src/integrations/gateways/api-route.ts new file mode 100644 index 0000000000..f5b65b773e --- /dev/null +++ b/src/integrations/gateways/api-route.ts @@ -0,0 +1,125 @@ +import { defineCatalog, defineGateway } from '../define.js' + +const NON_CHAT_MODEL_PATTERN = + /(embedding|embed|dall-e|whisper|tts|rerank|moderation|omni-moderation|audio|^gpt-image-|^sora-|^veo-)/i + + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function getTrimmedString( + record: Record, + key: string, +): string | undefined { + const value = record[key] + return typeof value === 'string' ? value.trim() : undefined +} + +function getPositiveInteger(value: unknown): number | undefined { + if ( + typeof value === 'number' && + Number.isFinite(value) && + Number.isInteger(value) && + value > 0 + ) { + return value + } + return undefined +} + +export function mapApiRouteModel(raw: unknown) { + if (!isRecord(raw)) { + return null + } + + const id = getTrimmedString(raw, 'id') + if (!id || NON_CHAT_MODEL_PATTERN.test(id)) { + return null + } + + const name = getTrimmedString(raw, 'name') + const ownedBy = + getTrimmedString(raw, 'owned_by') || getTrimmedString(raw, 'ownedBy') + const label = name || (ownedBy ? `${id} (${ownedBy})` : id) + const contextWindow = + getPositiveInteger(raw.context_length) ?? + getPositiveInteger(raw.context_window) + + return { + id, + apiName: id, + label, + ...(contextWindow !== undefined ? { contextWindow } : {}), + } +} + +const curatedModels = [ + { + id: 'claude-sonnet-4-6', + apiName: 'claude-sonnet-4-6', + aliases: ['sonnet-4.6', 'claude-sonnet'], + modelDescriptorId: 'claude-sonnet-4-6', + label: 'Claude Sonnet 4.6', + contextWindow: 200_000, + maxOutputTokens: 8_192, + }, +] + +const catalog = defineCatalog({ + source: 'hybrid', + discovery: { + kind: 'openai-compatible', + requiresAuth: true, + mapModel: mapApiRouteModel, + }, + discoveryCacheTtl: '1d', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, + models: [...curatedModels], +}) + +export default defineGateway({ + id: 'api-route', + label: 'API Route', + category: 'aggregating', + defaultBaseUrl: 'https://global.api-route.com/v1', + defaultModel: 'claude-sonnet-4-6', + supportsModelRouting: true, + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['API_ROUTE_API_KEY'], + dedicatedCredentialsOnly: false, + }, + startup: { + probeReadiness: 'openai-compatible-models', + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + supportsApiFormatSelection: false, + supportsAuthHeaders: false, + maxTokensField: 'max_tokens', + }, + }, + preset: { + id: 'api-route', + description: 'API Route OpenAI-compatible multi-model gateway', + vendorId: 'openai', + apiKeyEnvVars: ['API_ROUTE_API_KEY'], + modelEnvVars: ['API_ROUTE_MODEL', 'OPENAI_MODEL'], + }, + validation: { + kind: 'credential-env', + routing: { + matchDefaultBaseUrl: true, + matchBaseUrlHosts: ['global.api-route.com'], + }, + credentialEnvVars: ['API_ROUTE_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], + missingCredentialMessage: + 'API Route auth is required. Set API_ROUTE_API_KEY.', + }, + catalog, + usage: { supported: false }, +}) diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 7878d15c42..ee24bd6065 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -20,6 +20,7 @@ import vendorXai from '../vendors/xai.js' import vendorXiaomiMimo from '../vendors/xiaomi-mimo.js' import vendorZai from '../vendors/zai.js' import gatewayAimlapi from '../gateways/aimlapi.js' +import gatewayApiRoute from '../gateways/api-route.js' import gatewayApismart from '../gateways/apismart.js' import gatewayAtlasCloud from '../gateways/atlas-cloud.js' import gatewayAtomicChat from '../gateways/atomic-chat.js' @@ -93,7 +94,7 @@ import modelXai from '../models/xai.js' import modelXiaomiMimo from '../models/xiaomi-mimo.js' export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGemini, vendorLongcat, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] -export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayCommandcode, gatewayConcentrate, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[] +export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayApiRoute, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayCommandcode, gatewayConcentrate, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[] export const ANTHROPIC_PROXY_DESCRIPTORS = [anthropicproxyCustom] as const satisfies readonly AnthropicProxyDescriptor[] export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandFireworks, brandGemini, brandGlm, brandGpt, brandKimi, brandLing, brandLlama, brandLongcat, brandMacaron, brandMinimax, brandMistral, brandNearai, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandTencent, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[] export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelFireworksMerged, modelGemini, modelGlm, modelGpt, modelKimi, modelLing, modelLlama, modelLongcat, modelMacaron, modelMinimax, modelMistral, modelNearai, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelTencent, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[] diff --git a/src/integrations/generated/integrationManifest.generated.ts b/src/integrations/generated/integrationManifest.generated.ts index c1da980ccf..77b29a5b85 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -90,6 +90,21 @@ export const PROVIDER_PRESET_MANIFEST = [ "DASHSCOPE_API_KEY" ] }, + { + "preset": "api-route", + "routeKind": "gateway", + "routeId": "api-route", + "vendorId": "openai", + "gatewayId": "api-route", + "description": "API Route OpenAI-compatible multi-model gateway", + "apiKeyEnvVars": [ + "API_ROUTE_API_KEY" + ], + "modelEnvVars": [ + "API_ROUTE_MODEL", + "OPENAI_MODEL" + ] + }, { "preset": "apismart", "routeKind": "gateway", @@ -591,6 +606,7 @@ export const ORDERED_PROVIDER_PRESETS = [ "anthropic", "dashscope-cn", "dashscope-intl", + "api-route", "apismart", "atlas-cloud", "azure-openai", diff --git a/src/integrations/providerUiMetadata.ts b/src/integrations/providerUiMetadata.ts index bb2fd93935..2340f45293 100644 --- a/src/integrations/providerUiMetadata.ts +++ b/src/integrations/providerUiMetadata.ts @@ -47,7 +47,8 @@ function hasUsableEnvValue(envVar: string, value: string | undefined): boolean { envVar === 'LLMTR_API_KEY' || envVar === 'CMD_API_KEY' || envVar === 'COMMANDCODE_API_KEY' || - envVar === 'COMMAND_CODE_API_KEY' + envVar === 'COMMAND_CODE_API_KEY' || + envVar === 'API_ROUTE_API_KEY' ) { return hasUsableOpenAICredential(value) } diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index e34ba35af0..08ec33ca8a 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -1223,6 +1223,15 @@ test('resolveActiveRouteIdFromEnv honors an explicit OpenAI opt-out over Concent expect(resolveActiveRouteIdFromEnv({ CLAUDE_CODE_USE_OPENAI: '0', CONCENTRATE_API_KEY: 'concentrate-key' })).not.toBe('concentrate') }) +test('resolveActiveRouteIdFromEnv honors an explicit OpenAI opt-out over API Route', () => { + expect( + resolveActiveRouteIdFromEnv({ + CLAUDE_CODE_USE_OPENAI: '0', + API_ROUTE_API_KEY: 'api-route-key', + }), + ).not.toBe('api-route') +}) + test('resolveActiveRouteIdFromEnv refines generic OpenAI profile by Concentrate base URL', () => { expect( resolveActiveRouteIdFromEnv({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index c5fa861cda..d7e520a229 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -246,7 +246,8 @@ function hasUsableEnvCredentialValue( envVar === 'LLMTR_API_KEY' || envVar === 'CMD_API_KEY' || envVar === 'COMMANDCODE_API_KEY' || - envVar === 'COMMAND_CODE_API_KEY' + envVar === 'COMMAND_CODE_API_KEY' || + envVar === 'API_ROUTE_API_KEY' ) { return hasUsableOpenAICredential(value) } @@ -539,6 +540,54 @@ export function isCanonicalCommandcodeInferenceBaseUrl( } } +const API_ROUTE_CANONICAL_INFERENCE_BASE_URL = + 'https://global.api-route.com/v1' + +export function isCanonicalApiRouteInferenceBaseUrl( + value: string | undefined, +): boolean { + const trimmed = value?.trim() + if (!trimmed) { + return false + } + + try { + const canonical = new URL(API_ROUTE_CANONICAL_INFERENCE_BASE_URL) + const candidate = new URL(trimmed) + const normalizePath = (pathname: string): string => + pathname.replace(/\/+$/, '') || '/' + return ( + candidate.protocol === 'https:' && + !candidate.port && + !candidate.search && + !candidate.hash && + candidate.hostname.toLowerCase() === canonical.hostname.toLowerCase() && + normalizePath(candidate.pathname) === normalizePath(canonical.pathname) + ) + } catch { + return false + } +} + +export function isApiRouteBaseUrl(value: string | undefined): boolean { + const trimmed = value?.trim() + if (!trimmed) { + return false + } + + try { + const parsed = new URL(trimmed) + return ( + parsed.protocol === 'https:' && + !parsed.port && + (parsed.hostname.toLowerCase() === 'global.api-route.com' || + parsed.hostname.toLowerCase() === 'api-route.com') + ) + } catch { + return false + } +} + export function getConcentrateBaseUrlOverride( processEnv: NodeJS.ProcessEnv = process.env, ): string | undefined { @@ -1039,6 +1088,18 @@ export function hasConcentrateEnvOnlyProviderIntent( ) } +export function hasApiRouteEnvOnlyProviderIntent( + processEnv: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + hasUsableOpenAICredential(processEnv.API_ROUTE_API_KEY) && + !hasConflictingOpenAIBaseUrlForRoute(processEnv, isApiRouteBaseUrl) && + !(processEnv.CLAUDE_CODE_USE_OPENAI !== undefined && + !isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)) && + hasNoExplicitNonOpenAIProvider(processEnv) + ) +} + export function resolveEnvOnlyProviderRouteId( processEnv: NodeJS.ProcessEnv = process.env, ): @@ -1053,6 +1114,7 @@ export function resolveEnvOnlyProviderRouteId( | 'clinepass' | 'apismart' | 'concentrate' + | 'api-route' | null { if ( hasMiniMaxRouteIntent(processEnv) && @@ -1105,6 +1167,10 @@ export function resolveEnvOnlyProviderRouteId( return 'concentrate' } + if (hasApiRouteEnvOnlyProviderIntent(processEnv)) { + return 'api-route' + } + return null } @@ -1203,6 +1269,13 @@ export function resolveRouteCredentialValue( ) { return undefined } + if ( + routeId === 'api-route' && + options?.baseUrl !== undefined && + !isCanonicalApiRouteInferenceBaseUrl(options.baseUrl) + ) { + return undefined + } return getRouteCredentialValue(routeId, processEnv) } @@ -1327,6 +1400,12 @@ export function resolveRouteIdFromBaseUrl( ) { continue } + if ( + route.id === 'api-route' && + !isCanonicalApiRouteInferenceBaseUrl(baseUrl) + ) { + continue + } return route.id } } @@ -1347,7 +1426,9 @@ export function resolveRouteIdFromBaseUrl( (route.id === 'llmtr' && !isCanonicalLlmtrInferenceBaseUrl(baseUrl)) || (route.id === 'commandcode' && - !isCanonicalCommandcodeInferenceBaseUrl(baseUrl)) + !isCanonicalCommandcodeInferenceBaseUrl(baseUrl)) || + (route.id === 'api-route' && + !isApiRouteBaseUrl(baseUrl)) ) { continue } @@ -1393,6 +1474,9 @@ function profileRouteHonorsBaseUrlBoundary( if (routeId === 'commandcode') { return isCanonicalCommandcodeInferenceBaseUrl(baseUrl) } + if (routeId === 'api-route') { + return isCanonicalApiRouteInferenceBaseUrl(baseUrl) + } if (routeId === 'zai') { return !baseUrl || isCanonicalZaiCodingPlanBaseUrl(baseUrl) } diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 13696df80e..64ce699100 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -86,6 +86,8 @@ const originalEnv = { LONGCAT_API_KEY: process.env.LONGCAT_API_KEY, AIMLAPI_API_KEY: process.env.AIMLAPI_API_KEY, APISMART_API_KEY: process.env.APISMART_API_KEY, + API_ROUTE_API_KEY: process.env.API_ROUTE_API_KEY, + API_ROUTE_MODEL: process.env.API_ROUTE_MODEL, CONCENTRATE_API_KEY: process.env.CONCENTRATE_API_KEY, CONCENTRATE_BASE_URL: process.env.CONCENTRATE_BASE_URL, CONCENTRATE_MODEL: process.env.CONCENTRATE_MODEL, @@ -204,6 +206,8 @@ beforeEach(async () => { delete process.env.LONGCAT_API_KEY delete process.env.AIMLAPI_API_KEY delete process.env.APISMART_API_KEY + delete process.env.API_ROUTE_API_KEY + delete process.env.API_ROUTE_MODEL delete process.env.CONCENTRATE_API_KEY delete process.env.CONCENTRATE_BASE_URL delete process.env.CONCENTRATE_MODEL @@ -267,6 +271,8 @@ afterEach(() => { restoreEnv('LONGCAT_API_KEY', originalEnv.LONGCAT_API_KEY) restoreEnv('AIMLAPI_API_KEY', originalEnv.AIMLAPI_API_KEY) restoreEnv('APISMART_API_KEY', originalEnv.APISMART_API_KEY) + restoreEnv('API_ROUTE_API_KEY', originalEnv.API_ROUTE_API_KEY) + restoreEnv('API_ROUTE_MODEL', originalEnv.API_ROUTE_MODEL) restoreEnv('CONCENTRATE_API_KEY', originalEnv.CONCENTRATE_API_KEY) restoreEnv('CONCENTRATE_BASE_URL', originalEnv.CONCENTRATE_BASE_URL) restoreEnv('CONCENTRATE_MODEL', originalEnv.CONCENTRATE_MODEL) @@ -850,6 +856,206 @@ test('env-only ApiSmart setup withholds its key from a noncanonical same-host UR expect(process.env.OPENAI_API_KEY).toBeUndefined() }) +test('routes env-only API Route requests through the OpenAI-compatible shim', async () => { + let capturedUrl: string | undefined + let capturedHeaders: Headers | undefined + let capturedBody: Record | undefined + + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.GEMINI_API_KEY + delete process.env.GEMINI_MODEL + delete process.env.GEMINI_BASE_URL + delete process.env.GEMINI_AUTH_MODE + process.env.API_ROUTE_API_KEY = 'api-route-test-key' + process.env.API_ROUTE_MODEL = 'claude-sonnet-4-6' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Proxy-Auth: ambient-proxy-secret' + + globalThis.fetch = (async (input, init) => { + capturedUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + capturedHeaders = new Headers(init?.headers) + capturedBody = JSON.parse(String(init?.body)) as Record + + return new Response( + JSON.stringify({ + id: 'chatcmpl-api-route', + model: 'claude-sonnet-4-6', + choices: [ + { + message: { + role: 'assistant', + content: 'api-route ok', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 8, + completion_tokens: 3, + total_tokens: 11, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as typeof fetch + + const client = await getAnthropicClient({ + maxRetries: 0, + model: 'claude-sonnet-4-6', + }) + const response = await client.messages.create({ + max_tokens: 64, + messages: [{ role: 'user', content: 'hello' }], + model: 'claude-sonnet-4-6', + }) + + expect(response.content[0]).toEqual({ + type: 'text', + text: 'api-route ok', + }) + expect(capturedUrl).toBe( + 'https://global.api-route.com/v1/chat/completions', + ) + expect(capturedHeaders?.get('authorization')).toBe( + 'Bearer api-route-test-key', + ) + expect(capturedHeaders?.get('x-proxy-auth')).toBeNull() + expect(capturedBody?.model).toBe('claude-sonnet-4-6') + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') + expect(process.env.OPENAI_BASE_URL).toBe('https://global.api-route.com/v1') + expect(process.env.OPENAI_API_KEY).toBe('api-route-test-key') + expect(process.env.OPENAI_MODEL).toBe('claude-sonnet-4-6') +}) + +test('env-only API Route setup prefers API_ROUTE_API_KEY over stale OPENAI_API_KEYS pool', async () => { + let capturedHeaders: Headers | undefined + + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.GEMINI_API_KEY + delete process.env.GEMINI_MODEL + delete process.env.GEMINI_BASE_URL + delete process.env.GEMINI_AUTH_MODE + process.env.API_ROUTE_API_KEY = 'api-route-correct-key' + process.env.OPENAI_API_KEYS = 'stale-key-1,stale-key-2' + process.env.OPENAI_BASE_URL = 'https://global.api-route.com/v1' + + globalThis.fetch = (async (_input, init) => { + capturedHeaders = new Headers(init?.headers) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-api-route-precedence', + model: 'claude-sonnet-4-6', + choices: [ + { + message: { + role: 'assistant', + content: 'api-route ok', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 8, + completion_tokens: 3, + total_tokens: 11, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as typeof fetch + + const client = await getAnthropicClient({ + maxRetries: 0, + model: 'claude-sonnet-4-6', + }) + await client.messages.create({ + max_tokens: 64, + messages: [{ role: 'user', content: 'hello' }], + model: 'claude-sonnet-4-6', + }) + + expect(capturedHeaders?.get('authorization')).toBe( + 'Bearer api-route-correct-key', + ) + expect(capturedHeaders?.get('authorization')).not.toContain('stale-key') + expect(process.env.OPENAI_API_KEYS).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBe('api-route-correct-key') +}) + +test('env-only API Route setup withholds its key from a noncanonical URL', async () => { + let capturedHeaders: Headers | undefined + + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.GEMINI_API_KEY + delete process.env.GEMINI_MODEL + delete process.env.GEMINI_BASE_URL + delete process.env.GEMINI_AUTH_MODE + process.env.API_ROUTE_API_KEY = 'api-route-test-key' + process.env.OPENAI_BASE_URL = 'https://global.api-route.com/v1/models' + process.env.OPENAI_API_KEYS = 'ambient-key-pool-1,ambient-key-pool-2' + + globalThis.fetch = (async (_input, init) => { + capturedHeaders = new Headers(init?.headers) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-api-route-noncanonical', + model: 'claude-sonnet-4-6', + choices: [ + { + message: { + role: 'assistant', + content: 'api-route noncanonical ok', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 8, + completion_tokens: 3, + total_tokens: 11, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as typeof fetch + + const client = await getAnthropicClient({ + maxRetries: 0, + model: 'claude-sonnet-4-6', + }) + await client.messages.create({ + max_tokens: 64, + messages: [{ role: 'user', content: 'hello' }], + model: 'claude-sonnet-4-6', + }) + + expect(capturedHeaders?.get('authorization')).toBeNull() + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') + expect(process.env.OPENAI_BASE_URL).toBe( + 'https://global.api-route.com/v1/models', + ) + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEYS).toBeUndefined() +}) + test('routes env-only Concentrate requests through the OpenAI-compatible shim', async () => { let capturedUrl: string | undefined let capturedHeaders: Headers | undefined diff --git a/src/services/api/client.ts b/src/services/api/client.ts index ba3e585c31..10bcaef36f 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -43,6 +43,7 @@ import { getLongcatBaseUrlOverride, getMiniMaxBaseUrlOverride, getNearaiBaseUrlOverride, + isCanonicalApiRouteInferenceBaseUrl, isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, getRouteDefaultBaseUrl, @@ -449,6 +450,39 @@ function applyConcentrateEnvOnlyDefaults(): void { delete process.env.ANTHROPIC_CUSTOM_HEADERS } +function applyApiRouteEnvOnlyDefaults(): void { + const baseUrlOverride = + usableProviderConfigEnvValue(process.env.OPENAI_BASE_URL) || + usableProviderConfigEnvValue(process.env.OPENAI_API_BASE) || + undefined + const modelOverride = + usableProviderConfigEnvValue(process.env.API_ROUTE_MODEL) || + usableProviderConfigEnvValue(process.env.OPENAI_MODEL) || + undefined + const apiKey = process.env.API_ROUTE_API_KEY + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = + baseUrlOverride ?? getRouteDefaultBaseUrl('api-route') + process.env.OPENAI_MODEL = modelOverride ?? getRouteDefaultModel('api-route') + if ( + hasUsableOpenAICredential(apiKey) && + isCanonicalApiRouteInferenceBaseUrl(process.env.OPENAI_BASE_URL) + ) { + process.env.OPENAI_API_KEY = apiKey + delete process.env.OPENAI_API_KEYS + } else { + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_API_KEYS + } + delete process.env.OPENAI_API_FORMAT + delete process.env.OPENAI_AZURE_STYLE + delete process.env.OPENAI_AUTH_HEADER + delete process.env.OPENAI_AUTH_SCHEME + delete process.env.OPENAI_AUTH_HEADER_VALUE + delete process.env.ANTHROPIC_CUSTOM_HEADERS +} + function usableProviderConfigEnvValue( value: string | undefined, ): string | undefined { @@ -573,6 +607,8 @@ export async function getAnthropicClient({ envOnlyProviderRouteId === 'apismart' && !useMiniMaxEnvOnlyProvider const useConcentrateEnvOnlyProvider = envOnlyProviderRouteId === 'concentrate' && !useMiniMaxEnvOnlyProvider + const useApiRouteEnvOnlyProvider = + envOnlyProviderRouteId === 'api-route' && !useMiniMaxEnvOnlyProvider if (useMiniMaxEnvOnlyProvider) applyMiniMaxEnvOnlyDefaults(model) if (useXiaomiMimoEnvOnlyProvider) applyXiaomiMimoEnvOnlyDefaults() if (useXaiEnvOnlyProvider) applyXaiEnvOnlyDefaults() @@ -582,6 +618,7 @@ export async function getAnthropicClient({ if (useAimlapiEnvOnlyProvider) applyAimlapiEnvOnlyDefaults() if (useApismartEnvOnlyProvider) applyApismartEnvOnlyDefaults() if (useConcentrateEnvOnlyProvider) applyConcentrateEnvOnlyDefaults() + if (useApiRouteEnvOnlyProvider) applyApiRouteEnvOnlyDefaults() const containerId = process.env.CLAUDE_CODE_CONTAINER_ID const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID diff --git a/src/services/api/openaiShim/requestExecutor.test.ts b/src/services/api/openaiShim/requestExecutor.test.ts index 9d3e05918e..52b962d86e 100644 --- a/src/services/api/openaiShim/requestExecutor.test.ts +++ b/src/services/api/openaiShim/requestExecutor.test.ts @@ -21,6 +21,7 @@ const originalEnv = { OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, LLMTR_API_KEY: process.env.LLMTR_API_KEY, + API_ROUTE_API_KEY: process.env.API_ROUTE_API_KEY, CMD_API_KEY: process.env.CMD_API_KEY, COMMANDCODE_API_KEY: process.env.COMMANDCODE_API_KEY, COMMAND_CODE_API_KEY: process.env.COMMAND_CODE_API_KEY, @@ -455,6 +456,7 @@ beforeEach(async () => { process.env.OPENAI_API_KEY = 'test-key' delete process.env.OPENAI_API_KEYS delete process.env.LLMTR_API_KEY + delete process.env.API_ROUTE_API_KEY delete process.env.CMD_API_KEY delete process.env.COMMANDCODE_API_KEY delete process.env.COMMAND_CODE_API_KEY @@ -508,6 +510,7 @@ afterEach(() => { restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) restoreEnv('LLMTR_API_KEY', originalEnv.LLMTR_API_KEY) + restoreEnv('API_ROUTE_API_KEY', originalEnv.API_ROUTE_API_KEY) restoreEnv('CMD_API_KEY', originalEnv.CMD_API_KEY) restoreEnv('COMMANDCODE_API_KEY', originalEnv.COMMANDCODE_API_KEY) restoreEnv('COMMAND_CODE_API_KEY', originalEnv.COMMAND_CODE_API_KEY) @@ -588,6 +591,22 @@ test('Concentrate selection prefers its dedicated key over a generic OPENAI_API_ expect(captured.authorization).toBe('Bearer concentrate-key') }) +test('API Route selection prefers its dedicated key over a generic OPENAI_API_KEYS pool', async () => { + process.env.API_ROUTE_API_KEY = 'api-route-key' + process.env.OPENAI_API_KEYS = 'generic-openai-key-a,generic-openai-key-b' + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_MODEL + + const result = applyProviderFlag('api-route', []) + expect(result.error).toBeUndefined() + + const captured = await captureChatCompletionRequest('claude-sonnet-4-6') + + expect(captured.url).toBe('https://global.api-route.com/v1/chat/completions') + expect(captured.authorization).toBe('Bearer api-route-key') +}) + test('selected LLMTR route sends LLMTR_API_KEY through the generic route credential resolver', async () => { process.env.LLMTR_API_KEY = 'llmtr-key' delete process.env.OPENAI_API_KEYS diff --git a/src/services/api/openaiShim/requestExecutor.ts b/src/services/api/openaiShim/requestExecutor.ts index 373c70b54c..9203533ef5 100644 --- a/src/services/api/openaiShim/requestExecutor.ts +++ b/src/services/api/openaiShim/requestExecutor.ts @@ -327,6 +327,7 @@ export async function executeOpenAIRequest( requestProcessEnv.ATLAS_CLOUD_API_KEY, requestProcessEnv.APISMART_API_KEY, requestProcessEnv.CONCENTRATE_API_KEY, + requestProcessEnv.API_ROUTE_API_KEY, requestProcessEnv.NEARAI_API_KEY, requestProcessEnv.FIREWORKS_API_KEY, requestProcessEnv.LONGCAT_API_KEY, diff --git a/src/utils/envFile.test.ts b/src/utils/envFile.test.ts index 741b427616..fffd5e99f8 100644 --- a/src/utils/envFile.test.ts +++ b/src/utils/envFile.test.ts @@ -24,6 +24,8 @@ const TEST_ENV_KEYS = [ 'COMMAND_CODE_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', + 'API_ROUTE_API_KEY', + 'API_ROUTE_MODEL', 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENAI_AZURE_STYLE', @@ -296,6 +298,49 @@ describe('loadEnvFile', () => { }) }) + it('loads documented API Route env-only provider setup values', () => { + const filePath = writeTempEnvFile([ + 'API_ROUTE_API_KEY=api-route-key', + 'API_ROUTE_MODEL=claude-sonnet-4-6', + ].join('\n')) + + const loaded = loadEnvFile(filePath) + + expect(process.env.API_ROUTE_API_KEY).toBe('api-route-key') + expect(process.env.API_ROUTE_MODEL).toBe('claude-sonnet-4-6') + expect(loaded).toEqual({ + API_ROUTE_API_KEY: 'api-route-key', + API_ROUTE_MODEL: 'claude-sonnet-4-6', + }) + }) + + it('preserves existing API Route env values over provider env-file values', () => { + process.env.API_ROUTE_API_KEY = 'shell-api-route-key' + process.env.API_ROUTE_MODEL = 'shell-api-route-model' + const filePath = writeTempEnvFile([ + 'API_ROUTE_API_KEY=file-api-route-key', + 'API_ROUTE_MODEL=file-api-route-model', + ].join('\n')) + + const loaded = loadEnvFile(filePath) + + expect(process.env.API_ROUTE_API_KEY).toBe('shell-api-route-key') + expect(process.env.API_ROUTE_MODEL).toBe('shell-api-route-model') + expect(loaded).toEqual({}) + }) + + it('rejects API Route env files atomically when any variable is unsupported', () => { + const filePath = writeTempEnvFile([ + 'API_ROUTE_API_KEY=api-route-key', + 'UNSAFE_API_ROUTE_SETTING=blocked', + ].join('\n')) + + expect(() => loadEnvFile(filePath)).toThrow( + 'Unsupported variable UNSAFE_API_ROUTE_SETTING in --provider-env-file', + ) + expect(process.env.API_ROUTE_API_KEY).toBeUndefined() + }) + it('loads the dedicated LLMTR credential without selecting a route', () => { const filePath = writeTempEnvFile('LLMTR_API_KEY=llmtr-key') diff --git a/src/utils/envFile.ts b/src/utils/envFile.ts index 664dd46748..abccb28338 100644 --- a/src/utils/envFile.ts +++ b/src/utils/envFile.ts @@ -17,6 +17,8 @@ const ALLOWED_ENV_FILE_KEYS = new Set([ 'ATLAS_CLOUD_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', + 'API_ROUTE_API_KEY', + 'API_ROUTE_MODEL', 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_DEFAULT_REGION', 'AWS_PROFILE', diff --git a/src/utils/managedEnv.test.ts b/src/utils/managedEnv.test.ts index f1a3013c08..2663b00a98 100644 --- a/src/utils/managedEnv.test.ts +++ b/src/utils/managedEnv.test.ts @@ -18,7 +18,10 @@ import { loadEnvFile, rememberLoadedEnvFileValues, } from './envFile.js' -import { applyConfigEnvironmentVariables } from './managedEnv.js' +import { + applyConfigEnvironmentVariables, + applySafeConfigEnvironmentVariables, +} from './managedEnv.js' const ENV_KEYS = [ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', @@ -28,6 +31,8 @@ const ENV_KEYS = [ 'CMD_API_KEY', 'COMMANDCODE_API_KEY', 'COMMAND_CODE_API_KEY', + 'API_ROUTE_API_KEY', + 'API_ROUTE_MODEL', 'OPENAI_API_KEY', 'OPENAI_AZURE_STYLE', 'OPENAI_BASE_URL', @@ -95,6 +100,36 @@ function writeTempEnvFile(content: string): string { return filePath } +describe('host-managed API Route settings boundary', () => { + for (const [name, applySettings] of [ + ['pre-trust', applySafeConfigEnvironmentVariables], + ['post-trust', applyConfigEnvironmentVariables], + ] as const) { + it(`preserves host routing against API Route settings during ${name} merge`, () => { + process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'host-model' + process.env.OPENAI_API_KEY = 'host-key' + saveGlobalConfig(current => ({ + ...current, + env: { + API_ROUTE_API_KEY: 'settings-api-route-key', + API_ROUTE_MODEL: 'settings-api-route-model', + }, + })) + + applySettings() + + expect(process.env.API_ROUTE_API_KEY).toBeUndefined() + expect(process.env.API_ROUTE_MODEL).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1') + expect(process.env.OPENAI_MODEL).toBe('host-model') + expect(process.env.OPENAI_API_KEY).toBe('host-key') + }) + } +}) + describe('applyConfigEnvironmentVariables', () => { it('preserves the complete host-managed Command Code route against settings env', () => { process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1' diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index d0008343fd..629293b00b 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -43,6 +43,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CMD_API_KEY', 'COMMANDCODE_API_KEY', 'COMMAND_CODE_API_KEY', + 'API_ROUTE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_API_KEYS', 'CLAUDE_CODE_SKIP_BEDROCK_AUTH', @@ -65,6 +66,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION', 'CLAUDE_CODE_SUBAGENT_MODEL', + 'API_ROUTE_MODEL', 'OPENAI_MODEL', ]) diff --git a/src/utils/model/model.openai-shim-providers.test.ts b/src/utils/model/model.openai-shim-providers.test.ts index 9ce00d8423..4809c11272 100644 --- a/src/utils/model/model.openai-shim-providers.test.ts +++ b/src/utils/model/model.openai-shim-providers.test.ts @@ -80,6 +80,8 @@ const SAVED_ENV = { CONCENTRATE_API_KEY: process.env.CONCENTRATE_API_KEY, CONCENTRATE_BASE_URL: process.env.CONCENTRATE_BASE_URL, CONCENTRATE_MODEL: process.env.CONCENTRATE_MODEL, + API_ROUTE_API_KEY: process.env.API_ROUTE_API_KEY, + API_ROUTE_MODEL: process.env.API_ROUTE_MODEL, OPENAI_MODEL: process.env.OPENAI_MODEL, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, CODEX_API_KEY: process.env.CODEX_API_KEY, @@ -142,6 +144,8 @@ beforeEach(async () => { delete process.env.CONCENTRATE_API_KEY delete process.env.CONCENTRATE_BASE_URL delete process.env.CONCENTRATE_MODEL + delete process.env.API_ROUTE_API_KEY + delete process.env.API_ROUTE_MODEL delete process.env.OPENAI_MODEL delete process.env.OPENAI_BASE_URL delete process.env.CODEX_API_KEY @@ -434,6 +438,39 @@ test.each(['null', 'undefined', ' '])( }, ) +test('API Route selects its dedicated model before client normalization', async () => { + saveGlobalConfig(current => ({ ...current, model: 'stale-other-provider-model' })) + process.env.API_ROUTE_API_KEY = 'api-route-test' + process.env.API_ROUTE_MODEL = 'custom-api-route-model' + + const { + getDefaultMainLoopModelSetting, + getMainLoopModel, + getUserSpecifiedModelSetting, + } = await importFreshModelModule() + expect(getUserSpecifiedModelSetting()).toBe('custom-api-route-model') + expect(getDefaultMainLoopModelSetting()).toBe('custom-api-route-model') + expect(getMainLoopModel()).toBe('custom-api-route-model') +}) + +test('API Route honors its legacy OpenAI model fallback before client normalization', async () => { + saveGlobalConfig(current => ({ ...current, model: 'stale-other-provider-model' })) + process.env.API_ROUTE_API_KEY = 'api-route-test' + process.env.OPENAI_MODEL = 'legacy-api-route-model' + + const { getMainLoopModel, getUserSpecifiedModelSetting } = + await importFreshModelModule() + expect(getUserSpecifiedModelSetting()).toBe('legacy-api-route-model') + expect(getMainLoopModel()).toBe('legacy-api-route-model') +}) + +test('API Route uses its descriptor default before client normalization', async () => { + process.env.API_ROUTE_API_KEY = 'api-route-test' + + const { getDefaultMainLoopModelSetting } = await importFreshModelModule() + expect(getDefaultMainLoopModelSetting()).toBe('claude-sonnet-4-6') +}) + 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 84119809e6..a3138ed254 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -69,6 +69,17 @@ function getAllowedConcentrateConfigModel(): string | undefined { return undefined } +function getAllowedApiRouteConfigModel(): string | undefined { + for (const value of [ + process.env.API_ROUTE_MODEL, + process.env.OPENAI_MODEL, + ]) { + const model = getUsableProviderConfigModel(value) + if (model && isModelAllowed(model)) return model + } + return undefined +} + export function getSmallFastModel(): ModelName { if (process.env.ANTHROPIC_SMALL_FAST_MODEL) return process.env.ANTHROPIC_SMALL_FAST_MODEL if (isCustomAnthropicProvider()) { @@ -173,7 +184,9 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined { // consume its dedicated setting here rather than falling through to a // saved model from an unrelated provider. ? getAllowedConcentrateConfigModel() - : (provider === 'gemini' ? process.env.GEMINI_MODEL : undefined) || + : activeRouteId === 'api-route' + ? getAllowedApiRouteConfigModel() + : (provider === 'gemini' ? process.env.GEMINI_MODEL : undefined) || (provider === 'mistral' ? process.env.MISTRAL_MODEL : undefined) || (provider === 'minimax' ? getMiniMaxModelEnv() : undefined) || (isOpenAIShimProvider ? process.env.OPENAI_MODEL : undefined) || @@ -404,6 +417,13 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias { 'deepseek-v4-flash' ) } + if (resolveActiveRouteIdFromEnv(process.env) === 'api-route') { + return ( + getAllowedApiRouteConfigModel() || + getRouteDefaultModel('api-route') || + 'claude-sonnet-4-6' + ) + } // Custom Anthropic-compatible endpoints intentionally retain the legacy // firstParty provider category, so prefer their explicitly configured model // before the subscription and PAYG defaults below. diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index 8605b4c0b2..55932ab12c 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -47,6 +47,8 @@ const ENV_KEYS = [ 'ATLAS_CLOUD_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', + 'API_ROUTE_API_KEY', + 'API_ROUTE_MODEL', 'CONCENTRATE_API_KEY', 'CONCENTRATE_BASE_URL', 'CONCENTRATE_MODEL', @@ -101,6 +103,8 @@ const RESET_KEYS = [ 'ATLAS_CLOUD_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', + 'API_ROUTE_API_KEY', + 'API_ROUTE_MODEL', 'CONCENTRATE_API_KEY', 'CONCENTRATE_BASE_URL', 'CONCENTRATE_MODEL', @@ -1848,3 +1852,150 @@ describe('applyProviderFlag - concentrate', () => { expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined() }) }) + +describe('applyProviderFlag - api-route', () => { + test('sets API Route OpenAI-compatible defaults and mirrors API_ROUTE_API_KEY', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + + const result = applyProviderFlag('api-route', []) + + expect(result.error).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') + expect(process.env.OPENAI_BASE_URL).toBe('https://global.api-route.com/v1') + expect(process.env.OPENAI_API_KEY).toBe('api-route-secret-key') + expect(process.env.OPENAI_MODEL).toBe('claude-sonnet-4-6') + }) + + test('uses API_ROUTE_MODEL from env when --model is not provided', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + process.env.API_ROUTE_MODEL = 'custom-api-route-model' + + applyProviderFlag('api-route', []) + + expect(process.env.OPENAI_MODEL).toBe('custom-api-route-model') + }) + + test('explicit --model overrides API_ROUTE_MODEL', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + process.env.API_ROUTE_MODEL = 'custom-api-route-model' + + applyProviderFlag('api-route', ['--model', 'flag-model']) + + expect(process.env.OPENAI_MODEL).toBe('flag-model') + }) + + test.each(['', ' ', 'null', 'undefined', ' NULL '])( + 'does not contaminate OPENAI_MODEL with unusable API_ROUTE_MODEL %j', + placeholder => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + process.env.API_ROUTE_MODEL = placeholder + + applyProviderFlag('api-route', []) + + expect(process.env.OPENAI_MODEL).toBe('claude-sonnet-4-6') + }, + ) + + test('does not forward the dedicated key to a preserved custom base URL', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + process.env.OPENAI_BASE_URL = 'https://custom-proxy.example.com/v1' + + applyProviderFlag('api-route', []) + + expect(process.env.OPENAI_BASE_URL).toBe( + 'https://custom-proxy.example.com/v1', + ) + expect(process.env.OPENAI_API_KEY).toBeUndefined() + }) + + test('dedicated key overrides a lingering OPENAI_API_KEY from another provider', () => { + process.env.OPENAI_API_KEY = 'existing-openai-key' + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + + applyProviderFlag('api-route', []) + + expect(process.env.OPENAI_API_KEY).toBe('api-route-secret-key') + }) + + test('clears OPENAI_API_KEYS when dedicated API Route key is set on canonical endpoint', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + process.env.OPENAI_API_KEYS = 'stale-key-1,stale-key-2' + + applyProviderFlag('api-route', []) + + expect(process.env.OPENAI_API_KEY).toBe('api-route-secret-key') + expect(process.env.OPENAI_API_KEYS).toBeUndefined() + }) + + test('clears a stale OPENAI_API_KEY when no API Route key is set', () => { + delete process.env.API_ROUTE_API_KEY + process.env.OPENAI_API_KEY = 'existing-openai-key' + + applyProviderFlag('api-route', []) + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + }) + + test.each(['SUA_CHAVE', 'sua_chave', 'null', 'undefined', ' NULL '])( + 'does not mirror placeholder API Route credential %s', + placeholder => { + process.env.API_ROUTE_API_KEY = placeholder + + applyProviderFlag('api-route', []) + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + }, + ) + + test('clears unsupported OpenAI shim settings from a previous route', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + process.env.OPENAI_API_FORMAT = 'responses' + process.env.OPENAI_AUTH_HEADER = 'x-api-key' + process.env.OPENAI_AUTH_SCHEME = 'raw' + process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-value' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Proxy-Auth: proxy-secret' + + applyProviderFlag('api-route', []) + + expect(process.env.OPENAI_API_FORMAT).toBeUndefined() + expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined() + expect(process.env.OPENAI_AUTH_SCHEME).toBeUndefined() + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBeUndefined() + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined() + }) + + test('clears a copied API Route key from OPENAI_API_KEY when switching to another provider', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + process.env.OPENAI_API_KEY = 'api-route-secret-key' + + applyProviderFlag('openai', []) + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + }) + + test('switching from API Route to Ollama replaces the route identity and credentials', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + + applyProviderFlag('api-route', []) + applyProviderFlag('ollama', []) + + expect(process.env.API_ROUTE_API_KEY).toBeUndefined() + expect(process.env.API_ROUTE_MODEL).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('http://localhost:11434/v1') + expect(process.env.OPENAI_API_KEY).toBe('ollama') + expect(resolveActiveRouteIdFromEnv(process.env)).toBe('ollama') + }) + + test('switching from API Route to OpenAI replaces the known gateway endpoint', () => { + process.env.API_ROUTE_API_KEY = 'api-route-secret-key' + + applyProviderFlag('api-route', []) + applyProviderFlag('openai', []) + + expect(process.env.API_ROUTE_API_KEY).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1') + expect(resolveActiveRouteIdFromEnv(process.env)).toBe('openai') + }) +}) + + diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index e35cdbb522..b36ac116a2 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -31,6 +31,7 @@ import { import { PRESET_VENDOR_MAP } from '../integrations/compatibility.js' import { getCommandcodeChatCompletionsModelError } from '../integrations/gateways/commandcode.js' import { + isCanonicalApiRouteInferenceBaseUrl, isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, isCanonicalLlmtrInferenceBaseUrl, @@ -401,6 +402,9 @@ export function applyProviderFlag( : process.env.OPENAI_API_KEY !== undefined && process.env.OPENAI_API_KEY === process.env.APISMART_API_KEY ? 'apismart' + : process.env.OPENAI_API_KEY !== undefined && + process.env.OPENAI_API_KEY === process.env.API_ROUTE_API_KEY + ? 'api-route' : process.env.OPENAI_API_KEY !== undefined && process.env.OPENAI_API_KEY === process.env.CONCENTRATE_API_KEY ? 'concentrate' @@ -470,6 +474,8 @@ export function applyProviderFlag( // OpenAI-compatible env-only route override it later in startup. delete process.env.APISMART_API_KEY delete process.env.APISMART_MODEL + delete process.env.API_ROUTE_API_KEY + delete process.env.API_ROUTE_MODEL delete process.env.ANTHROPIC_AUTH_TOKEN delete process.env.ANTHROPIC_CUSTOM_HEADERS break @@ -517,6 +523,8 @@ export function applyProviderFlag( // known gateway endpoint, but preserve a user-supplied custom endpoint. delete process.env.APISMART_API_KEY delete process.env.APISMART_MODEL + delete process.env.API_ROUTE_API_KEY + delete process.env.API_ROUTE_MODEL applyOpenAIBaseUrlDefault(provider, defaultBaseUrl) if (effectiveModel) process.env.OPENAI_MODEL = effectiveModel break @@ -888,7 +896,9 @@ export function applyProviderFlag( (provider === 'llmtr' && isCanonicalLlmtrInferenceBaseUrl(getConfiguredOpenAIBaseUrl())) || (provider === 'commandcode' && - isCanonicalCommandcodeInferenceBaseUrl(getConfiguredOpenAIBaseUrl())) + isCanonicalCommandcodeInferenceBaseUrl(getConfiguredOpenAIBaseUrl())) || + (provider === 'api-route' && + isCanonicalApiRouteInferenceBaseUrl(getConfiguredOpenAIBaseUrl())) ) { clearUnsupportedOpenAIShimSettings(provider) delete process.env.ANTHROPIC_CUSTOM_HEADERS @@ -918,6 +928,35 @@ export function applyProviderFlag( delete process.env.OPENAI_API_KEY } } + if ( + provider === 'api-route' && + isCanonicalApiRouteInferenceBaseUrl(getConfiguredOpenAIBaseUrl()) + ) { + const dedicatedApiRouteKey = hasUsableOpenAICredential( + process.env.API_ROUTE_API_KEY, + ) + ? process.env.API_ROUTE_API_KEY + : undefined + if (dedicatedApiRouteKey) { + process.env.OPENAI_API_KEY = dedicatedApiRouteKey + delete process.env.OPENAI_API_KEYS + } else { + delete process.env.OPENAI_API_KEY + } + } + if (provider === 'api-route') { + const apiRouteModel = usableProviderModelEnvValue( + process.env.API_ROUTE_MODEL, + ) + if (apiRouteModel) { + process.env.OPENAI_MODEL = apiRouteModel + } else { + process.env.OPENAI_MODEL ??= + usableProviderModelEnvValue(process.env.OPENAI_MODEL) || + defaultModel || + 'claude-sonnet-4-6' + } + } if (defaultModel) { process.env.OPENAI_MODEL ??= defaultModel } @@ -925,9 +964,9 @@ export function applyProviderFlag( break } - // A provider flag selects a complete route for this process. Concentrate's - // dedicated variables are another source of route identity, so leaving them - // behind can re-select Concentrate after a later OpenAI-compatible provider + // A provider flag selects a complete route for this process. Dedicated + // variables are another source of route identity, so leaving them + // behind can re-select a provider after a later OpenAI-compatible provider // has applied its defaults. Keep their lifecycle at the selection boundary, // rather than relying on individual switch branches to remember cleanup. // This runs only after the selected branch succeeds, so an invalid @@ -935,6 +974,10 @@ export function applyProviderFlag( if (provider !== 'concentrate') { clearConcentrateProviderState() } + if (provider !== 'api-route') { + delete process.env.API_ROUTE_API_KEY + delete process.env.API_ROUTE_MODEL + } return {} } diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 4148c58633..4231fc0c01 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -3402,6 +3402,37 @@ test('openai launch removes legacy generic credentials from a noncanonical Conce } }) +test('API Route saved profile relaunch uses persisted generic key/model over ambient dedicated state', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'api-route', + OPENAI_BASE_URL: 'https://global.api-route.com/v1', + OPENAI_MODEL: 'saved-api-route-model', + OPENAI_API_KEY: 'saved-api-route-key', + }), + goal: 'balanced', + processEnv: { + API_ROUTE_API_KEY: 'ambient-api-route-key', + API_ROUTE_MODEL: 'ambient-api-route-model', + }, + }) + + assert.equal(env.API_ROUTE_API_KEY, undefined) + assert.equal(env.API_ROUTE_MODEL, undefined) + assert.equal(env.OPENAI_BASE_URL, 'https://global.api-route.com/v1') + assert.equal(env.OPENAI_MODEL, 'saved-api-route-model') + assert.equal(env.OPENAI_API_KEY, 'saved-api-route-key') + assert.equal( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: env.OPENAI_BASE_URL, + processEnv: env, + }), + 'saved-api-route-key', + ) +}) + test('buildStartupEnvFromProfile preserves Concentrate env-only setup over a saved profile', async () => { const env = await buildStartupEnvFromProfile({ persisted: profile('openai', { @@ -3424,6 +3455,50 @@ test('buildStartupEnvFromProfile preserves Concentrate env-only setup over a sav assert.equal(resolveActiveRouteIdFromEnv(env), 'concentrate') }) +test('applyStartupEnvFromProfile preserves API Route env-only setup over a saved profile', async () => { + const processEnv: NodeJS.ProcessEnv = { + API_ROUTE_API_KEY: 'api-route-env-only-key', + API_ROUTE_MODEL: 'api-route-env-only-model', + } + + const error = await applyStartupEnvFromProfile({ + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEY: 'sk-persisted', + }), + processEnv, + }) + + assert.equal(error, null) + assert.equal(processEnv.API_ROUTE_API_KEY, 'api-route-env-only-key') + assert.equal(processEnv.API_ROUTE_MODEL, 'api-route-env-only-model') + assert.equal(processEnv.OPENAI_BASE_URL, undefined) + assert.equal(processEnv.OPENAI_API_KEY, undefined) + assert.equal(resolveActiveRouteIdFromEnv(processEnv), 'api-route') +}) + +test('applyStartupEnvFromProfile preserves API Route key-only descriptor-default intent', async () => { + const processEnv: NodeJS.ProcessEnv = { + API_ROUTE_API_KEY: 'api-route-env-only-key', + } + + const error = await applyStartupEnvFromProfile({ + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEY: 'sk-persisted', + }), + processEnv, + }) + + assert.equal(error, null) + assert.equal(processEnv.API_ROUTE_API_KEY, 'api-route-env-only-key') + assert.equal(processEnv.OPENAI_MODEL, undefined) + assert.equal(resolveActiveRouteIdFromEnv(processEnv), 'api-route') + assert.equal(await getProviderValidationError(processEnv), null) +}) + test('buildConcentrateProfileEnv prefers CONCENTRATE_MODEL over OPENAI_MODEL', () => { const env = buildConcentrateProfileEnv({ apiKey: 'concentrate-secret-key', @@ -3473,3 +3548,64 @@ test('buildOpenAIProfileEnv does not stamp a generic key as Concentrate credenti assert.equal(env?.OPENAI_API_KEY, 'generic-proxy-key') assert.equal(env?.CONCENTRATE_API_KEY, undefined) }) + +test('openai launch withholds ambient API Route credentials from a keyless proxy profile on restart', async () => { + for (const ambient of [ + { OPENAI_API_KEY: 'ambient-api-route-key' }, + { OPENAI_API_KEYS: 'ambient-key-a,ambient-key-b' }, + ]) { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'api-route', + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'claude-sonnet-4-6', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + ...ambient, + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'api-route') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.OPENAI_API_KEYS, undefined) + } + + const canonical = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'api-route', + OPENAI_BASE_URL: 'https://global.api-route.com/v1', + OPENAI_MODEL: 'claude-sonnet-4-6', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://global.api-route.com/v1', + OPENAI_API_KEY: 'ambient-api-route-key', + }, + }) + assert.equal(canonical.OPENAI_API_KEY, 'ambient-api-route-key') +}) + +test('buildStartupEnvFromProfile applies saved API Route proxy profile even when ambient credentials are present', async () => { + const processEnv: NodeJS.ProcessEnv = { + OPENAI_API_KEY: 'ambient-key', + OPENAI_API_KEYS: 'ambient-key-1,ambient-key-2', + } + + const env = await buildStartupEnvFromProfile({ + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'api-route', + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'claude-sonnet-4-6', + }), + processEnv, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1') + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'api-route') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.OPENAI_API_KEYS, undefined) +}) diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 1ca51e2221..3c71f1950e 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -25,6 +25,7 @@ import { getErrnoCode } from './errors.js' import { getRouteDefaultBaseUrl, getRouteDefaultModel, + isCanonicalApiRouteInferenceBaseUrl, isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, isCanonicalLlmtrInferenceBaseUrl, @@ -118,6 +119,8 @@ const PROFILE_ENV_KEYS = [ 'ATLAS_CLOUD_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', + 'API_ROUTE_API_KEY', + 'API_ROUTE_MODEL', 'NEARAI_API_KEY', 'FIREWORKS_API_KEY', 'LONGCAT_API_KEY', @@ -219,6 +222,7 @@ export type ProfileEnv = { MIMO_API_KEY?: string ATLAS_CLOUD_API_KEY?: string APISMART_API_KEY?: string + API_ROUTE_API_KEY?: string CLINE_API_KEY?: string NEARAI_API_KEY?: string FIREWORKS_API_KEY?: string @@ -1490,6 +1494,7 @@ function hasConcreteProviderSelection( // before the env-only resolver has a chance to see it. return ( sanitizeApiKey(processEnv.APISMART_API_KEY) !== undefined || + sanitizeApiKey(processEnv.API_ROUTE_API_KEY) !== undefined || sanitizeApiKey(processEnv.FIREWORKS_API_KEY) !== undefined || sanitizeApiKey(processEnv.NEARAI_API_KEY) !== undefined || sanitizeApiKey(processEnv.LONGCAT_API_KEY) !== undefined || @@ -2179,12 +2184,17 @@ export async function buildLaunchEnv(options: { effectiveOpenAIRouteId === 'commandcode' && !!env.OPENAI_BASE_URL?.trim() && !isCanonicalCommandcodeInferenceBaseUrl(env.OPENAI_BASE_URL) + const isNoncanonicalApiRouteLaunch = + effectiveOpenAIRouteId === 'api-route' && + !!env.OPENAI_BASE_URL?.trim() && + !isCanonicalApiRouteInferenceBaseUrl(env.OPENAI_BASE_URL) const isNoncanonicalDedicatedOpenAILaunch = isNoncanonicalAimlapiLaunch || isNoncanonicalApismartLaunch || isNoncanonicalConcentrateLaunch || isNoncanonicalLlmtrLaunch || - isNoncanonicalCommandcodeLaunch + isNoncanonicalCommandcodeLaunch || + isNoncanonicalApiRouteLaunch if (isNoncanonicalDedicatedOpenAILaunch) { delete env.OPENAI_API_KEY delete env.OPENAI_API_KEYS @@ -2508,12 +2518,18 @@ export async function buildStartupEnvFromProfile(options?: { persisted.env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'commandcode' && !!persisted.env.OPENAI_BASE_URL?.trim() && !isCanonicalCommandcodeInferenceBaseUrl(persisted.env.OPENAI_BASE_URL) + const persistedApiRouteProxy = + persisted?.profile === 'openai' && + persisted.env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'api-route' && + !!persisted.env.OPENAI_BASE_URL?.trim() && + !isCanonicalApiRouteInferenceBaseUrl(persisted.env.OPENAI_BASE_URL) if ( hasConcreteProviderSelection(processEnv) && !persistedApismartProxy && !persistedConcentrateProxy && !persistedLlmtrProxy && - !persistedCommandcodeProxy + !persistedCommandcodeProxy && + !persistedApiRouteProxy ) { return processEnv } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index d4848f9204..29eee7fedd 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -14,6 +14,7 @@ async function importFreshProvidersModule() { const originalEnv = { ...process.env } const originalCwd = process.cwd() +const originalFetch = globalThis.fetch const RESTORED_KEYS = [ 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', @@ -73,6 +74,8 @@ const RESTORED_KEYS = [ 'ATLAS_CLOUD_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', + 'API_ROUTE_API_KEY', + 'API_ROUTE_MODEL', 'LLMTR_API_KEY', 'CMD_API_KEY', 'COMMANDCODE_API_KEY', @@ -136,6 +139,7 @@ afterEach(() => { } mock.restore() + globalThis.fetch = originalFetch mockConfigState = createMockConfigState() process.chdir(originalCwd) if (testConfigDir) { @@ -297,6 +301,17 @@ function buildConcentrateProfile(overrides: Partial = {}): Prov }) } +function buildApiRouteProfile(overrides: Partial = {}): ProviderProfile { + return buildProfile({ + provider: 'api-route', + name: 'API Route', + baseUrl: 'https://global.api-route.com/v1', + model: 'claude-sonnet-4-6', + apiKey: 'api-route-test-key', + ...overrides, + }) +} + function buildLlmtrProfile(overrides: Partial = {}): ProviderProfile { return buildProfile({ provider: 'llmtr', @@ -370,6 +385,147 @@ describe('applyProviderProfileToProcessEnv', () => { ).toBe('selected-new') }, 20_000) + test('API Route saved profile mirrors its dedicated credential and route identity', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'api-route', + name: 'API Route', + baseUrl: 'https://global.api-route.com/v1', + model: 'saved-api-route-model', + apiKey: 'saved-api-route-key', + }), + ) + + expect(process.env.API_ROUTE_API_KEY).toBe('saved-api-route-key') + expect(process.env.API_ROUTE_MODEL).toBeUndefined() + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('api-route') + expect(process.env.OPENAI_BASE_URL).toBe('https://global.api-route.com/v1') + expect(process.env.OPENAI_API_KEY).toBe('saved-api-route-key') + expect(process.env.OPENAI_MODEL).toBe('saved-api-route-model') + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: process.env.OPENAI_BASE_URL, + processEnv: process.env, + }), + ).toBe('saved-api-route-key') + }, 20_000) + + test('API Route saved profile clears competing dedicated env and uses its saved key/model', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + process.env.API_ROUTE_API_KEY = 'ambient-api-route-key' + process.env.API_ROUTE_MODEL = 'ambient-api-route-model' + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'api-route', + name: 'API Route', + baseUrl: 'https://global.api-route.com/v1', + model: 'saved-api-route-model', + apiKey: 'saved-api-route-key', + }), + ) + + expect(process.env.API_ROUTE_API_KEY).toBe('saved-api-route-key') + expect(process.env.API_ROUTE_MODEL).toBeUndefined() + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('api-route') + expect(process.env.OPENAI_API_KEY).toBe('saved-api-route-key') + expect(process.env.OPENAI_MODEL).toBe('saved-api-route-model') + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: process.env.OPENAI_BASE_URL, + processEnv: process.env, + }), + ).toBe('saved-api-route-key') + }, 20_000) + + test('switching away from API Route clears dedicated route state', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + process.env.API_ROUTE_API_KEY = 'ambient-api-route-key' + process.env.API_ROUTE_MODEL = 'ambient-api-route-model' + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'anthropic', + name: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-6', + apiKey: 'anthropic-key', + }), + ) + + expect(process.env.API_ROUTE_API_KEY).toBeUndefined() + expect(process.env.API_ROUTE_MODEL).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.ANTHROPIC_API_KEY).toBe('anthropic-key') + }, 20_000) + + test('API Route profile without a base URL uses the canonical endpoint and ambient key', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + process.env.API_ROUTE_API_KEY = 'ambient-api-route-key' + + applyProviderProfileToProcessEnv( + buildApiRouteProfile({ baseUrl: undefined, apiKey: undefined }), + ) + + expect(process.env.OPENAI_BASE_URL).toBe('https://global.api-route.com/v1') + expect(process.env.OPENAI_API_KEY).toBe('ambient-api-route-key') + expect(process.env.API_ROUTE_API_KEY).toBe('ambient-api-route-key') + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('api-route') + }, 20_000) + + test('retargeted API Route profile withholds its dedicated credential and skips discovery', async () => { + const fetchMock = mock(() => Promise.resolve(new Response('{}'))) + globalThis.fetch = fetchMock as unknown as typeof fetch + const { setActiveProviderProfile } = await importFreshProviderProfileModules() + const profile = buildApiRouteProfile({ + id: 'api_route_proxy', + baseUrl: 'https://proxy.example/v1', + }) + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [profile], + })) + + setActiveProviderProfile(profile.id, { configDir: testConfigDir ?? undefined }) + await Promise.resolve() + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.API_ROUTE_API_KEY).toBeUndefined() + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('api-route') + expect(fetchMock).not.toHaveBeenCalled() + + const persisted = JSON.parse( + readFileSync(join(testConfigDir!, '.openclaude-profile.json'), 'utf8'), + ) + expect(persisted.env).toEqual({ + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'api-route', + OPENAI_BASE_URL: 'https://proxy.example/v1', + OPENAI_MODEL: 'claude-sonnet-4-6', + }) + + const { buildStartupEnvFromProfile } = await import( + `./providerProfile.js?ts=${Date.now()}-${Math.random()}` + ) + const startupEnv = await buildStartupEnvFromProfile({ + persisted, + processEnv: { + API_ROUTE_API_KEY: 'ambient-api-route-key', + OPENAI_API_KEY: 'ambient-api-route-key', + }, + }) + expect(startupEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('api-route') + expect(startupEnv.OPENAI_API_KEY).toBeUndefined() + expect(startupEnv.API_ROUTE_API_KEY).toBeUndefined() + }, 20_000) + test('keyless canonical LLMTR profile adopts its ambient dedicated key', async () => { const { applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() @@ -4256,6 +4412,60 @@ describe('setActiveProviderProfile', () => { } }) + test('keyed canonical API Route profiles persist and restart with route identity', 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 apiRouteProfile = buildApiRouteProfile({ + id: 'api_route_profile', + }) + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [apiRouteProfile], + })) + + setActiveProviderProfile(apiRouteProfile.id, { configDir }) + const persisted = JSON.parse( + readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + ) + + expect(persisted.env).toMatchObject({ + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'api-route', + OPENAI_BASE_URL: 'https://global.api-route.com/v1', + OPENAI_API_KEY: 'api-route-test-key', + API_ROUTE_API_KEY: 'api-route-test-key', + }) + + const { buildStartupEnvFromProfile } = await import( + `./providerProfile.js?ts=${Date.now()}-${Math.random()}` + ) + const startupEnv = await buildStartupEnvFromProfile({ + persisted, + processEnv: {}, + }) + + expect(startupEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('api-route') + expect(startupEnv.OPENAI_BASE_URL).toBe('https://global.api-route.com/v1') + expect(startupEnv.OPENAI_API_KEY).toBe('api-route-test-key') + expect( + resolveRouteCredentialValue({ + routeId: 'api-route', + baseUrl: startupEnv.OPENAI_BASE_URL, + processEnv: startupEnv, + }), + ).toBe('api-route-test-key') + } finally { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + rmSync(configDir, { recursive: true, force: true }) + } + }) + test('keyed canonical LLMTR profiles persist their saved dedicated credential across restart', 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 7a8a92ac77..2d606a25ab 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -55,6 +55,7 @@ import { getRouteDefaultBaseUrl, isCloudflareBaseUrl, isClinePassBaseUrl, + isCanonicalApiRouteInferenceBaseUrl, isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, isCanonicalLlmtrInferenceBaseUrl, @@ -184,6 +185,15 @@ function isConcentrateProfile(profile: ProviderProfile): boolean { return !baseUrl || isCanonicalConcentrateInferenceBaseUrl(baseUrl) } +function isApiRouteProfile(profile: ProviderProfile): boolean { + const { route } = resolveProfileCompatibility(profile.provider) + if (route.routeId !== 'api-route') { + return false + } + const baseUrl = profile.baseUrl?.trim() + return !baseUrl || isCanonicalApiRouteInferenceBaseUrl(baseUrl) +} + function isLlmtrProfile(profile: ProviderProfile): boolean { const { route } = resolveProfileCompatibility(profile.provider) if (route.routeId !== 'llmtr') { @@ -297,6 +307,7 @@ export function resolveProfileCapabilityRouteId( (providerRouteId === 'cloudflare' || providerRouteId === 'longcat' || providerRouteId === 'concentrate' || + providerRouteId === 'api-route' || providerRouteId === 'llmtr' || providerRouteId === 'commandcode') && baseUrl && @@ -306,6 +317,8 @@ export function resolveProfileCapabilityRouteId( ? isLongcatBaseUrl(baseUrl) : providerRouteId === 'concentrate' ? isCanonicalConcentrateInferenceBaseUrl(baseUrl) + : providerRouteId === 'api-route' + ? isCanonicalApiRouteInferenceBaseUrl(baseUrl) : providerRouteId === 'commandcode' ? isCanonicalCommandcodeInferenceBaseUrl(baseUrl) : isCanonicalLlmtrInferenceBaseUrl(baseUrl)) @@ -941,6 +954,10 @@ function isProcessEnvAlignedWithProfile( ? !includeApiKey || sameOptionalEnvValue(processEnv.APISMART_API_KEY, profile.apiKey) : true) && + (isApiRouteProfile(profile) + ? !includeApiKey || + sameOptionalEnvValue(processEnv.API_ROUTE_API_KEY, profile.apiKey) + : true) && (isCommandcodeProfile(profile) ? !includeApiKey || sameOptionalEnvValue( @@ -1110,9 +1127,11 @@ export function applyProviderProfileToProcessEnv( ? getRouteDefaultBaseUrl('apismart') ?? profile.baseUrl : route.routeId === 'concentrate' && !profile.baseUrl?.trim() ? getRouteDefaultBaseUrl('concentrate') ?? profile.baseUrl - : route.routeId === 'commandcode' && !profile.baseUrl?.trim() - ? getRouteDefaultBaseUrl('commandcode') ?? profile.baseUrl - : profile.baseUrl + : route.routeId === 'api-route' && !profile.baseUrl?.trim() + ? getRouteDefaultBaseUrl('api-route') ?? profile.baseUrl + : route.routeId === 'commandcode' && !profile.baseUrl?.trim() + ? getRouteDefaultBaseUrl('commandcode') ?? profile.baseUrl + : profile.baseUrl const openAIProfileEnv: ProfileEnv = { OPENAI_BASE_URL: normalizedProfileBaseUrl, OPENAI_MODEL: primaryModel, @@ -1143,6 +1162,8 @@ export function applyProviderProfileToProcessEnv( route.routeId === 'apismart' && !isApismartProfile(profile) const withholdRetargetedConcentrateCredential = route.routeId === 'concentrate' && !isConcentrateProfile(profile) + const withholdRetargetedApiRouteCredential = + route.routeId === 'api-route' && !isApiRouteProfile(profile) const withholdRetargetedLlmtrCredential = route.routeId === 'llmtr' && !isLlmtrProfile(profile) const withholdRetargetedCommandcodeCredential = @@ -1151,6 +1172,7 @@ export function applyProviderProfileToProcessEnv( profile.apiKey && !withholdRetargetedApismartCredential && !withholdRetargetedConcentrateCredential && + !withholdRetargetedApiRouteCredential && !withholdRetargetedLlmtrCredential && !withholdRetargetedCommandcodeCredential ) { @@ -1193,6 +1215,9 @@ export function applyProviderProfileToProcessEnv( if (isConcentrateProfile(profile)) { openAIProfileEnv.CONCENTRATE_API_KEY = profile.apiKey } + if (isApiRouteProfile(profile)) { + openAIProfileEnv.API_ROUTE_API_KEY = profile.apiKey + } if (isCommandcodeProfile(profile)) { openAIProfileEnv.CMD_API_KEY = profile.apiKey } @@ -1277,6 +1302,17 @@ export function applyProviderProfileToProcessEnv( } } } + if (route.routeId === 'api-route') { + openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'api-route' + if (isApiRouteProfile(profile) && !profile.apiKey) { + const ambientApiRouteKey = sanitizeApiKey(process.env.API_ROUTE_API_KEY) + if (ambientApiRouteKey) { + openAIProfileEnv.OPENAI_API_KEY = + openAIProfileEnv.OPENAI_API_KEY ?? ambientApiRouteKey + openAIProfileEnv.API_ROUTE_API_KEY = ambientApiRouteKey + } + } + } // A keyless canonical LLMTR profile may use the dedicated credential from // the ambient environment. Profile application clears all managed provider // variables before installing this object, so adopt it here while the @@ -1593,12 +1629,18 @@ function buildOpenAICompatibleStartupEnv( return null } const activeProfileRouteId = resolveProfileRoute(activeProfile.provider).routeId + const normalizedBaseUrl = + activeProfileRouteId === 'api-route' && !activeProfile.baseUrl?.trim() + ? getRouteDefaultBaseUrl('api-route') ?? activeProfile.baseUrl + : activeProfile.baseUrl const withholdRetargetedApismartCredential = activeProfileRouteId === 'apismart' && !isApismartProfile(activeProfile) const withholdRetargetedConcentrateCredential = activeProfileRouteId === 'concentrate' && !isConcentrateProfile(activeProfile) + const withholdRetargetedApiRouteCredential = + activeProfileRouteId === 'api-route' && !isApiRouteProfile(activeProfile) const withholdRetargetedLlmtrCredential = activeProfileRouteId === 'llmtr' && !isLlmtrProfile(activeProfile) const withholdRetargetedCommandcodeCredential = @@ -1608,6 +1650,7 @@ function buildOpenAICompatibleStartupEnv( activeProfile.provider === 'aimlapi' || resolveRouteIdFromBaseUrl(activeProfile.baseUrl) === 'aimlapi' const isConcentrateProfileFlag = isConcentrateProfile(activeProfile) + const isApiRouteProfileFlag = isApiRouteProfile(activeProfile) const isLlmtrProfileFlag = isLlmtrProfile(activeProfile) const isCommandcodeProfileFlag = isCommandcodeProfile(activeProfile) @@ -1615,13 +1658,14 @@ function buildOpenAICompatibleStartupEnv( activeProfile.apiKey && !withholdRetargetedApismartCredential && !withholdRetargetedConcentrateCredential && + !withholdRetargetedApiRouteCredential && !withholdRetargetedLlmtrCredential && !withholdRetargetedCommandcodeCredential ) { const strictEnv = buildOpenAIProfileEnv({ goal: 'balanced', model: activeProfile.model, - baseUrl: activeProfile.baseUrl, + baseUrl: normalizedBaseUrl, apiKey: activeProfile.apiKey, apiFormat: activeProfile.apiFormat, azureStyle: activeProfile.azureStyle ? '1' : undefined, @@ -1655,6 +1699,10 @@ function buildOpenAICompatibleStartupEnv( if (isConcentrateProfileFlag) { strictEnv.CONCENTRATE_API_KEY = activeProfile.apiKey } + if (isApiRouteProfileFlag) { + strictEnv.API_ROUTE_API_KEY = activeProfile.apiKey + strictEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'api-route' + } if (isLlmtrProfileFlag) { strictEnv.LLMTR_API_KEY = activeProfile.apiKey } @@ -1688,7 +1736,7 @@ function buildOpenAICompatibleStartupEnv( } const env: ProfileEnv = { - OPENAI_BASE_URL: activeProfile.baseUrl, + OPENAI_BASE_URL: normalizedBaseUrl, OPENAI_MODEL: getPrimaryModel(activeProfile.model), ...(activeProfile.apiFormat ? { OPENAI_API_FORMAT: activeProfile.apiFormat } : {}), ...(activeProfile.azureStyle ? { OPENAI_AZURE_STYLE: '1' } : {}), @@ -1719,6 +1767,9 @@ function buildOpenAICompatibleStartupEnv( if (activeProfileRouteId === 'concentrate') { env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'concentrate' } + if (activeProfileRouteId === 'api-route') { + env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'api-route' + } if (activeProfileRouteId === 'llmtr') { env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'llmtr' } @@ -1729,6 +1780,7 @@ function buildOpenAICompatibleStartupEnv( activeProfile.apiKey && !withholdRetargetedApismartCredential && !withholdRetargetedConcentrateCredential && + !withholdRetargetedApiRouteCredential && !withholdRetargetedLlmtrCredential && !withholdRetargetedCommandcodeCredential ) { @@ -1760,6 +1812,9 @@ function buildOpenAICompatibleStartupEnv( if (isConcentrateProfileFlag) { env.CONCENTRATE_API_KEY = activeProfile.apiKey } + if (isApiRouteProfileFlag) { + env.API_ROUTE_API_KEY = activeProfile.apiKey + } if (isLlmtrProfileFlag) { env.LLMTR_API_KEY = activeProfile.apiKey } @@ -1788,6 +1843,12 @@ function buildOpenAICompatibleStartupEnv( if (isCloudflareBaseUrl(activeProfile.baseUrl)) { env.CLOUDFLARE_API_TOKEN = activeProfile.apiKey } + } else if (isApiRouteProfileFlag) { + const ambientApiRouteKey = sanitizeApiKey(process.env.API_ROUTE_API_KEY) + if (ambientApiRouteKey) { + env.OPENAI_API_KEY = ambientApiRouteKey + env.API_ROUTE_API_KEY = ambientApiRouteKey + } } else { delete env.OPENAI_API_KEY } @@ -2020,6 +2081,9 @@ function triggerStartupDiscoveryRefreshForProfile( if (route.routeId === 'apismart' && !isApismartProfile(profile)) { return } + if (route.routeId === 'api-route' && !isApiRouteProfile(profile)) { + return + } if (route.routeId === 'llmtr' && !isLlmtrProfile(profile)) { return } diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 69919cd8cd..2c362cd7c0 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -38,6 +38,8 @@ const ENV_KEYS = [ 'COMMANDCODE_API_KEY', 'COMMAND_CODE_API_KEY', 'APISMART_API_KEY', + 'API_ROUTE_API_KEY', + 'API_ROUTE_MODEL', 'CONCENTRATE_API_KEY', 'CONCENTRATE_BASE_URL', 'CONCENTRATE_MODEL', @@ -479,6 +481,46 @@ test.each(['SUA_CHAVE', 'sua_chave', 'null', 'undefined', ' NULL '])( }, ) +test('API Route saved generic profile credential validates on the canonical endpoint', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://global.api-route.com/v1' + process.env.OPENAI_MODEL = 'saved-model' + process.env.OPENAI_API_KEY = 'saved-profile-key' + delete process.env.API_ROUTE_API_KEY + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('API Route key-only setup validates before client defaults are applied', async () => { + process.env.API_ROUTE_API_KEY = 'api-route-key' + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('noncanonical same-host base rejects a selected API_ROUTE_API_KEY route', async () => { + process.env.OPENAI_BASE_URL = 'https://global.api-route.com/staging/v1' + process.env.API_ROUTE_API_KEY = 'api-route-key' + + await expect(getProviderValidationError(process.env)).resolves.toBe( + 'API Route credentials require the canonical https://global.api-route.com/v1 endpoint.', + ) +}) + +test.each(['SUA_CHAVE', 'sua_chave', 'null', 'undefined', ' NULL '])( + 'API Route validation rejects placeholder API_ROUTE_API_KEY %s', + async placeholder => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://global.api-route.com/v1' + process.env.API_ROUTE_API_KEY = placeholder + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_API_KEYS + + await expect(getProviderValidationError(process.env)).resolves.toBe( + 'API Route auth is required. Set API_ROUTE_API_KEY.', + ) + }, +) + 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 888418e63e..c1dec35c11 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -16,6 +16,7 @@ import { getRouteCredentialValue, getRouteDescriptor, getRouteDefaultModel, + isCanonicalApiRouteInferenceBaseUrl, isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, isCanonicalCommandcodeInferenceBaseUrl, @@ -144,6 +145,7 @@ function hasUsableCredentialEnvValue( envVar === 'OPENAI_API_KEY' || envVar === 'AIMLAPI_API_KEY' || envVar === 'APISMART_API_KEY' || + envVar === 'API_ROUTE_API_KEY' || envVar === 'CONCENTRATE_API_KEY' || envVar === 'LLMTR_API_KEY' || envVar === 'CMD_API_KEY' || @@ -270,12 +272,13 @@ function getRuntimeValidationTarget( return enabledTarget } - // The documented CONCENTRATE_API_KEY-only setup is routed before the client - // applies its default base URL. Select its descriptor directly so startup - // validates the dedicated credential, including a noncanonical dedicated - // base URL, instead of returning early for an unset OpenAI mode. - if (resolveActiveRouteIdFromEnv(env) === 'concentrate') { - return validationTargets.find(target => target.descriptor.id === 'concentrate') + // The documented CONCENTRATE_API_KEY-only and API_ROUTE_API_KEY-only setup + // is routed before the client applies its default base URL. Select its descriptor + // directly so startup validates the dedicated credential, including a noncanonical + // dedicated base URL, instead of returning early for an unset OpenAI mode. + const activeRouteId = resolveActiveRouteIdFromEnv(env) + if (activeRouteId === 'concentrate' || activeRouteId === 'api-route') { + return validationTargets.find(target => target.descriptor.id === activeRouteId) } if (!useOpenAI) { @@ -306,6 +309,8 @@ function getRuntimeValidationTarget( !isCanonicalApismartInferenceBaseUrl(request.baseUrl)) || (target.descriptor.id === 'concentrate' && !isCanonicalConcentrateInferenceBaseUrl(request.baseUrl)) || + (target.descriptor.id === 'api-route' && + !isCanonicalApiRouteInferenceBaseUrl(request.baseUrl)) || (target.descriptor.id === 'commandcode' && !isCanonicalCommandcodeInferenceBaseUrl(request.baseUrl))) ) { @@ -435,6 +440,17 @@ async function getDescriptorValidationError( return 'Concentrate credentials require the canonical https://api.concentrate.ai/v1 endpoint.' } + const apiRouteBaseUrl = + env.OPENAI_BASE_URL?.trim() || + env.OPENAI_API_BASE?.trim() + if ( + target.descriptor.id === 'api-route' && + apiRouteBaseUrl && + !isCanonicalApiRouteInferenceBaseUrl(apiRouteBaseUrl) + ) { + return 'API Route credentials require the canonical https://global.api-route.com/v1 endpoint.' + } + switch (validation.kind) { case 'credential-env': return getCredentialEnvValidationError(validation, env, options.request) diff --git a/web/src/data/providers.ts b/web/src/data/providers.ts index 70c373763d..44e46e6ffe 100644 --- a/web/src/data/providers.ts +++ b/web/src/data/providers.ts @@ -204,6 +204,14 @@ export const providers: Provider[] = [ envVars: ['APISMART_API_KEY', 'APISMART_MODEL'], notes: 'Unified OpenAI-compatible gateway at https://gw.apismart.ai/v1; defaults to DEEPSEEK_V4_FLASH with hybrid /v1/models discovery.', }, + { + id: 'api-route', + name: 'API Route', + group: 'gateways', + setup: '/provider or env vars', + envVars: ['API_ROUTE_API_KEY', 'API_ROUTE_MODEL'], + notes: 'OpenAI-compatible multi-model gateway at https://global.api-route.com/v1; defaults to claude-sonnet-4-6 with hybrid /v1/models discovery.', + }, { id: 'cloudflare', name: 'Cloudflare Workers AI',