From eff1b9c0f2eff38fe16f93ecef6f7a22030552ee Mon Sep 17 00:00:00 2001 From: gitdevine <210788251+gitdevine@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:44:13 +0200 Subject: [PATCH] fix(gemini-vertex): saved profile wins over ambient env in profile-only routing getAnthropicClient's saved-profile-only Vertex branch resolved the project/model from process.env before falling back to the profile, contradicting its own comment. A saved gemini-vertex profile targeting one project/model could route to an ambient GOOGLE_CLOUD_PROJECT / stale GEMINI_VERTEX_MODEL when no CLAUDE_CODE_USE_GEMINI_VERTEX flag is set. profileProject/profileModel are only populated when routedFromProfileOnly, so leading the precedence with them makes the selected profile win in profile-only mode while env still wins for an explicit Vertex selection. Regression locks the precedence (profile project + model win over ambient env). Co-Authored-By: Claude Fable 5 --- src/services/api/client.test.ts | 64 ++++++++++++++++++++++++++++++++- src/services/api/client.ts | 7 ++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 26fad3b6e..9951e4a17 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway, } from '../../integrations/index.js' +import * as providerProfilesModule from '../../utils/providerProfiles.js' import { getAnthropicClient } from './client.js' type FetchType = typeof globalThis.fetch @@ -428,6 +429,67 @@ test('routes Gemini Vertex provider requests through the native client', async ( }) }) +test('saved-profile-only Vertex prefers the profile project/model over ambient env', 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_GEMINI_VERTEX + // Ambient env that must NOT win over the selected profile in profile-only mode. + process.env.GOOGLE_CLOUD_PROJECT = 'ambient-project' + process.env.GEMINI_VERTEX_MODEL = 'ambient-model' + process.env.GEMINI_VERTEX_LOCATION = 'us-central1' + process.env.GEMINI_ACCESS_TOKEN = 'vertex-access-token' + + const spy = spyOn( + providerProfilesModule, + 'getActiveProviderProfile', + ).mockReturnValue({ + id: 'vertex_prof', + provider: 'gemini-vertex', + name: 'Vertex', + baseUrl: 'profile-project', + model: 'profile-model', + } as ReturnType) + + 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 + + try { + const client = (await getAnthropicClient({ + maxRetries: 0, + })) as unknown as ShimClient + + await client.beta.messages.create({ + model: 'profile-model', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + + // Profile project + model win; the ambient GOOGLE_CLOUD_PROJECT / stale + // GEMINI_VERTEX_MODEL must not leak into the request. + expect(capturedUrl).toContain('/projects/profile-project/') + expect(capturedUrl).toContain('/models/profile-model:generateContent') + expect(capturedUrl).not.toContain('ambient-project') + expect(capturedUrl).not.toContain('ambient-model') + } finally { + spy.mockRestore() + } +}) + test('Gemini Vertex provider uses GEMINI_VERTEX_MODEL instead of generic OPENAI_MODEL', async () => { let capturedUrl: string | undefined diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 1420b716a..98881f0ac 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -555,14 +555,17 @@ export async function getAnthropicClient({ const profileModel = routedFromProfileOnly && activeProfile?.model ? getPrimaryModel(activeProfile.model) : undefined - const project = getGeminiVertexProjectId(process.env) ?? profileProject + // In profile-only routing the selected profile must win over ambient env; + // profileProject/profileModel are only set when routedFromProfileOnly, so + // leading with them keeps env precedence for an explicit Vertex selection. + const project = profileProject ?? getGeminiVertexProjectId(process.env) const location = getGeminiVertexLocation(process.env) // getGeminiVertexModel already falls back to the default model; the trailing // literal only satisfies its `string | undefined` signature (it never // actually returns undefined) so geminiVertexModel stays a plain string. const geminiVertexModel = - process.env.GEMINI_VERTEX_MODEL?.trim() || profileModel || + process.env.GEMINI_VERTEX_MODEL?.trim() || model?.trim() || getGeminiVertexModel(process.env) || DEFAULT_GEMINI_VERTEX_MODEL