mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
feat: add OrcaRouter gateway descriptor (#2125)
* feat: add OrcaRouter gateway descriptor Add first-class support for OrcaRouter, an OpenAI-compatible model routing gateway, as a generated gateway descriptor with hybrid catalog discovery and curated flagship models (openai/gpt-5.5, anthropic/claude-sonnet-4.6, google/gemini-3.5-flash) plus its orcarouter/auto named router. Registers ORCAROUTER_API_KEY in the env-file allow-list, surfaces the provider in the web provider list and README, and updates the compatibility/registry tests for the new preset. Co-Authored-By: Claude <noreply@anthropic.com> * test: include OrcaRouter in provider picker navigation * fix(orcarouter): preserve profile credentials and model limits * fix(orcarouter): keep managed keys scoped to their profiles * fix(orcarouter): preserve credential ownership across process boundaries * fix: preserve Orca credential ownership across child startup * fix(orcarouter): preserve credential provenance and discovery auth * fix(orcarouter): preserve saved credential origin across launch paths * test(orcarouter): restore credential origin in shim tests * fix(orcarouter): preserve pane credential provenance * docs(orcarouter): clarify dedicated-key migration --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: jatmn <the@jat.mn>
This commit is contained in:
co-authored by
Claude
jatmn
parent
cb4a72bf1c
commit
7b83353db1
@@ -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` |
|
||||
|
||||
@@ -195,6 +195,7 @@ const PRESET_ORDER = [
|
||||
'OpenCode Go',
|
||||
'OpenCode Zen',
|
||||
'OpenRouter',
|
||||
'OrcaRouter',
|
||||
'Together AI',
|
||||
'Venice',
|
||||
'xAI',
|
||||
|
||||
@@ -35,6 +35,7 @@ const EXPECTED_PRESETS = [
|
||||
'merge-gateway',
|
||||
'azure-openai',
|
||||
'openrouter',
|
||||
'orcarouter',
|
||||
'lmstudio',
|
||||
'dashscope-cn',
|
||||
'dashscope-intl',
|
||||
|
||||
@@ -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<string | null> = []
|
||||
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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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<string, string> | 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<T> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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[])[]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+202
@@ -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<typeof fetch>[0], init?: Parameters<typeof fetch>[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<Anthropic, 'messages'>
|
||||
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<string | null> = []
|
||||
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() })
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<Parameters<typeof buildStartupEnvFromProfile>[0]>
|
||||
|
||||
@@ -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<string, unknown>
|
||||
} 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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<ProviderProfileInput, 'provider'> & {
|
||||
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<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user