fix(gemini-vertex): require project for token-only, gate logs, validate region

Addresses jatmn's round-7 review (6 findings):

- P1 hasCompleteProviderSelection: an access-token / token-only Vertex env
  (GEMINI_ACCESS_TOKEN or GEMINI_VERTEX_AUTH_MODE=access-token) with no project
  is unusable — the native client throws "project is required" since only ADC
  can derive a project. Such a stale shell no longer counts as complete, so it
  stops suppressing the saved active profile. Reverses the round-5 tests that
  encoded the opposite (now corrected).
- P1 logError gate: use isGeminiVertexEffectiveProvider() so a saved-profile-only
  Vertex session (no env flag) gets the same no-error-reporting treatment.
- P2 buildCurrentProviderSummary: merge the saved Vertex profile env so the
  /provider summary shows the saved project/model/location in profile-only mode.
- P2 detectProvider: honor profile-only Vertex routing via the effective
  provider/profile logic (mirrors getAnthropicClient) instead of the raw flag.
- P2 global-only models: reject gemini-3.5-flash on a regional GEMINI_VERTEX_
  LOCATION via isGlobalOnlyVertexModel() rather than building an unusable URL.
- P3 isNotEmptyMessage: reject typeless first content blocks ({ foo: 'bar' })
  before the non-text branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gitdevine
2026-06-27 21:05:29 +02:00
co-authored by Claude Opus 4.8
parent ac896eede2
commit 6236de2500
13 changed files with 358 additions and 44 deletions
+16 -4
View File
@@ -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/<project>/locations/<location> 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),
+31
View File
@@ -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)
}
})
})
+32 -5
View File
@@ -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/<project>/locations/<location>
// and throws when no project resolves. Mirror that contract here: when a
// project is missing, surface a clear "project required" state instead of a
+4 -1
View File
@@ -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',
+68 -5
View File
@@ -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
+9
View File
@@ -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({
+11
View File
@@ -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 {
+77
View File
@@ -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<string, string | undefined> = {}
let restoreConfig: ReturnType<typeof getGlobalConfig>
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)
})
})
+6 -2
View File
@@ -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()
@@ -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({
+6
View File
@@ -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
}
+46 -17
View File
@@ -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
+24 -10
View File
@@ -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 (