mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
Claude Code follow-up requests were still falling back to locally reconstructed functionCall parts, which dropped Gemini thought signatures and triggered INVALID_ARGUMENT errors from the official Gemini API. The replay path needed to survive real Claude request boundaries, not just idealized in-process test flows. This change makes Claude requests reuse X-Claude-Code-Session-Id as the shadow session key, records streamed Gemini tool turns before tool_use events are fully drained, and matches assistant tool_use turns to shadow state by tool_use id and normalized tool name before positional fallback. Together these fixes keep thoughtSignature-bearing Gemini tool calls available for the next request in the loop. Constraint: Claude Code sends a stable X-Claude-Code-Session-Id header while metadata.session_id may be absent on follow-up requests Rejected: Rely on metadata-only Claude session extraction | generated fresh session ids and broke cross-request shadow replay Rejected: Record Gemini shadow only after streaming completes | loses the race when the client sends the next request immediately after tool_use Confidence: high Scope-risk: narrow Reversibility: clean Directive: Preserve Gemini shadow continuity across requests by keying Claude sessions from the header first and persisting tool-call shadow before yielding tool_use events downstream Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header_precedes_metadata; cargo test --manifest-path src-tauri/Cargo.toml stores_tool_shadow_before_tool_use_events_are_fully_drained; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_matches_tool_use_turn_by_id_when_position_drifts; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_aligns_to_latest_turns_after_client_truncation Not-tested: Full src-tauri test suite without test filters; live end-to-end Gemini relay after this exact commit hash
1399 lines
45 KiB
Rust
1399 lines
45 KiB
Rust
//! 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>;
|
|
|
|
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();
|
|
|
|
if let Some(system) = build_system_instruction(body.get("system"))? {
|
|
result["systemInstruction"] = system;
|
|
}
|
|
|
|
if let Some(messages) = body.get("messages").and_then(|value| value.as_array()) {
|
|
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)
|
|
}
|
|
|
|
pub fn gemini_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
|
gemini_to_anthropic_with_shadow(body, None, None, None)
|
|
}
|
|
|
|
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);
|
|
|
|
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;
|
|
content.push(json!({
|
|
"type": "tool_use",
|
|
"id": function_call.get("id").and_then(|value| value.as_str()).unwrap_or(""),
|
|
"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>) -> Result<Option<Value>, ProxyError> {
|
|
let Some(system) = system else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if let Some(text) = system.as_str() {
|
|
if text.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
return Ok(Some(json!({
|
|
"parts": [{ "text": text }]
|
|
})));
|
|
}
|
|
|
|
let Some(blocks) = system.as_array() else {
|
|
return Err(ProxyError::TransformError(
|
|
"Anthropic system must be a string or an array".to_string(),
|
|
));
|
|
};
|
|
|
|
let texts: Vec<&str> = blocks
|
|
.iter()
|
|
.filter_map(|block| block.get("text").and_then(|value| value.as_str()))
|
|
.filter(|text| !text.is_empty())
|
|
.collect();
|
|
|
|
if texts.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(json!({
|
|
"parts": [{ "text": texts.join("\n\n") }]
|
|
})))
|
|
}
|
|
|
|
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
|
|
};
|
|
let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns);
|
|
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");
|
|
|
|
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);
|
|
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,
|
|
)?
|
|
}
|
|
} else {
|
|
convert_message_content_to_parts(
|
|
message.get("content"),
|
|
role,
|
|
&mut tool_name_by_id,
|
|
)?
|
|
}
|
|
} else {
|
|
convert_message_content_to_parts(message.get("content"), role, &mut tool_name_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;
|
|
}
|
|
|
|
shadow_turns.iter().enumerate().find_map(|(index, turn)| {
|
|
turn.tool_calls
|
|
.iter()
|
|
.any(|tool_call| {
|
|
tool_call
|
|
.id
|
|
.as_deref()
|
|
.is_some_and(|id| tool_use_ids.contains(id))
|
|
|| 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>,
|
|
) -> 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());
|
|
}
|
|
|
|
parts.push(json!({
|
|
"functionCall": {
|
|
"id": id,
|
|
"name": name,
|
|
"args": block.get("input").cloned().unwrap_or_else(|| json!({}))
|
|
}
|
|
}));
|
|
}
|
|
"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()
|
|
.ok_or_else(|| {
|
|
ProxyError::TransformError(format!(
|
|
"Unable to resolve Gemini functionResponse.name for tool_use_id `{tool_use_id}`"
|
|
))
|
|
})?;
|
|
|
|
parts.push(json!({
|
|
"functionResponse": {
|
|
"id": tool_use_id,
|
|
"name": name,
|
|
"response": normalize_tool_result_response(block.get("content"))
|
|
}
|
|
}));
|
|
}
|
|
"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>> {
|
|
content
|
|
.get("parts")
|
|
.and_then(|value| value.as_array())
|
|
.cloned()
|
|
.or_else(|| content.as_array().cloned())
|
|
}
|
|
|
|
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 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")?;
|
|
Some(GeminiToolCallMeta::new(
|
|
function_call.get("id").and_then(|value| value.as_str()),
|
|
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),
|
|
}
|
|
}
|
|
|
|
fn build_anthropic_usage(usage: Option<&Value>) -> Value {
|
|
let Some(usage) = usage else {
|
|
return json!({
|
|
"input_tokens": 0,
|
|
"output_tokens": 0
|
|
});
|
|
};
|
|
|
|
let input_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 output_tokens = total_tokens.saturating_sub(input_tokens);
|
|
|
|
let mut result = json!({
|
|
"input_tokens": input_tokens,
|
|
"output_tokens": output_tokens
|
|
});
|
|
|
|
if let Some(cached) = usage
|
|
.get("cachedContentTokenCount")
|
|
.and_then(|value| value.as_u64())
|
|
{
|
|
result["cache_read_input_tokens"] = json!(cached);
|
|
}
|
|
|
|
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_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");
|
|
assert_eq!(result["usage"]["input_tokens"], 12);
|
|
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"
|
|
);
|
|
}
|
|
}
|