feat(proxy): add Gemini Native schema, shadow store, transform, and streaming

- gemini_schema: Gemini generateContent request/response type definitions
- gemini_shadow: session-scoped shadow store for thinking signature and
  tool-call state replay across streaming chunks
- transform_gemini: bidirectional Anthropic Messages ↔ Gemini Native
  request/response conversion with thinking block and tool-use support
- streaming_gemini: Gemini SSE → Anthropic SSE streaming adapter with
  incremental thinking/text/tool_use delta emission
This commit is contained in:
YoVinchen
2026-04-06 11:11:30 +08:00
parent 3ce8245f2d
commit 8c09d8c7d3
4 changed files with 2074 additions and 0 deletions
@@ -0,0 +1,278 @@
//! Gemini tool schema helpers.
//!
//! Gemini `FunctionDeclaration` supports two schema channels:
//! - `parameters`: a restricted `Schema` subset
//! - `parametersJsonSchema`: richer JSON Schema via arbitrary JSON `Value`
//!
//! Anthropic tool schemas are closer to JSON Schema, so we choose the richer
//! channel when unsupported `Schema` fields are present.
use serde_json::{json, Map, Value};
#[derive(Debug, Clone, PartialEq)]
pub enum GeminiFunctionParameters {
Schema(Value),
JsonSchema(Value),
}
pub fn build_gemini_function_parameters(input_schema: Value) -> GeminiFunctionParameters {
let schema = normalize_json_schema(input_schema);
if requires_parameters_json_schema(&schema) {
GeminiFunctionParameters::JsonSchema(schema)
} else {
GeminiFunctionParameters::Schema(to_gemini_schema(schema))
}
}
fn normalize_json_schema(schema: Value) -> Value {
match schema {
Value::Object(mut obj) => {
obj.remove("$schema");
obj.remove("$id");
if let Some(properties) = obj
.get_mut("properties")
.and_then(|value| value.as_object_mut())
{
for value in properties.values_mut() {
*value = normalize_json_schema(value.clone());
}
}
if let Some(items) = obj.get_mut("items") {
*items = normalize_json_schema(items.clone());
}
for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
if let Some(values) = obj.get_mut(key).and_then(|value| value.as_array_mut()) {
for value in values.iter_mut() {
*value = normalize_json_schema(value.clone());
}
}
}
for key in ["not", "if", "then", "else", "additionalProperties"] {
if let Some(value) = obj.get_mut(key) {
*value = normalize_json_schema(value.clone());
}
}
Value::Object(obj)
}
Value::Array(values) => {
Value::Array(values.into_iter().map(normalize_json_schema).collect())
}
other => other,
}
}
fn requires_parameters_json_schema(schema: &Value) -> bool {
match schema {
Value::Object(obj) => object_requires_parameters_json_schema(obj),
Value::Array(values) => values.iter().any(requires_parameters_json_schema),
_ => false,
}
}
fn object_requires_parameters_json_schema(obj: &Map<String, Value>) -> bool {
for (key, value) in obj {
match key.as_str() {
"type" => {
if value.is_array() {
return true;
}
}
"format" | "title" | "description" | "nullable" | "enum" | "maxItems" | "minItems"
| "required" | "minProperties" | "maxProperties" | "minLength" | "maxLength"
| "pattern" | "example" | "propertyOrdering" | "default" | "minimum" | "maximum" => {}
"properties" => {
let Some(properties) = value.as_object() else {
return true;
};
if properties.values().any(requires_parameters_json_schema) {
return true;
}
}
"items" => {
if !value.is_object() || requires_parameters_json_schema(value) {
return true;
}
}
"anyOf" => {
let Some(values) = value.as_array() else {
return true;
};
if values.iter().any(requires_parameters_json_schema) {
return true;
}
}
// JSON Schema keywords that Gemini `parameters` does not accept.
"$ref"
| "$defs"
| "definitions"
| "additionalProperties"
| "unevaluatedProperties"
| "patternProperties"
| "oneOf"
| "allOf"
| "const"
| "not"
| "if"
| "then"
| "else"
| "dependentRequired"
| "dependentSchemas"
| "contains"
| "minContains"
| "maxContains"
| "prefixItems"
| "exclusiveMinimum"
| "exclusiveMaximum"
| "multipleOf"
| "examples" => return true,
// Be conservative for unknown keywords.
_ => return true,
}
}
false
}
fn to_gemini_schema(schema: Value) -> Value {
match schema {
Value::Object(obj) => {
let mut result = Map::new();
for (key, value) in obj {
match key.as_str() {
"type" | "format" | "title" | "description" | "nullable" | "enum"
| "maxItems" | "minItems" | "required" | "minProperties" | "maxProperties"
| "minLength" | "maxLength" | "pattern" | "example" | "propertyOrdering"
| "default" | "minimum" | "maximum" => {
result.insert(key, value);
}
"properties" => {
if let Some(properties) = value.as_object() {
let converted = properties
.iter()
.map(|(name, property_schema)| {
(name.clone(), to_gemini_schema(property_schema.clone()))
})
.collect();
result.insert("properties".to_string(), Value::Object(converted));
}
}
"items" => {
if value.is_object() {
result.insert("items".to_string(), to_gemini_schema(value));
}
}
"anyOf" => {
if let Some(values) = value.as_array() {
result.insert(
"anyOf".to_string(),
Value::Array(
values
.iter()
.map(|value| to_gemini_schema(value.clone()))
.collect(),
),
);
}
}
_ => {}
}
}
Value::Object(result)
}
other => other,
}
}
pub fn build_gemini_function_declaration(
name: &str,
description: Option<&str>,
input_schema: Value,
) -> Value {
let mut declaration = Map::new();
declaration.insert("name".to_string(), json!(name));
declaration.insert("description".to_string(), json!(description.unwrap_or("")));
match build_gemini_function_parameters(input_schema) {
GeminiFunctionParameters::Schema(schema) => {
declaration.insert("parameters".to_string(), schema);
}
GeminiFunctionParameters::JsonSchema(schema) => {
declaration.insert("parametersJsonSchema".to_string(), schema);
}
}
Value::Object(declaration)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uses_schema_for_simple_openapi_subset() {
let schema = json!({
"type": "object",
"properties": {
"city": { "type": "string", "description": "Target city" }
},
"required": ["city"]
});
let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema);
assert!(result.get("parameters").is_some());
assert!(result.get("parametersJsonSchema").is_none());
assert_eq!(result["parameters"]["properties"]["city"]["type"], "string");
}
#[test]
fn uses_parameters_json_schema_for_additional_properties() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false
});
let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema);
assert!(result.get("parameters").is_none());
assert!(result.get("parametersJsonSchema").is_some());
assert!(result["parametersJsonSchema"].get("$schema").is_none());
assert_eq!(
result["parametersJsonSchema"]["additionalProperties"],
false
);
}
#[test]
fn uses_parameters_json_schema_for_one_of() {
let schema = json!({
"type": "object",
"properties": {
"target": {
"oneOf": [
{ "type": "string" },
{ "type": "integer" }
]
}
}
});
let result = build_gemini_function_declaration("search", Some("Search"), schema);
assert!(result.get("parameters").is_none());
assert!(result.get("parametersJsonSchema").is_some());
}
}
@@ -0,0 +1,389 @@
//! Gemini Native shadow state
//!
//! Keeps provider/session-scoped assistant content snapshots and tool call metadata
//! so Gemini thought signatures and tool turns can be replayed without bloating
//! the main proxy files.
use serde_json::Value;
use std::collections::{HashMap, VecDeque};
use std::sync::RwLock;
/// Composite key for a Gemini shadow session.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GeminiShadowKey {
pub provider_id: String,
pub session_id: String,
}
impl GeminiShadowKey {
pub fn new(provider_id: impl Into<String>, session_id: impl Into<String>) -> Self {
Self {
provider_id: provider_id.into(),
session_id: session_id.into(),
}
}
}
/// Gemini function call metadata captured from an assistant turn.
#[derive(Debug, Clone, PartialEq)]
pub struct GeminiToolCallMeta {
pub id: Option<String>,
pub name: String,
pub args: Value,
pub thought_signature: Option<String>,
}
impl GeminiToolCallMeta {
pub fn new(
id: Option<impl Into<String>>,
name: impl Into<String>,
args: Value,
thought_signature: Option<impl Into<String>>,
) -> Self {
Self {
id: id.map(Into::into),
name: name.into(),
args,
thought_signature: thought_signature.map(Into::into),
}
}
}
/// Stored assistant turn snapshot.
#[derive(Debug, Clone, PartialEq)]
pub struct GeminiAssistantTurn {
pub assistant_content: Value,
pub tool_calls: Vec<GeminiToolCallMeta>,
}
impl GeminiAssistantTurn {
pub fn new(assistant_content: Value, tool_calls: Vec<GeminiToolCallMeta>) -> Self {
Self {
assistant_content,
tool_calls,
}
}
}
/// Session snapshot returned by read APIs.
#[derive(Debug, Clone, PartialEq)]
pub struct GeminiShadowSessionSnapshot {
pub provider_id: String,
pub session_id: String,
pub turns: Vec<GeminiAssistantTurn>,
}
#[derive(Debug, Clone)]
struct GeminiShadowSession {
turns: VecDeque<GeminiAssistantTurn>,
}
impl GeminiShadowSession {
fn new() -> Self {
Self {
turns: VecDeque::new(),
}
}
}
#[derive(Debug, Clone)]
struct GeminiShadowInner {
sessions: HashMap<GeminiShadowKey, GeminiShadowSession>,
session_order: VecDeque<GeminiShadowKey>,
}
impl GeminiShadowInner {
fn new() -> Self {
Self {
sessions: HashMap::new(),
session_order: VecDeque::new(),
}
}
}
/// Thread-safe shadow store for Gemini Native replay state.
///
/// The store is intentionally small and explicit:
/// - sessions are keyed by `(provider_id, session_id)`
/// - each session keeps only a bounded number of recent assistant turns
/// - the oldest session is evicted first when the store is full
#[derive(Debug)]
pub struct GeminiShadowStore {
max_sessions: usize,
max_turns_per_session: usize,
inner: RwLock<GeminiShadowInner>,
}
impl Default for GeminiShadowStore {
fn default() -> Self {
Self::with_limits(200, 64)
}
}
impl GeminiShadowStore {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
pub fn with_limits(max_sessions: usize, max_turns_per_session: usize) -> Self {
Self {
max_sessions: max_sessions.max(1),
max_turns_per_session: max_turns_per_session.max(1),
inner: RwLock::new(GeminiShadowInner::new()),
}
}
/// Record a Gemini assistant turn for later replay.
pub fn record_assistant_turn(
&self,
provider_id: impl Into<String>,
session_id: impl Into<String>,
assistant_content: Value,
tool_calls: Vec<GeminiToolCallMeta>,
) -> GeminiShadowSessionSnapshot {
let key = GeminiShadowKey::new(provider_id, session_id);
let turn = GeminiAssistantTurn::new(assistant_content, tool_calls);
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
Self::touch_session_order(&mut inner.session_order, &key);
let snapshot = {
let session = inner
.sessions
.entry(key.clone())
.or_insert_with(GeminiShadowSession::new);
session.turns.push_back(turn);
while session.turns.len() > self.max_turns_per_session {
session.turns.pop_front();
}
Self::snapshot_session(&key, session)
};
Self::prune_sessions(&mut inner, self.max_sessions);
snapshot
}
/// Get the latest assistant content for a provider/session pair.
#[allow(dead_code)]
pub fn latest_assistant_content(&self, provider_id: &str, session_id: &str) -> Option<Value> {
self.get_session(provider_id, session_id)
.and_then(|snapshot| {
snapshot
.turns
.last()
.map(|turn| turn.assistant_content.clone())
})
}
/// Get the latest tool calls for a provider/session pair.
#[allow(dead_code)]
pub fn latest_tool_calls(
&self,
provider_id: &str,
session_id: &str,
) -> Option<Vec<GeminiToolCallMeta>> {
self.get_session(provider_id, session_id)
.and_then(|snapshot| snapshot.turns.last().map(|turn| turn.tool_calls.clone()))
}
/// Read a full session snapshot.
pub fn get_session(
&self,
provider_id: &str,
session_id: &str,
) -> Option<GeminiShadowSessionSnapshot> {
let key = GeminiShadowKey::new(provider_id, session_id);
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
let snapshot = inner
.sessions
.get(&key)
.map(|session| Self::snapshot_session(&key, session));
if snapshot.is_some() {
Self::touch_session_order(&mut inner.session_order, &key);
}
snapshot
}
/// Remove a single session from the store.
#[allow(dead_code)]
pub fn clear_session(&self, provider_id: &str, session_id: &str) -> bool {
let key = GeminiShadowKey::new(provider_id, session_id);
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
let removed = inner.sessions.remove(&key).is_some();
if removed {
Self::remove_key_from_order(&mut inner.session_order, &key);
}
removed
}
/// Remove all sessions for a provider.
#[allow(dead_code)]
pub fn clear_provider(&self, provider_id: &str) -> usize {
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
let keys: Vec<_> = inner
.sessions
.keys()
.filter(|key| key.provider_id == provider_id)
.cloned()
.collect();
for key in &keys {
inner.sessions.remove(key);
Self::remove_key_from_order(&mut inner.session_order, key);
}
keys.len()
}
/// Number of tracked sessions.
#[allow(dead_code)]
pub fn session_count(&self) -> usize {
self.inner
.read()
.expect("gemini shadow lock poisoned")
.sessions
.len()
}
fn snapshot_session(
key: &GeminiShadowKey,
session: &GeminiShadowSession,
) -> GeminiShadowSessionSnapshot {
GeminiShadowSessionSnapshot {
provider_id: key.provider_id.clone(),
session_id: key.session_id.clone(),
turns: session.turns.iter().cloned().collect(),
}
}
fn touch_session_order(order: &mut VecDeque<GeminiShadowKey>, key: &GeminiShadowKey) {
if let Some(pos) = order.iter().position(|existing| existing == key) {
order.remove(pos);
}
order.push_back(key.clone());
}
#[allow(dead_code)]
fn remove_key_from_order(order: &mut VecDeque<GeminiShadowKey>, key: &GeminiShadowKey) {
if let Some(pos) = order.iter().position(|existing| existing == key) {
order.remove(pos);
}
}
fn prune_sessions(inner: &mut GeminiShadowInner, max_sessions: usize) {
while inner.sessions.len() > max_sessions {
let Some(evicted_key) = inner.session_order.pop_front() else {
break;
};
inner.sessions.remove(&evicted_key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn record_and_read_latest_turn() {
let store = GeminiShadowStore::with_limits(8, 4);
let snapshot = store.record_assistant_turn(
"provider-a",
"session-1",
json!({"parts": [{"text": "hello", "thoughtSignature": "sig-1"}]}),
vec![GeminiToolCallMeta::new(
Some("call-1"),
"get_weather",
json!({"location": "Tokyo"}),
Some("sig-1"),
)],
);
assert_eq!(snapshot.provider_id, "provider-a");
assert_eq!(snapshot.session_id, "session-1");
assert_eq!(snapshot.turns.len(), 1);
let content = store
.latest_assistant_content("provider-a", "session-1")
.expect("content");
assert_eq!(content["parts"][0]["text"], "hello");
assert_eq!(content["parts"][0]["thoughtSignature"], "sig-1");
let tool_calls = store
.latest_tool_calls("provider-a", "session-1")
.expect("tool calls");
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].id.as_deref(), Some("call-1"));
assert_eq!(tool_calls[0].name, "get_weather");
assert_eq!(tool_calls[0].args["location"], "Tokyo");
assert_eq!(tool_calls[0].thought_signature.as_deref(), Some("sig-1"));
}
#[test]
fn sessions_are_isolated_by_provider_and_session_id() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn("provider-a", "session-1", json!({"text": "a"}), vec![]);
store.record_assistant_turn("provider-b", "session-1", json!({"text": "b"}), vec![]);
store.record_assistant_turn("provider-a", "session-2", json!({"text": "c"}), vec![]);
assert_eq!(store.session_count(), 3);
assert_eq!(
store.latest_assistant_content("provider-a", "session-1"),
Some(json!({"text": "a"}))
);
assert_eq!(
store.latest_assistant_content("provider-b", "session-1"),
Some(json!({"text": "b"}))
);
assert_eq!(
store.latest_assistant_content("provider-a", "session-2"),
Some(json!({"text": "c"}))
);
}
#[test]
fn retains_only_latest_turns_per_session() {
let store = GeminiShadowStore::with_limits(8, 2);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 2}), vec![]);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 3}), vec![]);
let snapshot = store
.get_session("provider-a", "session-1")
.expect("snapshot");
assert_eq!(snapshot.turns.len(), 2);
assert_eq!(snapshot.turns[0].assistant_content, json!({"idx": 2}));
assert_eq!(snapshot.turns[1].assistant_content, json!({"idx": 3}));
}
#[test]
fn evicts_oldest_session_when_capacity_is_exceeded() {
let store = GeminiShadowStore::with_limits(2, 2);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]);
store.record_assistant_turn("provider-a", "session-3", json!({"idx": 3}), vec![]);
assert!(store.get_session("provider-a", "session-1").is_none());
assert!(store.get_session("provider-a", "session-2").is_some());
assert!(store.get_session("provider-a", "session-3").is_some());
}
#[test]
fn clear_session_and_provider_work() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]);
store.record_assistant_turn("provider-b", "session-3", json!({"idx": 3}), vec![]);
assert!(store.clear_session("provider-a", "session-1"));
assert!(store.get_session("provider-a", "session-1").is_none());
let removed = store.clear_provider("provider-a");
assert_eq!(removed, 1);
assert!(store.get_session("provider-a", "session-2").is_none());
assert!(store.get_session("provider-b", "session-3").is_some());
}
}
@@ -0,0 +1,619 @@
//! Gemini Native streaming conversion module.
//!
//! Converts Gemini `streamGenerateContent?alt=sse` chunks into Anthropic-style
//! SSE events for Claude-compatible clients.
use super::gemini_shadow::{GeminiShadowStore, GeminiToolCallMeta};
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::sync::Arc;
fn anthropic_usage_from_gemini(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, blocked: bool) -> &'static str {
if blocked {
return "refusal";
}
match reason {
Some("MAX_TOKENS") => "max_tokens",
Some("SAFETY")
| Some("RECITATION")
| Some("SPII")
| Some("BLOCKLIST")
| Some("PROHIBITED_CONTENT") => "refusal",
_ if has_tool_use => "tool_use",
_ => "end_turn",
}
}
fn extract_visible_text(parts: &[Value]) -> String {
parts
.iter()
.filter(|part| part.get("thought").and_then(|value| value.as_bool()) != Some(true))
.filter_map(|part| part.get("text").and_then(|value| value.as_str()))
.collect::<String>()
}
fn extract_tool_calls(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 extract_text_thought_signature(parts: &[Value]) -> Option<String> {
parts
.iter()
.filter(|part| part.get("text").is_some() && part.get("functionCall").is_none())
.filter_map(|part| {
part.get("thoughtSignature")
.or_else(|| part.get("thought_signature"))
.and_then(|value| value.as_str())
})
.next_back()
.map(ToString::to_string)
}
fn merge_tool_call_snapshots(
tool_call_snapshots: &mut Vec<GeminiToolCallMeta>,
incoming: Vec<GeminiToolCallMeta>,
) {
for tool_call in incoming {
let existing_index =
tool_call_snapshots
.iter()
.position(|existing| match (&existing.id, &tool_call.id) {
(Some(existing_id), Some(incoming_id)) => existing_id == incoming_id,
_ => existing.name == tool_call.name,
});
if let Some(index) = existing_index {
tool_call_snapshots[index] = tool_call;
} else {
tool_call_snapshots.push(tool_call);
}
}
}
fn build_shadow_assistant_parts(
text: Option<&str>,
text_thought_signature: Option<&str>,
tool_calls: &[GeminiToolCallMeta],
) -> Vec<Value> {
let mut parts = Vec::new();
if text.filter(|text| !text.is_empty()).is_some() || text_thought_signature.is_some() {
let mut part = json!({
"text": text.unwrap_or("")
});
if let Some(signature) = text_thought_signature {
part["thoughtSignature"] = json!(signature);
}
parts.push(part);
}
for tool_call in tool_calls {
let mut part = json!({
"functionCall": {
"id": tool_call.id.clone().unwrap_or_default(),
"name": tool_call.name,
"args": tool_call.args
}
});
if let Some(signature) = &tool_call.thought_signature {
part["thoughtSignature"] = json!(signature);
}
parts.push(part);
}
parts
}
fn encode_sse(event_name: &str, payload: &Value) -> Bytes {
Bytes::from(format!(
"event: {event_name}\ndata: {}\n\n",
serde_json::to_string(payload).unwrap_or_default()
))
}
pub fn create_anthropic_sse_stream_from_gemini<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
shadow_store: Option<Arc<GeminiShadowStore>>,
provider_id: Option<String>,
session_id: Option<String>,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut message_id: Option<String> = None;
let mut current_model: Option<String> = None;
let mut has_sent_message_start = false;
let mut accumulated_text = String::new();
let mut text_block_index: Option<u32> = None;
let mut next_content_index: u32 = 0;
let mut open_indices: HashSet<u32> = HashSet::new();
let mut tool_call_snapshots: Vec<GeminiToolCallMeta> = Vec::new();
let mut text_thought_signature: Option<String> = None;
let mut latest_usage: Option<Value> = None;
let mut latest_finish_reason: Option<String> = None;
let mut blocked_text: Option<String> = None;
tokio::pin!(stream);
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
while let Some(block) = take_sse_block(&mut buffer) {
if block.trim().is_empty() {
continue;
}
let mut data_lines: Vec<String> = Vec::new();
for line in block.lines() {
if let Some(data) = strip_sse_field(line, "data") {
data_lines.push(data.to_string());
}
}
if data_lines.is_empty() {
continue;
}
let data = data_lines.join("\n");
if data.trim() == "[DONE]" {
break;
}
let chunk_json: Value = match serde_json::from_str(&data) {
Ok(value) => value,
Err(_) => continue,
};
if message_id.is_none() {
message_id = chunk_json
.get("responseId")
.and_then(|value| value.as_str())
.map(ToString::to_string);
}
if current_model.is_none() {
current_model = chunk_json
.get("modelVersion")
.and_then(|value| value.as_str())
.map(ToString::to_string);
}
if latest_usage.is_none() {
latest_usage = chunk_json.get("usageMetadata").cloned();
}
if !has_sent_message_start {
let event = json!({
"type": "message_start",
"message": {
"id": message_id.clone().unwrap_or_default(),
"type": "message",
"role": "assistant",
"model": current_model.clone().unwrap_or_default(),
"usage": anthropic_usage_from_gemini(chunk_json.get("usageMetadata"))
}
});
yield Ok(encode_sse("message_start", &event));
has_sent_message_start = true;
}
if let Some(reason) = chunk_json
.get("promptFeedback")
.and_then(|value| value.get("blockReason"))
.and_then(|value| value.as_str())
{
blocked_text = Some(format!("Request blocked by Gemini safety filters: {reason}"));
}
if let Some(candidate) = chunk_json
.get("candidates")
.and_then(|value| value.as_array())
.and_then(|value| value.first())
{
if let Some(reason) = candidate.get("finishReason").and_then(|value| value.as_str()) {
latest_finish_reason = Some(reason.to_string());
}
if let Some(usage) = chunk_json.get("usageMetadata") {
latest_usage = Some(usage.clone());
}
if let Some(parts) = candidate
.get("content")
.and_then(|value| value.get("parts"))
.and_then(|value| value.as_array())
{
if let Some(signature) = extract_text_thought_signature(parts) {
text_thought_signature = Some(signature);
}
merge_tool_call_snapshots(
&mut tool_call_snapshots,
extract_tool_calls(parts),
);
let visible_text = extract_visible_text(parts);
if !visible_text.is_empty() {
let is_cumulative = visible_text.starts_with(&accumulated_text);
let delta = if is_cumulative {
visible_text[accumulated_text.len()..].to_string()
} else {
visible_text.clone()
};
if !delta.is_empty() {
let index = *text_block_index.get_or_insert_with(|| {
let assigned = next_content_index;
next_content_index += 1;
assigned
});
if !open_indices.contains(&index) {
let start_event = json!({
"type": "content_block_start",
"index": index,
"content_block": {
"type": "text",
"text": ""
}
});
yield Ok(encode_sse("content_block_start", &start_event));
open_indices.insert(index);
}
let delta_event = json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "text_delta",
"text": delta
}
});
yield Ok(encode_sse("content_block_delta", &delta_event));
if is_cumulative {
accumulated_text = visible_text;
} else {
accumulated_text.push_str(&delta);
}
}
}
}
}
}
}
Err(error) => {
yield Err(std::io::Error::other(error.to_string()));
return;
}
}
}
if !has_sent_message_start {
let event = json!({
"type": "message_start",
"message": {
"id": message_id.clone().unwrap_or_default(),
"type": "message",
"role": "assistant",
"model": current_model.clone().unwrap_or_default(),
"usage": anthropic_usage_from_gemini(latest_usage.as_ref())
}
});
yield Ok(encode_sse("message_start", &event));
}
if accumulated_text.is_empty() {
if let Some(blocked_text) = blocked_text.clone() {
let index = *text_block_index.get_or_insert_with(|| {
let assigned = next_content_index;
next_content_index += 1;
assigned
});
if !open_indices.contains(&index) {
let start_event = json!({
"type": "content_block_start",
"index": index,
"content_block": {
"type": "text",
"text": ""
}
});
yield Ok(encode_sse("content_block_start", &start_event));
open_indices.insert(index);
}
let delta_event = json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "text_delta",
"text": blocked_text
}
});
yield Ok(encode_sse("content_block_delta", &delta_event));
}
}
if let Some(index) = text_block_index {
if open_indices.remove(&index) {
let stop_event = json!({
"type": "content_block_stop",
"index": index
});
yield Ok(encode_sse("content_block_stop", &stop_event));
}
}
let tool_calls = tool_call_snapshots;
for tool_call in &tool_calls {
let index = next_content_index;
next_content_index += 1;
let start_event = json!({
"type": "content_block_start",
"index": index,
"content_block": {
"type": "tool_use",
"id": tool_call.id.clone().unwrap_or_default(),
"name": tool_call.name
}
});
yield Ok(encode_sse("content_block_start", &start_event));
let delta_event = json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "input_json_delta",
"partial_json": serde_json::to_string(&tool_call.args).unwrap_or_else(|_| "{}".to_string())
}
});
yield Ok(encode_sse("content_block_delta", &delta_event));
let stop_event = json!({
"type": "content_block_stop",
"index": index
});
yield Ok(encode_sse("content_block_stop", &stop_event));
}
if let (Some(store), Some(provider_id), Some(session_id)) = (
shadow_store.as_ref(),
provider_id.as_deref(),
session_id.as_deref(),
) {
let shadow_text = if accumulated_text.is_empty() {
blocked_text.as_deref()
} else {
Some(accumulated_text.as_str())
};
let shadow_parts = build_shadow_assistant_parts(
shadow_text,
text_thought_signature.as_deref(),
&tool_calls,
);
if !shadow_parts.is_empty() {
store.record_assistant_turn(
provider_id,
session_id,
json!({ "parts": shadow_parts }),
tool_calls.clone(),
);
}
}
let stop_reason = map_finish_reason(
latest_finish_reason.as_deref(),
!tool_calls.is_empty(),
blocked_text.is_some(),
);
let usage = anthropic_usage_from_gemini(latest_usage.as_ref());
let message_delta = json!({
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": Value::Null
},
"usage": usage
});
yield Ok(encode_sse("message_delta", &message_delta));
let message_stop = json!({ "type": "message_stop" });
yield Ok(encode_sse("message_stop", &message_stop));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::proxy::providers::gemini_shadow::GeminiShadowStore;
use crate::proxy::providers::transform_gemini::anthropic_to_gemini_with_shadow;
use std::sync::Arc;
fn collect_stream_output(chunks: Vec<&str>) -> String {
let owned_chunks: Vec<String> = chunks.into_iter().map(ToString::to_string).collect();
let stream = futures::stream::iter(
owned_chunks
.into_iter()
.map(|chunk| Ok::<Bytes, std::io::Error>(Bytes::from(chunk))),
);
let converted = create_anthropic_sse_stream_from_gemini(stream, None, None, None);
futures::executor::block_on(async move {
converted
.collect::<Vec<_>>()
.await
.into_iter()
.map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap())
.collect::<Vec<_>>()
.join("")
})
}
fn collect_stream_output_with_shadow(
chunks: Vec<&str>,
store: Arc<GeminiShadowStore>,
provider_id: &str,
session_id: &str,
) -> String {
let owned_chunks: Vec<String> = chunks.into_iter().map(ToString::to_string).collect();
let stream = futures::stream::iter(
owned_chunks
.into_iter()
.map(|chunk| Ok::<Bytes, std::io::Error>(Bytes::from(chunk))),
);
let converted = create_anthropic_sse_stream_from_gemini(
stream,
Some(store),
Some(provider_id.to_string()),
Some(session_id.to_string()),
);
futures::executor::block_on(async move {
converted
.collect::<Vec<_>>()
.await
.into_iter()
.map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap())
.collect::<Vec<_>>()
.join("")
})
}
#[test]
fn converts_text_stream_to_anthropic_sse() {
let output = collect_stream_output(vec![
"data: {\"responseId\":\"resp_1\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hel\"}]}}],\"usageMetadata\":{\"promptTokenCount\":10,\"totalTokenCount\":13}}\n\n",
"data: {\"responseId\":\"resp_1\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"text\":\"Hello\"}]}}],\"usageMetadata\":{\"promptTokenCount\":10,\"totalTokenCount\":15}}\n\n",
]);
assert!(output.contains("event: message_start"));
assert!(output.contains("\"type\":\"text_delta\""));
assert!(output.contains("\"text\":\"Hel\""));
assert!(output.contains("\"text\":\"lo\""));
assert!(output.contains("\"stop_reason\":\"end_turn\""));
assert!(output.contains("event: message_stop"));
}
#[test]
fn converts_function_call_stream_to_tool_use_events() {
let output = collect_stream_output(vec![
"data: {\"responseId\":\"resp_2\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"call_1\",\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\"}},\"thoughtSignature\":\"sig-1\"}]}}],\"usageMetadata\":{\"promptTokenCount\":5,\"totalTokenCount\":8}}\n\n",
]);
assert!(output.contains("\"type\":\"tool_use\""));
assert!(output.contains("\"name\":\"get_weather\""));
assert!(output.contains("\"type\":\"input_json_delta\""));
assert!(output.contains("\"stop_reason\":\"tool_use\""));
}
#[test]
fn converts_crlf_delimited_stream_to_anthropic_sse() {
let output = collect_stream_output(vec![
"data: {\"responseId\":\"resp_3\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hi\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":6}}\r\n\r\n",
"data: {\"responseId\":\"resp_3\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"text\":\"Hi there\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":9}}\r\n\r\n",
]);
assert!(output.contains("event: message_start"));
assert!(output.contains("\"type\":\"text_delta\""));
assert!(output.contains("\"text\":\"Hi\""));
assert!(output.contains("\"text\":\" there\""));
assert!(output.contains("event: message_stop"));
}
#[test]
fn stores_full_text_for_shadow_replay_across_delta_chunks() {
let store = Arc::new(GeminiShadowStore::with_limits(8, 4));
let output = collect_stream_output_with_shadow(
vec![
"data: {\"responseId\":\"resp_4\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hel\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":6}}\n\n",
"data: {\"responseId\":\"resp_4\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"text\":\"lo\"},{\"text\":\"\",\"thoughtSignature\":\"sig-1\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":8}}\n\n",
],
store.clone(),
"provider-a",
"session-1",
);
assert!(output.contains("\"text\":\"Hel\""));
assert!(output.contains("\"text\":\"lo\""));
let shadow = store
.latest_assistant_content("provider-a", "session-1")
.unwrap();
assert_eq!(shadow["parts"][0]["text"], "Hello");
assert_eq!(shadow["parts"][0]["thoughtSignature"], "sig-1");
let second_turn = anthropic_to_gemini_with_shadow(
json!({
"messages": [
{ "role": "user", "content": "Hi" },
{ "role": "assistant", "content": [{ "type": "text", "text": "Hello" }] },
{ "role": "user", "content": "Continue" }
]
}),
Some(store.as_ref()),
Some("provider-a"),
Some("session-1"),
)
.unwrap();
assert_eq!(second_turn["contents"][1]["role"], "model");
assert_eq!(second_turn["contents"][1]["parts"][0]["text"], "Hello");
assert_eq!(
second_turn["contents"][1]["parts"][0]["thoughtSignature"],
"sig-1"
);
}
}
@@ -0,0 +1,788 @@
//! 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};
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> {
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 content = Vec::new();
let mut has_tool_use = false;
for part in &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"),
) {
store.record_assistant_turn(
provider_id,
session_id,
content.clone(),
extract_tool_call_meta(&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 tool_name_by_id = std::collections::HashMap::<String, String>::new();
let total_assistant_messages = messages
.iter()
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("assistant"))
.count();
let shadow_start_index = total_assistant_messages.saturating_sub(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 shadow_index = assistant_seen_index
.checked_sub(shadow_start_index)
.filter(|index| *index < shadow_turns.len());
assistant_seen_index += 1;
if let Some(index) = shadow_index {
let shadow_turn = &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)?
};
contents.push(json!({
"role": gemini_role,
"parts": parts
}));
}
Ok(contents)
}
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()
.unwrap_or_default();
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())
}
fn merge_tool_names_from_shadow(
turn: &GeminiAssistantTurn,
tool_name_by_id: &mut std::collections::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());
}
}
}
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_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_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"));
}
}