mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat: add MiMo reasoning_content support for Claude Code proxy (#2990)
* feat: add MiMo reasoning_content support for Claude Code proxy MiMo requires reasoning_content to be passed back in multi-turn conversations with tool calls. Add "mimo" and "xiaomimimo" to the preserve_reasoning_content whitelist so thinking blocks are converted to reasoning_content when proxying Claude Code → MiMo. Also handle redacted_thinking blocks (encrypted by Claude Code across turns) by injecting a placeholder to prevent MiMo 400 errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: support MiMo reasoning histories - Fix the PR CI clippy failure for redacted thinking handling. - Preserve MiMo reasoning content on the OpenAI Chat proxy path. - Normalize Claude Desktop Anthropic thinking history for MiMo local routes. - Reject unsupported Claude Desktop direct model remaps. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -21,6 +21,8 @@ const CONFIG_LIBRARY_DIR: &str = "configLibrary";
|
||||
const GATEWAY_TOKEN_SETTING_KEY: &str = "claude_desktop_gateway_token";
|
||||
const CLAUDE_DESKTOP_PROXY_PREFIX: &str = "/claude-desktop";
|
||||
const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z";
|
||||
const MIMO_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]";
|
||||
const MIMO_TOOL_CALL_THINKING_PLACEHOLDER: &str = "tool call";
|
||||
|
||||
/// Claude Desktop 模型菜单识别的 route ID 前缀。
|
||||
pub const CLAUDE_ROUTE_PREFIX: &str = "claude-";
|
||||
@@ -531,6 +533,18 @@ fn direct_inference_model_specs(provider: &Provider) -> Result<Vec<InferenceMode
|
||||
),
|
||||
));
|
||||
}
|
||||
let upstream_model = route.model.trim();
|
||||
if !upstream_model.is_empty() && upstream_model != route_id {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.provider.direct_mapping_unsupported",
|
||||
format!(
|
||||
"Claude Desktop 直连模式不能映射模型: {route_id} -> {upstream_model};非 Claude 官方模型请使用本地路由模式"
|
||||
),
|
||||
format!(
|
||||
"Claude Desktop direct mode cannot map models: {route_id} -> {upstream_model}; use proxy mode for non-Claude official models"
|
||||
),
|
||||
));
|
||||
}
|
||||
result.push(InferenceModelSpec {
|
||||
name: route_id.to_string(),
|
||||
label_override: route
|
||||
@@ -706,9 +720,125 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
};
|
||||
|
||||
body["model"] = json!(route.upstream_model);
|
||||
if should_normalize_mimo_anthropic_thinking_history(provider, &route.upstream_model) {
|
||||
normalize_mimo_anthropic_thinking_history(&mut body);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn should_normalize_mimo_anthropic_thinking_history(
|
||||
provider: &Provider,
|
||||
upstream_model: &str,
|
||||
) -> bool {
|
||||
if !provider_uses_anthropic_messages_format(provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
is_mimo_identifier(upstream_model) || provider_has_mimo_endpoint(provider)
|
||||
}
|
||||
|
||||
fn provider_uses_anthropic_messages_format(provider: &Provider) -> bool {
|
||||
let api_format = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.api_format.as_deref())
|
||||
.or_else(|| {
|
||||
provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.unwrap_or("anthropic");
|
||||
|
||||
api_format.is_empty() || api_format == "anthropic"
|
||||
}
|
||||
|
||||
fn provider_has_mimo_endpoint(provider: &Provider) -> bool {
|
||||
let settings = &provider.settings_config;
|
||||
[
|
||||
settings
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(Value::as_str),
|
||||
settings.get("base_url").and_then(Value::as_str),
|
||||
settings.get("baseURL").and_then(Value::as_str),
|
||||
settings.get("apiEndpoint").and_then(Value::as_str),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(is_mimo_identifier)
|
||||
}
|
||||
|
||||
fn is_mimo_identifier(value: &str) -> bool {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("mimo") || value.contains("xiaomimimo")
|
||||
}
|
||||
|
||||
fn normalize_mimo_anthropic_thinking_history(body: &mut Value) {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for message in messages {
|
||||
if message.get("role").and_then(Value::as_str) != Some("assistant") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else {
|
||||
continue;
|
||||
};
|
||||
if !content
|
||||
.iter()
|
||||
.any(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut has_thinking = false;
|
||||
for block in content.iter_mut() {
|
||||
match block.get("type").and_then(Value::as_str) {
|
||||
Some("thinking") => {
|
||||
let has_non_empty_thinking = block
|
||||
.get("thinking")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if let Some(obj) = block.as_object_mut() {
|
||||
obj.remove("signature");
|
||||
}
|
||||
if has_non_empty_thinking {
|
||||
has_thinking = true;
|
||||
} else if let Some(obj) = block.as_object_mut() {
|
||||
obj.insert(
|
||||
"thinking".to_string(),
|
||||
json!(MIMO_TOOL_CALL_THINKING_PLACEHOLDER),
|
||||
);
|
||||
has_thinking = true;
|
||||
}
|
||||
}
|
||||
Some("redacted_thinking") => {
|
||||
*block = json!({
|
||||
"type": "thinking",
|
||||
"thinking": MIMO_REDACTED_THINKING_PLACEHOLDER
|
||||
});
|
||||
has_thinking = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_thinking {
|
||||
content.insert(
|
||||
0,
|
||||
json!({
|
||||
"type": "thinking",
|
||||
"thinking": MIMO_TOOL_CALL_THINKING_PLACEHOLDER
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proxy_gateway_base_url_from_db(db: &Database) -> Result<String, AppError> {
|
||||
// get_proxy_config is async-tagged but its body is fully synchronous (rusqlite
|
||||
// under a Mutex), so block_on cannot deadlock the calling thread.
|
||||
@@ -1179,6 +1309,31 @@ mod tests {
|
||||
provider
|
||||
}
|
||||
|
||||
fn mimo_anthropic_proxy_provider(id: &str) -> Provider {
|
||||
let mut provider = direct_provider(id);
|
||||
provider.name = "MiMo Proxy".to_string();
|
||||
provider.settings_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.xiaomimimo.com/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "test-token"
|
||||
}
|
||||
});
|
||||
provider.meta = Some(ProviderMeta {
|
||||
claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),
|
||||
api_format: Some("anthropic".to_string()),
|
||||
claude_desktop_model_routes: std::collections::HashMap::from([(
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "mimo-v2.5-pro".to_string(),
|
||||
label_override: Some("MiMo v2.5 Pro".to_string()),
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
)]),
|
||||
..Default::default()
|
||||
});
|
||||
provider
|
||||
}
|
||||
|
||||
fn oauth_proxy_provider(id: &str, provider_type: &str, api_format: &str) -> Provider {
|
||||
let mut provider = Provider::with_id(
|
||||
id.to_string(),
|
||||
@@ -1278,6 +1433,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_direct_rejects_model_mapping_to_non_claude_upstream() {
|
||||
let mut provider = direct_provider_with_models("direct-non-claude");
|
||||
provider
|
||||
.meta
|
||||
.as_mut()
|
||||
.expect("meta")
|
||||
.claude_desktop_model_routes
|
||||
.get_mut("claude-sonnet-4-6")
|
||||
.expect("route")
|
||||
.model = "mimo-v2.5-pro".to_string();
|
||||
|
||||
let err = validate_provider(&provider).expect_err("direct mapping should fail");
|
||||
assert!(err.to_string().contains("本地路由模式"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_apply_writes_local_gateway_profile_with_safe_models() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
@@ -1351,6 +1522,100 @@ mod tests {
|
||||
assert!(err.to_string().contains("claude-opus-4-7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_mimo_anthropic_rewrites_redacted_thinking_for_tool_history() {
|
||||
let provider = mimo_anthropic_proxy_provider("mimo");
|
||||
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({
|
||||
"model": "claude-sonnet-4-6",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "redacted_thinking", "data": "opaque"},
|
||||
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "README.md"}}
|
||||
]
|
||||
}]
|
||||
}),
|
||||
&provider,
|
||||
)
|
||||
.expect("map MiMo route");
|
||||
|
||||
assert_eq!(mapped["model"], json!("mimo-v2.5-pro"));
|
||||
assert_eq!(
|
||||
mapped["messages"][0]["content"][0]["type"],
|
||||
json!("thinking")
|
||||
);
|
||||
assert_eq!(
|
||||
mapped["messages"][0]["content"][0]["thinking"],
|
||||
json!("[redacted thinking]")
|
||||
);
|
||||
assert_eq!(
|
||||
mapped["messages"][0]["content"][1]["type"],
|
||||
json!("tool_use")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_mimo_anthropic_injects_thinking_for_tool_history_without_one() {
|
||||
let provider = mimo_anthropic_proxy_provider("mimo");
|
||||
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({
|
||||
"model": "claude-sonnet-4-6",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "README.md"}}
|
||||
]
|
||||
}]
|
||||
}),
|
||||
&provider,
|
||||
)
|
||||
.expect("map MiMo route");
|
||||
|
||||
assert_eq!(
|
||||
mapped["messages"][0]["content"][0]["type"],
|
||||
json!("thinking")
|
||||
);
|
||||
assert_eq!(
|
||||
mapped["messages"][0]["content"][0]["thinking"],
|
||||
json!("tool call")
|
||||
);
|
||||
assert_eq!(
|
||||
mapped["messages"][0]["content"][1]["type"],
|
||||
json!("tool_use")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_mimo_anthropic_keeps_thinking_text_but_drops_signature() {
|
||||
let provider = mimo_anthropic_proxy_provider("mimo");
|
||||
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({
|
||||
"model": "claude-sonnet-4-6",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Need to inspect the file.", "signature": "anthropic-signature"},
|
||||
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "README.md"}}
|
||||
]
|
||||
}]
|
||||
}),
|
||||
&provider,
|
||||
)
|
||||
.expect("map MiMo route");
|
||||
|
||||
assert_eq!(
|
||||
mapped["messages"][0]["content"][0]["thinking"],
|
||||
json!("Need to inspect the file.")
|
||||
);
|
||||
assert!(mapped["messages"][0]["content"][0]
|
||||
.get("signature")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_repairs_legacy_unsafe_route_without_colliding() {
|
||||
let mut provider = proxy_provider("proxy");
|
||||
|
||||
@@ -84,7 +84,11 @@ pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||
|
||||
fn is_reasoning_content_compatible_identifier(value: &str) -> bool {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("moonshot") || value.contains("kimi") || value.contains("deepseek")
|
||||
value.contains("moonshot")
|
||||
|| value.contains("kimi")
|
||||
|| value.contains("deepseek")
|
||||
|| value.contains("mimo")
|
||||
|| value.contains("xiaomimimo")
|
||||
}
|
||||
|
||||
fn should_preserve_reasoning_content_for_openai_chat(
|
||||
@@ -1801,4 +1805,39 @@ mod tests {
|
||||
assert_eq!(msg["reasoning_content"], "I should call the tool.");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_openai_chat_preserves_reasoning_content_for_mimo_provider() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.xiaomimimo.com/v1",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "mimo-v2.5-pro",
|
||||
"max_tokens": 64,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "I should call the tool."},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
|
||||
let msg = &transformed["messages"][0];
|
||||
assert_eq!(msg["reasoning_content"], "I should call the tool.");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +438,14 @@ fn convert_message_to_openai(
|
||||
}
|
||||
}
|
||||
}
|
||||
"redacted_thinking" if preserve_reasoning_content => {
|
||||
// Claude Code encrypts historical thinking into redacted_thinking blocks.
|
||||
// MiMo/DeepSeek require non-empty reasoning_content on assistant tool-call
|
||||
// messages, so inject a minimal placeholder when the real content is
|
||||
// unavailable. Skip when preserve_reasoning_content is off (generic
|
||||
// OpenAI-compatible path).
|
||||
reasoning_parts.push("[redacted thinking]".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -950,6 +958,26 @@ mod tests {
|
||||
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use_uses_redacted_thinking_placeholder() {
|
||||
let input = json!({
|
||||
"model": "mimo-v2.5-pro",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "redacted_thinking", "data": "opaque"},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai_with_reasoning_content(input, true).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["reasoning_content"], "[redacted thinking]");
|
||||
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_does_not_emit_reasoning_content_by_default() {
|
||||
let input = json!({
|
||||
|
||||
Reference in New Issue
Block a user