diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 568f3422b..c56856470 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -31,6 +31,7 @@ import { isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, isCanonicalLlmtrInferenceBaseUrl, + isCanonicalRequestyInferenceBaseUrl, isCanonicalMergeGatewayInferenceBaseUrl, isGmiCloudBaseUrl, isCanonicalCommandcodeInferenceBaseUrl, @@ -176,6 +177,7 @@ const PROFILE_ENV_KEYS = [ 'FIREWORKS_API_KEY', 'LONGCAT_API_KEY', 'LLMTR_API_KEY', + 'REQUESTY_API_KEY', 'MERGE_GATEWAY_API_KEY', 'CMD_API_KEY', 'COMMANDCODE_API_KEY', @@ -285,6 +287,7 @@ export type ProfileEnv = { FIREWORKS_API_KEY?: string LONGCAT_API_KEY?: string LLMTR_API_KEY?: string + REQUESTY_API_KEY?: string MERGE_GATEWAY_API_KEY?: string CMD_API_KEY?: string COMMANDCODE_API_KEY?: string @@ -2457,6 +2460,7 @@ export async function buildLaunchEnv(options: { 'APISMART_API_KEY', 'CONCENTRATE_API_KEY', 'LLMTR_API_KEY', + 'REQUESTY_API_KEY', 'MERGE_GATEWAY_API_KEY', 'CMD_API_KEY', 'COMMANDCODE_API_KEY', @@ -2507,6 +2511,9 @@ export async function buildLaunchEnv(options: { if (dedicatedKey === 'LLMTR_API_KEY' && effectiveOpenAIRouteId !== 'llmtr') { continue } + if (dedicatedKey === 'REQUESTY_API_KEY' && effectiveOpenAIRouteId !== 'requesty') { + continue + } if (dedicatedKey === 'MERGE_GATEWAY_API_KEY' && effectiveOpenAIRouteId !== 'merge-gateway') { continue } @@ -2547,6 +2554,10 @@ export async function buildLaunchEnv(options: { dedicatedKey === 'LLMTR_API_KEY' && !!dedicatedBaseUrl && !isCanonicalLlmtrInferenceBaseUrl(dedicatedBaseUrl) + const withholdAmbientRequestyKey = + dedicatedKey === 'REQUESTY_API_KEY' && + !!dedicatedBaseUrl && + !isCanonicalRequestyInferenceBaseUrl(dedicatedBaseUrl) const withholdAmbientMergeGatewayKey = dedicatedKey === 'MERGE_GATEWAY_API_KEY' && !!dedicatedBaseUrl && @@ -2560,6 +2571,7 @@ export async function buildLaunchEnv(options: { withholdAmbientApismartKey || withholdAmbientConcentrateKey || withholdAmbientLlmtrKey || + withholdAmbientRequestyKey || withholdAmbientMergeGatewayKey || withholdAmbientCommandcodeKey // Unlike the generic proxy-compatible routes above, Concentrate's @@ -2570,6 +2582,7 @@ export async function buildLaunchEnv(options: { if ( withholdAmbientConcentrateKey || withholdAmbientLlmtrKey || + withholdAmbientRequestyKey || withholdAmbientMergeGatewayKey || withholdAmbientCommandcodeKey ) { @@ -2618,6 +2631,18 @@ export async function buildLaunchEnv(options: { ? sanitizeApiKey(persistedOpenAICredential.value) : undefined) : undefined + // Same ownership rule for a selected canonical Requesty profile, including + // startup files that only persisted the saved key as OPENAI_API_KEY. + const persistedRequestyProfileKey = + dedicatedKey === 'REQUESTY_API_KEY' && + effectiveOpenAIRouteId === 'requesty' && + !!dedicatedBaseUrl && + isCanonicalRequestyInferenceBaseUrl(dedicatedBaseUrl) + ? sanitizeApiKey(persistedEnv.REQUESTY_API_KEY) || + (persistedOpenAICredential?.kind === 'usable' + ? sanitizeApiKey(persistedOpenAICredential.value) + : undefined) + : undefined const persistedMergeGatewayProfileKey = dedicatedKey === 'MERGE_GATEWAY_API_KEY' && effectiveOpenAIRouteId === 'merge-gateway' && @@ -2690,6 +2715,7 @@ export async function buildLaunchEnv(options: { ? sanitizeApiKey(persistedEnv[dedicatedKey]) : backfillDedicatedFromOpenAI || persistedLlmtrProfileKey || + persistedRequestyProfileKey || persistedMergeGatewayProfileKey || persistedCommandcodeProfileKey || persistedOrcarouterProfileKey || diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 25ccb9171..c25b216dd 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -95,6 +95,7 @@ const RESTORED_KEYS = [ 'API_ROUTE_API_KEY', 'API_ROUTE_MODEL', 'LLMTR_API_KEY', + 'REQUESTY_API_KEY', 'CMD_API_KEY', 'COMMANDCODE_API_KEY', 'COMMAND_CODE_API_KEY', @@ -353,6 +354,17 @@ function buildLlmtrProfile(overrides: Partial = {}): ProviderPr }) } +function buildRequestyProfile(overrides: Partial = {}): ProviderProfile { + return buildProfile({ + provider: 'requesty', + name: 'Requesty', + baseUrl: 'https://router.requesty.ai/v1', + model: 'openai/gpt-5-mini', + apiKey: 'requesty-test-key', + ...overrides, + }) +} + function buildCommandcodeProfile(overrides: Partial = {}): ProviderProfile { return buildProfile({ provider: 'commandcode', @@ -2596,6 +2608,54 @@ describe('applyProviderProfileToProcessEnv', () => { expect(process.env.LLMTR_API_KEY).toBeUndefined() }) + test('retargeted Requesty profile withholds its saved credential', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildRequestyProfile({ baseUrl: 'https://example.test/v1' }), + ) + + expect(process.env.OPENAI_BASE_URL).toBe('https://example.test/v1') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.REQUESTY_API_KEY).toBeUndefined() + }) + + test('Requesty profile clears an ambient dedicated key so its saved key wins', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + process.env.REQUESTY_API_KEY = 'ambient-old' + + applyProviderProfileToProcessEnv( + buildRequestyProfile({ apiKey: 'selected-new' }), + ) + + expect(process.env.OPENAI_API_KEY).toBe('selected-new') + expect( + resolveRouteCredentialValue({ + routeId: 'requesty', + baseUrl: process.env.OPENAI_BASE_URL, + processEnv: process.env, + }), + ).toBe('selected-new') + }) + + test('keyless canonical Requesty profile keeps its ambient dedicated key', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + process.env.REQUESTY_API_KEY = 'ambient-requesty-key' + + applyProviderProfileToProcessEnv(buildRequestyProfile({ apiKey: undefined })) + + expect( + resolveRouteCredentialValue({ + routeId: 'requesty', + baseUrl: process.env.OPENAI_BASE_URL, + processEnv: process.env, + }), + ).toBe('ambient-requesty-key') + }) + test('query-bearing LLMTR profiles retain generic proxy capabilities', async () => { const { addProviderProfile, applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() @@ -5228,6 +5288,83 @@ describe('setActiveProviderProfile', () => { } }) + test('Requesty profiles keep saved-key ownership across restart and withhold it when retargeted', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) + process.chdir(tempDir) + process.env.CLAUDE_CONFIG_DIR = configDir + + try { + const { setActiveProviderProfile } = + await importFreshProviderProfileModules() + const canonical = buildRequestyProfile({ id: 'requesty_profile' }) + const keyless = buildRequestyProfile({ + id: 'requesty_keyless', + apiKey: undefined, + }) + const retargeted = buildRequestyProfile({ + id: 'requesty_proxy', + baseUrl: 'https://example.test/v1', + }) + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [canonical, keyless, retargeted], + })) + const readPersisted = () => + JSON.parse( + readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + ) + const { buildStartupEnvFromProfile } = await import( + `./providerProfile.js?ts=${Date.now()}-${Math.random()}` + ) + const ambient = { REQUESTY_API_KEY: 'ambient-unrelated-key' } + + setActiveProviderProfile('requesty_profile', { configDir }) + const persisted = readPersisted() + expect(persisted.env).toMatchObject({ + OPENAI_BASE_URL: 'https://router.requesty.ai/v1', + OPENAI_API_KEY: 'requesty-test-key', + REQUESTY_API_KEY: 'requesty-test-key', + }) + const startupEnv = await buildStartupEnvFromProfile({ + persisted, + processEnv: ambient, + }) + expect(startupEnv.REQUESTY_API_KEY).toBe('requesty-test-key') + delete persisted.env.REQUESTY_API_KEY + const migratedStartupEnv = await buildStartupEnvFromProfile({ + persisted, + processEnv: ambient, + }) + expect(migratedStartupEnv.REQUESTY_API_KEY).toBe('requesty-test-key') + + setActiveProviderProfile('requesty_keyless', { configDir }) + const keylessStartupEnv = await buildStartupEnvFromProfile({ + persisted: readPersisted(), + processEnv: ambient, + }) + expect(keylessStartupEnv.REQUESTY_API_KEY).toBe('ambient-unrelated-key') + + setActiveProviderProfile('requesty_proxy', { configDir }) + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.REQUESTY_API_KEY).toBeUndefined() + const retargetedPersisted = readPersisted() + expect(retargetedPersisted.env.OPENAI_API_KEY).toBeUndefined() + expect(retargetedPersisted.env.REQUESTY_API_KEY).toBeUndefined() + const retargetedStartupEnv = await buildStartupEnvFromProfile({ + persisted: retargetedPersisted, + processEnv: ambient, + }) + expect(retargetedStartupEnv.OPENAI_BASE_URL).toBe('https://example.test/v1') + expect(retargetedStartupEnv.OPENAI_API_KEY).toBeUndefined() + expect(retargetedStartupEnv.REQUESTY_API_KEY).toBeUndefined() + } finally { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + rmSync(configDir, { recursive: true, force: true }) + } + }) + test('keyed canonical LLMTR profiles persist their saved dedicated credential across restart', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index a522acf30..134276be2 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -74,6 +74,7 @@ import { isCanonicalLlmtrInferenceBaseUrl, isCanonicalMergeGatewayInferenceBaseUrl, isCanonicalCommandcodeInferenceBaseUrl, + isCanonicalRequestyInferenceBaseUrl, isFireworksBaseUrl, isGmiCloudBaseUrl, isLongcatBaseUrl, @@ -248,6 +249,17 @@ function isLlmtrProfile(profile: ProviderProfile): boolean { return !baseUrl || isCanonicalLlmtrInferenceBaseUrl(baseUrl) } +function isRequestyProfile(profile: ProviderProfile): boolean { + const { route } = resolveProfileCompatibility(profile.provider) + if (route.routeId !== 'requesty') { + return false + } + const baseUrl = profile.baseUrl?.trim() + // A missing base URL resolves to the Requesty default, which is canonical. + // Any other URL is a retargeted endpoint and must not receive the saved key. + return !baseUrl || isCanonicalRequestyInferenceBaseUrl(baseUrl) +} + function isMergeGatewayProfile(profile: ProviderProfile): boolean { const { route, compatibilityMode } = resolveProfileCompatibility(profile.provider) if (compatibilityMode !== 'openai') return false @@ -1369,6 +1381,8 @@ export function applyProviderProfileToProcessEnv( route.routeId === 'api-route' && !isApiRouteProfile(profile) const withholdRetargetedLlmtrCredential = route.routeId === 'llmtr' && !isLlmtrProfile(profile) + const withholdRetargetedRequestyCredential = + route.routeId === 'requesty' && !isRequestyProfile(profile) const withholdRetargetedMergeGatewayCredential = route.routeId === 'merge-gateway' && !isMergeGatewayProfile(profile) const withholdRetargetedCommandcodeCredential = @@ -1382,6 +1396,7 @@ export function applyProviderProfileToProcessEnv( !withholdRetargetedConcentrateCredential && !withholdRetargetedApiRouteCredential && !withholdRetargetedLlmtrCredential && + !withholdRetargetedRequestyCredential && !withholdRetargetedMergeGatewayCredential && !withholdRetargetedCommandcodeCredential && !withholdRetargetedGmiCloudCredential @@ -1571,6 +1586,15 @@ export function applyProviderProfileToProcessEnv( } } } + // REQUESTY_API_KEY is profile-managed: installing this env clears the + // ambient value so a keyed profile's saved key wins. A keyless canonical + // profile adopts the ambient key here; a retargeted profile gets neither. + if (isRequestyProfile(profile) && !profile.apiKey) { + const ambientRequestyKey = sanitizeApiKey(process.env.REQUESTY_API_KEY) + if (ambientRequestyKey) { + openAIProfileEnv.REQUESTY_API_KEY = ambientRequestyKey + } + } if (isCommandcodeProfile(profile)) { openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'commandcode' if (!profile.apiKey) { @@ -1908,6 +1932,8 @@ function buildOpenAICompatibleStartupEnv( activeProfileRouteId === 'api-route' && !isApiRouteProfile(activeProfile) const withholdRetargetedLlmtrCredential = activeProfileRouteId === 'llmtr' && !isLlmtrProfile(activeProfile) + const withholdRetargetedRequestyCredential = + activeProfileRouteId === 'requesty' && !isRequestyProfile(activeProfile) const withholdRetargetedMergeGatewayCredential = activeProfileRouteId === 'merge-gateway' && !isMergeGatewayProfile(activeProfile) const withholdRetargetedCommandcodeCredential = @@ -1919,6 +1945,7 @@ function buildOpenAICompatibleStartupEnv( const isConcentrateProfileFlag = isConcentrateProfile(activeProfile) const isApiRouteProfileFlag = isApiRouteProfile(activeProfile) const isLlmtrProfileFlag = isLlmtrProfile(activeProfile) + const isRequestyProfileFlag = isRequestyProfile(activeProfile) const isMergeGatewayProfileFlag = isMergeGatewayProfile(activeProfile) const isCommandcodeProfileFlag = isCommandcodeProfile(activeProfile) @@ -1928,6 +1955,7 @@ function buildOpenAICompatibleStartupEnv( !withholdRetargetedConcentrateCredential && !withholdRetargetedApiRouteCredential && !withholdRetargetedLlmtrCredential && + !withholdRetargetedRequestyCredential && !withholdRetargetedMergeGatewayCredential && !withholdRetargetedCommandcodeCredential ) { @@ -1993,6 +2021,9 @@ function buildOpenAICompatibleStartupEnv( if (isLlmtrProfileFlag) { strictEnv.LLMTR_API_KEY = activeProfile.apiKey } + if (isRequestyProfileFlag) { + strictEnv.REQUESTY_API_KEY = activeProfile.apiKey + } if (isMergeGatewayProfileFlag) { strictEnv.MERGE_GATEWAY_API_KEY = activeProfile.apiKey } @@ -2078,6 +2109,7 @@ function buildOpenAICompatibleStartupEnv( !withholdRetargetedConcentrateCredential && !withholdRetargetedApiRouteCredential && !withholdRetargetedLlmtrCredential && + !withholdRetargetedRequestyCredential && !withholdRetargetedMergeGatewayCredential && !withholdRetargetedCommandcodeCredential ) { @@ -2113,6 +2145,9 @@ function buildOpenAICompatibleStartupEnv( if (isLlmtrProfileFlag) { env.LLMTR_API_KEY = activeProfile.apiKey } + if (isRequestyProfileFlag) { + env.REQUESTY_API_KEY = activeProfile.apiKey + } if (isMergeGatewayProfileFlag) { env.MERGE_GATEWAY_API_KEY = activeProfile.apiKey } diff --git a/src/utils/swarm/spawnUtils.test.ts b/src/utils/swarm/spawnUtils.test.ts index 1316bc1d5..627dbc185 100644 --- a/src/utils/swarm/spawnUtils.test.ts +++ b/src/utils/swarm/spawnUtils.test.ts @@ -67,6 +67,16 @@ test('buildInheritedEnvVars forwards an LLMTR credential without inventing route expect(envVars).not.toContain('OPENAI_BASE_URL=') }) +test('buildInheritedEnvVars forwards a Requesty credential without inventing route state', () => { + process.env.REQUESTY_API_KEY = 'requesty-key' + + const envVars = buildInheritedEnvVars() + + expect(envVars).toContain('REQUESTY_API_KEY=requesty-key') + expect(envVars).not.toContain('CLAUDE_CODE_USE_OPENAI=1') + expect(envVars).not.toContain('OPENAI_BASE_URL=') +}) + test('buildInheritedEnvVars forwards a GMI Cloud credential without inventing route state', () => { process.env.GMI_API_KEY = 'gmi-key' diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index 3489ae592..af9e4d90b 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -110,6 +110,7 @@ const TEAMMATE_ENV_VARS = [ 'VENICE_API_KEY', 'MIMO_API_KEY', 'LLMTR_API_KEY', + 'REQUESTY_API_KEY', 'MERGE_GATEWAY_API_KEY', 'CMD_API_KEY', 'COMMANDCODE_API_KEY',