Skip to content

Fix: forward the Vertex AI express-mode API key into the Agent Engines client - #268

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

Fix: forward the Vertex AI express-mode API key into the Agent Engines client#268
AmaadMartin wants to merge 3 commits into
mainfrom
fix/vertex-express-mode-api-key-client

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Jul 29, 2026

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

(No public GitHub issue is tracked for this bug; described below instead. The
placeholders above are left as-is per the required template.)

  1. Or, if no issue exists, describe the change:

Problem: Vertex AI express mode (an API key instead of a project/location
pair) is unusable in adk-js. VertexAiSessionService and
VertexAiMemoryBankService both resolve the express-mode key via
getExpressModeApiKey(), store it on this.expressModeApiKey — and then never
read it. Both construct their transport with
new Client({project: this.projectId, location: this.location}), and in express
mode those are undefined, so the underlying @google/genai ApiClient throws
from its constructor:

Authentication is not set up. Please provide either a project and location, or an API key, or a custom base URL.

Reproduce (the env var the checked-in getExpressModeApiKey() reads is
GOOGLE_GENAI_USE_VERTEXAI, see core/src/utils/vertex_ai_utils.ts):

export GOOGLE_GENAI_USE_VERTEXAI=true
export GOOGLE_API_KEY=<express-mode-key>
node -e "const {VertexAiSessionService}=require('@google/adk'); new VertexAiSessionService({})"

Solution: Add a single factory, createAgentEnginesClient(), in
core/src/utils/vertex_ai_utils.ts and call it from both services, so the
express/non-express decision exists in exactly one place. It mirrors the Python
reference (_get_api_client in src/google/adk/sessions/vertex_ai_session_service.py
and .../memory/vertex_ai_memory_bank_service.py), which likewise selects one
of two clients rather than merging the options:

  • express key set → build the @google/genai ApiClient directly with the
    key; project/location are deliberately dropped, as Python does.
  • otherwisenew Client({project, location}).agentEnginesInternal, byte
    for byte what the code does today.

The obvious one-line fix — passing apiKey into new Client({...}) — does not
work and was not attempted. @google-cloud/vertexai@1.12.0's Client
constructor is typed {project?, location?, apiEndpoint?} and at runtime always
builds new NodeAuth({googleAuthOptions: {...}}); it never reads an apiKey
option (the string apiKey does not occur in its published build/src at all).
So express mode has to construct the underlying client itself.

The key must be passed to both NodeAuth and ApiClient, and this is easy
to get wrong: ApiClient.getAuthHeaders() delegates entirely to
clientOptions.auth, so NodeAuth.apiKey is what emits the x-goog-api-key
header, while ApiClient.apiKey is what satisfies the auth check and suppresses
the projects/{p}/locations/{l} URL prefix (shouldPrependVertexProjectPath()
returns false when clientOptions.apiKey is set). Passing it to only one of
them silently produces a broken client — there is a dedicated test for that
(see the mutation testing below). The express client also replicates the
vendor's userAgentExtra: \vertex-genai-modules/${SDK_VERSION}`` so telemetry
does not change between modes.

Cross-language parity, and where it was overridden. The observable
behaviour follows Python: which auth material is used, and that project/location
are dropped when a key is present. The mechanism differs because the JS vendor
SDK exposes no api_key option, so adk-js builds the genai ApiClient
directly where Python calls vertexai.Client(api_key=...). Local TS convention
(naming, module layout) was kept.

Disclosed: one type suppression, at one site.
core/src/utils/vertex_ai_utils.ts contains exactly one cast:

return new AgentEngines(apiClient as unknown as AgentEnginesApiClient);

AgentEngines is compiled against the @google/genai copy that
@google-cloud/vertexai@1.12.0 resolves (1.52.0), while core resolves
@google/genai@2.9.0. The two ApiClient classes are identical at runtime but
nominally distinct to tsc because of the private customBaseUrl field, giving
TS2345: ... Types have separate declarations of a private property 'customBaseUrl'.
This is known debt, not a workaround of choice — I verified there is no
cast-free path today:

  • @google-cloud/vertexai@1.12.0 declares "@google/genai": "^1.45.0" and
    core/package.json declares "^2.9.0"; no single version satisfies both
    ranges, so deduping means force-overriding one package onto an undeclared
    major.
  • 1.12.0 is the latest published @google-cloud/vertexai (npm view shows no
    newer release built against genai 2.x).
  • @google-cloud/vertexai does not re-export ApiClient, so there is no
    same-declaration import path.

Follow-up: the workspace-wide @google/genai dedupe is already owned by
separate PRs (#226, #228). When one of those lands, this cast and its
AgentEnginesApiClient alias can be deleted outright. Deliberately not done
here: it would put package.json/package-lock.json churn in a bug fix and
collide with those PRs.

Collision check (required). Ran
gh pr list --repo AmaadMartin/adk-js --state open --limit 300 and diffed every
plausibly adjacent PR. No open PR lands this change. Two overlap by file
but not by logic, in disjoint regions:

I branched from main rather than stacking: this change is semantically
independent of both, and because #227 and #201 overlap different files there
is no single branch to stack on that resolves both. Heads-up for whoever merges
second: #227 and this PR both append to the end of
core/src/utils/vertex_ai_utils.ts and core/test/utils/vertex_ai_utils_test.ts,
so expect a trivial append-vs-append textual conflict there.

Known follow-up, deliberately out of scope.
VertexAiMemoryBankService still has no precondition check symmetric to
VertexAiSessionService's 'Either (Project ID and Location) or an expressModeApiKey is required.',
so with neither key nor project/location it keeps surfacing the raw genai error.
Fixing it touches the same constructor and would conflict with this change.

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.

Only targeted tests were run, on the exact pushed commit:

npx vitest run --project unit:core --project integration \
  core/test/utils/vertex_ai_utils_test.ts \
  core/test/sessions/vertex_ai_session_service_test.ts \
  core/test/memory/vertex_ai_memory_bank_service_test.ts \
  tests/integration/memory/vertex_ai_memory_bank_service_test.ts
#  -> Test Files 4 passed (4) | Tests 100 passed (100)

npm run build         # -> OK
npm run lint          # -> exit 0, no findings
npm run format:check  # -> All matched files use Prettier code style!
npm run docs:check    # -> OK (typedoc --treatWarningsAsErrors)
npx secretlint ...    # -> exit 0

Coverage of the new code, measured over those four files:

File                  | % Stmts | % Branch | % Funcs | % Lines
core/src/utils/vertex_ai_utils.ts |     100 |      100 |     100 |     100

Both rewritten service else branches are covered too; the residual uncovered
regions reported for the two service files (vertex_ai_session_service.ts
432-444/469-477, vertex_ai_memory_bank_service.ts 503-505/516-520) are
pre-existing and untouched by this change. No coverage-tool suppressions were
added, and no test was skipped, weakened, or deleted.

npm run ts:check is pre-existing broken on main (745 errors, all from
test trees that cannot resolve @google/adk). I measured it before and after:
745 both ways, and zero errors in any file this PR touches under core/src/.
CI does not run ts:check.

CI status — read this before re-running

run-tests is green on ubuntu-latest and macos-latest. The
windows-latest job is red on a pre-existing, unrelated flake, and I am
reporting that rather than papering over it.

I re-ran the Windows job three times. It failed each time — on a different,
unrelated suite
:

Run Failing suite Error
1 tests/integration/app_loader/app_loader_test.ts Test timed out in 40000ms (passed on re-run, taking 63.8s)
2 tests/integration/a2a/input_required/input_required_test.ts CLI exited prematurely with code 1
3 tests/integration/a2a/basic/a2a_agent_test.ts CLI exited prematurely with code 1
4 tests/integration/tools/run_skill_script_tool_test.ts (PowerShell cases) Test timed out in 5000ms

In every one of those four runs all 100 tests in the four suites this PR
touches passed on Windows
, e.g.
✓ unit:core core/test/sessions/vertex_ai_session_service_test.ts (54 tests),
and the whole matrix was green on Linux and macOS. The rest of the Windows run
was 213 passed / 1 failed each time.

I root-caused the a2a failures rather than assuming flakiness. The spawned test
server prints:

[ADK CLI] Error starting API server: listen EACCES: permission denied ::1:49738

BaseTestServer.getRandomPort() in tests/integration/test_case_utils.ts is
40000 + Math.floor(Math.random() * 10000) — range 40000-49999, which overlaps
the Windows dynamic/ephemeral range starting at 49152, where Windows reserves
random excluded blocks. Binding inside one yields EACCES, and the port is
picked blindly with no bind-and-retry, so a different suite loses the lottery on
each run. Filed as separate follow-up work; deliberately not fixed here,
since it is unrelated test-harness churn and PRs #235/#247/#256/#260 (app_loader
timeouts) and #210/#224/#233/#254 (Windows PowerShell/subprocess timeouts)
already contend over these same files.

Evidence this is not caused by this change: all three a2a suites pass locally on
this branch (Test Files 3 passed | Tests 6 passed); the failures are EACCES
on a socket bind and child-process timeouts, and this diff contains no
networking, server, port, or process code; and the failing suite changes every
run while its sibling a2a suites pass in the same run.

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

Proof the new tests can fail (mutation testing). Every new test was run
against mutated source and confirmed to FAIL:

# Mutation Tests killed Failure message
1 Force createAgentEnginesClient down the old new Client({project, location}).agentEnginesInternal path for the express case 7 (utils express-branch test, both services' express-mode construction tests, both stubbed-transport tests) Authentication is not set up. Please provide either a project and location, or an API key, or a custom base URL.
2 new NodeAuth({apiKey})new NodeAuth({googleAuthOptions: {scopes: [...]}}) 3 (the header assertions) expected null to be 'fake-express-key'
3 Drop userAgentExtra from the express client 1 expected undefined to be 'vertex-genai-modules/1.12.0'
4 Drop apiKey from the ApiClient options (leaving it on NodeAuth) 4 Authentication is not set up. ...

Mutations 2 and 4 are the important pair: they prove the tests pin the
both-places requirement rather than just "the constructor didn't throw".

New tests:

  • core/test/utils/vertex_ai_utils_test.tscreateExpressModeApiClient
    reports getApiKey()/getProject()/getLocation()/isVertexAI() correctly,
    emits x-goog-api-key from getAuthHeaders(), and matches the vendor user
    agent; createAgentEnginesClient covers both branches plus the
    no-credentials throw (which pins that the non-express path is untouched).
  • core/test/sessions/vertex_ai_session_service_test.ts
    new VertexAiSessionService({}) under
    GOOGLE_GENAI_USE_VERTEXAI=true + GOOGLE_API_KEY no longer throws (the exact
    reported regression); same for an explicit expressModeApiKey; and a
    stubbed-fetch test driving createSession() end to end that asserts the URL
    is https://aiplatform.googleapis.com/v1beta1/reasoningEngines/12345/sessions
    no projects/.../locations/... prefix — with x-goog-api-key on the
    request.
  • core/test/memory/vertex_ai_memory_bank_service_test.ts — the same two
    construction cases for the memory bank.
  • tests/integration/memory/vertex_ai_memory_bank_service_test.ts — express-mode
    searchMemory() over a stubbed transport, asserting
    .../v1beta1/reasoningEngines/test-engine-id/memories:retrieve and the key
    header. No network calls; consistent with the existing mock-injecting
    integration tests in that file.

All tests use the fake key 'fake-express-key', set GOOGLE_GENAI_USE_VERTEXAI
explicitly, and restore process.env in afterEach. The API key never appears
in a log line, error message, or user agent.

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

Not runnable in CI — needs a real express-mode key. To verify by hand:

export GOOGLE_GENAI_USE_VERTEXAI=true
export GOOGLE_API_KEY=<a real express-mode key>
import {VertexAiSessionService} from '@google/adk';

const service = new VertexAiSessionService({agentEngineId: '<engine-id>'});
const session = await service.createSession({
  appName: '<engine-id>',
  userId: 'u',
});

Expected: the constructor no longer throws, a session is returned, and the
outbound request goes to
https://aiplatform.googleapis.com/v1beta1/reasoningEngines/<engine-id>/sessions
with an x-goog-api-key header and no projects/.../locations/... prefix.
Before this change the constructor threw
Authentication is not set up. ....

Behaviour with projectId + location is unchanged — same Client, same
arguments — and is covered by the pre-existing tests, which all still pass.

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 3 commits July 29, 2026 13:33
…s client

VertexAiSessionService and VertexAiMemoryBankService resolved an
express-mode API key and then constructed their transport with
new Client({project, location}). In express mode both are undefined, so
the underlying genai ApiClient threw 'Authentication is not set up' and
the resolved key was never read.

@google-cloud/vertexai's Client has no API key option, so express mode
now builds the genai ApiClient directly with the key handed to both
NodeAuth (which emits x-goog-api-key) and ApiClient (which uses it to
skip the projects/{p}/locations/{l} URL prefix). The non-express path
still goes through Client with identical arguments.
Adds unit coverage for createExpressModeApiClient and both branches of
createAgentEnginesClient, express-mode construction for both services,
and stubbed-transport tests asserting outgoing requests carry
x-goog-api-key with no projects/{p}/locations/{l} path prefix.
The exported AgentEnginesClientOptions interface had exactly one consumer
in the same module and is not part of the public API surface.
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