mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
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:
co-authored by
Claude Opus 4.8
parent
ac896eede2
commit
6236de2500
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user