fix(proxy/gemini): align shadow id with client-visible id in non-streaming path

When Gemini returns a `functionCall` without an id (common in 2.x
parallel calls), `gemini_to_anthropic_with_shadow_and_hints` previously
generated TWO independent synthesized UUIDs:

  1. Line 186-197 — synthesized id `A` used for the Anthropic-visible
     `content[tool_use].id` returned to the client.
  2. Line 850-881 — `extract_tool_call_meta` independently synthesized
     id `B ≠ A`, which populated `shadow_turn.tool_calls[i].id`.

`shadow_content` (line 225-228, cloned from `rectified_parts`) retained
the original missing/empty id. Result: the client sees id `A`, the
shadow store holds id `B`.

On the next turn, `convert_messages_to_contents` builds
`tool_name_by_id` from `build_tool_name_map_from_shadow_turns`, which
uses `tool_calls[i].id` — so the map contains `B → name` but not
`A → name`. When the client sends back `tool_result(tool_use_id=A)`,
resolution fails with:

  Unable to resolve Gemini functionResponse.name for tool_use_id `A`

This affects both truncated histories (client sends only the
tool_result) and full histories (shadow-replay branch at line 342-354
skips `convert_message_content_to_parts`, so the assistant tool_use
block never registers id `A` itself).

Fix: make `rectified_parts` the single source of truth. After
`rectify_tool_call_parts`, run a pre-pass that writes
`synthesize_tool_call_id()` back into any `functionCall` that lacks a
non-empty id. All three readers — the content builder (186-197), the
shadow_content clone (225-228), and `extract_tool_call_meta` — then
observe the same id. `shadow_parts()` already strips synthesized ids on
replay (line 616-628), so the internal identifier never leaks to
Gemini upstream.

This mirrors the streaming path, which already has single-source-of-
truth semantics via `tool_call_snapshots` in `streaming_gemini.rs` —
no change needed there.

Tests (5 new in `transform_gemini::tests`):
- `non_stream_shadow_id_matches_client_visible_id`: asserts
  `response.content[0].id == shadow.tool_calls[0].id ==
  shadow.assistant_content.parts[0].functionCall.id`.
- `non_stream_missing_id_scenario_a_truncated_history_resolves`: turn 2
  sends only `[tool_result(id=A)]`; resolution must succeed.
- `non_stream_missing_id_scenario_b_full_history_replay_resolves`: turn 2
  sends `[assistant(tool_use=A), tool_result(A)]`; shadow-replay branch
  strips the synth id from outgoing `functionCall` while still
  resolving the subsequent `tool_result`.
- `non_stream_preserves_original_gemini_id_when_present`: regression —
  genuine Gemini ids flow through unchanged.
- `non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay`:
  defensive — shadow-replay path must strip synth ids from both
  `functionCall.id` and `functionResponse.id`.

All 854 lib tests pass; cargo fmt + clippy -D warnings clean.

Addresses Codex follow-up P1 on #1918.
This commit is contained in:
Jason
2026-04-16 17:38:07 +08:00
parent 1f3ebe9c62
commit b02b8f3b1b
@@ -163,6 +163,34 @@ pub fn gemini_to_anthropic_with_shadow_and_hints(
let mut rectified_parts = parts.clone();
rectify_tool_call_parts(&mut rectified_parts, tool_schema_hints);
// Pre-pass: for every `functionCall` that lacks an id (or carries an
// empty-string id), synthesize one and write it back into
// `rectified_parts`. Three independent readers — the
// Anthropic-visible `content[tool_use]` block below, the shadow
// store's `assistant_content` (cloned from `rectified_parts` further
// down), and `extract_tool_call_meta(&rectified_parts)` that populates
// `shadow_turn.tool_calls` — must all see the same id. Otherwise the
// client would receive id A while the shadow stored id B, and the
// next round's `tool_result(tool_use_id=A)` would fail to resolve
// through `tool_name_by_id` (which is built from the shadow), raising
// `Unable to resolve Gemini functionResponse.name`. Streaming path
// already has this single-source-of-truth property via
// `tool_call_snapshots`.
for part in rectified_parts.iter_mut() {
let Some(function_call) = part.get_mut("functionCall").and_then(|v| v.as_object_mut())
else {
continue;
};
let needs_synth = function_call
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.is_empty())
.unwrap_or(true);
if needs_synth {
function_call.insert("id".to_string(), json!(synthesize_tool_call_id()));
}
}
let mut content = Vec::new();
let mut has_tool_use = false;
@@ -1625,4 +1653,314 @@ mod tests {
"get_weather"
);
}
// ------------------------------------------------------------------
// Non-streaming shadow id coherence regressions.
//
// When Gemini returns a `functionCall` without an id (common in 2.x
// parallel calls) the proxy must synthesize a single id that is
// consistent across:
// (a) the Anthropic `content[tool_use].id` sent to the client
// (b) `shadow_content.parts[].functionCall.id` recorded in shadow
// (c) `shadow_turn.tool_calls[].id` recorded in shadow
// Previously the non-streaming path generated independent UUIDs in (a)
// and (c), so the next round's `tool_result(tool_use_id=A)` would
// fail to resolve through `tool_name_by_id` (populated from (c)).
// ------------------------------------------------------------------
/// The id surfaced to the Anthropic client must equal the id recorded
/// in the shadow's `tool_calls` metadata and the shadow's serialized
/// `functionCall.id`. All three are read back as the same string.
#[test]
fn non_stream_shadow_id_matches_client_visible_id() {
let store = GeminiShadowStore::with_limits(8, 4);
let body = json!({
"responseId": "r-coherence",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [{
"functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } }
}]
}
}]
});
let response = gemini_to_anthropic_with_shadow_and_hints(
body,
Some(&store),
Some("prov"),
Some("sess"),
None,
)
.unwrap();
let client_id = response["content"][0]["id"].as_str().unwrap();
assert!(
is_synthesized_tool_call_id(client_id),
"client-facing id must be synthesized for no-id Gemini responses"
);
let snapshot = store.get_session("prov", "sess").expect("shadow recorded");
// (c) tool_calls metadata must agree with the client-visible id.
let shadow_tool_call_id = snapshot.turns[0].tool_calls[0]
.id
.as_deref()
.expect("tool_calls id populated");
assert_eq!(
shadow_tool_call_id, client_id,
"shadow.tool_calls id must equal client-visible id"
);
// (b) assistant_content parts must agree too, so that
// `merge_tool_names_from_parts` sees the same id on replay.
let shadow_part_id = snapshot.turns[0].assistant_content["parts"][0]["functionCall"]["id"]
.as_str()
.expect("assistant_content functionCall id populated");
assert_eq!(
shadow_part_id, client_id,
"shadow assistant_content functionCall.id must equal client-visible id"
);
}
/// Scenario A: the client-side history was truncated so the next
/// request only contains `[tool_result(tool_use_id=A)]` without a
/// preceding assistant echo. The request must still resolve because
/// `build_tool_name_map_from_shadow_turns` now surfaces the same id
/// the client was given.
#[test]
fn non_stream_missing_id_scenario_a_truncated_history_resolves() {
let store = GeminiShadowStore::with_limits(8, 4);
let turn1 = json!({
"responseId": "r-truncated",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [{
"functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } }
}]
}
}]
});
let anthropic_response = gemini_to_anthropic_with_shadow_and_hints(
turn1,
Some(&store),
Some("prov"),
Some("sess"),
None,
)
.unwrap();
let client_id = anthropic_response["content"][0]["id"]
.as_str()
.unwrap()
.to_string();
// Turn 2 — client replays ONLY the tool_result. No assistant echo.
let turn2_input = json!({
"messages": [
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": &client_id, "content": "sunny" }
]
}
]
});
let result =
anthropic_to_gemini_with_shadow(turn2_input, Some(&store), Some("prov"), Some("sess"))
.expect("scenario A must resolve tool name through shadow");
assert_eq!(
result["contents"][0]["parts"][0]["functionResponse"]["name"],
"get_weather"
);
}
/// Scenario B: the client replays the full history. The proxy picks
/// the shadow-replay branch (not `convert_message_content_to_parts`),
/// which strips the synthesized id from the outgoing `functionCall`.
/// `tool_name_by_id` must still have been populated from the shadow
/// so the following `tool_result(A)` resolves.
#[test]
fn non_stream_missing_id_scenario_b_full_history_replay_resolves() {
let store = GeminiShadowStore::with_limits(8, 4);
let turn1 = json!({
"responseId": "r-full",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [{
"functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } }
}]
}
}]
});
let anthropic_response = gemini_to_anthropic_with_shadow_and_hints(
turn1,
Some(&store),
Some("prov"),
Some("sess"),
None,
)
.unwrap();
let client_id = anthropic_response["content"][0]["id"]
.as_str()
.unwrap()
.to_string();
// Turn 2 — full history: assistant tool_use + tool_result.
let turn2_input = json!({
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": &client_id,
"name": "get_weather",
"input": { "city": "Tokyo" }
}
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": &client_id, "content": "sunny" }
]
}
]
});
let result =
anthropic_to_gemini_with_shadow(turn2_input, Some(&store), Some("prov"), Some("sess"))
.expect("scenario B must resolve tool name through shadow replay");
// Shadow-replay path: `functionCall.id` is stripped for the
// assistant turn (the synthesized id must not leak upstream).
assert!(
result["contents"][0]["parts"][0]["functionCall"]
.get("id")
.is_none(),
"synthesized id must not leak to Gemini in shadow replay"
);
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["name"],
"get_weather"
);
// The tool_result round-trip resolves through the shadow map.
assert_eq!(
result["contents"][1]["parts"][0]["functionResponse"]["name"],
"get_weather"
);
}
/// Regression: when Gemini returns an id, nothing is synthesized.
/// The original id is round-tripped in both the Anthropic response
/// and the shadow store, and it flows back to Gemini on the next
/// functionResponse.
#[test]
fn non_stream_preserves_original_gemini_id_when_present() {
let store = GeminiShadowStore::with_limits(8, 4);
let body = json!({
"responseId": "r-preserve",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [{
"functionCall": {
"id": "call_real_1",
"name": "get_weather",
"args": { "city": "Tokyo" }
}
}]
}
}]
});
let response = gemini_to_anthropic_with_shadow_and_hints(
body,
Some(&store),
Some("prov"),
Some("sess"),
None,
)
.unwrap();
assert_eq!(response["content"][0]["id"], "call_real_1");
let snapshot = store.get_session("prov", "sess").unwrap();
assert_eq!(
snapshot.turns[0].tool_calls[0].id.as_deref(),
Some("call_real_1")
);
assert_eq!(
snapshot.turns[0].assistant_content["parts"][0]["functionCall"]["id"],
"call_real_1"
);
}
/// Defensive: if a shadow turn somehow carries a synthesized
/// `functionCall.id` (e.g. recorded by this path), replaying it via
/// `anthropic_to_gemini_with_shadow` must strip the id before sending
/// upstream, so Gemini never sees the internal identifier.
#[test]
fn non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay() {
let store = GeminiShadowStore::with_limits(8, 4);
let synth = synthesize_tool_call_id();
store.record_assistant_turn(
"prov",
"sess",
json!({
"parts": [{
"functionCall": {
"id": &synth,
"name": "get_weather",
"args": { "city": "Tokyo" }
}
}]
}),
vec![GeminiToolCallMeta::new(
Some(synth.clone()),
"get_weather",
json!({ "city": "Tokyo" }),
None::<String>,
)],
);
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": &synth,
"name": "get_weather",
"input": { "city": "Tokyo" }
}
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": &synth, "content": "sunny" }
]
}
]
});
let result =
anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
.unwrap();
assert!(
result["contents"][0]["parts"][0]["functionCall"]
.get("id")
.is_none(),
"shadow replay must strip synthesized functionCall.id"
);
assert!(
result["contents"][1]["parts"][0]["functionResponse"]
.get("id")
.is_none(),
"functionResponse.id must also be omitted for synthesized ids"
);
}
}