Files
CC-Switch/src-tauri/src/proxy/providers/transform_gemini.rs
T
Jason a5903d8600 feat(health-check): replace real-LLM probe with HTTP reachability check
The provider panel health check sent a real streaming model request, which many third-party providers block (401/403/WAF), causing false negatives while only stable official endpoints passed. Replace it with a lightweight reachability probe: GET the provider base_url and treat any HTTP response (200/4xx/5xx) as reachable; only DNS/connect/TLS/timeout count as failure. Latency is the probe's TTFB.

Backend (services/stream_check.rs): rewrite ~2200 -> ~350 lines, dropping real-request building, format conversion, auth and API-path resolution while keeping per-app base_url extraction. Defaults: 8s timeout, 1 retry, 1500ms degraded threshold.

Failover invariant: the reachability check must never reset the circuit breaker (reachable != usable; a 403 host is reachable but broken for real traffic). Remove the resetCircuitBreaker call from useStreamCheck; failover failure detection stays driven solely by real proxy traffic (forwarder/circuit_breaker untouched). useResetCircuitBreaker is kept dormant for a future manual-recovery entry.

Open the check to all providers: drop the official/copilot/codex-oauth/third-party gating and the 'sends a real request' confirm dialog. For official providers whose base_url is intentionally empty, fall back to the endpoint the client actually uses (Claude -> api.anthropic.com, Codex -> chatgpt.com/backend-api/codex, Gemini -> generativelanguage). Non-official providers with a missing base_url still error to avoid a false green light. Claude Desktop Official is native 1P mode (talks to claude.ai, cc-switch not in the request path, no reliable endpoint) so its button stays hidden.

Slim StreamCheckConfig and per-provider testConfig to timeout/threshold/retries (drop test model + prompt); sync zh/en/ja/zh-TW. Retain the now-unused anthropic_to_openai/anthropic_to_gemini transform utilities and their test suites.
2026-06-14 21:21:45 +08:00

2311 lines
81 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Gemini Native format conversion module.
//!
//! Converts Anthropic Messages requests to Gemini `generateContent` requests,
//! and Gemini `GenerateContentResponse` payloads back to Anthropic Messages
//! responses for Claude-compatible clients.
use super::gemini_schema::build_gemini_function_declaration;
use super::gemini_shadow::{GeminiAssistantTurn, GeminiShadowStore, GeminiToolCallMeta};
use crate::proxy::error::ProxyError;
use serde_json::{json, Map, Value};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AnthropicToolSchemaHint {
expected_keys: Vec<String>,
required_keys: Vec<String>,
}
pub type AnthropicToolSchemaHints = HashMap<String, AnthropicToolSchemaHint>;
/// Prefix used for Anthropic-visible tool call ids that we synthesize when
/// Gemini's `functionCall` omits the `id` field (Gemini 2.x parallel calls
/// often do). The prefix is how downstream request-path code recognizes that
/// the id is not a real Gemini id and must be stripped before forwarding back
/// to Gemini as `functionResponse.id`.
pub(crate) const SYNTHESIZED_ID_PREFIX: &str = "gemini_synth_";
/// Generate a unique tool-call id that is safe to expose to Anthropic clients
/// but must not be sent upstream to Gemini. Uses UUID v4 simple encoding
/// (32 lowercase hex chars) so that any number of parallel calls in the same
/// response remain distinguishable.
pub(crate) fn synthesize_tool_call_id() -> String {
format!("{SYNTHESIZED_ID_PREFIX}{}", uuid::Uuid::new_v4().simple())
}
/// Returns true if `id` was produced by [`synthesize_tool_call_id`] and
/// therefore must be stripped when building Gemini request bodies.
pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool {
id.starts_with(SYNTHESIZED_ID_PREFIX)
}
/// Anthropic 请求 → Gemini 原生请求。
///
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
#[allow(dead_code)]
pub fn anthropic_to_gemini(body: Value) -> Result<Value, ProxyError> {
anthropic_to_gemini_with_shadow(body, None, None, None)
}
pub fn anthropic_to_gemini_with_shadow(
body: Value,
shadow_store: Option<&GeminiShadowStore>,
provider_id: Option<&str>,
session_id: Option<&str>,
) -> Result<Value, ProxyError> {
let mut result = json!({});
let shadow_turns = shadow_store
.zip(provider_id)
.zip(session_id)
.and_then(|((store, provider_id), session_id)| store.get_session(provider_id, session_id))
.map(|snapshot| snapshot.turns)
.unwrap_or_default();
let messages = body.get("messages").and_then(|value| value.as_array());
let system_instruction = build_system_instruction(
body.get("system"),
messages.map(|messages| messages.as_slice()),
)?;
if let Some(system) = system_instruction {
result["systemInstruction"] = system;
}
if let Some(messages) = messages {
result["contents"] = json!(convert_messages_to_contents(messages, &shadow_turns)?);
}
if let Some(generation_config) = build_generation_config(&body) {
result["generationConfig"] = generation_config;
}
if let Some(tools) = body.get("tools").and_then(|value| value.as_array()) {
let function_declarations: Vec<Value> = tools
.iter()
.filter(|tool| tool.get("type").and_then(|value| value.as_str()) != Some("BatchTool"))
.map(|tool| {
build_gemini_function_declaration(
tool.get("name")
.and_then(|value| value.as_str())
.unwrap_or(""),
tool.get("description").and_then(|value| value.as_str()),
tool.get("input_schema")
.cloned()
.unwrap_or_else(|| json!({})),
)
})
.collect();
if !function_declarations.is_empty() {
result["tools"] = json!([{ "functionDeclarations": function_declarations }]);
}
}
if let Some(tool_config) = map_tool_choice(body.get("tool_choice"))? {
result["toolConfig"] = tool_config;
}
Ok(result)
}
/// Convenience wrapper over [`gemini_to_anthropic_with_shadow_and_hints`]
/// with no shadow store or schema hints. Used by the shared
/// `ProviderAdapter::transform_response` path and by tests.
#[allow(dead_code)] // kept as public API for non-streaming transform paths
pub fn gemini_to_anthropic(body: Value) -> Result<Value, ProxyError> {
gemini_to_anthropic_with_shadow(body, None, None, None)
}
/// Convenience wrapper for callers that have a shadow store but no tool
/// schema hints. Production call sites funnel through
/// [`gemini_to_anthropic_with_shadow_and_hints`] directly; this helper exists
/// for test ergonomics and future external callers.
#[allow(dead_code)] // kept as public API for shadow-only transform paths
pub fn gemini_to_anthropic_with_shadow(
body: Value,
shadow_store: Option<&GeminiShadowStore>,
provider_id: Option<&str>,
session_id: Option<&str>,
) -> Result<Value, ProxyError> {
gemini_to_anthropic_with_shadow_and_hints(body, shadow_store, provider_id, session_id, None)
}
pub fn gemini_to_anthropic_with_shadow_and_hints(
body: Value,
shadow_store: Option<&GeminiShadowStore>,
provider_id: Option<&str>,
session_id: Option<&str>,
tool_schema_hints: Option<&AnthropicToolSchemaHints>,
) -> Result<Value, ProxyError> {
if let Some(block_reason) = body
.get("promptFeedback")
.and_then(|value| value.get("blockReason"))
.and_then(|value| value.as_str())
{
let text = format!("Request blocked by Gemini safety filters: {block_reason}");
return Ok(json!({
"id": body.get("responseId").and_then(|value| value.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": [{ "type": "text", "text": text }],
"model": body.get("modelVersion").and_then(|value| value.as_str()).unwrap_or(""),
"stop_reason": "refusal",
"stop_sequence": Value::Null,
"usage": build_anthropic_usage(body.get("usageMetadata"))
}));
}
let candidate = body
.get("candidates")
.and_then(|value| value.as_array())
.and_then(|value| value.first())
.ok_or_else(|| {
ProxyError::TransformError("No candidates in Gemini response".to_string())
})?;
let parts = candidate
.get("content")
.and_then(|value| value.get("parts"))
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
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;
for part in &rectified_parts {
if part.get("thought").and_then(|value| value.as_bool()) == Some(true) {
continue;
}
if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
if !text.is_empty() {
content.push(json!({
"type": "text",
"text": text
}));
}
continue;
}
if let Some(function_call) = part.get("functionCall") {
has_tool_use = true;
let id = function_call
.get("id")
.and_then(|value| value.as_str())
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.unwrap_or_else(synthesize_tool_call_id);
content.push(json!({
"type": "tool_use",
"id": id,
"name": function_call.get("name").and_then(|value| value.as_str()).unwrap_or(""),
"input": function_call.get("args").cloned().unwrap_or_else(|| json!({}))
}));
}
}
let stop_reason = map_finish_reason(
candidate
.get("finishReason")
.and_then(|value| value.as_str()),
has_tool_use,
);
let anthropic_response = json!({
"id": body.get("responseId").and_then(|value| value.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("modelVersion").and_then(|value| value.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": Value::Null,
"usage": build_anthropic_usage(body.get("usageMetadata"))
});
if let (Some(store), Some(provider_id), Some(session_id), Some(content)) = (
shadow_store,
provider_id,
session_id,
candidate.get("content"),
) {
let mut shadow_content = content.clone();
if let Some(parts_value) = shadow_content.get_mut("parts") {
*parts_value = json!(rectified_parts.clone());
}
store.record_assistant_turn(
provider_id,
session_id,
shadow_content,
extract_tool_call_meta(&rectified_parts),
);
}
Ok(anthropic_response)
}
pub fn extract_gemini_model(body: &Value) -> Option<&str> {
body.get("model").and_then(|value| value.as_str())
}
fn build_system_instruction(
system: Option<&Value>,
messages: Option<&[Value]>,
) -> Result<Option<Value>, ProxyError> {
let mut texts = Vec::new();
if let Some(system) = system {
collect_system_texts(system, &mut texts)?;
}
if let Some(messages) = messages {
for message in messages {
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
continue;
}
if let Some(content) = message.get("content") {
collect_system_texts(content, &mut texts)?;
}
}
}
if texts.is_empty() {
return Ok(None);
}
Ok(Some(json!({
"parts": [{ "text": texts.join("\n\n") }]
})))
}
fn collect_system_texts(value: &Value, texts: &mut Vec<String>) -> Result<(), ProxyError> {
if let Some(text) = value.as_str() {
if !text.is_empty() {
texts.push(text.to_string());
}
return Ok(());
}
let Some(blocks) = value.as_array() else {
return Err(ProxyError::TransformError(
"Anthropic system must be a string or an array".to_string(),
));
};
texts.extend(
blocks
.iter()
.filter_map(|block| block.get("text").and_then(|value| value.as_str()))
.filter(|text| !text.is_empty())
.map(ToString::to_string),
);
Ok(())
}
fn build_generation_config(body: &Value) -> Option<Value> {
let mut config = Map::new();
if let Some(value) = body.get("max_tokens") {
config.insert("maxOutputTokens".to_string(), value.clone());
}
if let Some(value) = body.get("temperature") {
config.insert("temperature".to_string(), value.clone());
}
if let Some(value) = body.get("top_p") {
config.insert("topP".to_string(), value.clone());
}
if let Some(value) = body.get("stop_sequences") {
config.insert("stopSequences".to_string(), value.clone());
}
if config.is_empty() {
None
} else {
Some(Value::Object(config))
}
}
fn convert_messages_to_contents(
messages: &[Value],
shadow_turns: &[GeminiAssistantTurn],
) -> Result<Vec<Value>, ProxyError> {
let mut contents = Vec::new();
let mut used_shadow_indices = HashSet::new();
let total_assistant_messages = messages
.iter()
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("assistant"))
.count();
let effective_shadow_turns = if shadow_turns.len() > total_assistant_messages {
&shadow_turns[shadow_turns.len() - total_assistant_messages..]
} else {
shadow_turns
};
// Build tool name and thought_signature maps from shadow store.
// These are used to resolve tool_result→functionResponse names and to
// attach thought signatures when replaying tool_use→functionCall.
let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns);
let mut thought_signature_by_id = build_thought_signature_map_from_shadow_turns(shadow_turns);
// Pre-scan all assistant messages in the request body to seed
// tool_name_by_id with every tool_use id mentioned in the conversation
// history. This ensures tool_result blocks can always resolve their
// function name even when the shadow store has aged out the relevant
// turn (e.g. long conversations, session restarts, or concurrent
// session churn).
for message in messages {
if message.get("role").and_then(|v| v.as_str()) != Some("assistant") {
continue;
}
if let Some(blocks) = message.get("content").and_then(|c| c.as_array()) {
for block in blocks {
if block.get("type").and_then(|v| v.as_str()) != Some("tool_use") {
continue;
}
let id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
if !id.is_empty() && !name.is_empty() {
tool_name_by_id
.entry(id.to_string())
.or_insert_with(|| name.to_string());
}
}
}
}
let shadow_start_index = total_assistant_messages.saturating_sub(effective_shadow_turns.len());
let mut assistant_seen_index = 0usize;
for message in messages {
let role = message
.get("role")
.and_then(|value| value.as_str())
.unwrap_or("user");
if role == "system" {
continue;
}
let gemini_role = if role == "assistant" { "model" } else { "user" };
let parts = if role == "assistant" {
let positional_shadow_index = assistant_seen_index
.checked_sub(shadow_start_index)
.filter(|index| *index < effective_shadow_turns.len())
.filter(|index| !used_shadow_indices.contains(index));
let tool_use_match_index = find_matching_shadow_turn_for_assistant_message(
message.get("content"),
effective_shadow_turns,
)
.filter(|index| !used_shadow_indices.contains(index));
assistant_seen_index += 1;
let shadow_index = tool_use_match_index.or(positional_shadow_index);
if let Some(index) = shadow_index {
used_shadow_indices.insert(index);
let shadow_turn = &effective_shadow_turns[index];
merge_tool_names_from_shadow(shadow_turn, &mut tool_name_by_id);
merge_thought_signatures_from_shadow(shadow_turn, &mut thought_signature_by_id);
if let Some(parts) = shadow_parts(&shadow_turn.assistant_content) {
parts
} else {
convert_message_content_to_parts(
message.get("content"),
role,
&mut tool_name_by_id,
&thought_signature_by_id,
)?
}
} else {
convert_message_content_to_parts(
message.get("content"),
role,
&mut tool_name_by_id,
&thought_signature_by_id,
)?
}
} else {
convert_message_content_to_parts(
message.get("content"),
role,
&mut tool_name_by_id,
&thought_signature_by_id,
)?
};
if role == "assistant" {
merge_tool_names_from_parts(&parts, &mut tool_name_by_id);
}
contents.push(json!({
"role": gemini_role,
"parts": parts
}));
}
Ok(contents)
}
fn find_matching_shadow_turn_for_assistant_message(
content: Option<&Value>,
shadow_turns: &[GeminiAssistantTurn],
) -> Option<usize> {
let (tool_use_ids, tool_use_names) = extract_assistant_tool_use_keys(content);
if tool_use_ids.is_empty() && tool_use_names.is_empty() {
return None;
}
// Prefer exact tool-call id match. With identical tool suffixes across
// servers (e.g. `server_a:search` and `server_b:search`) the
// normalized-name clause below would otherwise match an earlier shadow
// turn whose id is actually wrong for this message, mis-routing replay
// state (functionCall id / thoughtSignature) for later tool_result
// resolution. Only fall back to name matching when id-based lookup fails
// or when the incoming message carries no ids at all.
if !tool_use_ids.is_empty() {
if let Some(index) = shadow_turns.iter().position(|turn| {
turn.tool_calls.iter().any(|tool_call| {
tool_call
.id
.as_deref()
.is_some_and(|id| tool_use_ids.contains(id))
})
}) {
return Some(index);
}
}
shadow_turns.iter().enumerate().find_map(|(index, turn)| {
turn.tool_calls
.iter()
.any(|tool_call| {
tool_use_names.contains(tool_call.name.as_str())
|| tool_use_names.contains(normalize_tool_name(&tool_call.name))
})
.then_some(index)
})
}
fn extract_assistant_tool_use_keys(content: Option<&Value>) -> (HashSet<String>, HashSet<String>) {
let mut tool_use_ids = HashSet::new();
let mut tool_use_names = HashSet::new();
let Some(blocks) = content.and_then(|value| value.as_array()) else {
return (tool_use_ids, tool_use_names);
};
for block in blocks {
if block.get("type").and_then(|value| value.as_str()) != Some("tool_use") {
continue;
}
if let Some(id) = block
.get("id")
.and_then(|value| value.as_str())
.filter(|id| !id.is_empty())
{
tool_use_ids.insert(id.to_string());
}
if let Some(name) = block
.get("name")
.and_then(|value| value.as_str())
.filter(|name| !name.is_empty())
{
tool_use_names.insert(name.to_string());
tool_use_names.insert(normalize_tool_name(name).to_string());
}
}
(tool_use_ids, tool_use_names)
}
fn normalize_tool_name(name: &str) -> &str {
name.rsplit(':').next().unwrap_or(name)
}
fn convert_message_content_to_parts(
content: Option<&Value>,
role: &str,
tool_name_by_id: &mut std::collections::HashMap<String, String>,
thought_signature_by_id: &std::collections::HashMap<String, String>,
) -> Result<Vec<Value>, ProxyError> {
let Some(content) = content else {
return Ok(Vec::new());
};
if let Some(text) = content.as_str() {
return Ok(vec![json!({ "text": text })]);
}
let Some(blocks) = content.as_array() else {
return Err(ProxyError::TransformError(
"Anthropic message content must be a string or array".to_string(),
));
};
let mut parts = Vec::new();
for block in blocks {
let block_type = block
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|value| value.as_str()) {
parts.push(json!({ "text": text }));
}
}
"image" => {
let source = block.get("source").ok_or_else(|| {
ProxyError::TransformError("Gemini image block missing source".to_string())
})?;
let source_type = source
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("");
if source_type != "base64" {
return Err(ProxyError::TransformError(format!(
"Gemini Native only supports base64 image sources, got `{source_type}`"
)));
}
parts.push(json!({
"inlineData": {
"mimeType": source.get("media_type").and_then(|value| value.as_str()).unwrap_or("image/png"),
"data": source.get("data").and_then(|value| value.as_str()).unwrap_or("")
}
}));
}
"document" => {
let source = block.get("source").ok_or_else(|| {
ProxyError::TransformError("Gemini document block missing source".to_string())
})?;
let source_type = source
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("");
if source_type != "base64" {
return Err(ProxyError::TransformError(format!(
"Gemini Native only supports base64 document sources, got `{source_type}`"
)));
}
parts.push(json!({
"inlineData": {
"mimeType": source.get("media_type").and_then(|value| value.as_str()).unwrap_or("application/pdf"),
"data": source.get("data").and_then(|value| value.as_str()).unwrap_or("")
}
}));
}
"tool_use" => {
if role != "assistant" {
return Err(ProxyError::TransformError(
"tool_use blocks are only valid in assistant messages".to_string(),
));
}
let id = block
.get("id")
.and_then(|value| value.as_str())
.unwrap_or("");
let name = block
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("");
if !id.is_empty() && !name.is_empty() {
tool_name_by_id.insert(id.to_string(), name.to_string());
}
// A synthesized id is an internal proxy identifier — never
// forward it to Gemini. Gemini will disambiguate the missing
// id by call order, matching its own earlier response shape.
let mut function_call = json!({
"name": name,
"args": block.get("input").cloned().unwrap_or_else(|| json!({}))
});
if !id.is_empty() && !is_synthesized_tool_call_id(id) {
function_call["id"] = json!(id);
}
// Re-attach the thought_signature that Gemini originally
// associated with this functionCall. The Anthropic format
// strips it from the tool_use block, but Gemini requires it
// on every functionCall in a multi-turn tool-use exchange.
// Without replaying the stored signature the upstream may
// reject with "missing a `thought_signature`".
if let Some(sig) = thought_signature_by_id.get(id) {
function_call["thoughtSignature"] = json!(sig);
}
parts.push(json!({ "functionCall": function_call }));
}
"tool_result" => {
let tool_use_id = block
.get("tool_use_id")
.and_then(|value| value.as_str())
.unwrap_or("");
let name = tool_name_by_id
.get(tool_use_id)
.cloned()
.or_else(|| {
// Last-resort fallback: scan every block in this content
// array for a tool_use whose id matches. This catches
// edge cases where the tool_use lives in a different
// content block of the same message (non-standard client
// behaviour) or in a re-ordered message array.
blocks.iter().find_map(|b| {
let t = b.get("type").and_then(|v| v.as_str())?;
if t != "tool_use" { return None; }
let id = b.get("id").and_then(|v| v.as_str())?;
if id != tool_use_id { return None; }
b.get("name").and_then(|v| v.as_str()).map(|n| n.to_string())
})
})
.ok_or_else(|| {
ProxyError::TransformError(format!(
"Unable to resolve Gemini functionResponse.name for tool_use_id `{tool_use_id}`"
))
})?;
// See `tool_use` above: synthesized ids must not leak upstream.
let mut function_response = json!({
"name": name,
"response": normalize_tool_result_response(block.get("content"))
});
if !tool_use_id.is_empty() && !is_synthesized_tool_call_id(tool_use_id) {
function_response["id"] = json!(tool_use_id);
}
parts.push(json!({ "functionResponse": function_response }));
}
"thinking" | "redacted_thinking" => {}
_ => {}
}
}
Ok(parts)
}
fn normalize_tool_result_response(content: Option<&Value>) -> Value {
match content {
Some(Value::String(text)) => json!({ "content": text }),
Some(Value::Array(blocks)) => {
let texts: Vec<&str> = blocks
.iter()
.filter(|block| block.get("type").and_then(|value| value.as_str()) == Some("text"))
.filter_map(|block| block.get("text").and_then(|value| value.as_str()))
.collect();
if texts.is_empty() {
json!({ "content": Value::Array(blocks.clone()) })
} else {
json!({ "content": texts.join("\n") })
}
}
Some(value) => json!({ "content": value.clone() }),
None => json!({ "content": "" }),
}
}
fn shadow_parts(content: &Value) -> Option<Vec<Value>> {
let mut parts = content
.get("parts")
.and_then(|value| value.as_array())
.cloned()
.or_else(|| content.as_array().cloned())?;
// Strip synthesized ids before these parts are replayed into a Gemini
// request body. The shadow store records the Anthropic-facing id so that
// a tool_result round-trip can find the tool's name, but sending the
// synthetic value as `functionCall.id` upstream would leak an internal
// identifier.
for part in &mut parts {
let Some(function_call) = part.get_mut("functionCall").and_then(|v| v.as_object_mut())
else {
continue;
};
let drop_id = function_call
.get("id")
.and_then(|v| v.as_str())
.map(|id| id.is_empty() || is_synthesized_tool_call_id(id))
.unwrap_or(true);
if drop_id {
function_call.remove("id");
}
}
Some(parts)
}
pub fn extract_anthropic_tool_schema_hints(body: &Value) -> AnthropicToolSchemaHints {
body.get("tools")
.and_then(|value| value.as_array())
.into_iter()
.flatten()
.filter_map(|tool| {
let name = tool.get("name").and_then(|value| value.as_str())?;
let input_schema = tool
.get("input_schema")
.and_then(|value| value.as_object())?;
let properties = input_schema
.get("properties")
.and_then(|value| value.as_object())?;
if properties.is_empty() {
return None;
}
let expected_keys = properties.keys().cloned().collect::<Vec<_>>();
let required_keys = input_schema
.get("required")
.and_then(|value| value.as_array())
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(ToString::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some((
name.to_string(),
AnthropicToolSchemaHint {
expected_keys,
required_keys,
},
))
})
.collect()
}
pub fn rectify_tool_call_parts(
parts: &mut [Value],
tool_schema_hints: Option<&AnthropicToolSchemaHints>,
) {
for part in parts {
let Some(function_call) = part
.get_mut("functionCall")
.and_then(|value| value.as_object_mut())
else {
continue;
};
let Some(name) = function_call
.get("name")
.and_then(|value| value.as_str())
.map(ToString::to_string)
else {
continue;
};
let Some(args) = function_call.get_mut("args") else {
continue;
};
if rectify_tool_call_args(&name, args, tool_schema_hints) {
log::info!("[Claude/Gemini] Rectified tool args for `{name}`");
}
}
}
pub fn rectify_tool_call_args(
tool_name: &str,
args: &mut Value,
tool_schema_hints: Option<&AnthropicToolSchemaHints>,
) -> bool {
let Some(tool_schema_hints) = tool_schema_hints else {
return false;
};
let Some(hint) = tool_schema_hints.get(tool_name) else {
return false;
};
let Some(args_object) = args.as_object_mut() else {
return false;
};
if args_object.is_empty() || hint.expected_keys.is_empty() {
return false;
}
let mut changed = false;
if hint.expected_keys.iter().any(|key| key == "skill") && !args_object.contains_key("skill") {
if let Some(value) = args_object.remove("name") {
args_object.insert("skill".to_string(), value);
changed = true;
}
}
let expects_parameters_key = hint.expected_keys.iter().any(|key| key == "parameters");
if !expects_parameters_key {
let extracted_parameters = args_object
.get("parameters")
.and_then(|value| value.as_object())
.map(|parameters_object| {
hint.expected_keys
.iter()
.filter_map(|expected_key| {
if args_object.contains_key(expected_key) {
return None;
}
let value = parameters_object.get(expected_key)?;
let normalized_value = match value {
Value::Array(values) if values.len() == 1 => values[0].clone(),
_ => value.clone(),
};
Some((expected_key.clone(), normalized_value))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !extracted_parameters.is_empty() {
for (expected_key, normalized_value) in extracted_parameters {
args_object.insert(expected_key, normalized_value);
}
args_object.remove("parameters");
changed = true;
}
}
if hint
.required_keys
.iter()
.all(|key| args_object.contains_key(key.as_str()))
{
return changed;
}
let expected_key_set = hint
.expected_keys
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
let unexpected_keys = args_object
.keys()
.filter(|key| !expected_key_set.contains(key.as_str()))
.cloned()
.collect::<Vec<_>>();
if unexpected_keys.len() != 1 {
return false;
}
let target_key = hint
.required_keys
.iter()
.find(|key| !args_object.contains_key(key.as_str()))
.cloned()
.or_else(|| {
if hint.expected_keys.len() == 1 && args_object.len() == 1 {
hint.expected_keys.first().cloned()
} else {
None
}
});
let Some(target_key) = target_key else {
return false;
};
if args_object.contains_key(&target_key) {
return false;
}
let source_key = &unexpected_keys[0];
let Some(value) = args_object.remove(source_key) else {
return false;
};
args_object.insert(target_key, value);
true
}
fn merge_tool_names_from_shadow(
turn: &GeminiAssistantTurn,
tool_name_by_id: &mut HashMap<String, String>,
) {
for tool_call in &turn.tool_calls {
if let Some(id) = &tool_call.id {
tool_name_by_id.insert(id.clone(), tool_call.name.clone());
}
}
if let Some(parts) = shadow_parts(&turn.assistant_content) {
merge_tool_names_from_parts(&parts, tool_name_by_id);
}
}
fn build_tool_name_map_from_shadow_turns(
shadow_turns: &[GeminiAssistantTurn],
) -> HashMap<String, String> {
let mut tool_name_by_id = HashMap::new();
for turn in shadow_turns {
merge_tool_names_from_shadow(turn, &mut tool_name_by_id);
}
tool_name_by_id
}
fn build_thought_signature_map_from_shadow_turns(
shadow_turns: &[GeminiAssistantTurn],
) -> HashMap<String, String> {
let mut thought_signature_by_id = HashMap::new();
for turn in shadow_turns {
merge_thought_signatures_from_shadow(turn, &mut thought_signature_by_id);
}
thought_signature_by_id
}
fn merge_thought_signatures_from_shadow(
turn: &GeminiAssistantTurn,
thought_signature_by_id: &mut HashMap<String, String>,
) {
for tool_call in &turn.tool_calls {
if let (Some(id), Some(sig)) = (&tool_call.id, &tool_call.thought_signature) {
thought_signature_by_id.insert(id.clone(), sig.clone());
}
}
}
fn merge_tool_names_from_parts(parts: &[Value], tool_name_by_id: &mut HashMap<String, String>) {
for part in parts {
let Some(function_call) = part.get("functionCall") else {
continue;
};
let Some(id) = function_call.get("id").and_then(|value| value.as_str()) else {
continue;
};
let Some(name) = function_call.get("name").and_then(|value| value.as_str()) else {
continue;
};
if !id.is_empty() && !name.is_empty() {
tool_name_by_id.insert(id.to_string(), name.to_string());
}
}
}
fn extract_tool_call_meta(parts: &[Value]) -> Vec<GeminiToolCallMeta> {
parts
.iter()
.filter_map(|part| {
let function_call = part.get("functionCall")?;
// Ensure every surfaced tool call carries a distinguishing id.
// Gemini 2.x may omit `id` on parallel calls; synthesizing a
// unique replacement prevents downstream merge/replay logic from
// collapsing distinct calls onto a single empty-string key.
let id = function_call
.get("id")
.and_then(|value| value.as_str())
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.unwrap_or_else(synthesize_tool_call_id);
Some(GeminiToolCallMeta::new(
Some(id),
function_call
.get("name")
.and_then(|value| value.as_str())
.unwrap_or(""),
function_call
.get("args")
.cloned()
.unwrap_or_else(|| json!({})),
part.get("thoughtSignature")
.or_else(|| part.get("thought_signature"))
.and_then(|value| value.as_str()),
))
})
.collect()
}
fn map_tool_choice(tool_choice: Option<&Value>) -> Result<Option<Value>, ProxyError> {
let Some(tool_choice) = tool_choice else {
return Ok(None);
};
match tool_choice {
Value::String(choice) => Ok(match choice.as_str() {
"auto" => Some(json!({
"functionCallingConfig": { "mode": "AUTO" }
})),
"none" => Some(json!({
"functionCallingConfig": { "mode": "NONE" }
})),
other => {
return Err(ProxyError::TransformError(format!(
"Unsupported Gemini tool_choice string: {other}"
)));
}
}),
Value::Object(object) => {
let Some(choice_type) = object.get("type").and_then(|value| value.as_str()) else {
return Ok(None);
};
let config = match choice_type {
"auto" => json!({ "mode": "AUTO" }),
"none" => json!({ "mode": "NONE" }),
"any" => json!({ "mode": "ANY" }),
"tool" => {
let name = object
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("");
json!({
"mode": "ANY",
"allowedFunctionNames": [name]
})
}
other => {
return Err(ProxyError::TransformError(format!(
"Unsupported Gemini tool_choice type: {other}"
)));
}
};
Ok(Some(json!({ "functionCallingConfig": config })))
}
_ => Ok(None),
}
}
/// Convert a Gemini `usageMetadata` object into an Anthropic-style `usage`
/// object. Used by both the streaming SSE converter and the non-streaming
/// transform path so the two emit identical shapes.
pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
let Some(usage) = usage else {
return json!({
"input_tokens": 0,
"output_tokens": 0
});
};
let prompt_tokens = usage
.get("promptTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let total_tokens = usage
.get("totalTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let cached_tokens = usage
.get("cachedContentTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
// Gemini 的 promptTokenCount 含缓存命中(cachedContentTokenCount);而 Anthropic
// 语义下 input_tokens 必须是不含 cache 的 fresh input、cache_read 单列。本路径转成
// Anthropic 后以 app_type=claude 记账,calculator 对 claude 设 input_includes_cache_read
// =false 不再从 input 扣 cache,因此这里必须先扣减,否则缓存 token 会被双重计费
// (一次按完整 input 价、一次按 cache_read 价)。output 仍按 total-prompt 计算
// (prompt 是总输入,扣减只作用于 input/cache 的拆分,不影响 output)。
let input_tokens = prompt_tokens.saturating_sub(cached_tokens);
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
let mut result = json!({
"input_tokens": input_tokens,
"output_tokens": output_tokens
});
if cached_tokens > 0 {
result["cache_read_input_tokens"] = json!(cached_tokens);
}
result
}
fn map_finish_reason(reason: Option<&str>, has_tool_use: bool) -> Value {
let mapped = match reason {
Some("MAX_TOKENS") => Some("max_tokens"),
Some("STOP") | Some("FINISH_REASON_UNSPECIFIED") | None => {
if has_tool_use {
Some("tool_use")
} else {
Some("end_turn")
}
}
Some("SAFETY")
| Some("RECITATION")
| Some("SPII")
| Some("BLOCKLIST")
| Some("PROHIBITED_CONTENT") => Some("refusal"),
Some(other) => {
log::warn!("[Claude/Gemini] Unknown Gemini finishReason `{other}`, using end_turn");
Some("end_turn")
}
};
match mapped {
Some(value) => json!(value),
None => Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anthropic_to_gemini_maps_system_and_messages() {
let input = json!({
"model": "gemini-2.5-pro",
"max_tokens": 128,
"system": "You are helpful.",
"messages": [
{ "role": "user", "content": "Hello" }
]
});
let result = anthropic_to_gemini(input).unwrap();
assert_eq!(
result["systemInstruction"]["parts"][0]["text"],
"You are helpful."
);
assert_eq!(result["contents"][0]["role"], "user");
assert_eq!(result["contents"][0]["parts"][0]["text"], "Hello");
assert_eq!(result["generationConfig"]["maxOutputTokens"], 128);
}
#[test]
fn anthropic_to_gemini_merges_system_messages_into_system_instruction() {
let input = json!({
"model": "gemini-3-pro",
"system": [{ "type": "text", "text": "Top level system." }],
"messages": [
{ "role": "system", "content": "Message system." },
{
"role": "system",
"content": [{ "type": "text", "text": "Block system." }]
},
{ "role": "user", "content": "Hello" }
]
});
let result = anthropic_to_gemini(input).unwrap();
assert_eq!(
result["systemInstruction"]["parts"][0]["text"],
"Top level system.\n\nMessage system.\n\nBlock system."
);
assert_eq!(result["contents"].as_array().unwrap().len(), 1);
assert_eq!(result["contents"][0]["role"], "user");
assert_eq!(result["contents"][0]["parts"][0]["text"], "Hello");
}
#[test]
fn anthropic_to_gemini_maps_tools_and_tool_results() {
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Tokyo" } }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" }
]
}
],
"tools": [
{
"name": "get_weather",
"description": "Weather lookup",
"input_schema": { "type": "object", "properties": { "city": { "type": "string" } } }
}
],
"tool_choice": { "type": "tool", "name": "get_weather" }
});
let result = anthropic_to_gemini(input).unwrap();
assert_eq!(
result["tools"][0]["functionDeclarations"][0]["name"],
"get_weather"
);
assert!(result["tools"][0]["functionDeclarations"][0]
.get("parameters")
.is_some());
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["name"],
"get_weather"
);
assert_eq!(
result["contents"][1]["parts"][0]["functionResponse"]["name"],
"get_weather"
);
assert_eq!(
result["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"][0],
"get_weather"
);
}
#[test]
fn anthropic_to_gemini_resolves_tool_result_name_from_shadow_content() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn(
"provider-a",
"session-1",
json!({
"parts": [{
"functionCall": {
"id": "call_1",
"name": "get_weather",
"args": { "city": "Tokyo" }
}
}]
}),
vec![],
);
let input = json!({
"messages": [
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" }
]
}
]
});
let result = anthropic_to_gemini_with_shadow(
input,
Some(&store),
Some("provider-a"),
Some("session-1"),
)
.unwrap();
assert_eq!(
result["contents"][0]["parts"][0]["functionResponse"]["name"],
"get_weather"
);
}
#[test]
fn anthropic_to_gemini_rejects_tool_result_without_resolvable_name() {
let input = json!({
"messages": [
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" }
]
}
]
});
let error = anthropic_to_gemini(input).unwrap_err();
assert!(error
.to_string()
.contains("Unable to resolve Gemini functionResponse.name"));
}
#[test]
fn anthropic_to_gemini_uses_parameters_json_schema_for_rich_tool_schema() {
let input = json!({
"tools": [
{
"name": "search",
"description": "Search data",
"input_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"],
"additionalProperties": false
}
}
]
});
let result = anthropic_to_gemini(input).unwrap();
let declaration = &result["tools"][0]["functionDeclarations"][0];
assert!(declaration.get("parameters").is_none());
assert!(declaration.get("parametersJsonSchema").is_some());
assert!(declaration["parametersJsonSchema"].get("$schema").is_none());
assert_eq!(
declaration["parametersJsonSchema"]["additionalProperties"],
false
);
}
#[test]
fn gemini_to_anthropic_maps_text_and_usage() {
let input = json!({
"responseId": "resp_1",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [{ "text": "Hello from Gemini" }]
}
}],
"usageMetadata": {
"promptTokenCount": 12,
"totalTokenCount": 20,
"cachedContentTokenCount": 3
}
});
let result = gemini_to_anthropic(input).unwrap();
assert_eq!(result["id"], "resp_1");
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello from Gemini");
assert_eq!(result["stop_reason"], "end_turn");
// input_tokens = promptTokenCount(12) - cachedContentTokenCount(3) = 9fresh input)。
// Gemini 的 promptTokenCount 含缓存命中,但 Anthropic 语义要求 input 不含 cache、
// cache_read 单列;二者相加(9+3)=总输入 12。扣减避免本路径以 app_type=claude
// 记账时把缓存 token 双重计费。
assert_eq!(result["usage"]["input_tokens"], 9);
assert_eq!(result["usage"]["output_tokens"], 8);
assert_eq!(result["usage"]["cache_read_input_tokens"], 3);
}
#[test]
fn gemini_to_anthropic_maps_function_calls_to_tool_use() {
let input = json!({
"responseId": "resp_2",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [{
"functionCall": {
"id": "call_1",
"name": "get_weather",
"args": { "city": "Tokyo" }
}
}]
}
}],
"usageMetadata": {
"promptTokenCount": 10,
"totalTokenCount": 15
}
});
let result = gemini_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["id"], "call_1");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn gemini_to_anthropic_rectifies_tool_args_from_schema_hints() {
let input = json!({
"responseId": "resp_2",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [{
"functionCall": {
"id": "call_1",
"name": "Skill",
"args": {
"name": "git-commit",
"parameters": {
"args": ["详细分析内容 编写提交信息 分多次提交代码"]
}
}
}
}]
}
}]
});
let hints = extract_anthropic_tool_schema_hints(&json!({
"tools": [{
"name": "Skill",
"input_schema": {
"type": "object",
"properties": {
"skill": { "type": "string" },
"args": { "type": "string" }
},
"required": ["skill"]
}
}]
}));
let result =
gemini_to_anthropic_with_shadow_and_hints(input, None, None, None, Some(&hints))
.unwrap();
assert_eq!(result["content"][0]["input"]["skill"], "git-commit");
assert_eq!(
result["content"][0]["input"]["args"],
"详细分析内容 编写提交信息 分多次提交代码"
);
assert!(result["content"][0]["input"].get("name").is_none());
assert!(result["content"][0]["input"].get("parameters").is_none());
}
#[test]
fn gemini_to_anthropic_preserves_legitimate_parameters_arg() {
let input = json!({
"responseId": "resp_params",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [{
"functionCall": {
"id": "call_1",
"name": "ConfigTool",
"args": {
"parameters": {
"mode": "safe",
"retries": 2
}
}
}
}]
}
}]
});
let hints = extract_anthropic_tool_schema_hints(&json!({
"tools": [{
"name": "ConfigTool",
"input_schema": {
"type": "object",
"properties": {
"parameters": {
"type": "object",
"properties": {
"mode": { "type": "string" },
"retries": { "type": "integer" }
}
}
},
"required": ["parameters"]
}
}]
}));
let result =
gemini_to_anthropic_with_shadow_and_hints(input, None, None, None, Some(&hints))
.unwrap();
assert_eq!(result["content"][0]["input"]["parameters"]["mode"], "safe");
assert_eq!(result["content"][0]["input"]["parameters"]["retries"], 2);
}
#[test]
fn gemini_to_anthropic_maps_blocked_prompt_to_refusal() {
let input = json!({
"responseId": "resp_3",
"modelVersion": "gemini-2.5-flash",
"promptFeedback": { "blockReason": "SAFETY" },
"usageMetadata": {
"promptTokenCount": 4,
"totalTokenCount": 4
}
});
let result = gemini_to_anthropic(input).unwrap();
assert_eq!(result["stop_reason"], "refusal");
assert_eq!(result["content"][0]["type"], "text");
assert!(result["content"][0]["text"]
.as_str()
.unwrap()
.contains("SAFETY"));
}
#[test]
fn shadow_replay_aligns_to_latest_turns_after_client_truncation() {
let store = GeminiShadowStore::with_limits(8, 4);
// Record 3 shadow turns (assistant messages 0, 1, 2)
for i in 0..3 {
store.record_assistant_turn(
"prov",
"sess",
json!({
"parts": [{
"functionCall": {
"id": format!("call_{i}"),
"name": format!("tool_{i}"),
"args": {}
}
}]
}),
vec![],
);
}
// Client truncates history: only sends assistant messages 1 and 2
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "call_1", "name": "tool_1", "input": {} }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_1", "content": "ok" }
]
},
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "call_2", "name": "tool_2", "input": {} }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_2", "content": "ok" }
]
}
]
});
let result =
anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
.unwrap();
// Shadow turns[1] (tool_1) should align with first assistant message,
// shadow turns[2] (tool_2) with the second — not turns[0] and turns[1].
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["name"],
"tool_1"
);
assert_eq!(
result["contents"][2]["parts"][0]["functionCall"]["name"],
"tool_2"
);
}
#[test]
fn shadow_replay_matches_tool_use_turn_by_id_when_position_drifts() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn(
"prov",
"sess",
json!({
"parts": [{
"functionCall": {
"id": "call_1",
"name": "Bash",
"args": { "command": "ls -R" }
},
"thoughtSignature": "sig-tool-1"
}]
}),
vec![GeminiToolCallMeta::new(
Some("call_1"),
"Bash",
json!({ "command": "ls -R" }),
Some("sig-tool-1"),
)],
);
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_1",
"name": "default_api:Bash",
"input": { "command": "ls -R" }
}
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_1", "content": "ok" }
]
},
{
"role": "assistant",
"content": [
{ "type": "text", "text": "local-only assistant turn without Gemini shadow" }
]
}
]
});
let result =
anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
.unwrap();
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["name"],
"Bash"
);
assert_eq!(
result["contents"][0]["parts"][0]["thoughtSignature"],
"sig-tool-1"
);
}
/// Regression for P1: two shadow turns whose suffix-normalized names
/// collide (e.g. `server_a:search` / `server_b:search` both normalize to
/// `search`). When the incoming assistant tool_use carries a valid,
/// different id, exact-id matching must win over the normalized-name
/// clause — otherwise replay picks the wrong shadow turn and later
/// tool_result resolution mis-routes.
#[test]
fn shadow_replay_prefers_exact_id_match_over_normalized_name_collision() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn(
"prov",
"sess",
json!({
"parts": [{
"functionCall": {
"id": "call_a",
"name": "server_a:search",
"args": { "q": "alpha" }
},
"thoughtSignature": "sig-a"
}]
}),
vec![GeminiToolCallMeta::new(
Some("call_a"),
"server_a:search",
json!({ "q": "alpha" }),
Some("sig-a"),
)],
);
store.record_assistant_turn(
"prov",
"sess",
json!({
"parts": [{
"functionCall": {
"id": "call_b",
"name": "server_b:search",
"args": { "q": "beta" }
},
"thoughtSignature": "sig-b"
}]
}),
vec![GeminiToolCallMeta::new(
Some("call_b"),
"server_b:search",
json!({ "q": "beta" }),
Some("sig-b"),
)],
);
// Two assistant turns: the first references call_b, the second
// call_a. Positional fallback would align msg[0] to turn 0 (call_a)
// and msg[1] to turn 1 (call_b) — both wrong. The old `||` chain
// would also mis-match through the normalized "search" name.
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "call_b", "name": "server_b:search", "input": { "q": "beta" } }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_b", "content": "ok-b" }
]
},
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "call_a", "name": "server_a:search", "input": { "q": "alpha" } }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_a", "content": "ok-a" }
]
}
]
});
let result =
anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
.unwrap();
// msg[0] replays shadow turn 1 (server_b:search) because id=call_b.
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["name"],
"server_b:search"
);
assert_eq!(
result["contents"][0]["parts"][0]["thoughtSignature"],
"sig-b"
);
// msg[2] replays shadow turn 0 (server_a:search) because id=call_a,
// even though turn 1 was already consumed above.
assert_eq!(
result["contents"][2]["parts"][0]["functionCall"]["name"],
"server_a:search"
);
assert_eq!(
result["contents"][2]["parts"][0]["thoughtSignature"],
"sig-a"
);
}
/// When the incoming tool_use carries no id (or only empty-string ids),
/// the layered matcher must still fall back to name-based matching so
/// that shadow replay keeps working for providers that omit ids.
#[test]
fn shadow_replay_falls_back_to_name_when_ids_absent() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn(
"prov",
"sess",
json!({
"parts": [{
"functionCall": {
"name": "lookup",
"args": {}
},
"thoughtSignature": "sig-lookup"
}]
}),
vec![GeminiToolCallMeta::new(
None::<&str>,
"lookup",
json!({}),
Some("sig-lookup"),
)],
);
// id is an empty string; extract_assistant_tool_use_keys filters it
// out, so tool_use_ids is empty and matching must go through names.
// A trailing user text turn keeps the assistant turn well-formed
// without feeding a tool_result back (which would require a real id).
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "", "name": "lookup", "input": {} }
]
},
{
"role": "user",
"content": "ack"
}
]
});
let result =
anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
.unwrap();
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["name"],
"lookup"
);
assert_eq!(
result["contents"][0]["parts"][0]["thoughtSignature"],
"sig-lookup"
);
}
/// Regression for P1: Gemini 2.x may return parallel calls without ids.
/// Each Anthropic-visible tool_use must carry a unique id so the Claude
/// Code client can map tool_result responses back correctly.
#[test]
fn gemini_to_anthropic_synthesizes_unique_ids_for_missing_functioncall_ids() {
let input = json!({
"responseId": "r1",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"finishReason": "STOP",
"content": {
"parts": [
{ "functionCall": { "name": "foo", "args": {} } },
{ "functionCall": { "name": "foo", "args": { "k": 1 } } }
]
}
}]
});
let result = gemini_to_anthropic(input).unwrap();
let id0 = result["content"][0]["id"].as_str().unwrap();
let id1 = result["content"][1]["id"].as_str().unwrap();
assert!(is_synthesized_tool_call_id(id0));
assert!(is_synthesized_tool_call_id(id1));
assert_ne!(id0, id1, "synthesized ids must be unique per call");
}
/// Ensures the proxy does not leak synthesized ids back to Gemini when
/// Claude Code replies with a tool_result: the id must be stripped from
/// both `functionCall.id` and `functionResponse.id`.
#[test]
fn tool_result_with_synthesized_id_omits_id_in_gemini_request() {
let synth = synthesize_tool_call_id();
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": &synth, "name": "get_weather", "input": { "city": "X" } }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": &synth, "content": "sunny" }
]
}
]
});
let result = anthropic_to_gemini(input).unwrap();
let fc = &result["contents"][0]["parts"][0]["functionCall"];
assert!(
fc.get("id").is_none(),
"synthesized id must not leak upstream in functionCall"
);
assert_eq!(fc["name"], "get_weather");
let fr = &result["contents"][1]["parts"][0]["functionResponse"];
assert!(
fr.get("id").is_none(),
"synthesized id must not leak upstream in functionResponse"
);
assert_eq!(fr["name"], "get_weather");
}
/// Genuine Gemini-assigned ids must round-trip unchanged so that Gemini
/// can correlate the tool result with its own prior functionCall entry.
#[test]
fn tool_result_with_genuine_gemini_id_round_trips() {
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "call_real_1", "name": "get_weather", "input": {} }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_real_1", "content": "ok" }
]
}
]
});
let result = anthropic_to_gemini(input).unwrap();
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["id"],
"call_real_1"
);
assert_eq!(
result["contents"][1]["parts"][0]["functionResponse"]["id"],
"call_real_1"
);
}
/// Shadow replay must also strip synthesized ids when it reconstructs
/// the assistant's `functionCall` parts from a previously recorded turn.
#[test]
fn shadow_replay_strips_synthesized_id_from_function_call() {
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();
// The assistant message was replayed from shadow; its synthesized id
// must be absent from the upstream functionCall representation.
assert!(result["contents"][0]["parts"][0]["functionCall"]
.get("id")
.is_none());
// And the tool_result round-trip must still resolve the name via the
// shadow map even when the id is synthesized.
assert_eq!(
result["contents"][1]["parts"][0]["functionResponse"]["name"],
"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"
);
}
}