diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 380c34364..48583b586 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -601,6 +601,15 @@ test('resolveActiveRouteIdFromEnv keeps explicit OpenAI mode compatible with AI/ ).toBe('aimlapi') }) +test('resolveActiveRouteIdFromEnv keeps explicit OpenAI mode compatible with ApiSmart key-only setup', () => { + expect( + resolveActiveRouteIdFromEnv({ + APISMART_API_KEY: 'apismart-key', + CLAUDE_CODE_USE_OPENAI: '1', + }), + ).toBe('apismart') +}) + test('resolveActiveRouteIdFromEnv does not infer AI/ML API with a conflicting OpenAI base URL', () => { expect( resolveActiveRouteIdFromEnv({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index bff41a0c9..7d50a7d68 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -819,10 +819,14 @@ export function hasClinePassEnvOnlyProviderIntent( export function hasApismartEnvOnlyProviderIntent( processEnv: NodeJS.ProcessEnv = process.env, ): boolean { + // Match AIMLAPI: ApiSmart is an OpenAI-compatible dedicated-key route, so a + // lingering CLAUDE_CODE_USE_OPENAI=1 from a prior OpenAI session must not + // suppress env-only ApiSmart identity. Only true non-OpenAI providers + // (Gemini/GitHub/Bedrock/...) block this intent. return ( hasUsableOpenAICredential(processEnv.APISMART_API_KEY) && !hasConflictingOpenAIBaseUrlForRoute(processEnv, isApismartBaseUrl) && - hasNoExplicitNonOpenAICompatibleProvider(processEnv) + hasNoExplicitNonOpenAIProvider(processEnv) ) } diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index a619b66c1..39a9e4821 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -289,6 +289,7 @@ test('buildApismartProfileEnv prefers APISMART_MODEL over OPENAI_MODEL', () => { assert.ok(env) assert.equal(env?.OPENAI_MODEL, 'KIMI_K3') + assert.equal(env?.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'apismart') }) test('buildApismartProfileEnv refuses to copy the dedicated credential to a custom endpoint', () => { @@ -300,6 +301,44 @@ test('buildApismartProfileEnv refuses to copy the dedicated credential to a cust assert.equal(env, null) }) +test('openai launch withholds ambient ApiSmart credentials from a keyless proxy profile on restart', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart', + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'DEEPSEEK_V4_FLASH', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_API_KEY: 'ambient-apismart-key', + APISMART_API_KEY: 'ambient-apismart-key', + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'apismart') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.APISMART_API_KEY, undefined) + + const canonical = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart', + OPENAI_BASE_URL: 'https://gw.apismart.ai/v1', + OPENAI_MODEL: 'DEEPSEEK_V4_FLASH', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://gw.apismart.ai/v1', + OPENAI_API_KEY: 'ambient-apismart-key', + APISMART_API_KEY: 'ambient-apismart-key', + }, + }) + assert.equal(canonical.OPENAI_API_KEY, 'ambient-apismart-key') + assert.equal(canonical.APISMART_API_KEY, 'ambient-apismart-key') +}) + test('openai launch carries APISMART_API_KEY only when the route resolves to apismart', async () => { const offRoute = await buildLaunchEnv({ profile: 'openai', diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index c2f497890..85c01d828 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -679,6 +679,7 @@ export function buildApismartProfileEnv(options: { defaultModel, OPENAI_API_KEY: key, APISMART_API_KEY: key, + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart', } } @@ -1396,12 +1397,17 @@ function hasConcreteProviderSelection( return true } - // Env-only provider setups — no CLAUDE_CODE_USE_* flag needed + // Env-only provider setups — no CLAUDE_CODE_USE_* flag needed. + // ApiSmart deliberately follows AIMLAPI here: a bare APISMART_API_KEY is NOT + // a "concrete selection" that skips the persisted-profile launch path. + // Env-only ApiSmart routing is applied later via resolveEnvOnlyProviderRouteId + // + applyApismartEnvOnlyDefaults. Treating the dedicated key as concrete would + // short-circuit buildStartupEnvFromProfile and let an ambient mirrored + // OPENAI_API_KEY reach a keyless ApiSmart proxy profile on relaunch. return ( sanitizeApiKey(processEnv.FIREWORKS_API_KEY) !== undefined || sanitizeApiKey(processEnv.NEARAI_API_KEY) !== undefined || - sanitizeApiKey(processEnv.LONGCAT_API_KEY) !== undefined || - sanitizeApiKey(processEnv.APISMART_API_KEY) !== undefined + sanitizeApiKey(processEnv.LONGCAT_API_KEY) !== undefined ) } @@ -2052,12 +2058,12 @@ export async function buildLaunchEnv(options: { } else { delete env.CLAUDE_CODE_PROVIDER_ROUTE_ID } - // A keyless retained aimlapi profile on a non-canonical (proxy) base URL must - // not receive the ambient canonical credential via the generic OPENAI_API_KEY - // /OPENAI_API_KEYS alias either (the generic selection above prefers the live - // shell value). Re-source the generic credential from the profile's OWN - // persisted env and drop a purely ambient one. - // Scoped to a launch that actually carries the aimlapi identity. A profile + // A keyless retained aimlapi/apismart profile on a non-canonical (proxy) base + // URL must not receive the ambient canonical credential via the generic + // OPENAI_API_KEY / OPENAI_API_KEYS alias either (the generic selection above + // prefers the live shell value). Re-source the generic credential from the + // profile's OWN persisted env and drop a purely ambient one. + // Scoped to a launch that actually carries the route identity. A profile // retargeted to an endpoint it was not saved for keeps no identity, so it is // handled by the route-agnostic precedence above rather than here: forcing the // profile's own credential in would both hand a key to an endpoint it was not @@ -2066,7 +2072,13 @@ export async function buildLaunchEnv(options: { effectiveOpenAIRouteId === 'aimlapi' && !!env.OPENAI_BASE_URL?.trim() && !isCanonicalAimlapiInferenceBaseUrl(env.OPENAI_BASE_URL) - if (isNoncanonicalAimlapiLaunch) { + const isNoncanonicalApismartLaunch = + effectiveOpenAIRouteId === 'apismart' && + !!env.OPENAI_BASE_URL?.trim() && + !isApismartBaseUrl(env.OPENAI_BASE_URL) + const isNoncanonicalDedicatedOpenAILaunch = + isNoncanonicalAimlapiLaunch || isNoncanonicalApismartLaunch + if (isNoncanonicalDedicatedOpenAILaunch) { delete env.OPENAI_API_KEY delete env.OPENAI_API_KEYS const persistedCredential = resolveOpenAICredentialEnvSelection(persistedEnv) @@ -2126,16 +2138,23 @@ export async function buildLaunchEnv(options: { ) { continue } - // On a non-canonical (proxy) aimlapi base URL, never source AIMLAPI_API_KEY - // from ambient/session credentials — that would leak the canonical AIMLAPI - // key to a user-controlled proxy on restart. The profile's OWN persisted key - // is still applied, since the user configured that key for that proxy. - const aimlapiBaseUrl = env.OPENAI_BASE_URL?.trim() + // On a non-canonical (proxy) aimlapi/apismart base URL, never source the + // dedicated key from ambient/session credentials — that would leak the + // canonical provider key to a user-controlled proxy on restart. The + // profile's OWN persisted key is still applied, since the user configured + // that key for that proxy. + const dedicatedBaseUrl = env.OPENAI_BASE_URL?.trim() const withholdAmbientAimlapiKey = dedicatedKey === 'AIMLAPI_API_KEY' && - !!aimlapiBaseUrl && - !isCanonicalAimlapiInferenceBaseUrl(aimlapiBaseUrl) - const dedicatedValue = withholdAmbientAimlapiKey + !!dedicatedBaseUrl && + !isCanonicalAimlapiInferenceBaseUrl(dedicatedBaseUrl) + const withholdAmbientApismartKey = + dedicatedKey === 'APISMART_API_KEY' && + !!dedicatedBaseUrl && + !isApismartBaseUrl(dedicatedBaseUrl) + const withholdAmbientDedicatedKey = + withholdAmbientAimlapiKey || withholdAmbientApismartKey + const dedicatedValue = withholdAmbientDedicatedKey ? sanitizeApiKey(persistedEnv[dedicatedKey]) : (dedicatedKey === 'AIMLAPI_API_KEY' && openAICredential?.kind === 'usable' ? sanitizeApiKey(openAICredential.value) @@ -2157,9 +2176,9 @@ export async function buildLaunchEnv(options: { // client, and its own filter only drops `authorization`, `x-api-key` and // `api-key` — a custom-named header such as `X-Proxy-Auth: ` survives // and is sent on every request. So an ambient value must be withheld from a - // non-canonical aimlapi launch exactly like the API key and the custom-auth - // trio; only headers the profile itself persisted are restored. - const customHeaders = isNoncanonicalAimlapiLaunch + // non-canonical aimlapi/apismart launch exactly like the API key and the + // custom-auth trio; only headers the profile itself persisted are restored. + const customHeaders = isNoncanonicalDedicatedOpenAILaunch ? persistedCustomHeaders : shellCustomHeaders || persistedCustomHeaders if (customHeaders) { diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index b6cbfb1e7..fb8bc3e9a 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -827,6 +827,7 @@ describe('applyProviderProfileToProcessEnv', () => { expect(process.env.OPENAI_MODEL).toBe('DEEPSEEK_V4_FLASH') expect(process.env.OPENAI_API_KEY).toBe('apismart-test-key') expect(process.env.APISMART_API_KEY).toBe('apismart-test-key') + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart') expect(getFreshAPIProvider()).toBe('openai') }) @@ -863,6 +864,7 @@ describe('applyProviderProfileToProcessEnv', () => { expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example/v1') expect(process.env.OPENAI_API_KEY).toBeUndefined() expect(process.env.APISMART_API_KEY).toBeUndefined() + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart') }) test.each(['SUA_CHAVE', 'sua_chave', 'null', 'undefined', ' NULL '])( @@ -3214,7 +3216,7 @@ describe('setActiveProviderProfile', () => { } }) - test('retargeted ApiSmart profiles persist without their dedicated credential', async () => { + test('retargeted ApiSmart profiles keep route identity but persist without their dedicated credential', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) process.chdir(tempDir) @@ -3241,9 +3243,25 @@ describe('setActiveProviderProfile', () => { expect(result?.id).toBe('apismart_proxy') expect(persisted.profile).toBe('openai') expect(persisted.env).toEqual({ + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart', OPENAI_BASE_URL: 'https://proxy.example/v1', OPENAI_MODEL: 'DEEPSEEK_V4_FLASH', }) + + const { buildStartupEnvFromProfile } = await import( + `./providerProfile.js?ts=${Date.now()}-${Math.random()}` + ) + const startupEnv = await buildStartupEnvFromProfile({ + persisted, + processEnv: { + APISMART_API_KEY: 'ambient-apismart-key', + OPENAI_API_KEY: 'ambient-apismart-key', + }, + }) + + expect(startupEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart') + expect(startupEnv.APISMART_API_KEY).toBeUndefined() + expect(startupEnv.OPENAI_API_KEY).toBeUndefined() } finally { process.chdir(originalCwd) rmSync(tempDir, { recursive: true, force: true }) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 7b139296e..81779e930 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -1080,6 +1080,13 @@ export function applyProviderProfileToProcessEnv( openAIProfileEnv.AIMLAPI_API_KEY ?? ambientAimlapiKey } } + // Keep ApiSmart route identity even when the profile is retargeted to a + // proxy. Dedicated credentials stay withheld above; the route id is what + // lets buildLaunchEnv refuse ambient APISMART_API_KEY / mirrored + // OPENAI_API_KEY on relaunch (AIMLAPI parity). + if (route.routeId === 'apismart') { + openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'apismart' + } if (route.gatewayId === 'nvidia-nim') { openAIProfileEnv.NVIDIA_NIM = '1' } @@ -1442,6 +1449,12 @@ function buildOpenAICompatibleStartupEnv( if (isAimlapiProfile) { env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi' } + // Preserve ApiSmart identity on retargeted/proxy startup envs so relaunch + // withholding can refuse ambient dedicated credentials. Canonical profiles + // already stamp this via buildApismartProfileEnv. + if (resolveProfileRoute(activeProfile.provider).routeId === 'apismart') { + env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'apismart' + } if (activeProfile.apiKey && !withholdRetargetedApismartCredential) { env.OPENAI_API_KEY = activeProfile.apiKey if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) {