diff --git a/README.md b/README.md index 9e6438ab0..56aa6de51 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,7 @@ Advanced and source-build guides: | 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 | | GMI Cloud | `/provider`, `--provider gmi-cloud`, or env vars | Uses `GMI_API_KEY` at `https://api.gmi-serving.com/v1` and defaults to `Qwen/Qwen3.8-Max`; `--provider gmi-cloud` also reads an optional `GMI_MODEL`, and the model list comes from authenticated discovery | +| OrcaRouter | `/provider`, or `ORCAROUTER_API_KEY` with `--provider orcarouter` | OpenAI-compatible model routing gateway at `https://api.orcarouter.ai/v1`, defaults to `openai/gpt-5.5`, with `orcarouter/auto` smart routing and authenticated model discovery. Existing environment setups must replace `OPENAI_API_KEY` with `ORCAROUTER_API_KEY`, even when a custom authentication header supplies the request credential. Custom headers remain supported. | | 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 5b9a4f2e0..4bfda8430 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -195,6 +195,7 @@ const PRESET_ORDER = [ 'OpenCode Go', 'OpenCode Zen', 'OpenRouter', + 'OrcaRouter', 'Together AI', 'Venice', 'xAI', diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index d1b98d26b..f3d5cd813 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -35,6 +35,7 @@ const EXPECTED_PRESETS = [ 'merge-gateway', 'azure-openai', 'openrouter', + 'orcarouter', 'lmstudio', 'dashscope-cn', 'dashscope-intl', diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index f198aae4e..191c4fd2e 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -1636,13 +1636,18 @@ const customAuthCases: Array<{ { name: 'invalid header', value: undefined, scheme: undefined, expected: { authorization: 'Bearer pooled-key' } }, ] for (const auth of customAuthCases) { - test(`custom discovery auth ${auth.name}/${auth.scheme}/${auth.value}`, async () => { + test.each([ + ['custom', 'https://custom.example/v1'], + ['orcarouter', 'https://api.orcarouter.ai/v1'], + ] as const)(`%s discovery auth ${auth.name}/${auth.scheme}/${auth.value}`, async (routeId, baseUrl) => { const { refreshStartupDiscoveryForActiveRoute, discoverModelsForRoute, getDiscoveryCacheKey } = await loadDiscoveryServiceModule() const { resolveModelRuntimeLimits } = await import('./runtimeMetadata.js') const env: NodeJS.ProcessEnv = { CLAUDE_CODE_USE_OPENAI: '1', - OPENAI_BASE_URL: 'https://custom.example/v1', - OPENAI_API_KEYS: 'pooled-key,second-key', + OPENAI_BASE_URL: baseUrl, + ...(routeId === 'custom' + ? { OPENAI_API_KEYS: 'pooled-key,second-key' } + : { ORCAROUTER_API_KEY: 'pooled-key' }), OPENAI_AUTH_HEADER: auth.name, OPENAI_AUTH_HEADER_VALUE: auth.value, OPENAI_AUTH_SCHEME: auth.scheme, @@ -1660,22 +1665,24 @@ for (const auth of customAuthCases) { new Headers(init?.headers).forEach((value, name) => { headers[name] = value }) requests.push({ url: String(input), headers }) return Promise.resolve(new Response(JSON.stringify({ - data: [{ id: 'remote-model', context_window: 123456 }], + data: [{ id: 'remote-model', [routeId === 'custom' ? 'context_window' : 'context_length']: 123456 }], }), { status: 200, headers: { 'Content-Type': 'application/json' } })) }) as unknown as typeof globalThis.fetch) - const first = await refreshStartupDiscoveryForActiveRoute({ processEnv: env }) + const first = routeId === 'custom' + ? await refreshStartupDiscoveryForActiveRoute({ processEnv: env }) + : await discoverModelsForRoute(routeId, { baseUrl, processEnv: env, headers: callerHeaders }) expect(first?.source).toBe('network') - expect(requests).toEqual([{ url: 'https://custom.example/v1/models', headers: { ...auth.expected, 'x-tenant': 'tenant-a' } }]) + expect(requests).toEqual([{ url: `${baseUrl}/models`, headers: { ...auth.expected, 'x-tenant': 'tenant-a' } }]) // The picker supplies the resolved credential; startup supplies its env. - const cached = await discoverModelsForRoute('custom', { + const cached = await discoverModelsForRoute(routeId, { baseUrl: env.OPENAI_BASE_URL, apiKey: 'pooled-key', processEnv: env, headers: callerHeaders, }) expect(cached?.source).toBe('cache') expect(requests).toHaveLength(1) expect(resolveModelRuntimeLimits({ model: 'remote-model', processEnv: env }).contextWindow).toBe(123456) const identityOptions = { baseUrl: env.OPENAI_BASE_URL, apiKey: 'pooled-key', processEnv: env } - expect(getDiscoveryCacheKey('custom', { ...identityOptions, headers: callerHeaders })).toBe( - getDiscoveryCacheKey('custom', { ...identityOptions, headers: { 'X-Tenant': 'tenant-a' } }), + expect(getDiscoveryCacheKey(routeId, { ...identityOptions, headers: callerHeaders })).toBe( + getDiscoveryCacheKey(routeId, { ...identityOptions, headers: { 'X-Tenant': 'tenant-a' } }), ) // An explicit custom-auth value selects a different account/catalog. if (auth.name === 'Authorization') { @@ -1715,3 +1722,60 @@ test('custom startup discovery retains its auth when another profile activates', }, }).contextWindow).toBe(123456) }) + +test('Orca discovery preserves reported context through the cache and runtime', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + const { resolveModelRuntimeLimits } = await import('./runtimeMetadata.js') + const env: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_OPENAI: '1', OPENAI_BASE_URL: 'https://api.orcarouter.ai/v1', + ORCAROUTER_API_KEY: 'orca-key', + } + const fetchMock = mock((_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer orca-key') + return Promise.resolve(Response.json({ data: [ + { id: 'anthropic/claude-sonnet-4.6', context_length: 1_000_000 }, + { id: 'discovered-chat-model', context_length: 262_144 }, + ] })) + }) + setMockFetch(fetchMock as unknown as typeof globalThis.fetch) + const options = { baseUrl: env.OPENAI_BASE_URL, apiKey: 'orca-key' } + expect((await discoverModelsForRoute('orcarouter', { + ...options, forceRefresh: true, + }))?.source).toBe('network') + expect((await discoverModelsForRoute('orcarouter', options))?.source).toBe('cache') + expect(fetchMock).toHaveBeenCalledTimes(1) + for (const [model, contextWindow] of [ + ['anthropic/claude-sonnet-4.6', 1_000_000], + ['discovered-chat-model', 262_144], + ] as const) { + expect(resolveModelRuntimeLimits({ model, processEnv: env }).contextWindow) + .toBe(contextWindow) + } +}) + +// Capture the caller's identity before disk cache I/O, including the normal +// dedicated-key path when no explicit auth override exists. +test.each([undefined, 'profile-a-secret'])( + 'Orca discovery retains launch identity across a profile change (override: %s)', async override => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + const { resolveModelRuntimeLimits } = await import('./runtimeMetadata.js') + const env: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_OPENAI: '1', OPENAI_BASE_URL: 'https://api.orcarouter.ai/v1', + ORCAROUTER_API_KEY: 'profile-a-key', + ...(override ? { OPENAI_AUTH_HEADER: 'Authorization', OPENAI_AUTH_HEADER_VALUE: override } : {}), + } + const launchEnv = { ...env } + const headers: Array = [] + setMockFetch(mock((_input, init) => { + headers.push(new Headers(init?.headers).get('authorization')) + return Promise.resolve(Response.json({ data: [{ id: 'account-model', context_length: 123456 }] })) + }) as unknown as typeof globalThis.fetch) + const pending = discoverModelsForRoute('orcarouter', { processEnv: env, baseUrl: env.OPENAI_BASE_URL }) + env.ORCAROUTER_API_KEY = 'profile-b-key' + if (override) env.OPENAI_AUTH_HEADER_VALUE = 'profile-b-secret' + expect((await pending)?.source).toBe('network') + expect(headers).toEqual([`Bearer ${override ?? 'profile-a-key'}`]) + expect(resolveModelRuntimeLimits({ model: 'account-model', processEnv: launchEnv }).contextWindow).toBe(123456) + expect(resolveModelRuntimeLimits({ model: 'account-model', processEnv: env }).contextWindow).toBeUndefined() + }, +) diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 0fd6aa0d0..2e92208d0 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -174,12 +174,18 @@ function getRouteBaseUrl( return options?.baseUrl ?? getRouteDescriptor(routeId)?.defaultBaseUrl } -// Keep custom endpoint authentication identical for requests and cache readers. +function supportsCustomDiscoveryAuth(routeId: string): boolean { + // Orca's named route preserves the custom authentication its endpoint + // supported before registration, including cache and in-flight identity. + return routeId === 'custom' || routeId === 'orcarouter' +} + +// Keep configurable authentication identical for requests and cache readers. function getCustomDiscoveryAuth( routeId: string, options?: { apiKey?: string; baseUrl?: string; processEnv?: NodeJS.ProcessEnv }, ): Record | undefined { - if (routeId !== 'custom') return undefined + if (!supportsCustomDiscoveryAuth(routeId)) return undefined const env = options?.processEnv ?? process.env const name = env.OPENAI_AUTH_HEADER?.trim() if (!name || !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) return undefined @@ -241,7 +247,7 @@ function getRouteDiscoveryApiKey( routeId, baseUrl, processEnv: - routeId === 'custom' ? options?.processEnv ?? process.env : process.env, + supportsCustomDiscoveryAuth(routeId) ? options?.processEnv ?? process.env : process.env, }), ) } @@ -260,7 +266,7 @@ export async function resolveDiscoveryRequestOptions< resolverOptions?: { refreshXaiOAuth?: boolean }, ): Promise { const next = { ...(options ?? {}) } as T - if (routeId === 'custom') { + if (supportsCustomDiscoveryAuth(routeId)) { // Profile activation can change the global env while cache I/O is pending. next.processEnv = { ...(next.processEnv ?? process.env) } return next @@ -548,7 +554,7 @@ export async function discoverModelsForRoute( try { const discoveryOptions = await resolveDiscoveryRequestOptions( routeId, - routeId === 'custom' ? cachedOptions : options, + supportsCustomDiscoveryAuth(routeId) ? cachedOptions : options, ) const discoveryCacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) const discovered = await runDiscovery(routeId, discoveryOptions) @@ -635,7 +641,7 @@ export async function refreshStartupDiscoveryForRoute( } return discoverModelsForRoute(routeId, { - ...(routeId === 'custom' ? cachedOptions : options), + ...(supportsCustomDiscoveryAuth(routeId) ? cachedOptions : options), forceRefresh: true, }) } diff --git a/src/integrations/gateways/orcarouter.test.ts b/src/integrations/gateways/orcarouter.test.ts new file mode 100644 index 000000000..d820ecde7 --- /dev/null +++ b/src/integrations/gateways/orcarouter.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test' + +import orcarouter from './orcarouter.js' + +describe('orcarouter gateway', () => { + test('uses hybrid discovery with dedicated credentials', () => { + expect(orcarouter.id).toBe('orcarouter') + expect(orcarouter.catalog?.source).toBe('hybrid') + expect(orcarouter.catalog?.discovery?.kind).toBe('openai-compatible') + expect(orcarouter.setup.dedicatedCredentialsOnly).toBe(true) + expect(orcarouter.setup.credentialEnvVars).toEqual(['ORCAROUTER_API_KEY']) + expect(orcarouter.defaultBaseUrl).toBe('https://api.orcarouter.ai/v1') + expect(orcarouter.defaultModel).toBe('openai/gpt-5.5') + }) + + test('curated catalog exposes auto routing plus flagship models', () => { + const models = orcarouter.catalog?.models ?? [] + expect(models.some(model => model.apiName === 'orcarouter/auto')).toBe(true) + expect(models.some(model => model.apiName === 'openai/gpt-5.5')).toBe(true) + expect( + models.some(model => model.apiName === 'anthropic/claude-sonnet-4.6'), + ).toBe(true) + expect( + models.some(model => model.apiName === 'google/gemini-3.5-flash'), + ).toBe(true) + }) +}) diff --git a/src/integrations/gateways/orcarouter.ts b/src/integrations/gateways/orcarouter.ts new file mode 100644 index 000000000..641d6bb72 --- /dev/null +++ b/src/integrations/gateways/orcarouter.ts @@ -0,0 +1,83 @@ +import { defineGateway } from '../define.js' +import { getTrimmedString, isRecord } from '../modelMapping.js' + +function mapOrcarouterModel(raw: unknown) { + if (!isRecord(raw)) return null + const id = getTrimmedString(raw, 'id') + if (!id) return null + const context = raw.context_length + return { + id, + apiName: id, + label: id, + ...(typeof context === 'number' && Number.isInteger(context) && context > 0 + ? { contextWindow: context } + : {}), + } +} + +export default defineGateway({ + id: 'orcarouter', + label: 'OrcaRouter', + category: 'aggregating', + defaultBaseUrl: 'https://api.orcarouter.ai/v1', + defaultModel: 'openai/gpt-5.5', + supportsModelRouting: true, + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['ORCAROUTER_API_KEY'], + dedicatedCredentialsOnly: true, + }, + startup: { + probeReadiness: 'openai-compatible-models', + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + supportsApiFormatSelection: true, + supportsAuthHeaders: true, + }, + }, + preset: { + id: 'orcarouter', + description: 'OrcaRouter OpenAI-compatible endpoint', + apiKeyEnvVars: ['ORCAROUTER_API_KEY'], + vendorId: 'openai', + }, + catalog: { + source: 'hybrid', + discovery: { kind: 'openai-compatible', mapModel: mapOrcarouterModel }, + discoveryCacheTtl: '1d', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, + models: [ + { + id: 'orcarouter-auto', + apiName: 'orcarouter/auto', + label: 'Auto — Smart Routing (via OrcaRouter)', + notes: 'Gateway routes to the best model for each request', + }, + { + id: 'orcarouter-gpt-5.5', + apiName: 'openai/gpt-5.5', + label: 'GPT-5.5 (via OrcaRouter)', + modelDescriptorId: 'gpt-5.5', + }, + { + id: 'orcarouter-claude-sonnet-4-6', + apiName: 'anthropic/claude-sonnet-4.6', + label: 'Claude Sonnet 4.6 (via OrcaRouter)', + modelDescriptorId: 'claude-sonnet-4-6', + maxOutputTokens: 64_000, + }, + { + id: 'orcarouter-gemini-3.5-flash', + apiName: 'google/gemini-3.5-flash', + label: 'Gemini 3.5 Flash (via OrcaRouter)', + modelDescriptorId: 'gemini-3.5-flash', + }, + ], + }, + usage: { supported: false }, +}) diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index cae1ada87..18d429495 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -50,6 +50,7 @@ import gatewayOllama from '../gateways/ollama.js' import gatewayOpencodeGo from '../gateways/opencode-go.js' import gatewayOpencode from '../gateways/opencode.js' import gatewayOpenrouter from '../gateways/openrouter.js' +import gatewayOrcarouter from '../gateways/orcarouter.js' import gatewayTogether from '../gateways/together.js' import gatewayVertex from '../gateways/vertex.js' import gatewayXiaomiMimoToken from '../gateways/xiaomi-mimo-token.js' @@ -97,7 +98,7 @@ import modelXai from '../models/xai.js' import modelXiaomiMimo from '../models/xiaomi-mimo.js' export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGeminiVertex, vendorGemini, vendorLongcat, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] -export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayApiRoute, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayCommandcode, gatewayConcentrate, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGmiCloud, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, gatewayLmstudio, gatewayMergeGateway, 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, gatewayGmiCloud, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, gatewayLmstudio, gatewayMergeGateway, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayOrcarouter, 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 3259ff19a..118ab2f70 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -493,6 +493,17 @@ export const PROVIDER_PRESET_MANIFEST = [ "OPENROUTER_API_KEY" ] }, + { + "preset": "orcarouter", + "routeKind": "gateway", + "routeId": "orcarouter", + "vendorId": "openai", + "gatewayId": "orcarouter", + "description": "OrcaRouter OpenAI-compatible endpoint", + "apiKeyEnvVars": [ + "ORCAROUTER_API_KEY" + ] + }, { "preset": "together", "routeKind": "gateway", @@ -666,6 +677,7 @@ export const ORDERED_PROVIDER_PRESETS = [ "opencode-go", "opencode", "openrouter", + "orcarouter", "together", "venice", "xai", diff --git a/src/integrations/index.test.ts b/src/integrations/index.test.ts index be562035f..8f027c0c5 100644 --- a/src/integrations/index.test.ts +++ b/src/integrations/index.test.ts @@ -68,6 +68,9 @@ describe('loaded registry validation', () => { // so there is no concrete model descriptor to reference. 'gitlawb-opengateway:opengateway-auto', 'merge-gateway:merge-gateway-default-routing', + // Virtual model — OrcaRouter's `orcarouter/auto` router resolves the + // serving model server-side, so there is no concrete descriptor. + 'orcarouter:orcarouter-auto', // Cloudflare Workers AI serves provider-specific quantized builds // (`@cf/...`) with no shared cross-provider model descriptor, the same // situation as azure-deployment above. See gateways/cloudflare.ts (#1100). diff --git a/src/services/api/agentRouting.test.ts b/src/services/api/agentRouting.test.ts index 47869220e..33eecc148 100644 --- a/src/services/api/agentRouting.test.ts +++ b/src/services/api/agentRouting.test.ts @@ -875,3 +875,20 @@ describe('resolveAgentRunModelRouting: in-process teammate route identity', () = }) }) }) + +test.each([undefined, 'parent-orca-key'])( + 'spawned Orca override owns its key with ambient %s', ambient => { + const env: NodeJS.ProcessEnv = { ORCAROUTER_API_KEY: ambient } + applyAgentProviderOverrideToEnv({ + model: 'openai/gpt-5.5', baseURL: 'https://api.orcarouter.ai/v1', + apiKey: 'configured-agent-key', + }, env) + expect(env.ORCAROUTER_API_KEY).toBe('configured-agent-key') + expect(env.OPENAI_API_KEY).toBe('configured-agent-key') + applyAgentProviderOverrideToEnv({ + model: 'custom', baseURL: 'https://custom.example/v1', apiKey: 'custom-key', + }, env) + expect(env.ORCAROUTER_API_KEY).toBeUndefined() + expect(env.OPENAI_API_KEY).toBe('custom-key') + }, +) diff --git a/src/services/api/agentRouting.ts b/src/services/api/agentRouting.ts index d97646b86..4d88dea65 100644 --- a/src/services/api/agentRouting.ts +++ b/src/services/api/agentRouting.ts @@ -57,6 +57,7 @@ const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [ 'OPENAI_AUTH_SCHEME', 'OPENAI_AUTH_HEADER_VALUE', 'MERGE_GATEWAY_API_KEY', + 'ORCAROUTER_API_KEY', 'CMD_API_KEY', 'COMMANDCODE_API_KEY', 'COMMAND_CODE_API_KEY', @@ -365,6 +366,9 @@ export function applyAgentProviderOverrideToEnv( env.OPENAI_MODEL = providerOverride.model env.OPENAI_BASE_URL = providerOverride.baseURL env.OPENAI_API_KEY = providerOverride.apiKey + if (resolveRouteIdFromBaseUrl(providerOverride.baseURL) === 'orcarouter') { + env.ORCAROUTER_API_KEY = providerOverride.apiKey + } if (resolveRouteIdFromBaseUrl(providerOverride.baseURL) === 'merge-gateway') { const key = sanitizeApiKey(providerOverride.apiKey) if (key) env.MERGE_GATEWAY_API_KEY = key diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 8571c2f84..5185ad051 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2,10 +2,12 @@ import { APIError } from '@anthropic-ai/sdk' import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { getEventListeners } from 'node:events' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +import { snapshotEnv } from '../../test/envSnapshot.js' import { asMockFetch } from '../../test/typedMocks.js' import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway } from '../../integrations/index.ts' import { applyProviderFlag } from '../../utils/providerFlag.ts' import { applyProviderProfileToProcessEnv } from '../../utils/providerProfiles.ts' +import { PROFILE_ORCAROUTER_KEY_SHA256 } from '../../utils/providerProfile.js' import { __resetInterruptionTraceForTests, __waitForInterruptionTraceFlushForTests, @@ -70,6 +72,7 @@ const originalEnv = { LONGCAT_API_KEY: process.env.LONGCAT_API_KEY, CLINE_API_KEY: process.env.CLINE_API_KEY, GMI_API_KEY: process.env.GMI_API_KEY, + ORCAROUTER_API_KEY: process.env.ORCAROUTER_API_KEY, OPENGATEWAY_API_KEY: process.env.OPENGATEWAY_API_KEY, OPENGATEWAY_BASE_URL: process.env.OPENGATEWAY_BASE_URL, OPENCODE_API_KEY: process.env.OPENCODE_API_KEY, @@ -80,6 +83,7 @@ const originalEnv = { } const originalFetch = globalThis.fetch +let restoreOrcarouterCredentialOrigin: () => void function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { @@ -495,6 +499,7 @@ async function captureChatCompletionRequest( beforeEach(async () => { await acquireSharedMutationLock('openaiShim.test.ts') + restoreOrcarouterCredentialOrigin = snapshotEnv([PROFILE_ORCAROUTER_KEY_SHA256], { clear: true }) process.env.OPENAI_BASE_URL = 'http://example.test/v1' delete process.env.OPENAI_API_BASE process.env.OPENAI_API_KEY = 'test-key' @@ -532,6 +537,7 @@ beforeEach(async () => { delete process.env.LONGCAT_API_KEY delete process.env.CLINE_API_KEY delete process.env.GMI_API_KEY + delete process.env.ORCAROUTER_API_KEY delete process.env.OPENGATEWAY_API_KEY delete process.env.OPENGATEWAY_BASE_URL delete process.env.OPENCODE_API_KEY @@ -581,6 +587,7 @@ afterEach(() => { restoreEnv('LONGCAT_API_KEY', originalEnv.LONGCAT_API_KEY) restoreEnv('CLINE_API_KEY', originalEnv.CLINE_API_KEY) restoreEnv('GMI_API_KEY', originalEnv.GMI_API_KEY) + restoreEnv('ORCAROUTER_API_KEY', originalEnv.ORCAROUTER_API_KEY) restoreEnv('OPENGATEWAY_API_KEY', originalEnv.OPENGATEWAY_API_KEY) restoreEnv('OPENGATEWAY_BASE_URL', originalEnv.OPENGATEWAY_BASE_URL) restoreEnv('OPENCODE_API_KEY', originalEnv.OPENCODE_API_KEY) @@ -592,6 +599,7 @@ afterEach(() => { _clearRegistryForTesting() ensureIntegrationsLoaded() } finally { + restoreOrcarouterCredentialOrigin() releaseSharedMutationLock() } }) @@ -2696,6 +2704,20 @@ test('longcat prefers its dedicated credential over a copied key from another pr expect(captured.authorization).toBe('Bearer fake-longcat-key') }) +test('an Orca profile sends its selected key instead of the ambient parent key', async () => { + process.env.ORCAROUTER_API_KEY = 'ambient-parent-key' + applyProviderProfileToProcessEnv({ + id: 'orca-wire', name: 'Orca wire', provider: 'orcarouter', + baseUrl: 'https://api.orcarouter.ai/v1', model: 'openai/gpt-5.5', + apiKey: 'selected-orca-key', + }) + + const captured = await captureChatCompletionRequest('openai/gpt-5.5') + + expect(captured.url).toBe('https://api.orcarouter.ai/v1/chat/completions') + expect(captured.authorization).toBe('Bearer selected-orca-key') +}) + test('longcat provider flag never falls back to an OPENAI_API_KEYS pool', async () => { process.env.OPENAI_API_KEYS = 'other-provider-secret' delete process.env.LONGCAT_API_KEY diff --git a/src/test/fixtures/orcarouterCredentials.fixture.ts b/src/test/fixtures/orcarouterCredentials.fixture.ts new file mode 100644 index 000000000..120a951f7 --- /dev/null +++ b/src/test/fixtures/orcarouterCredentials.fixture.ts @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict' +import type Anthropic from '@anthropic-ai/sdk' +import { createOpenAIShimClient } from '../../services/api/openaiShim.js' +import { enableConfigs, getGlobalConfig, saveGlobalConfig, type ProviderProfile } from '../../utils/config.js' +import { applyConfigEnvironmentVariables, applySafeConfigEnvironmentVariables } from '../../utils/managedEnv.js' +import { applySavedProfileToCurrentSession, applyStartupEnvFromProfile, buildLaunchEnv, createProfileFile } from '../../utils/providerProfile.js' +import { addProviderProfile, applyActiveProviderProfileFromConfig, getProviderProfiles, setActiveProviderProfile, updateProviderProfile } from '../../utils/providerProfiles.js' +import { getProviderValidationError } from '../../utils/providerValidation.js' +import { quote } from '../../utils/bash/shellQuote.js' +import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js' + +const [scenario, provider = 'openai', option] = process.argv.slice(2) +const profile = { + id: 'orca-process', name: 'Orca process', provider, + baseUrl: 'https://api.orcarouter.ai/v1', model: 'openai/gpt-5.5', + apiKey: 'selected-key', +} +enableConfigs() +getGlobalConfig() // Start the normal watcher before either process writes. +let request: { url: string; input: boolean; messages: boolean } | undefined +async function authorization() { + let header: string | null = null + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: Parameters[1]) => { + header = new Headers(init?.headers).get('authorization') + const body = JSON.parse(String(init?.body)) + request = { url: String(input), input: !!body.input, messages: !!body.messages } + if (request.url.endsWith('/responses')) { + return Response.json({ + id: 'reply', object: 'response', status: 'completed', model: profile.model, + output: [{ type: 'message', id: 'msg', role: 'assistant', status: 'completed', + content: [{ type: 'output_text', text: 'done', annotations: [] }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }) + } + return Response.json({ + id: 'reply', model: profile.model, + choices: [{ message: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, { preconnect: globalThis.fetch.preconnect }) + const client = createOpenAIShimClient({}) as Pick + await client.messages.create({ + model: profile.model, messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, stream: false, + }) + return header +} +function activate(selectedProfile: ProviderProfile = profile) { + saveGlobalConfig(current => ({ + ...current, providerProfiles: [selectedProfile], activeProviderProfileId: selectedProfile.id, + })) + setActiveProviderProfile(selectedProfile.id, { configDir: process.env.OPENCLAUDE_CONFIG_DIR }) +} +function finish(value: unknown): never { + console.log(JSON.stringify(value)) + process.exit(0) +} +if (scenario === 'pane-child' || scenario === 'pane-keyless-child') { + applySafeConfigEnvironmentVariables() + await applyStartupEnvFromProfile() + const initialAuthorization = await authorization() + if (scenario === 'pane-keyless-child') { + assert(addProviderProfile({ ...profile, name: 'Keyless Orca', apiKey: undefined })) + finish({ initialAuthorization, authorization: await authorization() }) + } + finish({ validation: await getProviderValidationError(), authorization: initialAuthorization }) +} +if (scenario === 'api-format') { + assert(option === 'responses' || option === 'responses_compat') + saveGlobalConfig(current => ({ + ...current, providerProfiles: [{ ...profile, apiFormat: option }], + activeProviderProfileId: profile.id, + })) + applySafeConfigEnvironmentVariables() + await applyStartupEnvFromProfile() + finish({ apiFormat: getProviderProfiles()[0]?.apiFormat, + authorization: await authorization(), request }) +} +if (scenario === 'legacy-startup' || scenario === 'legacy-session') { + const persisted = createProfileFile('openai', { + OPENAI_BASE_URL: profile.baseUrl, OPENAI_MODEL: profile.model, + OPENAI_API_KEY: profile.apiKey, + }) + const error = scenario === 'legacy-startup' + ? await applyStartupEnvFromProfile({ persisted }) + : await applySavedProfileToCurrentSession({ profileFile: persisted }) + assert.equal(error, null) + assert.equal(await authorization(), 'Bearer selected-key') + if (option === 'replacement') process.env.ORCAROUTER_API_KEY = 'replacement-shell-key' + assert(addProviderProfile({ ...profile, name: 'Keyless Orca', apiKey: undefined })) + finish({ authorization: await authorization() }) +} +if (scenario === 'shell-switch') { + const first = { ...profile, apiKey: undefined } + const second = { ...first, id: 'second-keyless-orca' } + process.env.ORCAROUTER_API_KEY = 'shell-key' + saveGlobalConfig(current => ({ ...current, providerProfiles: [first, second] })) + const headers: Array = [] + for (const selected of [first, second, first]) { + setActiveProviderProfile(selected.id, { configDir: process.env.OPENCLAUDE_CONFIG_DIR }) + headers.push(await authorization()) + assert.equal(getGlobalConfig().providerProfiles?.find(p => p.id === selected.id)?.apiKey, undefined) + } + finish({ headers, validation: await getProviderValidationError() }) +} +if (scenario === 'pane') { + if (option === 'shell') { + process.env.ORCAROUTER_API_KEY = 'shell-key' + activate({ ...profile, apiKey: undefined }) + } else { + activate() + if (option === 'replacement') process.env.ORCAROUTER_API_KEY = 'replacement-shell-key' + } + // An existing tmux login shell has none of the leader's provider credentials. + const child = Bun.spawnSync(['/bin/sh', '-c', + 'env ' + buildInheritedEnvVars() + ' ' + + quote([process.execPath, import.meta.path, option ? 'pane-keyless-child' : 'pane-child', provider]), + ], { + env: Object.fromEntries([ + 'PATH', 'NODE_ENV', 'TMPDIR', 'TMP', 'TEMP', 'CARGO_TARGET_DIR', 'GOCACHE', + 'GOMODCACHE', 'NPM_CONFIG_CACHE', 'BUN_INSTALL_CACHE_DIR', 'PIP_CACHE_DIR', + 'XDG_CACHE_HOME', 'BUN_RUNTIME_TRANSPILER_CACHE_PATH', + ].map(key => [key, process.env[key]])), + timeout: 10_000, + }) + assert.equal(child.exitCode, 0, child.stderr.toString()) + finish(JSON.parse(child.stdout.toString())) +} +if (scenario === 'host-pre' || scenario === 'host-post') { + if (option !== 'unmanaged') process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = profile.baseUrl + process.env.OPENAI_MODEL = profile.model + process.env.OPENAI_API_KEY = 'host-key' + process.env.ORCAROUTER_API_KEY = 'host-key' + saveGlobalConfig(current => ({ ...current, env: { ORCAROUTER_API_KEY: 'settings-key' } })) + if (scenario === 'host-pre') applySafeConfigEnvironmentVariables() + else applyConfigEnvironmentVariables() + finish({ authorization: await authorization() }) +} +if (scenario === 'edit') { + const edited = updateProviderProfile(profile.id, { ...profile, apiKey: undefined }) + assert(edited) + assert.equal(edited.apiKey, undefined) + finish({ savedKeyRemoved: true, authorization: await authorization() }) +} +if (scenario === 'remove-inherited') { + activate() + const child = Bun.spawnSync([process.execPath, import.meta.path, 'remove-aligned', provider, option], { + env: process.env, timeout: 10_000, + }) + assert.equal(child.exitCode, 0, child.stderr.toString()) + finish(JSON.parse(child.stdout.toString())) +} +if (scenario === 'remove-before-startup' || scenario === 'remove-before-launch') { + activate() + if (option === 'generic-removed') delete process.env.OPENAI_API_KEY + if (option === 'replacement') { + process.env.ORCAROUTER_API_KEY = 'replacement-shell-key' + delete process.env.OPENAI_API_KEY + } + let inheritedEnv = { ...process.env } + const editor = Bun.spawnSync([process.execPath, import.meta.path, 'edit', provider], { + env: process.env, timeout: 5_000, + }) + assert.equal(editor.exitCode, 0, editor.stderr.toString()) + if (scenario === 'remove-before-launch') { + inheritedEnv = await buildLaunchEnv({ + profile: 'openai', persisted: null, goal: 'balanced', processEnv: inheritedEnv, + }) + } + const child = Bun.spawnSync([process.execPath, import.meta.path, 'pane-child'], { + env: inheritedEnv, timeout: 5_000, + }) + assert.equal(child.exitCode, 0, child.stderr.toString()) + finish(JSON.parse(child.stdout.toString())) +} +if (scenario === 'remove') activate() +else { + assert.equal(scenario, 'remove-aligned') + applyActiveProviderProfileFromConfig() +} +if (option === 'generic-removed') delete process.env.OPENAI_API_KEY +if (option === 'replacement') { + process.env.ORCAROUTER_API_KEY = 'replacement-shell-key' + delete process.env.OPENAI_API_KEY +} +// Let the subsequent write have a newer timestamp than our write-through cache. +await Bun.sleep(30) +const child = Bun.spawnSync([process.execPath, import.meta.path, 'edit', provider], { + env: process.env, timeout: 5_000, +}) +assert.equal(child.exitCode, 0, child.stderr.toString()) +const edited = JSON.parse(child.stdout.toString()) as { authorization: string | null } +assert.equal(edited.authorization, option === 'replacement' ? 'Bearer replacement-shell-key' : null) +const deadline = Date.now() + 5_000 +while (getGlobalConfig().providerProfiles?.[0]?.apiKey && Date.now() < deadline) { + await Bun.sleep(25) +} +assert.equal(getGlobalConfig().providerProfiles?.[0]?.apiKey, undefined) +applyConfigEnvironmentVariables() +finish({ authorization: await authorization() }) diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index 60eef9869..f01e37f95 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -1309,3 +1309,12 @@ test('clearSessionContextWindowOverride resets state for session isolation', () expect(getSessionContextWindowOverride('gpt-4o')).toBeUndefined() expect(getContextWindowForModel('gpt-4o')).not.toBe(256_000) }) + +test.each([[32_000, 32_000], [90_000, 64_000]])( + 'Orca Sonnet output request %s uses its routed ceiling', (requested, expected) => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.orcarouter.ai/v1' + process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(requested) + expect(getMaxOutputTokensForModel('anthropic/claude-sonnet-4.6')).toBe(expected) + }, +) diff --git a/src/utils/envFile.ts b/src/utils/envFile.ts index fc50b667b..824eba5a5 100644 --- a/src/utils/envFile.ts +++ b/src/utils/envFile.ts @@ -118,6 +118,7 @@ const ALLOWED_ENV_FILE_KEYS = new Set([ 'OLLAMA_API_KEY', 'OLLAMA_BASE_URL', 'OPENROUTER_API_KEY', + 'ORCAROUTER_API_KEY', 'OPENAI_API_BASE', 'OPENAI_API_FORMAT', 'OPENAI_API_KEYS', diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index f637a760c..d1f33c0cd 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -51,6 +51,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'COMMAND_CODE_API_KEY', 'API_ROUTE_API_KEY', 'MERGE_GATEWAY_API_KEY', + 'ORCAROUTER_API_KEY', 'OPENAI_API_KEY', 'OPENAI_API_KEYS', 'GEMINI_ACCESS_TOKEN', diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index af05a6559..3190183f4 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -50,6 +50,7 @@ const ENV_KEYS = [ 'MIMO_API_KEY', 'ATLAS_CLOUD_API_KEY', 'GMI_API_KEY', + 'ORCAROUTER_API_KEY', 'GMI_MODEL', 'APISMART_API_KEY', 'APISMART_MODEL', @@ -2086,4 +2087,16 @@ describe('applyProviderFlag - api-route', () => { }) }) +test.each([ + ['orca-key', undefined], ['independent-openai-key', 'independent-openai-key'], +] as const)('OpenAI startup handles Orca generic alias %s', (generic, expected) => { + process.env.OPENAI_BASE_URL = 'https://api.orcarouter.ai/v1' + process.env.ORCAROUTER_API_KEY = 'orca-key' + process.env.OPENAI_API_KEY = generic + expect(applyProviderFlag('openai', [])).toEqual({}) + expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1') + if (expected === undefined) expect(process.env.OPENAI_API_KEY).toBeUndefined() + else expect(process.env.OPENAI_API_KEY).toBe(expected) +}) + diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 023d11e12..1eb0f6fac 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -402,6 +402,9 @@ export function applyProviderFlag( : process.env.OPENAI_API_KEY !== undefined && process.env.OPENAI_API_KEY === process.env.GMI_API_KEY ? 'gmi-cloud' + : process.env.OPENAI_API_KEY !== undefined && + process.env.OPENAI_API_KEY === process.env.ORCAROUTER_API_KEY + ? 'orcarouter' : process.env.OPENAI_API_KEY !== undefined && process.env.OPENAI_API_KEY === process.env.APISMART_API_KEY ? 'apismart' diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 1d55e570c..feb7bef5a 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -3779,3 +3779,38 @@ test('buildStartupEnvFromProfile applies saved API Route proxy profile even when assert.equal(env.OPENAI_API_KEY, undefined) assert.equal(env.OPENAI_API_KEYS, undefined) }) + +test('Orca relaunch migrates only the selected canonical profile credential', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', goal: 'balanced', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.orcarouter.ai/v1', + OPENAI_MODEL: 'openai/gpt-5.5', OPENAI_API_KEY: 'saved-orca-key', + }), + processEnv: { ORCAROUTER_API_KEY: 'ambient-other-account' }, + }) + assert.equal(resolveRouteCredentialValue({ + routeId: 'orcarouter', baseUrl: env.OPENAI_BASE_URL, processEnv: env, + }), 'saved-orca-key') + const ambientOnly = await buildLaunchEnv({ + profile: 'openai', persisted: null, goal: 'balanced', + processEnv: { + OPENAI_BASE_URL: 'https://api.orcarouter.ai/v1', + OPENAI_MODEL: 'openai/gpt-5.5', OPENAI_API_KEY: 'unrelated-generic-key', + }, + }) + assert.equal(ambientOnly.ORCAROUTER_API_KEY, undefined) +}) + +test('OpenAI profile builder materializes the canonical Orca key only at its route', () => { + const env = buildOpenAIProfileEnv({ + goal: 'balanced', baseUrl: 'https://api.orcarouter.ai/v1', + model: 'openai/gpt-5.5', apiKey: 'selected-orca-key', processEnv: {}, + }) + assert.equal(env?.ORCAROUTER_API_KEY, 'selected-orca-key') + const custom = buildOpenAIProfileEnv({ + goal: 'balanced', baseUrl: 'https://custom.example/v1', + model: 'custom', apiKey: 'custom-key', processEnv: {}, + }) + assert.equal(custom?.ORCAROUTER_API_KEY, undefined) +}) diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 9482cd1b8..85f5e4b68 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -1,4 +1,5 @@ import { getGeminiVertexProjectId, geminiVertexAdcWillResolveProject } from './geminiAuth.js' +import { createHash } from 'node:crypto' import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { @@ -64,8 +65,23 @@ export const DEFAULT_MISTRAL_MODEL = 'mistral-vibe-cli-latest' export const DEFAULT_STARTUP_PROVIDER_ENV_VAR = 'CLAUDE_CODE_DEFAULT_STARTUP_PROVIDER' +export const PROFILE_ORCAROUTER_KEY_SHA256 = + 'CLAUDE_CODE_PROVIDER_PROFILE_ORCAROUTER_KEY_SHA256' + +export function orcarouterCredentialFingerprint(value: string): string { + return createHash('sha256').update(sanitizeApiKey(value)?.trim() ?? '').digest('hex') +} + +export function isManagedOrcarouterCredential(env: NodeJS.ProcessEnv): boolean { + const key = sanitizeApiKey(env.ORCAROUTER_API_KEY) + // Credential origin is independent of selection markers: legacy profile + // files have no plural profile id, and rebuilding an env can reset selection. + return !!key && env[PROFILE_ORCAROUTER_KEY_SHA256] === orcarouterCredentialFingerprint(key) +} + const PROFILE_ENV_KEYS = [ 'CLAUDE_CODE_USE_GEMINI_VERTEX', + PROFILE_ORCAROUTER_KEY_SHA256, 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_GEMINI', @@ -122,6 +138,7 @@ const PROFILE_ENV_KEYS = [ 'MIMO_API_KEY', 'ATLAS_CLOUD_API_KEY', 'GMI_API_KEY', + 'ORCAROUTER_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', 'API_ROUTE_API_KEY', @@ -181,6 +198,7 @@ export type ProviderProfile = | 'clinepass' export type ProfileEnv = { + CLAUDE_CODE_PROVIDER_PROFILE_ORCAROUTER_KEY_SHA256?: string ANTHROPIC_BASE_URL?: string ANTHROPIC_MODEL?: string ANTHROPIC_API_KEY?: string @@ -228,6 +246,7 @@ export type ProfileEnv = { MIMO_API_KEY?: string ATLAS_CLOUD_API_KEY?: string GMI_API_KEY?: string + ORCAROUTER_API_KEY?: string APISMART_API_KEY?: string API_ROUTE_API_KEY?: string CLINE_API_KEY?: string @@ -1071,6 +1090,9 @@ export function buildOpenAIProfileEnv(options: { ...(resolveRouteIdFromBaseUrl(resolvedBaseUrl) === 'aimlapi' && key ? { AIMLAPI_API_KEY: key } : {}), + ...(resolveRouteIdFromBaseUrl(resolvedBaseUrl) === 'orcarouter' && key + ? { ORCAROUTER_API_KEY: key } + : {}), OPENAI_BASE_URL: resolvedBaseUrl, OPENAI_MODEL: normalizedModel, ...(options.apiFormat ? { OPENAI_API_FORMAT: options.apiFormat } : {}), @@ -2306,6 +2328,7 @@ export async function buildLaunchEnv(options: { for (const dedicatedKey of [ 'ATLAS_CLOUD_API_KEY', 'GMI_API_KEY', + 'ORCAROUTER_API_KEY', 'APISMART_API_KEY', 'CONCENTRATE_API_KEY', 'LLMTR_API_KEY', @@ -2347,6 +2370,9 @@ export async function buildLaunchEnv(options: { if (dedicatedKey === 'APISMART_API_KEY' && effectiveOpenAIRouteId !== 'apismart') { continue } + if (dedicatedKey === 'ORCAROUTER_API_KEY' && effectiveOpenAIRouteId !== 'orcarouter') { + continue + } if (dedicatedKey === 'CONCENTRATE_API_KEY' && effectiveOpenAIRouteId !== 'concentrate') { continue } @@ -2499,18 +2525,35 @@ export async function buildLaunchEnv(options: { ? sanitizeApiKey(persistedOpenAICredential.value) : undefined) : undefined + // Recover only the selected canonical profile's own key, including older + // files that predate its dedicated alias. Never promote a shell generic key. + const persistedOrcarouterProfileKey = + dedicatedKey === 'ORCAROUTER_API_KEY' && + effectiveOpenAIRouteId === 'orcarouter' && + resolveRouteIdFromBaseUrl(persistedOpenAIBaseUrl) === 'orcarouter' + ? sanitizeApiKey(persistedEnv.ORCAROUTER_API_KEY) || + (persistedOpenAICredential?.kind === 'usable' + ? sanitizeApiKey(persistedOpenAICredential.value) + : undefined) + : undefined const dedicatedValue = withholdAmbientDedicatedKey ? sanitizeApiKey(persistedEnv[dedicatedKey]) : backfillDedicatedFromOpenAI || persistedLlmtrProfileKey || persistedMergeGatewayProfileKey || persistedCommandcodeProfileKey || + persistedOrcarouterProfileKey || sanitizeApiKey(processEnv[dedicatedKey]) || sanitizeApiKey(persistedEnv[dedicatedKey]) || backfillLegacyApismartProfileKey || backfillLegacyConcentrateProfileKey if (dedicatedValue) { env[dedicatedKey] = dedicatedValue + if (dedicatedKey === 'ORCAROUTER_API_KEY' && + (persistedOrcarouterProfileKey || + (!sanitizeApiKey(processEnv.ORCAROUTER_API_KEY) && persistedEnv.ORCAROUTER_API_KEY))) { + env[PROFILE_ORCAROUTER_KEY_SHA256] = orcarouterCredentialFingerprint(dedicatedValue) + } } } if (effectiveOpenAIRouteId === 'nvidia-nim') { @@ -2699,8 +2742,16 @@ export function applyProfileEnvToProcessEnv( targetEnv: NodeJS.ProcessEnv, nextEnv: NodeJS.ProcessEnv, ): void { + const retainedOrcarouterOrigin = + isManagedOrcarouterCredential(targetEnv) && + sanitizeApiKey(targetEnv.ORCAROUTER_API_KEY) === sanitizeApiKey(nextEnv.ORCAROUTER_API_KEY) + ? targetEnv[PROFILE_ORCAROUTER_KEY_SHA256] + : undefined clearManagedProfileEnv(targetEnv) Object.assign(targetEnv, nextEnv) + if (retainedOrcarouterOrigin) { + targetEnv[PROFILE_ORCAROUTER_KEY_SHA256] = retainedOrcarouterOrigin + } } type StartupEnvOptions = NonNullable[0]> diff --git a/src/utils/providerProfiles.orcarouter.process.test.ts b/src/utils/providerProfiles.orcarouter.process.test.ts new file mode 100644 index 000000000..d1f0067c3 --- /dev/null +++ b/src/utils/providerProfiles.orcarouter.process.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const fixture = fileURLToPath(new URL('../test/fixtures/orcarouterCredentials.fixture.ts', import.meta.url)) +const childEnvKeys = [ + 'PATH', 'TMPDIR', 'TMP', 'TEMP', 'CARGO_TARGET_DIR', 'GOCACHE', 'GOMODCACHE', + 'NPM_CONFIG_CACHE', 'BUN_INSTALL_CACHE_DIR', 'PIP_CACHE_DIR', 'XDG_CACHE_HOME', + 'BUN_RUNTIME_TRANSPILER_CACHE_PATH', +] +function run(scenario: string, provider = 'openai', option = '') { + const configDir = mkdtempSync(join(tmpdir(), 'orca-credentials-')) + try { + const child = Bun.spawnSync([process.execPath, fixture, scenario, provider, option], { + env: { + ...Object.fromEntries(childEnvKeys.map(key => [key, process.env[key]])), + OPENCLAUDE_CONFIG_DIR: configDir, NODE_ENV: 'development', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + }, + // Real config polling and a second executable need more than the default + // test deadline: child deadline 15s, test deadline 20s allows teardown. + timeout: 15_000, + }) + expect(child.exitCode, child.stderr.toString()).toBe(0) + return JSON.parse(child.stdout.toString().trim()) as Record + } finally { + rmSync(configDir, { recursive: true, force: true }) + } +} +test.skipIf(process.platform === 'win32')('Orca pane inheritance authenticates after real child startup', () => { + const result = run('pane') + expect(result.validation).toBeNull() + expect(result.authorization).toBe('Bearer selected-key') +}, 20_000) + +for (const provider of ['openai', 'orcarouter']) { + for (const [option, initialAuthorization, authorization] of [ + ['saved', 'Bearer selected-key', null], + ['shell', 'Bearer shell-key', 'Bearer shell-key'], + ['replacement', 'Bearer replacement-shell-key', 'Bearer replacement-shell-key'], + ] as const) { + test.skipIf(process.platform === 'win32')(`${provider} Orca pane keyless switch respects ${option} credential ownership`, () => { + expect(run('pane', provider, option)).toEqual({ initialAuthorization, authorization }) + }, 20_000) + } +} + +for (const phase of ['host-pre', 'host-post']) { + test.each([true, false])(`Orca ${phase} settings merge preserves credential ownership (host managed: %s)`, managed => { + expect(run(phase, 'openai', managed ? '' : 'unmanaged').authorization) + .toBe(managed ? 'Bearer host-key' : 'Bearer settings-key') + }, 20_000) +} +for (const provider of ['openai', 'orcarouter']) { + for (const scenario of ['legacy-startup', 'legacy-session']) { + test.each(['', 'replacement'])(`${provider} Orca keyless selection after ${scenario} rejects saved credentials (%s)`, option => { + expect(run(scenario, provider, option).authorization) + .toBe(option === 'replacement' ? 'Bearer replacement-shell-key' : null) + }, 20_000) + } + test.each(['', 'generic-removed', 'replacement'])( + `${provider} Orca launcher preserves removed-key ownership (%s)`, option => { + expect(run('remove-before-launch', provider, option).authorization) + .toBe(option === 'replacement' ? 'Bearer replacement-shell-key' : null) + }, 20_000) + test(`${provider} Orca keyless profile switches retain genuine shell authentication`, () => { + const result = run('shell-switch', provider) + expect(result.headers).toEqual(['Bearer shell-key', 'Bearer shell-key', 'Bearer shell-key']) + expect(result.validation).toBeNull() + }, 20_000) + test.each(['', 'generic-removed', 'replacement'])( + `${provider} Orca startup rejects an inherited key removed before startup (%s)`, option => { + expect(run('remove-before-startup', provider, option).authorization) + .toBe(option === 'replacement' ? 'Bearer replacement-shell-key' : null) + }, 20_000) + test(`${provider} Orca initializes ownership when startup inherits an aligned profile`, () => { + expect(run('remove-inherited', provider, 'generic-removed').authorization).toBeNull() + }, 20_000) + test.each(['', 'generic-removed', 'replacement'])( + `${provider} Orca reconciles another process's key removal (%s)`, option => { + expect(run('remove', provider, option).authorization) + .toBe(option === 'replacement' ? 'Bearer replacement-shell-key' : null) + }, 20_000) +} + +for (const provider of ['openai', 'custom']) { + test.each(['responses', 'responses_compat'])(`${provider} Orca startup preserves explicit API format %s`, apiFormat => { + const result = run('api-format', provider, apiFormat) + expect(result.apiFormat).toBe(apiFormat) + expect(result.authorization).toBe('Bearer selected-key') + expect(result.request).toEqual({ + url: 'https://api.orcarouter.ai/v1/responses', input: true, messages: false, + }) + }, 20_000) +} diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index c3c2a6efc..950300e29 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -25,6 +25,7 @@ const originalFetch = globalThis.fetch const RESTORED_KEYS = [ 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + 'CLAUDE_CODE_PROVIDER_PROFILE_ORCAROUTER_KEY_SHA256', 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'CLAUDE_CONFIG_DIR', 'OPENCLAUDE_CONFIG_DIR', @@ -85,6 +86,7 @@ const RESTORED_KEYS = [ 'MIMO_API_KEY', 'ATLAS_CLOUD_API_KEY', 'GMI_API_KEY', + 'ORCAROUTER_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', 'API_ROUTE_API_KEY', @@ -6475,3 +6477,86 @@ test('DEFAULT_MISTRAL_MODEL matches the mistral gateway defaultModel', async () expect(mistralGateway.defaultModel).toBeDefined() expect(DEFAULT_MISTRAL_MODEL).toBe(mistralGateway.defaultModel!) }) + +test('keyless canonical Orca profile retains the dedicated shell credential', async () => { + const { applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() + process.env.ORCAROUTER_API_KEY = 'ambient-orca-key' + const saved = buildProfile({ + provider: 'openai', baseUrl: 'https://api.orcarouter.ai/v1', + model: 'openai/gpt-5.5', apiKey: undefined, + }) + applyProviderProfileToProcessEnv(saved) + expect(resolveRouteCredentialValue({ + routeId: 'orcarouter', baseUrl: saved.baseUrl, + })).toBe('ambient-orca-key') + applyProviderProfileToProcessEnv(saved) + expect(process.env.ORCAROUTER_API_KEY).toBe('ambient-orca-key') + applyProviderProfileToProcessEnv({ ...saved, baseUrl: 'https://custom.example/v1' }) + expect(process.env.ORCAROUTER_API_KEY).toBeUndefined() +}) + +test.each([false, true])('a keyless Orca selection rejects another profile key (generic removed: %s)', async removeGeneric => { + const { setActiveProviderProfile } = await importFreshProviderProfileModules() + const keyed = buildProfile({ id: 'keyed-orca', provider: 'orcarouter', + baseUrl: 'https://api.orcarouter.ai/v1', model: 'openai/gpt-5.5', apiKey: 'account-a-key' }) + const keyless = { ...keyed, id: 'keyless-orca', apiKey: undefined } + saveMockGlobalConfig(current => ({ ...current, providerProfiles: [keyed, keyless] })) + setActiveProviderProfile(keyed.id, { configDir: testConfigDir! }) + if (removeGeneric) delete process.env.OPENAI_API_KEY + setActiveProviderProfile(keyless.id, { configDir: testConfigDir! }) + expect(process.env.ORCAROUTER_API_KEY).toBeUndefined() + expect(resolveRouteCredentialValue({ routeId: 'orcarouter', baseUrl: keyless.baseUrl })) + .toBeUndefined() + const persisted = JSON.parse(readFileSync(join(testConfigDir!, '.openclaude-profile.json'), 'utf8')) + expect(persisted.env.ORCAROUTER_API_KEY).toBeUndefined() +}) + +test.each([ + ['removed-account-key', undefined], + ['replacement-shell-key', 'replacement-shell-key'], +] as const)('removing the active Orca profile key handles current key %s', async (currentKey, expected) => { + const { setActiveProviderProfile, updateProviderProfile } = await importFreshProviderProfileModules() + const keyed = buildProfile({ provider: 'orcarouter', baseUrl: 'https://api.orcarouter.ai/v1', + model: 'openai/gpt-5.5', apiKey: 'removed-account-key' }) + saveMockGlobalConfig(current => ({ ...current, providerProfiles: [keyed] })) + setActiveProviderProfile(keyed.id, { configDir: testConfigDir! }) + process.env.ORCAROUTER_API_KEY = currentKey + delete process.env.OPENAI_API_KEY + const edited = updateProviderProfile(keyed.id, { name: keyed.name, provider: keyed.provider, + baseUrl: keyed.baseUrl, model: keyed.model, apiKey: undefined }) + expect(edited?.apiKey).toBeUndefined() + if (expected === undefined) expect(process.env.ORCAROUTER_API_KEY).toBeUndefined() + else expect(process.env.ORCAROUTER_API_KEY).toBe(expected) +}) + +for (const provider of ['orcarouter', 'openai']) { + test(`${provider} Orca profile owns its credential through apply, persist and relaunch`, async () => { + const { setActiveProviderProfile, applyActiveProviderProfileFromConfig } = + await importFreshProviderProfileModules() + const saved = buildProfile({ + provider, apiKey: 'selected-orca-key', + baseUrl: 'https://api.orcarouter.ai/v1', model: 'openai/gpt-5.5', + }) + saveMockGlobalConfig(current => ({ ...current, providerProfiles: [saved] })) + process.env.ORCAROUTER_API_KEY = 'ambient-parent-key' + setActiveProviderProfile(saved.id, { configDir: testConfigDir! }) + expect(resolveRouteCredentialValue({ + routeId: 'orcarouter', baseUrl: saved.baseUrl, + })).toBe('selected-orca-key') + process.env.ORCAROUTER_API_KEY = 'drifted-key' + expect(applyActiveProviderProfileFromConfig()?.id).toBe(saved.id) + expect(process.env.ORCAROUTER_API_KEY).toBe('selected-orca-key') + + const persisted = JSON.parse(readFileSync( + join(testConfigDir!, '.openclaude-profile.json'), 'utf8', + )) + expect(persisted.env.ORCAROUTER_API_KEY).toBe('selected-orca-key') + const { buildStartupEnvFromProfile } = await import('./providerProfile.js') + const env = await buildStartupEnvFromProfile({ + persisted, processEnv: { ORCAROUTER_API_KEY: 'other-ambient-key' }, + }) + expect(resolveRouteCredentialValue({ + routeId: 'orcarouter', baseUrl: saved.baseUrl, processEnv: env, + })).toBe('selected-orca-key') + }) +} diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 471d2869a..17cd60c44 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -38,6 +38,9 @@ import { buildApismartProfileEnv, buildVertexProfileEnv, clearManagedProfileEnv, + isManagedOrcarouterCredential, + orcarouterCredentialFingerprint, + PROFILE_ORCAROUTER_KEY_SHA256, deleteProfileFile, type ProfileFileLocation, type ProfileEnv, @@ -117,6 +120,21 @@ export type ProviderPresetDefaults = Omit & { const PROFILE_ENV_APPLIED_FLAG = 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED' const PROFILE_ENV_APPLIED_ID = 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID' +function rememberManagedOrcarouterCredential( + profile: ProviderProfile, + processEnv: NodeJS.ProcessEnv = process.env, +): void { + const value = sanitizeApiKey(profile.apiKey)?.trim() + if (value && resolveRouteIdFromBaseUrl(profile.baseUrl) === 'orcarouter' && + processEnv[PROFILE_ENV_APPLIED_FLAG] === '1' && + trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) === profile.id && + sanitizeApiKey(processEnv.ORCAROUTER_API_KEY)?.trim() === value) { + // Inherit provenance with the applied markers: saved config can change + // before a child starts, so it cannot reconstruct ownership from config. + processEnv[PROFILE_ORCAROUTER_KEY_SHA256] = orcarouterCredentialFingerprint(value) + } +} + type ProfileCompatibilityMode = | 'anthropic' | 'gemini' @@ -864,6 +882,9 @@ function getHostDedicatedCredentialEnv( route?: { routeId?: string; vendorId?: string }, ): Record { const env: Record = {} + if (apiKey && resolveRouteIdFromBaseUrl(baseUrl) === 'orcarouter') { + env.ORCAROUTER_API_KEY = apiKey + } if (route?.routeId === 'xai' || isXaiBaseUrl(baseUrl)) { env.XAI_API_KEY = apiKey } @@ -901,6 +922,11 @@ function hostDedicatedCredentialsAligned( if (!includeApiKey) { return true } + if (!profile.apiKey && + resolveRouteIdFromBaseUrl(profile.baseUrl) === 'orcarouter' && + isManagedOrcarouterCredential(processEnv)) { + return false + } const dedicated = getHostDedicatedCredentialEnv( profile.baseUrl, profile.apiKey ?? '', @@ -1270,6 +1296,17 @@ export function applyProviderProfileToProcessEnv( OPENAI_BASE_URL: normalizedProfileBaseUrl, OPENAI_MODEL: primaryModel, } + // Keyless canonical profiles already accept the dedicated shell key. + // Reuse only actual shell input, not a key installed by this or another + // profile, including when another CLI removed this profile's saved key. + // Changing profile IDs does not change the origin of genuine shell input. + if (!profile.apiKey && + resolveRouteIdFromBaseUrl(normalizedProfileBaseUrl) === 'orcarouter') { + const ambientKey = sanitizeApiKey(process.env.ORCAROUTER_API_KEY) + if (ambientKey && !isManagedOrcarouterCredential(process.env)) { + openAIProfileEnv.ORCAROUTER_API_KEY = ambientKey + } + } const isAimlapiProfile = profile.provider === 'aimlapi' || route.routeId === 'aimlapi' || @@ -1543,6 +1580,7 @@ export function applyProviderProfileToProcessEnv( Object.assign(process.env, nextEnv) process.env[PROFILE_ENV_APPLIED_FLAG] = '1' process.env[PROFILE_ENV_APPLIED_ID] = profile.id + rememberManagedOrcarouterCredential(profile) } export function applyActiveProviderProfileFromConfig( @@ -1580,6 +1618,10 @@ export function applyActiveProviderProfileFromConfig( return undefined } + // Bootstrap older inherited environments while the matching saved keyed + // record is available; new environments already carry their fingerprint. + rememberManagedOrcarouterCredential(activeProfile, processEnv) + const isCurrentEnvProfileManaged = processEnv[PROFILE_ENV_APPLIED_FLAG] === '1' && trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) === activeProfile.id @@ -1696,6 +1738,7 @@ export function updateProviderProfile( } wasUpdated = true + rememberManagedOrcarouterCredential(currentProfiles[profileIndex]!) const nextProfiles = [...currentProfiles] nextProfiles[profileIndex] = updatedProfile diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index 8d7f6e71e..8f0a9af81 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -115,6 +115,9 @@ const TEAMMATE_ENV_VARS = [ 'COMMANDCODE_API_KEY', 'COMMAND_CODE_API_KEY', 'GMI_API_KEY', + 'ORCAROUTER_API_KEY', + // Preserve saved-key origin across panes for subsequent keyless selection. + 'CLAUDE_CODE_PROVIDER_PROFILE_ORCAROUTER_KEY_SHA256', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_MODEL', diff --git a/web/src/data/providers.ts b/web/src/data/providers.ts index 3add3eb35..1d02ab6d2 100644 --- a/web/src/data/providers.ts +++ b/web/src/data/providers.ts @@ -156,6 +156,14 @@ export const providers: Provider[] = [ envVars: ['MERGE_GATEWAY_API_KEY'], notes: 'Multi-provider model router with Chat Completions, Responses, authenticated catalog discovery, and routing policies.', }, + { + id: 'orcarouter', + name: 'OrcaRouter', + group: 'gateways', + setup: '/provider or ORCAROUTER_API_KEY with --provider orcarouter', + envVars: ['ORCAROUTER_API_KEY'], + notes: 'OpenAI-compatible model routing gateway with named auto routing. Existing environment setups must replace OPENAI_API_KEY with ORCAROUTER_API_KEY, even when a custom authentication header supplies the request credential. Custom headers remain supported.', + }, { id: 'near-ai', name: 'NEAR AI',