Skip to content

Fix: drop the unnecessary structural cast when clearing labels in Gemini.preprocessRequest - #411

Open
AmaadMartin wants to merge 2 commits into
mainfrom
fix/google-llm-labels-cast
Open

Fix: drop the unnecessary structural cast when clearing labels in Gemini.preprocessRequest#411
AmaadMartin wants to merge 2 commits into
mainfrom
fix/google-llm-labels-cast

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: Gemini.preprocessRequest (core/src/models/google_llm.ts) clears the outgoing request's billing labels before calling the Google AI Studio backend, and did so through an inline structural assertion:
(llmRequest.config as {labels?: unknown}).labels = undefined;

That assertion is not load-bearing, and it is actively harmful:

  • The property is already declared. LlmRequest.config is GenerateContentConfig from @google/genai (core/src/models/llm_request.ts:10,36). core/package.json pins "@google/genai": "^2.9.0" and package-lock.json resolves it to exactly 2.9.0; that published version declares labels?: Record<string, string>; at dist/genai.d.ts:4780, inside the export declare interface GenerateContentConfig block that opens at line 4661 (verified against the installed node_modules tree, not from memory). Nothing needed asserting.
  • The repo already writes the same property with no cast. core/src/agents/llm_agent.ts:1072-1078 does llmRequest.config ??= {}; llmRequest.config.labels ??= {}; and then indexes into it — compiling today under the same tsconfig. Two sites writing one property at two different levels of type rigour was the inconsistency.
  • The cast made the code less safe. as {labels?: unknown} widens the value type from Record<string, string> to unknown, so any value at all would have satisfied the assignment. A structural cast that widens a field is as any wearing a hat.

Solution: Write the declared property directly:

llmRequest.config.labels = undefined;

The diff to core/src is exactly one line. The explanatory comment about Google AI Studio API keys is kept verbatim.

Why = undefined and not delete:

  • exactOptionalPropertyTypes is not enabled, so assigning undefined to an optional property is legal under strict: true. Verified by following the whole extends chain: root tsconfig.jsongts@5.3.1/tsconfig-google.json (which sets only allowUnreachableCode, allowUnusedLabels, declaration, forceConsistentCasingInFileNames, lib, module, noEmitOnError, noFallthroughCasesInSwitch, noImplicitReturns, pretty, sourceMap, strict, target); core/tsconfig.json adds only rootDir/outDir/include/exclude. The only occurrence of exactOptionalPropertyTypes in this repo is inside the TS_CONFIG string template adk create writes into a scaffolded user project (dev/src/cli/cli_create.ts:33), which does not configure this repository's own compilation.
  • delete would also be a semantic change (the key would disappear, so 'labels' in config flips and Object.keys(config) shrinks) and would diverge from the adk-python reference, src/google/adk/models/google_llm.py, which assigns llm_request.config.labels = None. Parity wins here because the assignment form is observable.

Runtime behaviour is unchanged: labels is still set to undefined (key present, never deleted) on the Gemini API path, and still left untouched on the Vertex AI path.

No suppression of any kind was added — no @ts-expect-error, @ts-ignore, eslint-disable, any, as any, as never, as unknown as, index signature, or coverage-tool ignore, in either the source or the test. This PR removes an escape hatch; adding one back would defeat the point. git diff <base> -U0 | grep -E '@ts-expect-error|@ts-ignore|eslint-disable|v8 ignore|istanbul ignore|: any\b|as any|as never|as unknown as' returns nothing.

Collision check. Ran gh pr list --state open --limit 500 (314 open PRs) and grepped every plausibly adjacent diff for labels: none of them touch this property, and no open PR modifies preprocessRequest. Three open PRs (#366, #313, #332) do touch core/test/models/google_llm_test.ts, but in unrelated regions of the file, so this branches from main rather than stacking.

Explicitly left alone, as separate concerns: removeDisplayNameIfPresent's own (dataObj as FileData).displayName assertions in the same file, and the inlineData.displayName cast in runner.ts.

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.

The primary regression signal for the source change is the type checker itself: if labels were not a declared property of GenerateContentConfig, removing the cast would fail with TS2339 ("Property 'labels' does not exist"). npx tsc --noEmit -p core/tsconfig.json is clean (0 errors) with the cast gone — that is the proof the assertion was never doing anything.

On top of that, preprocessRequest's label stripping had zero behavioural coverage (core/test/models/google_llm_test.ts contained no occurrence of labels, preprocess, or apiBackend). Two new cases were added to the existing describe('generateContentAsync', ...) block, reusing the file's existing vi.mock('@google/genai') + TestGemini harness. No existing test was modified, repurposed, skipped, or deleted.

  1. should clear labels on the request config for the Gemini API backend — asserts both the captured outgoing argument (callArg.config?.labels) and the caller's own object (llmRequest.config?.labels) are undefined. These pin two distinct contracts: what goes on the wire, and the documented in-place mutation of the caller's request.
  2. should preserve labels on the request config for the Vertex AI backend — the negative case. Without it, a mutation that cleared labels unconditionally would still pass test 1.

The test's request literal (config: {labels: {'adk-agent-name': 'agent'}}) only compiles because GenerateContentConfig.labels is a declared property, so the tests double as a compile-time assertion of the same premise.

One deliberate deviation from the plan, disclosed: the plan sketched these against the streaming path with vi.fn().mockResolvedValue([]). A bare vi.fn() is typed Mock<(...args: any[]) => any>, which makes the captured callArg implicitly any — exactly the weak typing this PR exists to remove. Instead the mock is declared as vi.fn<typeof llm.apiClient.models.generateContent>(), so callArg is a real GenerateContentParameters and mockResolvedValue(new GenerateContentResponse()) is a real SDK value. That required the non-streaming call (generateContent returns a plain Promise<GenerateContentResponse>; the streaming sibling returns a Promise<AsyncGenerator<...>> that has no well-typed empty fixture). This tests the identical code path: preprocessRequest runs at google_llm.ts:166, before any stream/non-stream branching, and the object handed to the SDK is the same llmRequest.config reference in both branches. No streaming-specific behaviour is involved in label stripping, so a second streaming copy would add coverage of nothing.

Proof the tests can fail (mutation testing). Reverting the cast removal alone cannot fail a runtime test — the cast is type-level only, which is why the compiler is cited as the signal for that hunk. The two mutations below target the behaviour the new tests actually pin. The source was restored after each.

Mutation A — delete llmRequest.config.labels = undefined; entirely. Test 1 FAILS, test 2 still passes:

FAIL  core/test/models/google_llm_test.ts > GoogleLlm > generateContentAsync >
      should clear labels on the request config for the Gemini API backend
AssertionError: expected { 'adk-agent-name': 'agent' } to be undefined
- Expected: undefined
+ Received: { "adk-agent-name": "agent" }
 ❯ core/test/models/google_llm_test.ts:424:38

Mutation B — hoist the assignment above the if (this.apiBackend === GoogleLLMVariant.GEMINI_API) guard. Test 2 FAILS, test 1 still passes:

FAIL  core/test/models/google_llm_test.ts > GoogleLlm > generateContentAsync >
      should preserve labels on the request config for the Vertex AI backend
AssertionError: expected undefined to deeply equal { 'adk-agent-name': 'agent' }
- Expected: { "adk-agent-name": "agent" }
+ Received: undefined
 ❯ core/test/models/google_llm_test.ts:449:38

Commands run locally on the pushed commit (targeted only, never the full suite):

npx vitest run --project unit:core core/test/models/google_llm_test.ts
  → Test Files 1 passed (1) | Tests 23 passed (23)
npx tsc --noEmit -p core/tsconfig.json  → clean, 0 errors
npm run build                           → exit 0
npm run lint                            → exit 0, clean
npm run format:check                    → "All matched files use Prettier code style!"
npm run ts:check                        → 280 errors before the change, 280 after

npm run ts:check (root tsc --noEmit, which also sweeps the test trees) has 280 pre-existing errors on an untouched checkout of this base. That count is identical before and after this change; the only textual difference is a line-number shift on one pre-existing unrelated error in google_llm_test.ts (TS2820 on a 'audio' literal), caused by the added lines. No new diagnostic of any kind, and specifically no TS2339, TS2412, or TS2322.

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

None, and none are warranted — stated explicitly rather than claiming a run that did not happen. The change is provably behaviour-preserving (a type-level assertion removed from a single assignment), preprocessRequest is private with no exported surface, and the affected path only executes against a live Google AI Studio API key. The compiler check plus the two unit tests above are the complete verification story. For the same reason no tests/integration or tests/e2e file was added: that tree is reserved for build/packaging fixtures that run npm install per fixture, and a fixture here would be cost with no signal.

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 2 commits July 31, 2026 14:07
preprocessRequest clears GenerateContentConfig.labels for the Gemini API
backend and leaves it alone for Vertex AI, but neither branch had any test
coverage. Add both cases so the asymmetry is pinned before the surrounding
code is touched.
…ini.preprocessRequest

@google/genai already declares GenerateContentConfig.labels?: Record<string,
string>, so `(llmRequest.config as {labels?: unknown}).labels = undefined`
asserted a weaker type than the one the compiler had. Write the declared
property directly, matching the uncast sibling write in llm_agent.ts.

No runtime change: labels is still assigned undefined rather than deleted,
preserving parity with adk-python's `llm_request.config.labels = None`.
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