Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

## 参考项目

- 代理转发/转换参考[litellm](.reference/litellm)
- 代理转发/转换参考[new-api](.reference/new-api)
- kiro、codex、antigravity等2api参考[CLIProxyAPIPlus](.reference/CLIProxyAPIPlus)
- CLIProxyAPIPlus的可视化app参考[quotio](.reference/quotio)
111 changes: 111 additions & 0 deletions crates/token_proxy_core/src/proxy/anthropic_compat.test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,81 @@ fn anthropic_request_to_responses_maps_tools_and_tool_blocks() {
assert_eq!(input_items[2]["output"], json!("ok"));
}

#[test]
fn anthropic_request_to_responses_maps_reasoning_context_and_structured_output() {
let http_clients = ProxyHttpClients::new().expect("http clients");

let input = bytes_from_json(json!({
"model": "claude-3-7-sonnet",
"max_tokens": 256,
"system": [{ "type": "text", "text": "sys" }],
"thinking": { "type": "enabled", "budget_tokens": 6000 },
"output_format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": { "answer": { "type": "string" } },
"required": ["answer"]
}
},
"context_management": {
"edits": [
{
"type": "compact_20260112",
"trigger": { "type": "input_tokens", "value": 150000 }
}
]
},
"metadata": { "user_id": "user-123" },
"tools": [
{ "type": "web_search_20250305", "name": "web_search" }
],
"messages": [
{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "chain-of-thought summary" },
{ "type": "text", "text": "draft answer" }
]
}
]
}));

let output = run_async(async {
anthropic_request_to_responses(&input, &http_clients)
.await
.expect("transform")
});
let value = json_from_bytes(output);

assert_eq!(value["reasoning"]["effort"], json!("medium"));
assert_eq!(value["reasoning"]["summary"], json!("detailed"));
assert_eq!(value["text"]["format"]["type"], json!("json_schema"));
assert_eq!(
value["text"]["format"]["schema"]["required"],
json!(["answer"])
);
assert_eq!(value["context_management"][0]["type"], json!("compaction"));
assert_eq!(
value["context_management"][0]["compact_threshold"],
json!(150000)
);
assert_eq!(value["user"], json!("user-123"));
assert_eq!(value["tools"][0]["type"], json!("web_search_preview"));

let input_items = value["input"].as_array().expect("input array");
assert_eq!(input_items.len(), 1);
assert_eq!(input_items[0]["type"], json!("message"));
assert_eq!(input_items[0]["role"], json!("assistant"));
assert_eq!(input_items[0]["content"][0]["type"], json!("output_text"));
assert_eq!(
input_items[0]["content"][0]["text"],
json!("chain-of-thought summary")
);
assert_eq!(input_items[0]["content"][1]["type"], json!("output_text"));
assert_eq!(input_items[0]["content"][1]["text"], json!("draft answer"));
}

#[test]
fn responses_request_to_anthropic_maps_tool_choice_and_tool_result() {
let http_clients = ProxyHttpClients::new().expect("http clients");
Expand Down Expand Up @@ -157,6 +232,42 @@ fn responses_request_to_anthropic_maps_tool_choice_and_tool_result() {
assert_eq!(messages[2]["content"][0]["content"], json!("ok"));
}

#[test]
fn responses_response_to_anthropic_maps_reasoning_items_to_thinking_blocks() {
let input = bytes_from_json(json!({
"id": "resp_reasoning_item",
"model": "gpt-5",
"output": [
{
"id": "rs_1",
"type": "reasoning",
"summary": [
{ "type": "summary_text", "text": "first analyze then answer" }
]
},
{
"type": "message",
"role": "assistant",
"content": [
{ "type": "output_text", "text": "final answer" }
]
}
],
"usage": { "input_tokens": 3, "output_tokens": 5 }
}));

let output = responses_response_to_anthropic(&input, None).expect("transform");
let value = json_from_bytes(output);

assert_eq!(value["content"][0]["type"], json!("thinking"));
assert_eq!(
value["content"][0]["thinking"],
json!("first analyze then answer")
);
assert_eq!(value["content"][1]["type"], json!("text"));
assert_eq!(value["content"][1]["text"], json!("final answer"));
}

#[test]
fn responses_response_to_anthropic_includes_thinking_block() {
let input = bytes_from_json(json!({
Expand Down
121 changes: 121 additions & 0 deletions crates/token_proxy_core/src/proxy/anthropic_compat/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,28 @@ pub(super) async fn anthropic_request_to_responses(
out.insert("top_p".to_string(), top_p.clone());
}

if let Some(reasoning) = map_anthropic_thinking_to_responses_reasoning(object.get("thinking"))
{
out.insert("reasoning".to_string(), reasoning);
}

if let Some(text_format) = map_anthropic_output_format_to_responses_text(
object.get("output_format"),
object.get("output_config"),
) {
out.insert("text".to_string(), text_format);
}

if let Some(context_management) =
map_anthropic_context_management_to_responses(object.get("context_management"))
{
out.insert("context_management".to_string(), context_management);
}

if let Some(user) = map_anthropic_metadata_to_responses_user(object.get("metadata")) {
out.insert("user".to_string(), Value::String(user));
}

if let Some(stop) =
tools::map_anthropic_stop_sequences_to_openai_stop(object.get("stop_sequences"))
{
Expand Down Expand Up @@ -374,6 +396,13 @@ fn claude_message_to_responses_input_items(
message_parts.push(json!({ "type": text_part_type, "text": text }));
}
}
"thinking" => {
if let Some(text) = block.get("thinking").and_then(Value::as_str) {
if !text.is_empty() {
message_parts.push(json!({ "type": "output_text", "text": text }));
}
}
}
"image" => {
if let Some(part) = media::claude_image_block_to_input_image_part(block) {
message_parts.push(part);
Expand Down Expand Up @@ -626,3 +655,95 @@ fn ensure_claude_content_array_in_place(content: &mut Value) {
}
*content = Value::Array(Vec::new());
}

fn map_anthropic_thinking_to_responses_reasoning(value: Option<&Value>) -> Option<Value> {
let thinking = value?.as_object()?;
if thinking.get("type").and_then(Value::as_str) != Some("enabled") {
return None;
}

let budget = thinking
.get("budget_tokens")
.and_then(Value::as_i64)
.unwrap_or(0);
let effort = if budget >= 10_000 {
"high"
} else if budget >= 5_000 {
"medium"
} else if budget >= 2_000 {
"low"
} else {
"minimal"
};

Some(json!({
"effort": effort,
"summary": "detailed"
}))
}

fn map_anthropic_output_format_to_responses_text(
output_format: Option<&Value>,
output_config: Option<&Value>,
) -> Option<Value> {
let format = match output_format {
Some(Value::Object(object)) => Some(object),
_ => output_config
.and_then(Value::as_object)
.and_then(|config| config.get("format"))
.and_then(Value::as_object),
}?;

if format.get("type").and_then(Value::as_str) != Some("json_schema") {
return None;
}
let schema = format.get("schema")?;
Some(json!({
"format": {
"type": "json_schema",
"name": "structured_output",
"schema": schema,
"strict": true
}
}))
}

fn map_anthropic_context_management_to_responses(value: Option<&Value>) -> Option<Value> {
let context_management = value?.as_object()?;
let edits = context_management.get("edits")?.as_array()?;
let mut mapped = Vec::new();
for edit in edits {
let Some(edit) = edit.as_object() else {
continue;
};
if edit.get("type").and_then(Value::as_str) != Some("compact_20260112") {
continue;
}
let mut item = Map::new();
item.insert("type".to_string(), json!("compaction"));
if let Some(value) = edit
.get("trigger")
.and_then(Value::as_object)
.and_then(|trigger| trigger.get("value"))
.and_then(Value::as_i64)
{
item.insert("compact_threshold".to_string(), json!(value));
}
mapped.push(Value::Object(item));
}
if mapped.is_empty() {
None
} else {
Some(Value::Array(mapped))
}
}

fn map_anthropic_metadata_to_responses_user(value: Option<&Value>) -> Option<String> {
let metadata = value?.as_object()?;
let user = metadata.get("user_id")?.as_str()?.trim();
if user.is_empty() {
None
} else {
Some(user.chars().take(64).collect())
}
}
25 changes: 25 additions & 0 deletions crates/token_proxy_core/src/proxy/anthropic_compat/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ pub(super) fn responses_response_to_anthropic(
continue;
};
match item.get("type").and_then(Value::as_str) {
Some("reasoning") => {
let summary = extract_reasoning_summary(item);
if !summary.is_empty() {
thinking_text.push_str(&summary);
}
}
Some("message") => {
if item.get("role").and_then(Value::as_str) != Some("assistant") {
continue;
Expand Down Expand Up @@ -203,6 +209,25 @@ pub(super) fn anthropic_response_to_responses(body: &Bytes) -> Result<Bytes, Str
.map_err(|err| format!("Failed to serialize response: {err}"))
}

fn extract_reasoning_summary(item: &Map<String, Value>) -> String {
let Some(summary) = item.get("summary").and_then(Value::as_array) else {
return String::new();
};
let mut combined = String::new();
for part in summary {
let Some(part) = part.as_object() else {
continue;
};
if part.get("type").and_then(Value::as_str) != Some("summary_text") {
continue;
}
if let Some(text) = part.get("text").and_then(Value::as_str) {
combined.push_str(text);
}
}
combined
}

fn responses_function_call_to_tool_use(item: &Map<String, Value>) -> Option<Value> {
let call_id = item.get("call_id").and_then(Value::as_str).unwrap_or("");
let item_id = item.get("id").and_then(Value::as_str).unwrap_or("");
Expand Down
5 changes: 5 additions & 0 deletions crates/token_proxy_core/src/proxy/anthropic_compat/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ pub(super) fn map_anthropic_tools_to_responses(value: &Value) -> Value {

fn map_anthropic_tool(value: &Value) -> Option<Value> {
let tool = value.as_object()?;
let tool_type = tool.get("type").and_then(Value::as_str).unwrap_or("");
let tool_name = tool.get("name").and_then(Value::as_str).unwrap_or("");
if tool_type.starts_with("web_search") || tool_name == "web_search" {
return Some(json!({ "type": "web_search_preview" }));
}
let name = tool.get("name").and_then(Value::as_str)?;
let mut out = Map::new();
out.insert("type".to_string(), json!("function"));
Expand Down
4 changes: 4 additions & 0 deletions crates/token_proxy_core/src/proxy/gemini_compat/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,10 @@ fn function_response_to_chat_message(part: &serde_json::Map<String, Value>) -> O
if !name.is_empty() {
if let Some(message) = message.as_object_mut() {
message.insert("name".to_string(), Value::String(name.to_string()));
message.insert(
"tool_call_id".to_string(),
Value::String(format!("call_{name}")),
);
}
}
Some(message)
Expand Down
35 changes: 35 additions & 0 deletions crates/token_proxy_core/src/proxy/gemini_compat/request.test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,39 @@ fn gemini_request_to_chat_maps_function_response() {
let value: Value = serde_json::from_slice(&output).expect("json");
assert_eq!(value["messages"][0]["role"], json!("tool"));
assert_eq!(value["messages"][0]["name"], json!("getFoo"));
assert_eq!(value["messages"][0]["tool_call_id"], json!("call_getFoo"));
}

#[test]
fn gemini_request_to_chat_maps_parameters_json_schema() {
let input = json!({
"contents": [
{ "role": "user", "parts": [{ "text": "hi" }] }
],
"tools": [{
"functionDeclarations": [
{
"name": "getFoo",
"description": "x",
"parametersJsonSchema": {
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}
}
]
}]
});

let output = gemini_request_to_chat(
&Bytes::from(serde_json::to_vec(&input).unwrap()),
Some("gemini-1.5-flash"),
)
.expect("convert");
let value: Value = serde_json::from_slice(&output).expect("json");

assert_eq!(
value["tools"][0]["function"]["parameters"]["properties"]["query"]["type"],
json!("string")
);
}
13 changes: 11 additions & 2 deletions crates/token_proxy_core/src/proxy/gemini_compat/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,9 +505,18 @@ where
if state.name.is_empty() {
return None;
}
let args: Value = serde_json::from_str(&state.arguments).unwrap_or_else(|_| json!({}));
let args = if state.arguments.is_empty() {
json!({})
} else {
match serde_json::from_str::<Value>(&state.arguments) {
Ok(args) => args,
Err(_) => return None,
}
};
let name = state.name.clone();
self.tool_calls[index] = None;
Some(json!({
"functionCall": { "name": state.name, "args": args }
"functionCall": { "name": name, "args": args }
}))
}

Expand Down
Loading
Loading