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