Skip to content

fix(gateway): cache multi-candidate tool call ids - #3539

Merged
steebchen merged 1 commit into
theopenco:mainfrom
pacocartones:fix/multi-candidate-thought-signature
Aug 14, 2026
Merged

steebchen merged 1 commit into
theopenco:mainfrom
pacocartones:fix/multi-candidate-thought-signature

Conversation

@pacocartones

@pacocartones pacocartones commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

parseProviderResponse (Google branch) caches Gemini thought signatures in
Redis under the id it emits — ${name}_${shortid(24)}, as
thought_signature:<id> — so the signature can be re-injected when the client
replays the call next turn. The n > 1 branch of transformResponseToOpenai
(apps/gateway/src/chat/tools/transform-response-to-openai.ts:454-456)
regenerated tool calls from the raw parts with ${name}_${candidateIndex}_${fcIndex}
ids and dropped extra_content, so the id the client echoes back never matches
the cached key. The next-turn lookup in chat.ts
(redisClient.get("thought_signature:" + toolCall.id)) misses, the signature
is not re-injected, and Gemini rejects the replay with "Corrupted thought
signature"
— the exact failure the shortid fix (#1448) eliminated on the
streaming path. The n > 1 branch is the one path left behind: the
single-candidate path reuses parse's toolResults and is consistent.

This is the same "forgotten sibling branch" pattern as #3492 (our previous
fix) and #3504 (the maintainer's own follow-up).

Fix

Two changes in the multi-candidate branch:

  • Candidate 0 reuses the tool calls from toolResults (what
    parseProviderResponse already emitted and cached), so the id the client
    receives is exactly the one the signature is cached under.
  • Candidates 1+ (which parse does not process) get the same treatment
    parse applies to candidate 0: a unique ${name}_${shortid(24)} id, the
    inline extra_content.google.thought_signature, and the
    setex("thought_signature:<id>", 86400, sig) Redis entry under the emitted
    id.

Fallback keeps the branch safe: if toolResults is absent for candidate 0
(e.g. no tool calls parsed), the shortid path applies.

Tests

transform-response-to-openai.spec.ts:

  • The existing multi-candidate test no longer pins the broken
    ${name}_${candidateIndex}_${fcIndex} ids; it asserts the id scheme and
    that candidates are distinct.
  • New test: keeps multi-candidate Google tool_call ids in the thought_signature cache — mocks @llmgateway/cache (setex) and asserts:
    • choice 0 reuses the parsed tool calls verbatim (id + inline signature);
    • choice 1 gets a unique id, extra_content with its own signature, and a
      setex("thought_signature:<id>", 86400, sig) call under the emitted id;
    • no setex is written under choice 0's id from the transform (parse
      already did that).

Verification

Every command below was run and its output captured. The record is
reproducible — commands included so you can re-run them.

red — probe against main (c1cd97b), real functions via esbuild, before the
fix
(harness preserved at the hub):

parse toolResults[0].id      : get_weather_xxxxxxxxxxxxxxxxxxxxxxxx
                             + extra_content.google.thought_signature
Redis writes made by parse   : [{"key":"thought_signature:get_weather_xxxxxxxxxxxxxxxxxxxxxxxx", …}]
transform choice 0 tool_calls: id get_weather_0_0, extra_content undefined
RESULT: MISMATCH — next-turn GET thought_signature:get_weather_0_0 misses;
signature lost, Gemini 3 rejects replay
=== control (n=1) ===
CONTROL RESULT: MATCH — single-candidate path is consistent

green — same probe after the fix:

choice 0 tool_calls: id get_weather_xxxxxxxxxxxxxxxxxxxxxxxx
                     extra_content.google.thought_signature sig-candidate-0
choice 1 tool_calls: id get_weather_xxxxxxxxxxxxxxxxxxxxxxxx
                     extra_content.google.thought_signature sig-candidate-1
Redis writes: thought_signature:get_weather_xxx = sig-candidate-0
              thought_signature:get_weather_xxx = sig-candidate-1
RESULT: MATCH (no bug)
CONTROL RESULT: MATCH — single-candidate path is consistent

(The deterministic shortid stub makes both candidate ids equal in the probe;
the real shortid yields unique ids per candidate, asserted in the spec.)

green — vitest, full spec:

$ pnpm exec vitest run apps/gateway/src/chat/tools/transform-response-to-openai.spec.ts --no-file-parallelism
 ✓ apps/gateway/src/chat/tools/transform-response-to-openai.spec.ts (14 tests) 24ms
 Test Files  1 passed (1)
      Tests  14 passed (14)
[exit code 0]

Area suite — vitest run apps/gateway/src/chat/tools: 593/603 tests,
37/38 files pass
. The only failing file is
openai-content-filter.spec.ts (10 tests), a service-harness spec whose
fetch/Redis mocks time out in this environment; it imports only
openai-content-filter.ts, which this change does not touch (same class of
harness failures documented in previous PRs here).

Environment
so: Windows 11 (AMD64)
node --version: v24.14.1
pnpm --version: 9.15.9 (via npx; repo packageManager is pnpm@10.30.3)
vitest: 4.1.8
base: c1cd97ba5c5483b9493a2e5632855155702387e3 (origin/main @ 08-09)
fix commit: 7e76bf9b (2 files, +177/−13)

Gates: eslint ✅ on both files (after import/order fix) · prettier ✅ ·
git diff --check clean · gateway typecheck ✅ (builds in build:core,
turbo 12/12) · pnpm-lock.yaml untouched.

Collisions: no open PR touches the multi-candidate Google tool_call ids /
thought_signature path (checked live 2026-08-11). #3486 (reasoning tokens)
and #3233 (Claude on Azure) also touch transform-response-to-openai.ts but
in unrelated areas (usage tokens / new provider branch); no semantic overlap.

Not verified: a live multi-turn round-trip against Gemini (no provider keys on
this machine). The id↔Redis-key chain is covered end-to-end by the spec's
setex assertions and the probe's recorded Redis writes.


Disclosure: an AI coding assistant helped locate the drift and draft the test
scaffolding. The reproduction, the red→green cycle and this write-up were
verified by running the code; I own the change and will follow up on review
comments.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of Google responses containing multiple candidates and tool calls.
    • Preserved candidate-specific thought signatures for reliable follow-up processing.
    • Prevented tool-call identifiers and cached data from being overwritten across candidates.
    • Added graceful logging when caching metadata cannot be completed.

…t_signature cache

parseProviderResponse caches Gemini thought signatures under the id it
emits (${name}_${shortid(24)}); the n>1 branch of transformResponseToOpenai
regenerated ${name}_${candidateIndex}_${fcIndex} ids and dropped
extra_content, so the next-turn thought_signature:<id> lookup missed and
Gemini rejected the replay with "Corrupted thought signature". Reuse the
parsed tool calls for candidate 0 (the one parse processes) and give
candidates 1+ the same treatment: unique shortid id, inline signature, and
the Redis entry under the emitted id.

Co-Authored-By: Codebuff <noreply@codebuff.com>
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Google multi-candidate tool-call transformation now generates unique IDs for additional candidates, preserves candidate-specific thought signatures, caches signatures in Redis, and logs cache-write failures. Tests cover unique IDs, signature preservation, and cache isolation.

Changes

Google tool-call signature preservation

Layer / File(s) Summary
Tool-call ID and signature transformation
apps/gateway/src/chat/tools/transform-response-to-openai.ts
The transformer reuses candidate 0 tool calls, generates short unique IDs for additional candidates, attaches thought signatures, caches them in Redis for 24 hours, and logs cache failures.
Transformation behavior tests
apps/gateway/src/chat/tools/transform-response-to-openai.spec.ts
Tests mock Redis and logging, validate distinct generated IDs, and verify candidate-specific signatures and cache entries.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GoogleCandidate
  participant TransformResponse
  participant Redis
  participant Logger
  GoogleCandidate->>TransformResponse: Provide candidate tool call and thought signature
  TransformResponse->>Redis: Cache signature under emitted tool-call ID
  Redis-->>TransformResponse: Return cache result
  TransformResponse->>Logger: Log Redis write failure when applicable
Loading

Possibly related PRs

  • theopenco/llmgateway#3042: Extends the encrypted reasoning flow with candidate-specific Google thought signature preservation and caching.

Suggested reviewers: steebchen

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix for preserving multi-candidate Google tool-call IDs in the thought-signature cache.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/gateway/src/chat/tools/transform-response-to-openai.spec.ts (1)

304-399: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the cache-failure branch.

The new source path logs through logger.error when redisClient.setex rejects. No test drives that branch, so a regression there stays silent. Add a case that rejects setexMock once and asserts the transform still returns the tool call with its inline extra_content.

Also consider moving setexMock.mockClear() into a beforeEach so later tests inherit a clean mock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/tools/transform-response-to-openai.spec.ts` around
lines 304 - 399, Add a test for the redisClient.setex rejection path exercised
by transformResponseToOpenai, configuring setexMock to reject once and asserting
the transform still returns the tool call with its inline extra_content. Move
setexMock.mockClear() into beforeEach so every test starts with a clean mock,
while preserving the existing multi-candidate cache assertions.
apps/gateway/src/chat/tools/transform-response-to-openai.ts (1)

476-505: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the tool-call construction and signature caching into a shared helper, and drop the any annotation.

The id scheme, the thought_signature:<id> key, and the 86400 TTL are now duplicated between this transformer and parseProviderResponse. A single helper keeps the two writers in sync and removes the magic TTL. The coding guidelines require DRY and prohibit any unless absolutely necessary; a small local type is enough here.

♻️ Suggested shape
-									const toolCall: any = {
+									const toolCall: {
+										id: string;
+										type: "function";
+										function: { name: string; arguments: string };
+										extra_content?: {
+											google: { thought_signature: string };
+										};
+									} = {
 										id: `${part.functionCall.name}_${shortid(24)}`,
 										type: "function",

Then move the extra_content assignment plus the redisClient.setex call into a shared cacheGoogleThoughtSignature(id, signature) helper used by both this file and parseProviderResponse, with the TTL exported as a named constant.

As per coding guidelines: "never use any or as any unless absolutely necessary" and "Apply DRY principles for reusable code".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/tools/transform-response-to-openai.ts` around lines 476
- 505, Extract the tool-call creation and Google thought-signature caching from
the mapped callback into a shared helper used by this transformer and
parseProviderResponse. Replace toolCall’s any annotation with a local type,
centralize the existing thought_signature:<id> key and 86400-second TTL in an
exported named constant, and have cacheGoogleThoughtSignature handle
extra_content assignment plus redisClient.setex while preserving the current
error logging.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/gateway/src/chat/tools/transform-response-to-openai.spec.ts`:
- Around line 304-399: Add a test for the redisClient.setex rejection path
exercised by transformResponseToOpenai, configuring setexMock to reject once and
asserting the transform still returns the tool call with its inline
extra_content. Move setexMock.mockClear() into beforeEach so every test starts
with a clean mock, while preserving the existing multi-candidate cache
assertions.

In `@apps/gateway/src/chat/tools/transform-response-to-openai.ts`:
- Around line 476-505: Extract the tool-call creation and Google
thought-signature caching from the mapped callback into a shared helper used by
this transformer and parseProviderResponse. Replace toolCall’s any annotation
with a local type, centralize the existing thought_signature:<id> key and
86400-second TTL in an exported named constant, and have
cacheGoogleThoughtSignature handle extra_content assignment plus
redisClient.setex while preserving the current error logging.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e5e4e75c-797d-4bb4-a3f4-d5d6c91cf197

📥 Commits

Reviewing files that changed from the base of the PR and between fd34b4f and 7e76bf9.

📒 Files selected for processing (2)
  • apps/gateway/src/chat/tools/transform-response-to-openai.spec.ts
  • apps/gateway/src/chat/tools/transform-response-to-openai.ts

@steebchen steebchen changed the title fix(gateway): keep multi-candidate Google tool_call ids in the thought_signature cache fix(gateway): cache multi-candidate tool call ids Aug 14, 2026
@steebchen
steebchen merged commit d2e8c2d into theopenco:main Aug 14, 2026
11 checks passed
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.

2 participants