diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index 57f0fdbb2..43069714e 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -303,9 +303,21 @@ export function buildCurrentProviderSummary(options?: { }) if (effectiveRouteId === 'gemini-vertex') { - const vertexMetadata = getProviderPresetUiMetadata('gemini-vertex', processEnv) - const location = getGeminiVertexLocation(processEnv) - const project = getGeminiVertexProjectId(processEnv) + // Mirror getAnthropicClient: when routing purely from the saved profile (no + // env flag), the saved profile env supplies the project/model/location and + // wins over ambient process defaults. With an explicit env flag, the env + // values take precedence (the saved keys only fill gaps). + const routedFromProfileOnly = + !isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI_VERTEX) && + persisted?.profile === 'gemini-vertex' + const vertexEnv = ( + routedFromProfileOnly + ? { ...processEnv, ...persisted?.env } + : processEnv + ) as NodeJS.ProcessEnv + const vertexMetadata = getProviderPresetUiMetadata('gemini-vertex', vertexEnv) + const location = getGeminiVertexLocation(vertexEnv) + const project = getGeminiVertexProjectId(vertexEnv) // Mirror the startup screen: the native client always targets // /projects//locations/ and requires a project, so // surface a project-required state rather than a project-less endpoint. @@ -315,7 +327,7 @@ export function buildCurrentProviderSummary(options?: { return { providerLabel: vertexMetadata.label, modelLabel: getSafeDisplayValue( - getGeminiVertexModel(processEnv), + getGeminiVertexModel(vertexEnv), secretSource, ), endpointLabel: getSafeDisplayValue(endpoint, secretSource), diff --git a/src/components/StartupScreen.test.ts b/src/components/StartupScreen.test.ts index 8bfdf75f6..b957a4240 100644 --- a/src/components/StartupScreen.test.ts +++ b/src/components/StartupScreen.test.ts @@ -403,4 +403,35 @@ describe('detectProvider — Gemini Vertex endpoint reflects the runtime project 'https://aiplatform.googleapis.com/v1/projects/alias-project/locations/global', ) }) + + test('detects Vertex from a saved active profile with no env flag (profile-only)', () => { + // No CLAUDE_CODE_USE_GEMINI_VERTEX: routing comes purely from the saved + // active profile, exactly like getAnthropicClient. The profile's project + // (stored in baseUrl) and model must drive the startup display instead of + // falling through to the default Anthropic/OpenAI branch. + const restore = getGlobalConfig() + saveGlobalConfig(current => ({ + ...current, + providerProfiles: [ + { + id: 'saved_vertex', + name: 'Saved Vertex', + provider: 'gemini-vertex', + baseUrl: 'saved-proj', + model: 'gemini-2.5-pro', + }, + ], + activeProviderProfileId: 'saved_vertex', + })) + try { + const result = detectProvider() + expect(result.name).toBe('Gemini Vertex') + expect(result.model).toBe('gemini-2.5-pro') + expect(result.baseUrl).toBe( + 'https://aiplatform.googleapis.com/v1/projects/saved-proj/locations/global', + ) + } finally { + saveGlobalConfig(() => restore) + } + }) }) diff --git a/src/components/StartupScreen.ts b/src/components/StartupScreen.ts index ec886dc8a..39a4983c3 100644 --- a/src/components/StartupScreen.ts +++ b/src/components/StartupScreen.ts @@ -24,6 +24,12 @@ import { import { BRAND_TAGLINE } from '../constants/brand.js' import { getGlobalConfig } from '../utils/config.js' import { isEnvTruthy } from '../utils/envUtils.js' +import { getPrimaryModel } from '../utils/providerModels.js' +import { + geminiVertexProjectFromProfile, + getActiveProviderProfile, + isGeminiVertexEffectiveProvider, +} from '../utils/providerProfiles.js' import { ANSI_DIM, ANSI_RESET, ansiRgb } from '../utils/terminalAnsi.js' import { resolveLogoPalette, @@ -84,22 +90,43 @@ const LOGO_CLAUDE = [ // ─── Provider detection ─────────────────────────────────────────────────────── export function detectProvider(modelOverride?: string): { name: string; model: string; baseUrl: string; isLocal: boolean } { - const useGeminiVertex = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX) + // Mirror getAnthropicClient: a saved active gemini-vertex profile routes the + // session to Vertex even with no CLAUDE_CODE_USE_GEMINI_VERTEX env flag, so + // the startup display must reuse the effective-provider check (env flag OR + // saved profile) instead of looking at the raw env flag alone. + const activeProfile = getActiveProviderProfile() + const useGeminiVertex = isGeminiVertexEffectiveProvider( + process.env, + activeProfile, + ) const useGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) const useGithub = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) const useOpenAI = isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) const useMistral = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL) if (useGeminiVertex) { - // Use the shared resolvers so this display matches the runtime/provider - // summary contract (same default model, location and project-alias chain, - // with sanitization) instead of drifting via manual env reads. + // When routing purely from the saved profile (no env flag), the profile's + // own project (stored in baseUrl) and model win over ambient/default env — + // exactly as getAnthropicClient resolves them. Env still wins when the flag + // is explicitly set. Use the shared resolvers so this display matches the + // runtime/provider contract (default model, location and project-alias + // chain, with sanitization) instead of drifting via manual env reads. + const routedFromProfileOnly = + !isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX) + const profileProject = routedFromProfileOnly + ? geminiVertexProjectFromProfile(activeProfile?.baseUrl) + : undefined + const profileModel = + routedFromProfileOnly && activeProfile?.model + ? getPrimaryModel(activeProfile.model) + : undefined const model = modelOverride?.trim() || + profileModel || getGeminiVertexModel(process.env) || DEFAULT_GEMINI_VERTEX_MODEL const location = getGeminiVertexLocation(process.env) - const project = getGeminiVertexProjectId(process.env) + const project = profileProject ?? getGeminiVertexProjectId(process.env) // The native client always targets /projects//locations/ // and throws when no project resolves. Mirror that contract here: when a // project is missing, surface a clear "project required" state instead of a diff --git a/src/integrations/vendors/gemini-vertex.ts b/src/integrations/vendors/gemini-vertex.ts index be0d1b7c9..4956f686a 100644 --- a/src/integrations/vendors/gemini-vertex.ts +++ b/src/integrations/vendors/gemini-vertex.ts @@ -62,7 +62,10 @@ export default defineVendor({ contextWindow: 1_048_576, maxOutputTokens: 65_536, }, - // Thinking model — available on global endpoint only + // Thinking model — available on the global endpoint only. The client + // enforces this via isGlobalOnlyVertexModel (utils/geminiAuth.ts): a + // regional GEMINI_VERTEX_LOCATION with this model is rejected. Keep the + // two in sync. { id: 'gemini-3.5-flash', apiName: 'gemini-3.5-flash', diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 9951e4a17..56209d662 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -377,7 +377,9 @@ test('routes Gemini Vertex provider requests through the native client', async ( process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' process.env.GEMINI_VERTEX_PROJECT = 'project-test-1234' process.env.GEMINI_VERTEX_LOCATION = 'us-central1' - process.env.GEMINI_VERTEX_MODEL = 'gemini-3.5-flash' + // A regional location requires a regionally-available model (gemini-3.5-flash + // is global-only and would be rejected — see the dedicated test below). + process.env.GEMINI_VERTEX_MODEL = 'gemini-2.5-flash' process.env.GEMINI_ACCESS_TOKEN = 'vertex-access-token' process.env.OPENAI_MODEL = 'gpt-5.5' @@ -405,18 +407,18 @@ test('routes Gemini Vertex provider requests through the native client', async ( const client = (await getAnthropicClient({ maxRetries: 0, - model: 'gemini-3.5-flash', + model: 'gemini-2.5-flash', })) as unknown as ShimClient const response = await client.beta.messages.create({ - model: 'gemini-3.5-flash', + model: 'gemini-2.5-flash', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false, }) expect(capturedUrl).toBe( - 'https://us-central1-aiplatform.googleapis.com/v1/projects/project-test-1234/locations/us-central1/publishers/google/models/gemini-3.5-flash:generateContent', + 'https://us-central1-aiplatform.googleapis.com/v1/projects/project-test-1234/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent', ) expect(capturedHeaders?.get('authorization')).toBe('Bearer vertex-access-token') expect(capturedHeaders?.get('x-goog-user-project')).toBe('project-test-1234') @@ -425,10 +427,71 @@ test('routes Gemini Vertex provider requests through the native client', async ( ]) expect(response).toMatchObject({ role: 'assistant', - model: 'gemini-3.5-flash', + model: 'gemini-2.5-flash', }) }) +test('rejects a global-only Vertex model paired with a regional location', async () => { + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_VERTEX + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GEMINI_VERTEX_PROJECT = 'project-test-1234' + process.env.GEMINI_VERTEX_LOCATION = 'us-central1' + // gemini-3.5-flash is served only from the global endpoint, so a regional + // location must fail fast instead of building an unusable regional URL. + process.env.GEMINI_VERTEX_MODEL = 'gemini-3.5-flash' + process.env.GEMINI_ACCESS_TOKEN = 'vertex-access-token' + + await expect( + getAnthropicClient({ maxRetries: 0, model: 'gemini-3.5-flash' }), + ).rejects.toThrow(/only available on the global endpoint/) +}) + +test('allows a global-only Vertex model on the global endpoint', async () => { + let capturedUrl: string | undefined + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_VERTEX + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GEMINI_VERTEX_PROJECT = 'project-test-1234' + process.env.GEMINI_VERTEX_LOCATION = 'global' + process.env.GEMINI_VERTEX_MODEL = 'gemini-3.5-flash' + process.env.GEMINI_ACCESS_TOKEN = 'vertex-access-token' + + globalThis.fetch = (async (input, init) => { + capturedUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'vertex ok' }] } }], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as FetchType + + const client = (await getAnthropicClient({ + maxRetries: 0, + model: 'gemini-3.5-flash', + })) as unknown as ShimClient + + await client.beta.messages.create({ + model: 'gemini-3.5-flash', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + + expect(capturedUrl).toBe( + 'https://aiplatform.googleapis.com/v1/projects/project-test-1234/locations/global/publishers/google/models/gemini-3.5-flash:generateContent', + ) +}) + test('saved-profile-only Vertex prefers the profile project/model over ambient env', async () => { let capturedUrl: string | undefined diff --git a/src/services/api/client.ts b/src/services/api/client.ts index a600746cf..97f527030 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -53,6 +53,7 @@ import { getGeminiVertexLocation, getGeminiVertexModel, getGeminiVertexProjectId, + isGlobalOnlyVertexModel, resolveGeminiCredential, resolveGeminiVertexAuthMode, } from '../../utils/geminiAuth.js' @@ -589,6 +590,14 @@ export async function getAnthropicClient({ if (!vertexProject) { throw new Error('Gemini Vertex project is required via GEMINI_VERTEX_PROJECT or GOOGLE_CLOUD_PROJECT') } + // Some Vertex models are served only from the global endpoint; routing one + // to a regional location builds a URL the model is not available on. Fail + // fast with an actionable message instead of issuing a doomed request. + if (location !== 'global' && isGlobalOnlyVertexModel(geminiVertexModel)) { + throw new Error( + `Gemini Vertex model "${geminiVertexModel}" is only available on the global endpoint; set GEMINI_VERTEX_LOCATION=global (currently "${location}") to use it.`, + ) + } const { createGeminiVertexClient } = await import('./geminiVertexClient.js') return createGeminiVertexClient({ diff --git a/src/utils/geminiAuth.ts b/src/utils/geminiAuth.ts index 82174c3e5..63a9bb90c 100644 --- a/src/utils/geminiAuth.ts +++ b/src/utils/geminiAuth.ts @@ -83,6 +83,17 @@ export function getGeminiVertexModel( return sanitizeCredential(env.GEMINI_VERTEX_MODEL) ?? DEFAULT_GEMINI_VERTEX_MODEL } +// Models Vertex only serves from the global endpoint. Pairing one with a +// regional GEMINI_VERTEX_LOCATION would build a regional URL the model is not +// available on, so the client validates this before issuing the request. The +// catalog (integrations/vendors/gemini-vertex.ts) documents which models are +// global-only; keep the two in sync. +const GLOBAL_ONLY_VERTEX_MODELS = new Set(['gemini-3.5-flash']) + +export function isGlobalOnlyVertexModel(model: string | undefined): boolean { + return model !== undefined && GLOBAL_ONLY_VERTEX_MODELS.has(model.trim()) +} + export function getGeminiAuthMode( env: NodeJS.ProcessEnv = process.env, ): GeminiAuthMode | undefined { diff --git a/src/utils/log.errorReportingGate.test.ts b/src/utils/log.errorReportingGate.test.ts new file mode 100644 index 000000000..b0635e747 --- /dev/null +++ b/src/utils/log.errorReportingGate.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { getGlobalConfig, saveGlobalConfig } from './config.js' +import { getInMemoryErrors, logError } from './log.js' + +// Regression for the privacy gate in logError(): a Gemini Vertex session must +// get no-error-reporting treatment whether it routes via the env flag OR a +// saved active profile (effective-provider check), so a saved-profile-only +// Vertex session does not leak errors to the in-memory/reporting sink. +describe('logError error-reporting gate — Gemini Vertex effective provider', () => { + const PROVIDER_FLAGS = [ + 'CLAUDE_CODE_USE_GEMINI_VERTEX', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_USE_GITHUB', + ] + const savedFlags: Record = {} + let restoreConfig: ReturnType + + beforeEach(() => { + for (const flag of PROVIDER_FLAGS) { + savedFlags[flag] = process.env[flag] + delete process.env[flag] + } + restoreConfig = getGlobalConfig() + }) + + afterEach(() => { + saveGlobalConfig(() => restoreConfig) + for (const flag of PROVIDER_FLAGS) { + if (savedFlags[flag] === undefined) { + delete process.env[flag] + } else { + process.env[flag] = savedFlags[flag] + } + } + }) + + function recorded(marker: string): boolean { + return getInMemoryErrors().some(entry => entry.error.includes(marker)) + } + + test('records errors when not routing to a cloud provider (control)', () => { + saveGlobalConfig(current => ({ + ...current, + providerProfiles: [], + activeProviderProfileId: undefined, + })) + const marker = `gate-control-${Date.now()}` + logError(new Error(marker)) + expect(recorded(marker)).toBe(true) + }) + + test('suppresses errors for a saved-profile-only Gemini Vertex session', () => { + // No CLAUDE_CODE_USE_GEMINI_VERTEX flag — routing comes purely from the + // saved active profile, just like getAnthropicClient. + saveGlobalConfig(current => ({ + ...current, + providerProfiles: [ + { + id: 'saved_vertex', + name: 'Saved Vertex', + provider: 'gemini-vertex', + baseUrl: 'saved-proj', + model: 'gemini-2.5-pro', + }, + ], + activeProviderProfileId: 'saved_vertex', + })) + const marker = `gate-vertex-${Date.now()}` + logError(new Error(marker)) + expect(recorded(marker)).toBe(false) + }) +}) diff --git a/src/utils/log.ts b/src/utils/log.ts index 73a572162..ee55ed8a4 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -20,6 +20,7 @@ import { stripDisplayTags, stripDisplayTagsAllowEmpty } from './displayTags.js' import { isEnvTruthy } from './envUtils.js' import { toError } from './errors.js' import { isEssentialTrafficOnly } from './privacyLevel.js' +import { isGeminiVertexEffectiveProvider } from './providerProfiles.js' import { jsonParse } from './slowOperations.js' /** @@ -167,10 +168,13 @@ export function logError(error: unknown): void { try { // Check if error reporting should be disabled if ( - // Cloud providers (Bedrock/Vertex/Gemini Vertex/Foundry) always disable features + // Cloud providers (Bedrock/Vertex/Gemini Vertex/Foundry) always disable features. + // Gemini Vertex uses the effective-provider check so a saved-profile-only + // Vertex session (no CLAUDE_CODE_USE_GEMINI_VERTEX flag) gets the same + // no-error-reporting treatment as the env-flag route. isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX) || + isGeminiVertexEffectiveProvider() || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || process.env.DISABLE_ERROR_REPORTING || isEssentialTrafficOnly() diff --git a/src/utils/messages.isNotEmptyMessage.test.ts b/src/utils/messages.isNotEmptyMessage.test.ts index 9c0bc9f68..99c02bde9 100644 --- a/src/utils/messages.isNotEmptyMessage.test.ts +++ b/src/utils/messages.isNotEmptyMessage.test.ts @@ -29,6 +29,34 @@ describe('isNotEmptyMessage malformed-history hardening', () => { ).toBe(false) }) + test('treats a single typeless content block as empty', () => { + // A first block without a string `type` (e.g. `{ foo: 'bar' }`) is not a + // real content block, so it must not slip through the non-text branch as + // non-empty. + expect( + isNotEmptyMessage({ + type: 'user', + message: { content: [{ foo: 'bar' }] }, + } as unknown as Message), + ).toBe(false) + expect( + isNotEmptyMessage({ + type: 'user', + message: { content: [{ type: 123 }] }, + } as unknown as Message), + ).toBe(false) + }) + + test('still reports a non-empty single non-text block', () => { + // A well-formed non-text block (e.g. an image) is still non-empty. + expect( + isNotEmptyMessage({ + type: 'user', + message: { content: [{ type: 'image', source: {} }] }, + } as unknown as Message), + ).toBe(true) + }) + test('still reports a non-empty string user message', () => { expect( isNotEmptyMessage({ diff --git a/src/utils/messages.ts b/src/utils/messages.ts index d01af02f5..0ff9439c3 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -749,6 +749,12 @@ export function isNotEmptyMessage(message: Message): boolean { if (!firstBlock || typeof firstBlock !== 'object') { return false } + // Reject typeless blocks (e.g. `{ foo: 'bar' }`) before the non-text branch: + // without a string `type` we can't prove it's a real content block, so it + // must not count as non-empty. + if (typeof firstBlock.type !== 'string') { + return false + } if (firstBlock.type !== 'text') { return true } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 919dc74ad..f11991dc9 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -1450,11 +1450,12 @@ describe('applyActiveProviderProfileFromConfig', () => { } }) - test('does not override Gemini Vertex access-token startup selection with saved profile', async () => { - // Maintainer repro: CLAUDE_CODE_USE_GEMINI_VERTEX=1 + GEMINI_ACCESS_TOKEN - // (no project) is a concrete access-token selection — hasConcreteProviderSelection - // already treats it as such — so hasCompleteProviderSelection must agree and - // keep the saved profile from hijacking the explicit env selection. + test('applies saved profile over Gemini Vertex access-token env with no project', async () => { + // Maintainer repro (round 7): CLAUDE_CODE_USE_GEMINI_VERTEX=1 + + // GEMINI_ACCESS_TOKEN with no project is an UNUSABLE Vertex selection — the + // native client throws "project is required" because access-token mode + // cannot derive a project (only ADC can). A stale token-only shell must not + // suppress the saved active profile, so the saved profile is applied. const { applyActiveProviderProfileFromConfig } = await importFreshProviderProfileModules() process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' @@ -1471,20 +1472,17 @@ describe('applyActiveProviderProfileFromConfig', () => { activeProviderProfileId: 'saved_openai', } as any) - expect(applied).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX).toBe('1') - expect(process.env.GEMINI_ACCESS_TOKEN).toBe('ya29.startup-token') - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() - expect(process.env.OPENAI_MODEL).toBeUndefined() + expect(applied?.id).toBe('saved_openai') + expect(process.env.OPENAI_MODEL).toBe('gpt-4o') } finally { delete process.env.CLAUDE_CODE_USE_GEMINI_VERTEX delete process.env.GEMINI_ACCESS_TOKEN } }) - test('does not override Gemini Vertex access-token mode startup selection with saved profile', async () => { - // Symmetric to the token case: an explicit GEMINI_VERTEX_AUTH_MODE=access-token - // is itself a concrete selection (the token may arrive at request time). + test('applies saved profile over Gemini Vertex access-token mode with no project', async () => { + // Symmetric to the token case: GEMINI_VERTEX_AUTH_MODE=access-token with no + // project is equally unusable, so the saved profile wins. const { applyActiveProviderProfileFromConfig } = await importFreshProviderProfileModules() process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' @@ -1501,16 +1499,47 @@ describe('applyActiveProviderProfileFromConfig', () => { activeProviderProfileId: 'saved_openai', } as any) - expect(applied).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX).toBe('1') - expect(process.env.GEMINI_VERTEX_AUTH_MODE).toBe('access-token') - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() + expect(applied?.id).toBe('saved_openai') + expect(process.env.OPENAI_MODEL).toBe('gpt-4o') } finally { delete process.env.CLAUDE_CODE_USE_GEMINI_VERTEX delete process.env.GEMINI_VERTEX_AUTH_MODE } }) + test('keeps Gemini Vertex access-token startup selection when a project is set', async () => { + // With a project alias the access-token selection is usable, so it stays + // and the saved profile must not hijack it. + const { applyActiveProviderProfileFromConfig } = + await importFreshProviderProfileModules() + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GEMINI_ACCESS_TOKEN = 'ya29.startup-token' + process.env.GEMINI_VERTEX_PROJECT = 'explicit-project' + try { + const applied = applyActiveProviderProfileFromConfig({ + providerProfiles: [ + buildProfile({ + id: 'saved_openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + }), + ], + activeProviderProfileId: 'saved_openai', + } as any) + + expect(applied).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX).toBe('1') + expect(process.env.GEMINI_ACCESS_TOKEN).toBe('ya29.startup-token') + expect(process.env.GEMINI_VERTEX_PROJECT).toBe('explicit-project') + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() + expect(process.env.OPENAI_MODEL).toBeUndefined() + } finally { + delete process.env.CLAUDE_CODE_USE_GEMINI_VERTEX + delete process.env.GEMINI_ACCESS_TOKEN + delete process.env.GEMINI_VERTEX_PROJECT + } + }) + test('applies saved profile when the Gemini Vertex flag is bare (stale export)', async () => { // A lone CLAUDE_CODE_USE_GEMINI_VERTEX=1 with no project/model/location // is a stale shell export, not intent — same semantics as the other diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 5be5a5c8a..08d2f3a8d 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -501,19 +501,33 @@ function hasCompleteProviderSelection( ) } if (processEnv.CLAUDE_CODE_USE_GEMINI_VERTEX !== undefined) { - return ( + // An explicit project alias is enough to route, regardless of auth mode. + if ( trimOrUndefined(processEnv.GEMINI_VERTEX_PROJECT) !== undefined || trimOrUndefined(processEnv.GOOGLE_CLOUD_PROJECT) !== undefined || trimOrUndefined(processEnv.GCLOUD_PROJECT) !== undefined || - trimOrUndefined(processEnv.GOOGLE_PROJECT_ID) !== undefined || - trimOrUndefined(processEnv.GEMINI_VERTEX_MODEL) !== undefined || - trimOrUndefined(processEnv.GEMINI_VERTEX_LOCATION) !== undefined || - trimOrUndefined(processEnv.GEMINI_ACCESS_TOKEN) !== undefined || - trimOrUndefined(processEnv.GOOGLE_APPLICATION_CREDENTIALS) !== undefined || - trimOrUndefined(processEnv.GEMINI_VERTEX_AUTH_MODE)?.toLowerCase() === 'adc' || - trimOrUndefined(processEnv.GEMINI_VERTEX_AUTH_MODE)?.toLowerCase() === - 'access-token' - ) + trimOrUndefined(processEnv.GOOGLE_PROJECT_ID) !== undefined + ) { + return true + } + // ADC is the only auth mode that can derive a project from the credentials + // (getAnthropicClient falls back to credential.projectId for ADC only), so + // an ADC selection is complete even without an explicit project alias. + const authMode = trimOrUndefined( + processEnv.GEMINI_VERTEX_AUTH_MODE, + )?.toLowerCase() + if (authMode === 'adc') return true + if ( + authMode !== 'access-token' && + trimOrUndefined(processEnv.GOOGLE_APPLICATION_CREDENTIALS) !== undefined + ) { + return true + } + // access-token / token-only Vertex selections cannot route without a + // project — the native client throws "project is required" — so treating + // them as complete would suppress the saved active profile and strand + // startup on an unusable Vertex route. Require a project alias here. + return false } if (processEnv.CLAUDE_CODE_USE_GEMINI !== undefined) { return (