Skip to content

Fix: honor a Vertex AI Express Mode API key for Gemini model calls - #366

Open
AmaadMartin wants to merge 4 commits into
mainfrom
fix/vertex-express-mode-model-api-key
Open

Fix: honor a Vertex AI Express Mode API key for Gemini model calls#366
AmaadMartin wants to merge 4 commits into
mainfrom
fix/vertex-express-mode-model-api-key

Conversation

@AmaadMartin

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:

Problem: A Vertex AI Express Mode API key never reaches the model. On the Vertex branch geminiInitParams() fills project/location from GOOGLE_CLOUD_PROJECT/GOOGLE_CLOUD_LOCATION and both apiClient and liveApiClient construct new GoogleGenAI({vertexai, project, location, httpOptions}) without ever passing apiKey. @google/genai then discards the ambient key itself — with project/location supplied it hits else if ((options.project || options.location) && envApiKey) { this.apiKey = undefined; } — so agent model calls always authenticate with ADC. Two concrete symptoms:

  • new Gemini({model: 'gemini-2.5-flash', vertexai: true, apiKey: 'k'}) throws VertexAI project must be provided via constructor or GOOGLE_CLOUD_PROJECT environment variable. — Express Mode cannot even be constructed.
  • A GOOGLE_API_KEY-based deployment is a no-op for the LLM. dev/src/cli/deploy/deploy_utils.ts:103-105 writes GOOGLE_GENAI_USE_VERTEXAI=1, GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION into every generated Dockerfile unconditionally, so the ambient project always beat the key.

Today GOOGLE_API_KEY is consumed only by getExpressModeApiKey() (core/src/utils/vertex_ai_utils.ts) for the session and memory services, so the same environment gave those services Express Mode and the model ADC.

Solution: resolve an Express Mode key on the Vertex branch of geminiInitParams() (explicit apiKey, else GOOGLE_API_KEY on Node) and skip project/location resolution when one is in effect. Both Vertex clients now pass apiKey alongside project/location; when a key is in play the other two are undefined, which does not trip the SDK's (options.project || options.location) && options.apiKey mutual-exclusion guard. vertexai: true is still passed, so apiBackend stays VERTEX_AI and preprocessRequest keeps leaving labels/displayName alone.

Precedence (explicit beats ambient):

explicit apiKey explicit project/location env GOOGLE_API_KEY env project/location result
yes yes throw (mutually exclusive)
yes no any Express Mode with the explicit key
no yes any classic Vertex, explicit project/location (unchanged)
no no yes any Express Mode with the env key
no no no yes classic Vertex from env (unchanged)
no no no no throw VertexAI project must be provided… (unchanged)

Three intentional behaviour changes:

  1. An ambient GOOGLE_API_KEY now outranks an ambient project/location on the Vertex branch. This inverts @google/genai's own ambient tiebreak, deliberately: the generated Dockerfile always writes project and location, so under the SDK's ordering a key-based deployment could never drive the model — the bug this PR fixes. It also matches what ADK already does for the session/memory services in getExpressModeApiKey(). A logger.warn fires when a key is picked up while an ambient project/location is being ignored, and the escape hatches are to unset GOOGLE_API_KEY or pass project/location explicitly (explicit still wins, pinned by a test). The most likely way to hit this by accident is an adk create .env: generateEnvFile() (dev/src/cli/cli_create.ts:163-178) emits GOOGLE_API_KEY, then GOOGLE_GENAI_USE_VERTEXAI=0, then project/location and GOOGLE_GENAI_USE_VERTEXAI=1 when the user supplies both a key and a project+region.
  2. New throw when an explicit apiKey is combined with an explicit project/location under vertexai: true. Previously the key was silently ignored. The message mirrors getExpressModeApiKey(); the two pre-existing project/location errors keep their exact text and still fire on every non-express path. Failing at new Gemini(...) rather than at first use is the point — silently dropping a credential is what is being fixed.
  3. ApigeeLlm ordering fix, required to prevent an unintended change. apigeeToGeminiInitParams() flipped vertexai on after calling geminiInitParams, so an apigee/vertex_ai/… model first ran the Gemini API branch and could resolve GOOGLE_GENAI_API_KEY/GEMINI_API_KEY into apiKey; that key re-entered geminiInitParams through super(...) and would now be misread as an express key, flipping Apigee Vertex users from ADC to key auth. The provider is now settled before the call. Net Apigee behaviour, including every error message, is unchanged.

Notes for review:

  • GOOGLE_API_KEY means "Express key" on the Vertex branch while GEMINI_API_KEY/GOOGLE_GENAI_API_KEY keep meaning "AI Studio key". That is deliberate and matches getExpressModeApiKey(), which reads GOOGLE_API_KEY only. The Gemini API branch is untouched — a dedicated test pins that GOOGLE_API_KEY does not leak into it.
  • getExpressModeApiKey() is not reused: it gates on getBooleanEnvVar('GOOGLE_GENAI_USE_VERTEXAI') rather than the already-resolved params.vertexai, so it would silently discard an explicit {vertexai: true, apiKey}, and it reads process.env unguarded, where geminiInitParams wraps env reads in !isBrowser(). It is left untouched.
  • The live client's location: this.location || 'global' default is removed. It was unreachable — location is private readonly, assigned only from geminiInitParams, which throws on an empty location — and it would have broken Express Mode, since passing location: 'global' beside an apiKey makes the SDK throw. The SDK applies the identical default itself (if (!this.location && !this.apiKey) this.location = 'global') and correctly skips it in Express Mode.
  • Test fixture change, called out deliberately: core/test/models/apigee_llm_test.ts had its inline afterEach converted to a shared clearEnv bound to both beforeEach and afterEach, and GOOGLE_API_KEY/GEMINI_API_KEY added to it; core/test/models/google_llm_test.ts gets GOOGLE_API_KEY added to the existing clearEnv. This is purely additive isolation — no assertion or fixture value was rewritten, and no existing test was modified or deleted. Without it, an ambient GOOGLE_API_KEY (a developer machine, or a reused vitest worker) silently flips the existing Vertex tests into Express Mode.
  • No new dependency, no new export, no change to core/src/index.ts or core/src/common.ts. GeminiParams gains no field; only its JSDoc changed.
  • Collision check before starting (gh pr list --repo AmaadMartin/adk-js --state open --limit 300, then gh pr diff --name-only on every adjacent PR): no open PR lands this change. Fix: forward the Vertex AI express-mode API key into the Agent Engines client #268 forwards an express key into the Agent Engines client (@google-cloud/vertexai, disjoint files). Fix: accept GOOGLE_API_KEY in geminiInitParams so adk create .env files work #313 adds GOOGLE_API_KEY to the non-Vertex else branch, Fix: honor an explicitly passed vertexai: false in Gemini / ApigeeLlm #266 changes the vertexai flag resolution, Feat: Derive the Gemini vertexai gate from GOOGLE_GENAI_USE_ENTERPRISE (adk-python parity) #267 swaps the env gate — three PRs touching adjacent hunks of the same function and mutually exclusive as stacking bases, so this branches from main; the hunks are disjoint and any conflict is a trivial adjacent-line merge.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.

Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

13 new cases. 100% line and branch coverage on every changed line of core/src/models/google_llm.ts and core/src/models/apigee_llm.ts (measured with --coverage, then intersected with the diff's changed line ranges — 0 uncovered new statements, 0 uncovered new branches).

core/test/models/google_llm_test.tsgeminiInitParams Vertex AI Express Mode: explicit key with no project/location; env key beating an ambient project/location; env key with no ambient target; explicit project/location beating an env key; the mutual-exclusion throw for key+project, key+location and key+both; GOOGLE_API_KEY not leaking into the Gemini API branch; idempotency (an express result fed back in is unchanged, which ApigeeLlm relies on via super(...)). Vertex AI client credentials: express apiClient and liveApiClient options, and classic options staying apiKey-free.

core/test/models/apigee_llm_test.ts: apigee/vertex_ai/… still demands a project when GEMINI_API_KEY or GOOGLE_GENAI_API_KEY is set (the regression test for change 3), and reaches VERTEX_AI via GOOGLE_API_KEY with no project.

tests/integration/models/gemini_express_mode_test.ts (new): drives a real Gemini through the real @google/genai client with only globalThis.fetch stubbed, and asserts the outgoing request carries x-goog-api-key: <key> and a URL with no /projects/ segment — both for an explicit key and for GOOGLE_API_KEY with an ambient project/location. This is what proves the key reaches the wire rather than just the constructor.

Every new test was proven able to fail. Each mutation below was applied to the source, the suite re-run, and the source restored:

mutation tests killed failure message
git checkout main -- core/src/models/google_llm.ts (full revert) 8 unit + 2 integration VertexAI project must be provided via constructor or GOOGLE_CLOUD_PROJECT environment variable. / expected 'https://us-central1-aiplatform.google…' not to contain '/projects/'
drop apiKey: this.apiKey from both Vertex client constructions 2 unit + 2 integration expected undefined to be 'express-key' / Authentication is not set up. Please provide either a project and location, or an API key, or a custom base URL.
drop the mutual-exclusion throw and the !project && !location guard 4 unit expected [Function] to throw an error / expected 'env-express-key' to be undefined
make Express Mode keep the ambient GOOGLE_CLOUD_PROJECT 2 unit expected 'env-project' to be undefined / Cannot specify project or location and an Express Mode API key. (idempotency)
add GOOGLE_API_KEY to the non-Vertex else branch 1 unit expected 'env-express-key' to be undefined
restore the old apigeeToGeminiInitParams ordering 2 unit expected [Function] to throw an error
full google_llm.ts revert, against the Apigee suite 1 unit VertexAI project must be provided via constructor or GOOGLE_CLOUD_PROJECT environment variable.

Commands run locally on the pushed commit (all green):

npx vitest run --project unit:core core/test/models/            # 8 files, 241 tests
npx vitest run --project integration tests/integration/models/  # 2 files, 5 tests
npm run build
npm run lint
npm run format:check
npm run docs:check
npx secretlint "core/**/*.ts" "tests/integration/models/**/*.ts"

npm run ts:check reports pre-existing errors in the test tree on main (e.g. core/test/models/google_llm_test.ts:221, :809) and reports zero errors from any line this PR adds.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

No real Vertex AI Express Mode key was available, so no live generation was performed — this is not a claim that step 1 produced model output. What was verified locally instead, against the built core/dist/cjs bundle: step 2 (new Gemini({model, vertexai: true, apiKey}) with no environment at all now constructs and reports backend=VERTEX_AI, where it previously threw VertexAI project must be provided…) and step 3 (the ADC path with no key present still constructs, backend=VERTEX_AI). Also verified: the SDK's actual behaviour at the pinned @google/genai@2.9.0 (new GoogleGenAI({vertexai: true, apiKey: 'k', project: 'p'}) throws Project/location and API key are mutually exclusive in the client initializer.; {vertexai: true, apiKey: 'k'} keeps the key; {vertexai: true, project, location} with an ambient GOOGLE_API_KEY discards it), plus the wire-level integration test above.

npm install && npm run build

# 1. Express Mode wins even with an ambient project/location (the deploy case):
GOOGLE_GENAI_USE_VERTEXAI=1 \
GOOGLE_CLOUD_PROJECT=my-project GOOGLE_CLOUD_LOCATION=us-central1 \
GOOGLE_API_KEY=<express-key> \
node -e "
const {Gemini} = require('./core/dist/cjs/index.js');
const llm = new Gemini({model: 'gemini-2.5-flash'});
(async () => {
  for await (const r of llm.generateContentAsync({contents: [{role: 'user', parts: [{text: 'hi'}]}], config: {}})) {
    process.stdout.write(r.content?.parts?.[0]?.text ?? '');
  }
})();
"

# 2. Explicit express key, no environment at all (previously threw
#    'VertexAI project must be provided...'):
node -e "
const {Gemini} = require('./core/dist/cjs/index.js');
new Gemini({model: 'gemini-2.5-flash', vertexai: true, apiKey: '<express-key>'});
"

# 3. Regression: the ADC path with no key present is unchanged.
GOOGLE_GENAI_USE_VERTEXAI=1 GOOGLE_CLOUD_PROJECT=my-project \
GOOGLE_CLOUD_LOCATION=us-central1 node -e "
const {Gemini} = require('./core/dist/cjs/index.js');
new Gemini({model: 'gemini-2.5-flash'});
"

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 4 commits July 30, 2026 22:51
geminiInitParams never gave an API key to @google/genai on the Vertex
branch, and the SDK discards an environment key whenever project/location
are also supplied, so Express Mode was unreachable for model calls and a
GOOGLE_API_KEY-based deployment could never drive the agent's LLM.

Resolve an express key (explicit, else GOOGLE_API_KEY) on the Vertex
branch and drop project/location when one is in effect, since the SDK
rejects a client that carries both.
apigeeToGeminiInitParams flipped vertexai on after calling
geminiInitParams, so an apigee/vertex_ai/ model first ran the Gemini API
branch and could resolve GOOGLE_GENAI_API_KEY / GEMINI_API_KEY into
apiKey. That key re-entered geminiInitParams through super() and would
now be read as an Express Mode key, flipping Apigee Vertex users from
ADC to key auth. Decide the flag first and pass it in; errors and
messages are unchanged.
…ient options

Drop the two single-use helpers the first pass introduced. @google/genai's
mutual-exclusion guard is `(options.project || options.location) &&
options.apiKey`, so explicitly-undefined fields do not trip it and both
Vertex clients can pass a flat option object.

Also drop the unreachable `location: this.location || 'global'` default on
the live client: geminiInitParams throws on an empty location, and the SDK
applies the same default itself while correctly skipping it in Express Mode.

Raise the express-key notice from info to warn, since it reports a
credential source that outranks the ambient Vertex target.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant