From 13de4e85df7f5fadc8cd15a76076374dc112360b Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 22 Apr 2026 00:59:32 +0800 Subject: [PATCH] fix(provider): saved profile ignored when stale CLAUDE_CODE_USE_* in shell (#807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(provider): saved profile ignored when stale CLAUDE_CODE_USE_* in shell Users reported "my saved /provider profile isn't picked up at startup — the banner shows gpt-4o / api.openai.com even though I saved Moonshot". Root cause: applyActiveProviderProfileFromConfig() bailed out whenever hasProviderSelectionFlags(processEnv) was true — i.e. whenever ANY CLAUDE_CODE_USE_* flag was present. But a bare `CLAUDE_CODE_USE_OPENAI=1` with no paired OPENAI_BASE_URL / OPENAI_MODEL is almost always a stale shell export left over from a prior manual setup, not genuine startup intent. Respecting it skipped the saved profile and let StartupScreen.ts fall through to the hardcoded `gpt-4o` / `https://api.openai.com/v1` defaults — the exact symptom users see. Fix: narrow the guard from "any flag set" to "flag set AND at least one concrete config value (BASE_URL, MODEL, or API_KEY)". A bare stale flag no longer blocks the saved profile. A real shell selection (flag + URL or flag + model) still wins, preserving the "explicit startup intent overrides saved profile" contract. New helper: hasCompleteProviderSelection(env). Per-provider check for a paired concrete value. Bedrock/Vertex/Foundry keep the flag-alone semantic since they rely on ambient AWS/GCP credentials rather than env config. Three new tests cover the bug and the two counter-cases: - bare USE flag → profile applies (fixes the bug) - USE flag + BASE_URL → profile blocked (preserves explicit intent) - USE flag + MODEL → profile blocked (preserves explicit intent) Co-Authored-By: OpenClaude * fix(provider): don't overlay stale legacy profile on plural-managed env Second half of the "saved profile not picked up in banner" bug. The prior commit fixed the guard that prevented applyActiveProviderProfileFromConfig() from firing when a stale CLAUDE_CODE_USE_* flag was in the shell. But even when the plural system applies correctly, buildStartupEnvFromProfile() was then loading the legacy .openclaude-profile.json AND overwriting the plural-managed env with whatever that file contained. addProviderProfile() (the call path the /provider preset picker uses) does NOT sync the legacy file, so a user who went: manual setup: CLAUDE_CODE_USE_OPENAI=1 + OPENAI_MODEL=gpt-4o → writes .openclaude-profile.json as { openai, gpt-4o, ... } /provider: add Moonshot preset, mark active → writes plural config; legacy file UNCHANGED would see startup reliably apply Moonshot env first, then get it clobbered by the stale legacy file. Banner shows gpt-4o / api.openai.com while runtime ends up with the correct env via a different code path — exactly the user-reported symptom. Fix: in buildStartupEnvFromProfile, when the plural system has already set env (CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'), skip the legacy-file overlay entirely and return processEnv unchanged. Legacy is now strictly a first-run / fallback path for users who haven't adopted the plural system. Also removes the stripped-then-rebuilt env construction that was part of the old overlay path — no longer needed. Test updates: - Replaced "lets saved startup profile override profile-managed env" (encoded the old broken behavior) with a regression test that pins the new semantic: plural env survives when legacy is stale. - Added "falls back to legacy when plural hasn't applied" to pin the first-run path still works. Co-Authored-By: OpenClaude --------- Co-authored-by: OpenClaude --- src/utils/providerProfile.test.ts | 55 ++++++++++++++++----- src/utils/providerProfile.ts | 38 ++++++--------- src/utils/providerProfiles.test.ts | 77 ++++++++++++++++++++++++++++++ src/utils/providerProfiles.ts | 60 ++++++++++++++++++++++- 4 files changed, 195 insertions(+), 35 deletions(-) diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index f057c5f9..76e03278 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -572,31 +572,64 @@ test('buildStartupEnvFromProfile leaves explicit provider selections untouched', assert.equal(env.OPENAI_API_KEY, undefined) }) -test('buildStartupEnvFromProfile lets saved startup profile override profile-managed env', async () => { +test('buildStartupEnvFromProfile preserves plural-profile env when the legacy file is stale', async () => { + // Regression: a user saves a provider via /provider (plural system). + // addProviderProfile does NOT sync the legacy .openclaude-profile.json, + // so the legacy file retains whatever it had from an earlier setup (e.g. + // OpenAI defaults). At startup, applyActiveProviderProfileFromConfig() + // correctly applies the active plural profile (Moonshot) first, marking + // env with CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED=1. The legacy-file + // load must NOT overwrite that env — it previously did, surfacing as + // "banner shows the wrong provider / model". const processEnv = { CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1', - CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'saved_ollama', + CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'saved_moonshot', CLAUDE_CODE_USE_OPENAI: '1', - OPENAI_BASE_URL: 'http://localhost:11434/v1', - OPENAI_MODEL: 'llama3.1:8b', + OPENAI_BASE_URL: 'https://api.moonshot.ai/v1', + OPENAI_MODEL: 'kimi-k2.6', } const env = await buildStartupEnvFromProfile({ + // Stale legacy file — points at SambaNova, but user's active plural + // profile is Moonshot and was just applied. persisted: profile('openai', { - OPENAI_API_KEY: 'sk-persisted', + OPENAI_API_KEY: 'sk-stale', OPENAI_MODEL: 'Meta-Llama-3.1-70B-Instruct', OPENAI_BASE_URL: 'https://api.sambanova.ai/v1', }), processEnv, }) + assert.equal(env, processEnv) + assert.equal(env.OPENAI_BASE_URL, 'https://api.moonshot.ai/v1') + assert.equal(env.OPENAI_MODEL, 'kimi-k2.6') + // Plural markers are retained — downstream code uses them to verify the + // env still belongs to the profile it was applied from. + assert.equal(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, '1') + assert.equal(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, 'saved_moonshot') +}) + +test('buildStartupEnvFromProfile falls back to legacy file when plural system has not applied', async () => { + // Counter-example: first-run user with only the legacy file (no plural + // active profile yet). The legacy file is the correct source, so the + // load must proceed as before. + const processEnv = { + CLAUDE_CODE_USE_OPENAI: '1', + } + + const env = await buildStartupEnvFromProfile({ + persisted: profile('openai', { + OPENAI_API_KEY: 'sk-legacy', + OPENAI_MODEL: 'gpt-4o', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + }), + processEnv, + }) + assert.notEqual(env, processEnv) - assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1') - assert.equal(env.OPENAI_API_KEY, 'sk-persisted') - assert.equal(env.OPENAI_MODEL, 'Meta-Llama-3.1-70B-Instruct') - assert.equal(env.OPENAI_BASE_URL, 'https://api.sambanova.ai/v1') - assert.equal(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, undefined) - assert.equal(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, undefined) + assert.equal(env.OPENAI_API_KEY, 'sk-legacy') + assert.equal(env.OPENAI_BASE_URL, 'https://api.openai.com/v1') + assert.equal(env.OPENAI_MODEL, 'gpt-4o') }) test('buildStartupEnvFromProfile treats explicit falsey provider flags as user intent', async () => { diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 0753b660..df3aafaf 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -841,43 +841,35 @@ export async function buildStartupEnvFromProfile(options?: { const processEnv = options?.processEnv ?? process.env const persisted = options?.persisted ?? loadProfileFile() - // Saved /provider profiles should still win over provider-manager env that was - // auto-applied during startup. Only an explicit shell/flag provider selection - // should bypass the persisted startup profile. - // const profileManagedEnv = processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1' - // If the user explicitly selected a provider via env, allow it to bypass - // the persisted profile only when we can prove it was managed by the - // persisted profile env itself. + // The legacy single-profile file (~/.openclaude-profile.json) is a + // first-run / fallback mechanism. The newer plural provider-profile + // system (`/provider` presets + activeProviderProfileId in config) is + // applied earlier in the bootstrap via applyActiveProviderProfileFromConfig + // and signals completion with CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED=1. // - // Practically: on initial startup, provider routing env vars can already - // be present due to earlier auto-application steps. We should still apply - // the persisted profile rather than returning early. + // If the plural system has already set env, trust it — do NOT overlay the + // legacy file. addProviderProfile() does not sync the legacy file, so a + // stale legacy file (e.g. OpenAI defaults from an earlier manual setup) + // would otherwise overwrite the correct plural env and surface as the + // "banner shows gpt-4o / api.openai.com even though my saved profile is + // Moonshot" bug. + if (profileManagedEnv) { + return processEnv + } if (!persisted) { return processEnv } - const launchProcessEnv = profileManagedEnv - ? (() => { - const cleanedEnv = { ...processEnv } - for (const key of PROFILE_ENV_KEYS) { - delete cleanedEnv[key] - } - delete cleanedEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED - delete cleanedEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID - return cleanedEnv - })() - : processEnv - return buildLaunchEnv({ profile: persisted.profile, persisted, goal: options?.goal ?? normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL), - processEnv: launchProcessEnv, + processEnv, getOllamaChatBaseUrl: options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl, resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel, diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 52e4ea38..d222cadd 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -256,6 +256,83 @@ describe('applyActiveProviderProfileFromConfig', () => { expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b') }) + test('applies active profile when a bare CLAUDE_CODE_USE_OPENAI flag is stale (no BASE_URL/MODEL)', async () => { + // Regression: a leftover `CLAUDE_CODE_USE_OPENAI=1` in the shell with no + // paired OPENAI_BASE_URL / OPENAI_MODEL is not a real explicit selection + // — it's a stale export. The previous guard treated it as intent and + // skipped the saved profile, causing the startup banner to show hardcoded + // defaults (gpt-4o @ api.openai.com) instead of the user's active + // profile. + const { applyActiveProviderProfileFromConfig } = + await importFreshProviderProfileModules() + process.env.CLAUDE_CODE_USE_OPENAI = '1' + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_BASE + delete process.env.OPENAI_MODEL + + const applied = applyActiveProviderProfileFromConfig({ + providerProfiles: [ + buildProfile({ + id: 'saved_moonshot', + baseUrl: 'https://api.moonshot.ai/v1', + model: 'kimi-k2.6', + }), + ], + activeProviderProfileId: 'saved_moonshot', + } as any) + + expect(applied?.id).toBe('saved_moonshot') + expect(process.env.OPENAI_BASE_URL).toBe('https://api.moonshot.ai/v1') + expect(process.env.OPENAI_MODEL).toBe('kimi-k2.6') + }) + + test('still respects complete shell selection with USE flag + BASE_URL', async () => { + // Counter-example: when the user really did set both the flag AND a + // concrete BASE_URL, that IS explicit intent and wins over the saved + // profile. This preserves the original "explicit startup wins" semantic. + const { applyActiveProviderProfileFromConfig } = + await importFreshProviderProfileModules() + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://192.168.1.1:8080/v1' + delete process.env.OPENAI_MODEL + + const applied = applyActiveProviderProfileFromConfig({ + providerProfiles: [ + buildProfile({ + id: 'saved_moonshot', + baseUrl: 'https://api.moonshot.ai/v1', + model: 'kimi-k2.6', + }), + ], + activeProviderProfileId: 'saved_moonshot', + } as any) + + expect(applied).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('http://192.168.1.1:8080/v1') + }) + + test('still respects complete shell selection with USE flag + MODEL', async () => { + const { applyActiveProviderProfileFromConfig } = + await importFreshProviderProfileModules() + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_MODEL = 'gpt-4o-mini' + delete process.env.OPENAI_BASE_URL + + const applied = applyActiveProviderProfileFromConfig({ + providerProfiles: [ + buildProfile({ + id: 'saved_moonshot', + baseUrl: 'https://api.moonshot.ai/v1', + model: 'kimi-k2.6', + }), + ], + activeProviderProfileId: 'saved_moonshot', + } as any) + + expect(applied).toBeUndefined() + expect(process.env.OPENAI_MODEL).toBe('gpt-4o-mini') + }) + test('does not override explicit startup selection when profile marker is stale', async () => { const { applyActiveProviderProfileFromConfig } = await importFreshProviderProfileModules() diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 7c65ff9d..a44e3573 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -322,6 +322,58 @@ function hasProviderSelectionFlags( ) } +/** + * A "complete" explicit provider selection = a USE flag AND at least one + * concrete config value that tells us WHERE to route (a base URL) or WHAT + * to run (a model id). A bare `CLAUDE_CODE_USE_OPENAI=1` with nothing else + * is almost always a stale shell export from a previous session, not real + * intent — and if we respect it, we skip the user's saved active profile + * and fall back to hardcoded defaults (gpt-4o / api.openai.com), which is + * the exact bug users report as "my saved provider isn't picked up". + * + * Used to gate whether saved-profile env should override shell state at + * startup. The weaker `hasProviderSelectionFlags` is still used for the + * anthropic-profile conflict check (any flag is a conflict for + * first-party anthropic) and for alignment fingerprinting. + */ +function hasCompleteProviderSelection( + processEnv: NodeJS.ProcessEnv = process.env, +): boolean { + if (!hasProviderSelectionFlags(processEnv)) return false + if (processEnv.CLAUDE_CODE_USE_OPENAI !== undefined) { + return ( + trimOrUndefined(processEnv.OPENAI_BASE_URL) !== undefined || + trimOrUndefined(processEnv.OPENAI_API_BASE) !== undefined || + trimOrUndefined(processEnv.OPENAI_MODEL) !== undefined + ) + } + if (processEnv.CLAUDE_CODE_USE_GEMINI !== undefined) { + return ( + trimOrUndefined(processEnv.GEMINI_BASE_URL) !== undefined || + trimOrUndefined(processEnv.GEMINI_MODEL) !== undefined || + trimOrUndefined(processEnv.GEMINI_API_KEY) !== undefined || + trimOrUndefined(processEnv.GOOGLE_API_KEY) !== undefined + ) + } + if (processEnv.CLAUDE_CODE_USE_MISTRAL !== undefined) { + return ( + trimOrUndefined(processEnv.MISTRAL_BASE_URL) !== undefined || + trimOrUndefined(processEnv.MISTRAL_MODEL) !== undefined || + trimOrUndefined(processEnv.MISTRAL_API_KEY) !== undefined + ) + } + if (processEnv.CLAUDE_CODE_USE_GITHUB !== undefined) { + return ( + trimOrUndefined(processEnv.GITHUB_TOKEN) !== undefined || + trimOrUndefined(processEnv.GH_TOKEN) !== undefined || + trimOrUndefined(processEnv.OPENAI_MODEL) !== undefined + ) + } + // Bedrock / Vertex / Foundry signal cloud-provider routing in env; treat + // the flag alone as complete (these paths rely on ambient AWS/GCP creds). + return true +} + function hasConflictingProviderFlagsForProfile( processEnv: NodeJS.ProcessEnv, profile: ProviderProfile, @@ -564,9 +616,15 @@ export function applyActiveProviderProfileFromConfig( processEnv[PROFILE_ENV_APPLIED_FLAG] === '1' && trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) === activeProfile.id - if (!options?.force && (hasProviderSelectionFlags(processEnv) || processEnv[PROFILE_ENV_APPLIED_FLAG] === '1')) { + if (!options?.force && (hasCompleteProviderSelection(processEnv) || processEnv[PROFILE_ENV_APPLIED_FLAG] === '1')) { // Respect explicit startup provider intent. Auto-heal only when this // exact active profile previously applied the current env. + // NOTE: we gate on hasCompleteProviderSelection (flag + concrete config) + // rather than hasProviderSelectionFlags alone. A bare CLAUDE_CODE_USE_*=1 + // with no BASE_URL/MODEL is almost always a stale shell export, not + // intent — respecting it would skip the saved profile and fall through + // to hardcoded provider defaults, which surfaces as "my saved provider + // isn't being picked up at startup". if (!isCurrentEnvProfileManaged) { return undefined }