From c7b8a4575a82dc59101bf25c2977e1ee8694cd34 Mon Sep 17 00:00:00 2001 From: Gerard-Devlin Date: Thu, 11 Jun 2026 18:22:03 +0800 Subject: [PATCH] feat(providers): align compatible models with Claude Code runtime Route DeepSeek and Qwen through native Anthropic-compatible Claude Code endpoints, including canonical base URL handling and model environment overrides. Add CCR-style proxy transformer plumbing for remaining OpenAI-compatible providers and harden tool argument repair in the local Anthropic proxy. Preserve per-session provider behavior, improve stop/guidance flow handling, and update setup/chat store tests for provider routing. --- apps/desktop/src-tauri/src/anthropic_proxy.rs | 39 +- .../src-tauri/src/anthropic_proxy/messages.rs | 71 +-- .../src/anthropic_proxy/providers.rs | 23 +- .../src-tauri/src/anthropic_proxy/stream.rs | 2 + .../src-tauri/src/anthropic_proxy/tools.rs | 403 +++++++++++++- .../src/anthropic_proxy/transformers.rs | 148 +++++ apps/desktop/src-tauri/src/claude.rs | 511 +++++++++++++++++- apps/desktop/src-tauri/src/claude_process.rs | 31 +- .../stores/claude-chat-send-prompt.test.ts | 13 +- .../stores/claude-chat-store.test.ts | 44 +- .../stores/claude-setup-store.test.ts | 105 +++- .../__tests__/stores/multi-tab-merge.test.ts | 1 + .../components/claude-chat/chat-composer.tsx | 42 +- apps/desktop/src/components/claude-setup.tsx | 119 +++- apps/desktop/src/hooks/use-claude-events.ts | 5 +- apps/desktop/src/stores/claude-chat-store.ts | 149 +++-- apps/desktop/src/stores/claude-setup-store.ts | 41 +- 17 files changed, 1573 insertions(+), 174 deletions(-) create mode 100644 apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs diff --git a/apps/desktop/src-tauri/src/anthropic_proxy.rs b/apps/desktop/src-tauri/src/anthropic_proxy.rs index 8210e52..facb325 100644 --- a/apps/desktop/src-tauri/src/anthropic_proxy.rs +++ b/apps/desktop/src-tauri/src/anthropic_proxy.rs @@ -2,10 +2,12 @@ mod messages; mod providers; mod stream; mod tools; +mod transformers; use self::messages::{anthropic_to_openai_request, openai_to_anthropic_message}; use self::providers::apply_provider_request_transforms; use self::stream::{sse_response, stream_openai_sse_to_anthropic}; +use self::transformers::ProxyTransformerChain; use serde_json::{json, Value}; use std::net::SocketAddr; use std::sync::Arc; @@ -17,6 +19,8 @@ pub(crate) struct OpenAiProxyCredential { pub(crate) api_key: String, pub(crate) base_url: String, pub(crate) model: String, + pub(crate) transformers: Vec, + pub(crate) model_transformers: Vec, } pub(crate) async fn start_openai_anthropic_proxy( @@ -217,13 +221,16 @@ async fn handle_messages_to_stream( .get("stream") .and_then(|value| value.as_bool()) .unwrap_or(false); - let mut openai_request = anthropic_to_openai_request(&anthropic_request, credential)?; + let transformers = ProxyTransformerChain::for_credential(credential, wants_stream); + let mut openai_request = + anthropic_to_openai_request(&anthropic_request, credential, &transformers)?; openai_request["stream"] = Value::Bool(wants_stream); apply_provider_request_transforms( &mut openai_request, &anthropic_request, credential, wants_stream, + &transformers, ); if request_contains_openai_image_parts(&openai_request) && provider_rejects_openai_image_parts(credential) @@ -399,6 +406,7 @@ fn _assert_local_addr(_: SocketAddr) {} #[cfg(test)] mod tests { + use super::transformers::ProxyTransformerChain; use super::*; #[test] @@ -431,6 +439,8 @@ mod tests { api_key: "sk-test".to_string(), base_url: "https://api.example.com/v1".to_string(), model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), }; let request = json!({ "system": "system prompt", @@ -460,7 +470,12 @@ mod tests { }] }); - let converted = anthropic_to_openai_request(&request, &credential).unwrap(); + let converted = anthropic_to_openai_request( + &request, + &credential, + &ProxyTransformerChain::from_names(&[]), + ) + .unwrap(); assert_eq!(converted["model"], "qwen-test"); assert_eq!(converted["messages"][0]["role"], "system"); assert_eq!( @@ -477,6 +492,8 @@ mod tests { api_key: "sk-test".to_string(), base_url: "https://api.example.com/v1".to_string(), model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), }; let request = json!({ "messages": [ @@ -506,7 +523,12 @@ mod tests { ] }); - let converted = anthropic_to_openai_request(&request, &credential).unwrap(); + let converted = anthropic_to_openai_request( + &request, + &credential, + &ProxyTransformerChain::from_names(&[]), + ) + .unwrap(); assert_eq!(converted["messages"][0]["role"], "assistant"); assert_eq!(converted["messages"][1]["role"], "tool"); assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_1"); @@ -520,6 +542,8 @@ mod tests { api_key: "sk-test".to_string(), base_url: "https://api.example.com/v1".to_string(), model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), }; let request = json!({ "messages": [ @@ -539,7 +563,12 @@ mod tests { ] }); - let converted = anthropic_to_openai_request(&request, &credential).unwrap(); + let converted = anthropic_to_openai_request( + &request, + &credential, + &ProxyTransformerChain::from_names(&[]), + ) + .unwrap(); assert_eq!(converted["messages"][0]["role"], "assistant"); assert_eq!(converted["messages"][1]["role"], "tool"); assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_missing"); @@ -553,6 +582,8 @@ mod tests { api_key: "sk-test".to_string(), base_url: "https://api.example.com/v1".to_string(), model: "deepseek-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), }; let request = json!({ "model": "claude-sonnet-4" }); let response = json!({ diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/messages.rs b/apps/desktop/src-tauri/src/anthropic_proxy/messages.rs index 3cd10cc..133eea5 100644 --- a/apps/desktop/src-tauri/src/anthropic_proxy/messages.rs +++ b/apps/desktop/src-tauri/src/anthropic_proxy/messages.rs @@ -1,4 +1,5 @@ use super::tools::{normalized_tool_call_id, repair_tool_arguments, repaired_tool_arguments_value}; +use super::transformers::ProxyTransformerChain; use super::OpenAiProxyCredential; use serde_json::{json, Value}; @@ -7,6 +8,7 @@ const EXIT_TOOL_NAME: &str = "ExitTool"; pub(super) fn anthropic_to_openai_request( request: &Value, credential: &OpenAiProxyCredential, + transformers: &ProxyTransformerChain, ) -> Result { let mut messages = Vec::new(); if let Some(system) = request.get("system").and_then(flatten_anthropic_content) { @@ -43,15 +45,16 @@ pub(super) fn anthropic_to_openai_request( .filter_map(anthropic_tool_to_openai_tool) .collect::>(); if !converted.is_empty() { - let mut converted = converted; - let requested_tool_choice = openai_tool_choice(request.get("tool_choice")); - let tool_choice = if requested_tool_choice.is_object() { - requested_tool_choice + let tool_choice = if transformers.has_tooluse() { + Value::String("required".to_string()) } else { + openai_tool_choice(request.get("tool_choice")) + }; + let mut converted = converted; + if tool_choice == Value::String("required".to_string()) { append_exit_tool(&mut converted); append_exit_tool_reminder(&mut body); - Value::String("required".to_string()) - }; + } body["tools"] = Value::Array(converted); body["tool_choice"] = tool_choice; } @@ -469,10 +472,6 @@ fn assistant_content_to_openai(content: &Value) -> (String, Vec, Option String { - tool_result_content_to_openai(content).0 -} - fn tool_result_content_to_openai(content: &Value) -> (String, Vec) { if let Some(text) = content.as_str() { return (text.to_string(), Vec::new()); @@ -673,9 +672,15 @@ mod tests { api_key: "sk-test".to_string(), base_url: "https://api.example.com/v1".to_string(), model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), } } + fn transformers(names: &[&str]) -> ProxyTransformerChain { + ProxyTransformerChain::from_names(names) + } + #[test] fn preserves_anthropic_image_blocks_as_openai_image_url_parts() { let request = json!({ @@ -695,7 +700,8 @@ mod tests { }] }); - let converted = anthropic_to_openai_request(&request, &credential()).unwrap(); + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap(); assert_eq!(converted["messages"][0]["content"][0]["type"], "text"); assert_eq!(converted["messages"][0]["content"][1]["type"], "image_url"); @@ -743,7 +749,8 @@ mod tests { ] }); - let converted = anthropic_to_openai_request(&request, &credential()).unwrap(); + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap(); assert_eq!(converted["messages"][0]["role"], "assistant"); assert_eq!(converted["messages"][1]["role"], "tool"); @@ -784,7 +791,8 @@ mod tests { }] }); - let converted = anthropic_to_openai_request(&request, &credential()).unwrap(); + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap(); assert_eq!( converted["messages"][0]["thinking"]["content"], @@ -805,7 +813,8 @@ mod tests { }] }); - let converted = anthropic_to_openai_request(&request, &credential()).unwrap(); + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap(); let tool_names = converted["tools"] .as_array() .unwrap() @@ -822,17 +831,19 @@ mod tests { } #[test] - fn enters_tool_mode_when_tools_are_available() { + fn tooluse_transformer_forces_exit_tool_like_ccr() { let request = json!({ - "messages": [{ "role": "user", "content": "use the right tool" }], + "messages": [{ "role": "user", "content": "finish" }], "tools": [{ - "name": "Skill", - "description": "Load a skill", + "name": "Read", + "description": "Read a file", "input_schema": { "type": "object" } }] }); - let converted = anthropic_to_openai_request(&request, &credential()).unwrap(); + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&["tooluse"])) + .unwrap(); let tool_names = converted["tools"] .as_array() .unwrap() @@ -842,22 +853,18 @@ mod tests { .and_then(|value| value.as_str()) }) .collect::>(); - let reminder = converted["messages"] + + assert_eq!(converted["tool_choice"], "required"); + assert!(tool_names.contains(&"Read")); + assert!(tool_names.contains(&EXIT_TOOL_NAME)); + assert!(converted["messages"] .as_array() .unwrap() .iter() - .find(|message| { - message.get("role").and_then(|value| value.as_str()) == Some("system") - && message - .get("content") - .and_then(|value| value.as_str()) - .is_some_and(|content| content.contains("Tool mode is active")) - }); - - assert_eq!(converted["tool_choice"], "required"); - assert!(tool_names.contains(&"Skill")); - assert!(tool_names.contains(&EXIT_TOOL_NAME)); - assert!(reminder.is_some()); + .any(|message| message + .get("content") + .and_then(|value| value.as_str()) + .is_some_and(|content| content.contains("Tool mode is active")))); } #[test] diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/providers.rs b/apps/desktop/src-tauri/src/anthropic_proxy/providers.rs index d2d20dd..41fb669 100644 --- a/apps/desktop/src-tauri/src/anthropic_proxy/providers.rs +++ b/apps/desktop/src-tauri/src/anthropic_proxy/providers.rs @@ -1,3 +1,4 @@ +use super::transformers::ProxyTransformerChain; use super::OpenAiProxyCredential; use serde_json::{json, Value}; @@ -8,14 +9,17 @@ pub(super) fn apply_provider_request_transforms( anthropic_request: &Value, credential: &OpenAiProxyCredential, wants_stream: bool, + transformers: &ProxyTransformerChain, ) { - clean_cache_control(openai_request); + if transformers.has_cleancache() { + clean_cache_control(openai_request); + } - if wants_stream { + if wants_stream && transformers.has_streamoptions() { openai_request["stream_options"] = json!({ "include_usage": true }); } - if is_deepseek_provider(credential) { + if transformers.has_deepseek() { cap_number_field(openai_request, "max_tokens", DEEPSEEK_MAX_TOKENS); } @@ -24,12 +28,6 @@ pub(super) fn apply_provider_request_transforms( clean_null_optional_fields(openai_request); } -fn is_deepseek_provider(credential: &OpenAiProxyCredential) -> bool { - let base_url = credential.base_url.to_ascii_lowercase(); - let model = credential.model.to_ascii_lowercase(); - base_url.contains("deepseek") || model.contains("deepseek") -} - fn cap_number_field(body: &mut Value, key: &str, max: u64) { let Some(value) = body.get(key).and_then(|value| value.as_u64()) else { return; @@ -121,6 +119,8 @@ mod tests { api_key: "sk-test".to_string(), base_url: base_url.to_string(), model: model.to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), } } @@ -132,6 +132,7 @@ mod tests { &json!({}), &credential("https://api.example.com/v1", "qwen-test"), true, + &ProxyTransformerChain::from_names(&["streamoptions"]), ); assert_eq!(body["stream_options"]["include_usage"], true); @@ -145,6 +146,7 @@ mod tests { &json!({}), &credential("https://api.deepseek.com", "deepseek-chat"), false, + &ProxyTransformerChain::from_names(&["deepseek"]), ); assert_eq!(body["max_tokens"], DEEPSEEK_MAX_TOKENS); @@ -163,6 +165,7 @@ mod tests { }), &credential("https://api.example.com/v1", "qwen-test"), false, + &ProxyTransformerChain::from_names(&[]), ); assert_eq!(body["reasoning"]["max_tokens"], 4096); @@ -176,6 +179,7 @@ mod tests { &json!({}), &credential("https://api.openai.com/v1", "o3"), false, + &ProxyTransformerChain::from_names(&[]), ); assert!(body.get("max_tokens").is_none()); @@ -200,6 +204,7 @@ mod tests { &json!({}), &credential("https://api.example.com/v1", "qwen-test"), false, + &ProxyTransformerChain::from_names(&["cleancache"]), ); assert!(body["messages"][0]["content"][0] diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/stream.rs b/apps/desktop/src-tauri/src/anthropic_proxy/stream.rs index 7cffb4a..736178a 100644 --- a/apps/desktop/src-tauri/src/anthropic_proxy/stream.rs +++ b/apps/desktop/src-tauri/src/anthropic_proxy/stream.rs @@ -725,6 +725,8 @@ mod tests { api_key: "sk-test".to_string(), base_url: "https://api.example.com/v1".to_string(), model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), } } diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/tools.rs b/apps/desktop/src-tauri/src/anthropic_proxy/tools.rs index 8066448..6a114f5 100644 --- a/apps/desktop/src-tauri/src/anthropic_proxy/tools.rs +++ b/apps/desktop/src-tauri/src/anthropic_proxy/tools.rs @@ -18,27 +18,76 @@ pub(super) fn repair_tool_arguments(arguments: &str) -> String { if trimmed.is_empty() || trimmed == "{}" { return "{}".to_string(); } - if serde_json::from_str::(trimmed).is_ok() { - return trimmed.to_string(); + + let mut candidates = Vec::new(); + push_candidate(&mut candidates, trimmed.to_string()); + if let Some(extracted) = extract_json_like(trimmed) { + push_candidate(&mut candidates, extracted); } - for candidate in [ - extract_json_like(trimmed), - repair_balanced_json(trimmed.to_string()), - repair_single_quoted_json(trimmed), - ] - .into_iter() - .flatten() - { - let candidate = remove_trailing_commas(&candidate); - if let Ok(value) = serde_json::from_str::(&candidate) { - return value.to_string(); + let seeds = candidates.clone(); + for candidate in seeds { + let without_comments = strip_json_comments(&candidate); + push_candidate(&mut candidates, without_comments.clone()); + + let without_trailing_commas = remove_trailing_commas(&without_comments); + push_candidate(&mut candidates, without_trailing_commas.clone()); + + let json5_like = + normalize_single_quoted_strings("e_unquoted_object_keys(&without_trailing_commas)); + push_candidate(&mut candidates, json5_like.clone()); + + if let Some(with_commas) = insert_missing_commas_between_fields(&json5_like) { + push_candidate(&mut candidates, with_commas.clone()); + if let Some(balanced) = repair_balanced_json(with_commas) { + push_candidate(&mut candidates, balanced); + } + } + if let Some(balanced) = repair_balanced_json(json5_like) { + push_candidate(&mut candidates, balanced); + } + if let Some(balanced) = repair_balanced_json(without_trailing_commas) { + push_candidate(&mut candidates, balanced); + } + } + + for candidate in candidates { + if let Some(repaired) = parse_tool_arguments_candidate(&candidate) { + return repaired; } } "{}".to_string() } +fn push_candidate(candidates: &mut Vec, value: String) { + let value = value.trim().to_string(); + if value.is_empty() || candidates.iter().any(|candidate| candidate == &value) { + return; + } + candidates.push(value); +} + +fn parse_tool_arguments_candidate(value: &str) -> Option { + serde_json::from_str::(value) + .ok() + .or_else(|| serde_yaml::from_str::(value).ok()) + .and_then(canonical_tool_arguments) +} + +fn canonical_tool_arguments(value: Value) -> Option { + match value { + Value::Object(map) => { + if map.keys().any(|key| key.contains(':')) { + return None; + } + Some(Value::Object(map).to_string()) + } + Value::Array(_) => Some(value.to_string()), + _ => None, + } +} + fn trim_code_fence(value: &str) -> &str { let value = value.trim(); if !value.starts_with("```") { @@ -111,13 +160,6 @@ fn repair_balanced_json(value: String) -> Option { Some(output) } -fn repair_single_quoted_json(value: &str) -> Option { - if value.contains('"') || !value.contains('\'') { - return None; - } - Some(value.replace('\'', "\"")) -} - fn remove_trailing_commas(value: &str) -> String { let mut output = String::with_capacity(value.len()); let mut chars = value.chars().peekable(); @@ -158,6 +200,288 @@ fn remove_trailing_commas(value: &str) -> String { output } +fn strip_json_comments(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + let mut in_double_string = false; + let mut in_single_string = false; + let mut escaped = false; + + while let Some(ch) = chars.next() { + if in_double_string || in_single_string { + output.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if in_double_string && ch == '"' { + in_double_string = false; + } else if in_single_string && ch == '\'' { + in_single_string = false; + } + continue; + } + + match ch { + '"' => { + in_double_string = true; + output.push(ch); + } + '\'' => { + in_single_string = true; + output.push(ch); + } + '/' if chars.peek() == Some(&'/') => { + chars.next(); + for next in chars.by_ref() { + if next == '\n' { + output.push('\n'); + break; + } + } + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + let mut previous = '\0'; + for next in chars.by_ref() { + if previous == '*' && next == '/' { + break; + } + previous = next; + } + } + _ => output.push(ch), + } + } + + output +} + +fn quote_unquoted_object_keys(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 16); + let chars = value.chars().collect::>(); + let mut index = 0; + let mut in_double_string = false; + let mut in_single_string = false; + let mut escaped = false; + let mut expects_key = false; + + while index < chars.len() { + let ch = chars[index]; + if in_double_string || in_single_string { + output.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if in_double_string && ch == '"' { + in_double_string = false; + } else if in_single_string && ch == '\'' { + in_single_string = false; + } + index += 1; + continue; + } + + match ch { + '"' => { + in_double_string = true; + output.push(ch); + expects_key = false; + index += 1; + } + '\'' => { + in_single_string = true; + output.push(ch); + expects_key = false; + index += 1; + } + '{' | ',' => { + expects_key = true; + output.push(ch); + index += 1; + } + '}' | ']' => { + expects_key = false; + output.push(ch); + index += 1; + } + ch if expects_key && ch.is_whitespace() => { + output.push(ch); + index += 1; + } + ch if expects_key && is_identifier_start(ch) => { + let start = index; + index += 1; + while index < chars.len() && is_identifier_continue(chars[index]) { + index += 1; + } + let mut lookahead = index; + while lookahead < chars.len() && chars[lookahead].is_whitespace() { + lookahead += 1; + } + if lookahead < chars.len() && chars[lookahead] == ':' { + output.push('"'); + for key_ch in &chars[start..index] { + output.push(*key_ch); + } + output.push('"'); + expects_key = false; + } else { + for key_ch in &chars[start..index] { + output.push(*key_ch); + } + expects_key = false; + } + } + _ => { + output.push(ch); + index += 1; + } + } + } + + output +} + +fn normalize_single_quoted_strings(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + let mut in_double_string = false; + let mut in_single_string = false; + let mut escaped = false; + + while let Some(ch) = chars.next() { + if in_double_string { + output.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_double_string = false; + } + continue; + } + + if in_single_string { + if escaped { + match ch { + '\'' => output.push('\''), + '"' => { + output.push('\\'); + output.push('"'); + } + '\\' => output.push('\\'), + _ => { + output.push('\\'); + output.push(ch); + } + } + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '\'' { + output.push('"'); + in_single_string = false; + } else if ch == '"' { + output.push('\\'); + output.push('"'); + } else { + output.push(ch); + } + continue; + } + + if ch == '"' { + in_double_string = true; + output.push(ch); + } else if ch == '\'' { + in_single_string = true; + output.push('"'); + } else { + output.push(ch); + } + } + + if in_single_string { + output.push('"'); + } + output +} + +fn insert_missing_commas_between_fields(value: &str) -> Option { + let mut output = String::with_capacity(value.len() + 8); + let chars = value.chars().collect::>(); + let mut index = 0; + let mut changed = false; + let mut in_string = false; + let mut escaped = false; + + while index < chars.len() { + let ch = chars[index]; + output.push(ch); + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + index += 1; + continue; + } + + if ch == '"' { + in_string = true; + index += 1; + continue; + } + + if matches!(ch, '"' | '}' | ']' | '0'..='9' | 'e' | 'E' | 'l') { + let mut lookahead = index + 1; + while lookahead < chars.len() && chars[lookahead].is_whitespace() { + lookahead += 1; + } + if lookahead < chars.len() + && chars[lookahead] == '"' + && previous_non_whitespace(&chars, index) != Some(':') + { + output.push(','); + changed = true; + } + } + index += 1; + } + + changed.then_some(output) +} + +fn previous_non_whitespace(chars: &[char], index: usize) -> Option { + if index == 0 { + return None; + } + let mut cursor = index - 1; + loop { + if !chars[cursor].is_whitespace() { + return Some(chars[cursor]); + } + if cursor == 0 { + return None; + } + cursor -= 1; + } +} + +fn is_identifier_start(ch: char) -> bool { + ch == '_' || ch == '$' || ch.is_ascii_alphabetic() +} + +fn is_identifier_continue(ch: char) -> bool { + is_identifier_start(ch) || ch.is_ascii_digit() || ch == '-' || ch == '.' +} + #[cfg(test)] mod tests { use super::*; @@ -178,6 +502,45 @@ mod tests { ); } + #[test] + fn repairs_json5_style_tool_arguments_like_ccr_enhancetool() { + let repaired: Value = serde_json::from_str(&repair_tool_arguments( + "{file_path:'main.tex', replace_all:false,}", + )) + .unwrap(); + + assert_eq!( + repaired, + json!({ "file_path": "main.tex", "replace_all": false }) + ); + } + + #[test] + fn repairs_commented_tool_arguments() { + let repaired: Value = serde_json::from_str(&repair_tool_arguments( + "{\n // target file\n file_path: 'main.tex',\n old_string: 'A',\n new_string: 'B',\n}", + )) + .unwrap(); + + assert_eq!( + repaired, + json!({ "file_path": "main.tex", "old_string": "A", "new_string": "B" }) + ); + } + + #[test] + fn repairs_mixed_quote_tool_arguments() { + let repaired: Value = serde_json::from_str(&repair_tool_arguments( + "{\"file_path\": 'main.tex', \"pattern\": 'FastVID'}", + )) + .unwrap(); + + assert_eq!( + repaired, + json!({ "file_path": "main.tex", "pattern": "FastVID" }) + ); + } + #[test] fn falls_back_to_empty_object_for_unrepairable_arguments() { assert_eq!(repair_tool_arguments("not json at all"), "{}"); diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs b/apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs new file mode 100644 index 0000000..ca4153e --- /dev/null +++ b/apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs @@ -0,0 +1,148 @@ +use super::OpenAiProxyCredential; + +const CLEANCACHE: &str = "cleancache"; +const DEEPSEEK: &str = "deepseek"; +const ENHANCETOOL: &str = "enhancetool"; +const STREAMOPTIONS: &str = "streamoptions"; +const TOOLUSE: &str = "tooluse"; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct ProxyTransformerChain { + names: Vec, +} + +impl ProxyTransformerChain { + pub(super) fn for_credential(credential: &OpenAiProxyCredential, wants_stream: bool) -> Self { + let mut chain = Self::default(); + chain.push(CLEANCACHE); + if wants_stream { + chain.push(STREAMOPTIONS); + } + if is_deepseek_credential(credential) { + chain.push(DEEPSEEK); + } + + // ClaudePrism already buffers and repairs tool-call arguments before + // returning them to Claude Code. Naming it here keeps the behavior + // traceable to Claude Code Router's enhancetool transformer. + chain.push(ENHANCETOOL); + for name in &credential.transformers { + chain.push(name); + } + for name in &credential.model_transformers { + chain.push(name); + } + for name in configured_transformer_names() { + chain.push(&name); + } + chain + } + + pub(super) fn has(&self, name: &str) -> bool { + self.names + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(name)) + } + + pub(super) fn has_tooluse(&self) -> bool { + self.has(TOOLUSE) + } + + pub(super) fn has_cleancache(&self) -> bool { + self.has(CLEANCACHE) + } + + pub(super) fn has_deepseek(&self) -> bool { + self.has(DEEPSEEK) + } + + pub(super) fn has_streamoptions(&self) -> bool { + self.has(STREAMOPTIONS) + } + + #[cfg(test)] + pub(super) fn from_names(names: &[&str]) -> Self { + let mut chain = Self::default(); + for name in names { + chain.push(name); + } + chain + } + + fn push(&mut self, name: &str) { + let name = name.trim(); + if name.is_empty() || self.has(name) { + return; + } + self.names.push(name.to_ascii_lowercase()); + } +} + +fn configured_transformer_names() -> Vec { + std::env::var("CLAUDE_PRISM_PROXY_TRANSFORMERS") + .ok() + .into_iter() + .flat_map(|value| { + value + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>() + }) + .collect() +} + +fn is_deepseek_credential(credential: &OpenAiProxyCredential) -> bool { + let base_url = credential.base_url.to_ascii_lowercase(); + let model = credential.model.to_ascii_lowercase(); + base_url.contains("deepseek") || model.contains("deepseek") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential { + OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: base_url.to_string(), + model: model.to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + } + } + + #[test] + fn includes_ccr_style_defaults_for_common_provider_adapters() { + let chain = ProxyTransformerChain::for_credential( + &credential("https://api.deepseek.com", "deepseek-chat"), + true, + ); + + assert!(chain.has_cleancache()); + assert!(chain.has_streamoptions()); + assert!(chain.has_deepseek()); + assert!(chain.has(ENHANCETOOL)); + } + + #[test] + fn does_not_enable_tooluse_unless_configured() { + let chain = ProxyTransformerChain::for_credential( + &credential("https://api.example.com/v1", "qwen"), + false, + ); + + assert!(!chain.has_tooluse()); + } + + #[test] + fn accepts_explicit_model_transformers() { + let mut credential = credential("https://api.example.com/v1", "qwen"); + credential.model_transformers = vec!["tooluse".to_string()]; + + let chain = ProxyTransformerChain::for_credential(&credential, false); + + assert!(chain.has_tooluse()); + } +} diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index 1ae18d7..cc040d0 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -5,7 +5,7 @@ use crate::claude_process::{ }; use serde_json::{json, Value}; use std::borrow::Cow; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; @@ -43,6 +43,8 @@ struct StoredOpenAiCompatibleCredential { api_key: String, base_url: String, model: String, + transformers: Vec, + model_transformers: HashMap>, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] @@ -52,6 +54,10 @@ struct StoredOpenAiCompatibleCredentialConfig { api_key: String, base_url: String, model: String, + #[serde(default)] + transformers: Vec, + #[serde(default)] + model_transformers: HashMap>, } #[derive(Clone, Debug, serde::Serialize)] @@ -263,6 +269,36 @@ fn normalize_model(value: Option<&str>) -> Result, String> { Ok(Some(clean)) } +fn normalized_transformer_names(values: &[String]) -> Vec { + let mut seen = HashSet::new(); + values + .iter() + .map(|value| strip_nul(value).trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .filter(|value| seen.insert(value.clone())) + .collect() +} + +fn normalized_model_transformers( + values: &HashMap>, +) -> HashMap> { + values + .iter() + .filter_map(|(model, transformers)| { + let model = strip_nul(model).trim().to_string(); + if model.is_empty() { + return None; + } + let transformers = normalized_transformer_names(transformers); + if transformers.is_empty() { + None + } else { + Some((model, transformers)) + } + }) + .collect() +} + fn is_claude_model_selector(value: &str) -> bool { let model = value.trim().to_ascii_lowercase(); if model.starts_with("claude") { @@ -366,6 +402,8 @@ fn normalized_openai_compatible_credentials( api_key, base_url, model, + transformers: normalized_transformer_names(&credential.transformers), + model_transformers: normalized_model_transformers(&credential.model_transformers), }); } @@ -388,6 +426,8 @@ fn normalized_openai_compatible_credentials( api_key, base_url, model, + transformers: Vec::new(), + model_transformers: HashMap::new(), }); } } @@ -542,6 +582,17 @@ pub async fn save_anthropic_api_key( .map(|credential| credential.id.clone()) }) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let (transformers, model_transformers) = config + .openai_credentials + .iter() + .find(|item| item.id == credential_id) + .map(|item| { + ( + normalized_transformer_names(&item.transformers), + normalized_model_transformers(&item.model_transformers), + ) + }) + .unwrap_or_else(|| (Vec::new(), HashMap::new())); let credential = StoredOpenAiCompatibleCredentialConfig { id: credential_id.clone(), @@ -549,6 +600,8 @@ pub async fn save_anthropic_api_key( api_key: api_key.clone(), base_url: base_url.clone(), model: model.clone(), + transformers, + model_transformers, }; if let Some(existing) = config @@ -602,6 +655,8 @@ pub async fn verify_openai_compatible_api_key( api_key, base_url, model, + transformers: Vec::new(), + model_transformers: HashMap::new(), }; verify_openai_compatible_credential(&credential).await @@ -2124,8 +2179,25 @@ fn with_optional_bearer_auth( } } +fn with_optional_anthropic_key( + request: reqwest::RequestBuilder, + api_key: &str, +) -> reqwest::RequestBuilder { + if api_key.trim().is_empty() { + request + } else { + request.header("x-api-key", api_key).bearer_auth(api_key) + } +} + fn openai_models_url(base_url: &str) -> String { let clean = base_url.trim_end_matches('/'); + if let Some(origin) = deepseek_origin_for_anthropic_base_url(clean) { + return format!("{}/models", origin); + } + if let Some(origin) = qwen_origin_for_anthropic_base_url(clean) { + return format!("{}/compatible-mode/v1/models", origin); + } if let Some(root) = clean.strip_suffix("/chat/completions") { return format!("{}/models", root.trim_end_matches('/')); } @@ -2170,6 +2242,41 @@ fn openai_compatible_verification_body(model: &str) -> serde_json::Value { }) } +fn anthropic_messages_url(base_url: &str) -> String { + let clean = base_url.trim_end_matches('/'); + if clean.ends_with("/v1/messages") || clean.ends_with("/messages") { + clean.to_string() + } else if clean.ends_with("/v1") { + format!("{}/messages", clean) + } else { + format!("{}/v1/messages", clean) + } +} + +fn anthropic_verification_body(model: &str) -> serde_json::Value { + json!({ + "model": model, + "max_tokens": 16, + "messages": [{ + "role": "user", + "content": "Reply with exactly: ok", + }], + "stream": false, + }) +} + +fn anthropic_response_has_message_content(response: &Value) -> bool { + response + .get("content") + .and_then(|value| value.as_array()) + .is_some_and(|content| { + content.iter().any(|block| { + block.get("text").and_then(|value| value.as_str()).is_some() + || block.get("type").and_then(|value| value.as_str()) == Some("tool_use") + }) + }) +} + fn provider_error_excerpt(body: &str) -> String { let compact = body.split_whitespace().collect::>().join(" "); if compact.chars().count() <= 500 { @@ -2210,6 +2317,11 @@ async fn verify_openai_compatible_credential( .timeout(std::time::Duration::from_secs(20)) .build() .map_err(|err| format!("Failed to create provider client: {}", err))?; + + if let Some(anthropic_base_url) = native_anthropic_base_url(credential) { + return verify_native_anthropic_credential(&client, credential, &anthropic_base_url).await; + } + let request_body = openai_compatible_verification_body(&credential.model); let request = client @@ -2249,11 +2361,63 @@ async fn verify_openai_compatible_credential( Ok(()) } +async fn verify_native_anthropic_credential( + client: &reqwest::Client, + credential: &StoredOpenAiCompatibleCredential, + anthropic_base_url: &str, +) -> Result<(), String> { + let request_body = anthropic_verification_body(&credential.model); + let request = client + .post(anthropic_messages_url(anthropic_base_url)) + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01") + .body(request_body.to_string()); + let response = with_optional_anthropic_key(request, &credential.api_key) + .send() + .await + .map_err(|err| format!("Provider verification request failed: {}", err))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider verification response: {}", err))?; + + if !status.is_success() { + return Err(openai_compatible_verification_error(status, &response_text)); + } + + let response_json: serde_json::Value = serde_json::from_str(&response_text).map_err(|err| { + format!( + "Provider returned invalid JSON during verification: {}", + err + ) + })?; + if !anthropic_response_has_message_content(&response_json) { + return Err( + "Provider verification succeeded but did not return an Anthropic-compatible message response." + .to_string(), + ); + } + + Ok(()) +} + async fn send_openai_compatible_no_tools_text_request( client: &reqwest::Client, credential: &StoredOpenAiCompatibleCredential, messages: &[serde_json::Value], ) -> Result<(String, String), String> { + if let Some(anthropic_base_url) = native_anthropic_base_url(credential) { + return send_native_anthropic_no_tools_text_request( + client, + credential, + messages, + &anthropic_base_url, + ) + .await; + } + let request_body = json!({ "model": credential.model.clone(), "messages": messages, @@ -2307,6 +2471,69 @@ async fn send_openai_compatible_no_tools_text_request( Ok((content, reasoning)) } +async fn send_native_anthropic_no_tools_text_request( + client: &reqwest::Client, + credential: &StoredOpenAiCompatibleCredential, + messages: &[serde_json::Value], + anthropic_base_url: &str, +) -> Result<(String, String), String> { + let request_body = json!({ + "model": credential.model.clone(), + "max_tokens": 128, + "messages": messages, + "stream": false, + }); + + let request = client + .post(anthropic_messages_url(anthropic_base_url)) + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01") + .body(request_body.to_string()); + let response = with_optional_anthropic_key(request, &credential.api_key) + .send() + .await + .map_err(|err| format!("Provider request failed: {}", err))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider response: {}", err))?; + if !status.is_success() { + return Err(format!( + "Provider returned HTTP {}: {}", + status, response_text + )); + } + + let response: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|err| format!("Provider returned invalid JSON: {}", err))?; + let content = response + .get("content") + .and_then(|value| value.as_array()) + .map(|blocks| { + blocks + .iter() + .filter_map(|block| block.get("text").and_then(|value| value.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let reasoning = response + .get("content") + .and_then(|value| value.as_array()) + .map(|blocks| { + blocks + .iter() + .filter_map(|block| block.get("thinking").and_then(|value| value.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + + Ok((content, reasoning)) +} + async fn execute_openai_compatible_via_claude_proxy( window: WebviewWindow, project_path: String, @@ -2316,10 +2543,17 @@ async fn execute_openai_compatible_via_claude_proxy( effort_level: Option, credential: StoredOpenAiCompatibleCredential, ) -> Result<(), String> { + let model_transformers = credential + .model_transformers + .get(&credential.model) + .cloned() + .unwrap_or_default(); let proxy_url = start_openai_anthropic_proxy(OpenAiProxyCredential { api_key: credential.api_key.clone(), base_url: credential.base_url.clone(), model: credential.model.clone(), + transformers: credential.transformers.clone(), + model_transformers, }) .await?; let claude_path = find_claude_binary()?; @@ -2350,6 +2584,163 @@ async fn execute_openai_compatible_via_claude_proxy( .await } +async fn execute_openai_compatible_provider( + window: WebviewWindow, + project_path: String, + prompt: String, + tab_id: String, + args_prefix: Vec, + effort_level: Option, + credential: StoredOpenAiCompatibleCredential, +) -> Result<(), String> { + if uses_native_anthropic_route(&credential) { + return execute_openai_compatible_via_native_anthropic( + window, + project_path, + prompt, + tab_id, + args_prefix, + effort_level, + credential, + ) + .await; + } + + execute_openai_compatible_via_claude_proxy( + window, + project_path, + prompt, + tab_id, + args_prefix, + effort_level, + credential, + ) + .await +} + +async fn execute_openai_compatible_via_native_anthropic( + window: WebviewWindow, + project_path: String, + prompt: String, + tab_id: String, + args_prefix: Vec, + effort_level: Option, + credential: StoredOpenAiCompatibleCredential, +) -> Result<(), String> { + let anthropic_base_url = native_anthropic_base_url(&credential) + .ok_or_else(|| "Provider does not expose a native Anthropic endpoint".to_string())?; + let claude_path = find_claude_binary()?; + + let (mut args, stdin_payload) = with_prompt_transport(args_prefix, prompt); + args.extend(common_claude_args()); + + let mut cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); + apply_native_anthropic_provider_env(&mut cmd, &credential, &anthropic_base_url); + + spawn_claude_process( + window, + cmd, + tab_id, + stdin_payload, + Some(SpawnProviderMetadata { + provider: PROVIDER_OPENAI_COMPATIBLE, + provider_credential_id: credential.id, + model: credential.model, + }), + ) + .await +} + +fn apply_native_anthropic_provider_env( + cmd: &mut Command, + credential: &StoredOpenAiCompatibleCredential, + anthropic_base_url: &str, +) { + cmd.env("ANTHROPIC_BASE_URL", anthropic_base_url); + cmd.env("ANTHROPIC_AUTH_TOKEN", credential.api_key.as_str()); + cmd.env_remove("ANTHROPIC_API_KEY"); + cmd.env("ANTHROPIC_MODEL", credential.model.as_str()); + cmd.env("ANTHROPIC_SMALL_FAST_MODEL", credential.model.as_str()); + cmd.env("ANTHROPIC_DEFAULT_OPUS_MODEL", credential.model.as_str()); + cmd.env("ANTHROPIC_DEFAULT_SONNET_MODEL", credential.model.as_str()); + cmd.env("ANTHROPIC_DEFAULT_HAIKU_MODEL", credential.model.as_str()); + cmd.env("CLAUDE_CODE_SUBAGENT_MODEL", credential.model.as_str()); + cmd.env_remove("CLAUDE_MODEL"); +} + +fn uses_native_anthropic_route(credential: &StoredOpenAiCompatibleCredential) -> bool { + native_anthropic_base_url(credential).is_some() +} + +fn native_anthropic_base_url(credential: &StoredOpenAiCompatibleCredential) -> Option { + let origin = http_origin(&credential.base_url)?; + let lower_origin = origin.to_ascii_lowercase(); + if lower_origin == "https://api.deepseek.com" || lower_origin == "http://api.deepseek.com" { + let lower = credential.base_url.to_ascii_lowercase(); + if let Some(index) = lower.find("/anthropic") { + return Some(format!("{}{}", &credential.base_url[..index], "/anthropic")); + } + + return Some(format!("{}/anthropic", origin)); + } + + if is_qwen_anthropic_origin(&lower_origin) { + let lower = credential.base_url.to_ascii_lowercase(); + if let Some(index) = lower.find("/apps/anthropic") { + return Some(format!( + "{}{}", + &credential.base_url[..index], + "/apps/anthropic" + )); + } + + return Some(format!("{}/apps/anthropic", origin)); + } + + None +} + +fn deepseek_origin_for_anthropic_base_url(base_url: &str) -> Option { + let lower = base_url.to_ascii_lowercase(); + if !lower.contains("api.deepseek.com") || !lower.contains("/anthropic") { + return None; + } + http_origin(base_url) +} + +fn qwen_origin_for_anthropic_base_url(base_url: &str) -> Option { + let lower = base_url.to_ascii_lowercase(); + let origin = http_origin(base_url)?; + if !is_qwen_anthropic_origin(&origin.to_ascii_lowercase()) { + return None; + } + if lower.contains("/apps/anthropic") || lower.contains("/compatible-mode/") { + return Some(origin); + } + None +} + +fn is_qwen_anthropic_origin(lower_origin: &str) -> bool { + matches!( + lower_origin, + "https://dashscope.aliyuncs.com" + | "http://dashscope.aliyuncs.com" + | "https://dashscope-intl.aliyuncs.com" + | "http://dashscope-intl.aliyuncs.com" + ) +} + +fn http_origin(value: &str) -> Option { + let value = value.trim().trim_end_matches('/'); + let scheme_end = value.find("://")?; + let after_scheme = &value[scheme_end + 3..]; + let host_end = after_scheme.find('/').unwrap_or(after_scheme.len()); + if host_end == 0 { + return None; + } + Some(value[..scheme_end + 3 + host_end].to_string()) +} + #[tauri::command] pub async fn execute_claude_code( window: WebviewWindow, @@ -2368,7 +2759,7 @@ pub async fn execute_claude_code( { credential.model = model; } - return execute_openai_compatible_via_claude_proxy( + return execute_openai_compatible_provider( window, project_path, prompt, @@ -2411,7 +2802,7 @@ pub async fn continue_claude_code( { credential.model = model; } - return execute_openai_compatible_via_claude_proxy( + return execute_openai_compatible_provider( window, project_path, prompt, @@ -2455,7 +2846,7 @@ pub async fn resume_claude_code( { credential.model = model; } - return execute_openai_compatible_via_claude_proxy( + return execute_openai_compatible_provider( window, project_path, prompt, @@ -3358,15 +3749,22 @@ mod tests { id: "qwen".to_string(), label: "Qwen".to_string(), api_key: "sk-qwen".to_string(), - base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1".to_string(), + base_url: "https://dashscope.aliyuncs.com/apps/anthropic".to_string(), model: "qwen3-coder-plus".to_string(), + transformers: vec!["enhancetool".to_string()], + model_transformers: HashMap::new(), }, StoredOpenAiCompatibleCredentialConfig { id: "deepseek".to_string(), label: "DeepSeek".to_string(), api_key: "sk-deepseek".to_string(), - base_url: "https://api.deepseek.com".to_string(), + base_url: "https://api.deepseek.com/anthropic".to_string(), model: "deepseek-chat".to_string(), + transformers: vec!["deepseek".to_string()], + model_transformers: HashMap::from([( + "deepseek-chat".to_string(), + vec!["tooluse".to_string()], + )]), }, ], ..Default::default() @@ -3631,6 +4029,87 @@ mod tests { assert_eq!(credential.model, "deepseek-chat"); } + #[test] + fn test_deepseek_official_root_uses_native_anthropic_route() { + let config = test_openai_compatible_auth_config(); + let credential = openai_compatible_credential_by_id_from_config(&config, Some("deepseek")) + .unwrap() + .unwrap(); + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://api.deepseek.com/anthropic") + ); + } + + #[test] + fn test_qwen_official_anthropic_endpoint_uses_native_route() { + let config = test_openai_compatible_auth_config(); + let credential = openai_compatible_credential_by_id_from_config(&config, Some("qwen")) + .unwrap() + .unwrap(); + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://dashscope.aliyuncs.com/apps/anthropic") + ); + } + + #[test] + fn test_native_anthropic_route_preserves_explicit_anthropic_path() { + let credential = StoredOpenAiCompatibleCredential { + id: "deepseek-anthropic".to_string(), + label: "DeepSeek".to_string(), + api_key: "sk-test".to_string(), + base_url: "https://api.deepseek.com/anthropic/v1".to_string(), + model: "deepseek-v4-pro".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://api.deepseek.com/anthropic") + ); + } + + #[test] + fn test_non_deepseek_anthropic_path_stays_on_proxy_route() { + let credential = StoredOpenAiCompatibleCredential { + id: "other-anthropic".to_string(), + label: "Other".to_string(), + api_key: "sk-test".to_string(), + base_url: "https://router.example.com/anthropic".to_string(), + model: "qwen3".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert!(!uses_native_anthropic_route(&credential)); + assert_eq!(native_anthropic_base_url(&credential), None); + } + + #[test] + fn test_qwen_legacy_compatible_mode_uses_native_anthropic_route() { + let credential = StoredOpenAiCompatibleCredential { + id: "qwen-compatible".to_string(), + label: "Qwen".to_string(), + api_key: "sk-test".to_string(), + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1".to_string(), + model: "qwen3-max-2026-01-23".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://dashscope.aliyuncs.com/apps/anthropic") + ); + } + #[test] fn test_provider_id_lookup_rejects_missing_provider() { let config = test_openai_compatible_auth_config(); @@ -3668,6 +4147,18 @@ mod tests { ); } + #[test] + fn test_anthropic_messages_url_matches_deepseek_native_base() { + assert_eq!( + anthropic_messages_url("https://api.deepseek.com/anthropic"), + "https://api.deepseek.com/anthropic/v1/messages" + ); + assert_eq!( + anthropic_messages_url("https://api.deepseek.com/anthropic/v1"), + "https://api.deepseek.com/anthropic/v1/messages" + ); + } + #[test] fn test_openai_chat_completions_url_keeps_generic_openai_default() { assert_eq!( @@ -3698,6 +4189,10 @@ mod tests { openai_models_url("https://dashscope.aliyuncs.com/compatible-mode/v1"), "https://dashscope.aliyuncs.com/compatible-mode/v1/models" ); + assert_eq!( + openai_models_url("https://dashscope.aliyuncs.com/apps/anthropic"), + "https://dashscope.aliyuncs.com/compatible-mode/v1/models" + ); assert_eq!( openai_models_url("https://generativelanguage.googleapis.com/v1beta/openai/"), "https://generativelanguage.googleapis.com/v1beta/openai/models" @@ -3710,6 +4205,10 @@ mod tests { openai_models_url("https://api.openai.com"), "https://api.openai.com/v1/models" ); + assert_eq!( + openai_models_url("https://api.deepseek.com/anthropic"), + "https://api.deepseek.com/models" + ); assert_eq!( openai_models_url("http://localhost:11434"), "http://localhost:11434/v1/models" diff --git a/apps/desktop/src-tauri/src/claude_process.rs b/apps/desktop/src-tauri/src/claude_process.rs index b8cf90e..89700bc 100644 --- a/apps/desktop/src-tauri/src/claude_process.rs +++ b/apps/desktop/src-tauri/src/claude_process.rs @@ -6,6 +6,9 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, Command}; use tokio::sync::Mutex; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + #[derive(Clone)] pub struct ClaudeProcessState { pub processes: Arc>>, @@ -281,13 +284,14 @@ pub async fn stop_claude_process( let process_key = process_key(&window_label, &tab_id); let claude_state = window.state::(); let mut processes = claude_state.processes.lock().await; - if let Some(child) = processes.get_mut(&process_key) { + if let Some(mut child) = processes.remove(&process_key) { + drop(processes); let stopped = match mode { ClaudeStopMode::Terminate => { - let _ = child.start_kill(); + terminate_process_tree(&mut child).await; true } - ClaudeStopMode::Interrupt => interrupt_or_terminate(child).await, + ClaudeStopMode::Interrupt => interrupt_or_terminate(&mut child).await, }; return Ok(stopped); } @@ -315,7 +319,7 @@ async fn interrupt_or_terminate(child: &mut Child) -> bool { return true; } } - let _ = child.start_kill(); + terminate_process_tree(child).await; true } @@ -325,10 +329,27 @@ async fn interrupt_or_terminate(child: &mut Child) -> bool { // Tauri without a PTY/ConPTY session. For guided follow-ups, fall back to // terminating the current run so the frontend can immediately continue the // same tab with the queued guidance. - let _ = child.start_kill(); + terminate_process_tree(child).await; true } +#[cfg(windows)] +async fn terminate_process_tree(child: &mut Child) { + if let Some(pid) = child.id() { + let _ = Command::new("taskkill") + .creation_flags(CREATE_NO_WINDOW) + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .await; + } + let _ = child.start_kill(); +} + +#[cfg(not(windows))] +async fn terminate_process_tree(child: &mut Child) { + let _ = child.start_kill(); +} + /// Kill all Claude processes associated with a specific window label. /// Called when a window is destroyed. pub async fn kill_process_for_window(state: &ClaudeProcessState, window_label: &str) { diff --git a/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts b/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts index adaf5e1..8ad1158 100644 --- a/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts @@ -41,7 +41,8 @@ function resetClaudeChatStore() { id: "tab-default", title: "New Chat", sessionId: null, - providerKey: null, + providerKey: CLAUDE_CODE_PROVIDER_ID, + sessionProviderKey: null, messages: [], isStreaming: false, error: null, @@ -54,7 +55,7 @@ function resetClaudeChatStore() { pendingInitialPrompt: null, pendingAttachments: [], selectedModel: "opus", - selectedProviderCredentialId: null, + selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID, selectedProviderModels: {}, effortLevel: "medium", _cancelledByUser: false, @@ -200,7 +201,8 @@ describe("useClaudeChatStore.sendPrompt context assembly", () => { ? { ...tab, sessionId: "qwen-session", - providerKey: "openai-compatible:qwen-cred", + providerKey: CLAUDE_CODE_PROVIDER_ID, + sessionProviderKey: "openai-compatible:qwen-cred", messages: [ { type: "user", @@ -251,7 +253,8 @@ describe("useClaudeChatStore.sendPrompt context assembly", () => { ? { ...tab, sessionId: "shared-session", - providerKey: "openai-compatible:qwen-cred", + providerKey: "openai-compatible:deepseek-cred", + sessionProviderKey: "openai-compatible:qwen-cred", } : tab, ), @@ -270,8 +273,8 @@ describe("useClaudeChatStore.sendPrompt context assembly", () => { }); it("passes an OpenAI-compatible model override with the provider credential", async () => { + useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen-cred"); useClaudeChatStore.setState({ - selectedProviderCredentialId: "qwen-cred", selectedProviderModels: { "qwen-cred": "qwen3.7-plus" }, }); diff --git a/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts b/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts index af4977b..7a7c18a 100644 --- a/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts @@ -9,6 +9,7 @@ import { beforeEach(() => { localStorage.clear(); + sessionStorage.clear(); useClaudeChatStore.setState({ selectedProviderCredentialId: null }); }); @@ -56,26 +57,55 @@ describe("provider selection persistence", () => { .getState() .setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID); - expect(localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY)).toBe( - CLAUDE_CODE_PROVIDER_ID, - ); + expect( + sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBe(CLAUDE_CODE_PROVIDER_ID); + expect( + localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBeNull(); expect(loadSelectedProviderCredentialId()).toBe(CLAUDE_CODE_PROVIDER_ID); }); it("persists and clears OpenAI-compatible provider selections", () => { useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen"); - expect(localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY)).toBe( - "qwen", - ); + expect( + sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBe("qwen"); + expect( + localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBeNull(); useClaudeChatStore.getState().setSelectedProviderCredentialId(null); expect( - localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), ).toBeNull(); expect(loadSelectedProviderCredentialId()).toBeNull(); }); + + it("keeps provider selections isolated between chat tabs", () => { + const store = useClaudeChatStore.getState(); + const firstTabId = store.activeTabId; + + store.setSelectedProviderCredentialId("qwen"); + const secondTabId = store.createTab(); + useClaudeChatStore.getState().setSelectedProviderCredentialId("gemini"); + + expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe( + "gemini", + ); + + useClaudeChatStore.getState().setActiveTab(firstTabId); + expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe( + "qwen", + ); + + useClaudeChatStore.getState().setActiveTab(secondTabId); + expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe( + "gemini", + ); + }); }); describe("queued guidance", () => { diff --git a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts index 67c47e0..96220d7 100644 --- a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts @@ -133,7 +133,7 @@ describe("useClaudeSetupStore.saveApiKey", () => { provider_kind: "openai-compatible", account_email: null, provider_model: "deepseek-v4-pro", - provider_base_url: "https://api.deepseek.com", + provider_base_url: "https://api.deepseek.com/anthropic", missing_git: false, }; } @@ -143,13 +143,44 @@ describe("useClaudeSetupStore.saveApiKey", () => { id: "cred-1", label: "DeepSeek", model: "deepseek-v4-pro", - base_url: "https://api.deepseek.com", + base_url: "https://api.deepseek.com/anthropic", }, ]; } return null; }); + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://api.deepseek.com/anthropic", + "openai-compatible", + "deepseek-v4-pro", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com/anthropic", + model: "deepseek-v4-pro", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com/anthropic", + provider: "openai-compatible", + model: "deepseek-v4-pro", + credentialLabel: null, + }); + }); + + it("normalizes legacy DeepSeek root URLs to the native Anthropic endpoint", async () => { + vi.mocked(invoke).mockResolvedValue(null); + const success = await useClaudeSetupStore .getState() .saveApiKey( @@ -165,19 +196,81 @@ describe("useClaudeSetupStore.saveApiKey", () => { "verify_openai_compatible_api_key", { apiKey: "sk-test", - baseUrl: "https://api.deepseek.com", + baseUrl: "https://api.deepseek.com/anthropic", model: "deepseek-v4-pro", }, ); expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { apiKey: "sk-test", - baseUrl: "https://api.deepseek.com", + baseUrl: "https://api.deepseek.com/anthropic", provider: "openai-compatible", model: "deepseek-v4-pro", credentialLabel: null, }); }); + it("normalizes legacy Qwen compatible URLs to the native Anthropic endpoint", async () => { + vi.mocked(invoke).mockResolvedValue(null); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "openai-compatible", + "qwen3-max-2026-01-23", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + model: "qwen3-max-2026-01-23", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + provider: "openai-compatible", + model: "qwen3-max-2026-01-23", + credentialLabel: null, + }); + }); + + it("preserves Qwen native Anthropic URLs when saving credentials", async () => { + vi.mocked(invoke).mockResolvedValue(null); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://dashscope.aliyuncs.com/apps/anthropic/v1", + "openai-compatible", + "qwen3-max-2026-01-23", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + model: "qwen3-max-2026-01-23", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + provider: "openai-compatible", + model: "qwen3-max-2026-01-23", + credentialLabel: null, + }); + }); + it("allows local OpenAI-compatible providers without an API key", async () => { vi.mocked(invoke).mockImplementation(async (command) => { if (command === "check_claude_status") { @@ -244,7 +337,7 @@ describe("useClaudeSetupStore.saveApiKey", () => { .getState() .saveApiKey( "sk-test", - "https://api.deepseek.com", + "https://api.deepseek.com/anthropic", "openai-compatible", "deepseek-v4-pro", ); @@ -253,7 +346,7 @@ describe("useClaudeSetupStore.saveApiKey", () => { expect(invoke).toHaveBeenCalledTimes(1); expect(invoke).toHaveBeenCalledWith("verify_openai_compatible_api_key", { apiKey: "sk-test", - baseUrl: "https://api.deepseek.com", + baseUrl: "https://api.deepseek.com/anthropic", model: "deepseek-v4-pro", }); expect(useClaudeSetupStore.getState().error).toBe( diff --git a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts index 341a884..bfa6d7b 100644 --- a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts +++ b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts @@ -45,6 +45,7 @@ function resetStores() { title: "New Chat", sessionId: null, providerKey: null, + sessionProviderKey: null, messages: [], isStreaming: false, error: null, diff --git a/apps/desktop/src/components/claude-chat/chat-composer.tsx b/apps/desktop/src/components/claude-chat/chat-composer.tsx index e9bf98b..0fafd35 100644 --- a/apps/desktop/src/components/claude-chat/chat-composer.tsx +++ b/apps/desktop/src/components/claude-chat/chat-composer.tsx @@ -203,28 +203,14 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { [queuedGuidance], ); const openAiCredentials = useClaudeSetupStore((s) => s.openAiCredentials); - const activeOpenAiCredentialId = useClaudeSetupStore( - (s) => s.activeOpenAiCredentialId, - ); - const setActiveApiCredential = useClaudeSetupStore( - (s) => s.setActiveApiCredential, - ); const deleteApiCredential = useClaudeSetupStore((s) => s.deleteApiCredential); - const activeProviderCredential = - openAiCredentials.find( - (credential) => credential.id === activeOpenAiCredentialId, - ) ?? - openAiCredentials[0] ?? - null; const selectedProviderCredential = selectedProviderCredentialId && selectedProviderCredentialId !== CLAUDE_CODE_PROVIDER_ID ? (openAiCredentials.find( (credential) => credential.id === selectedProviderCredentialId, ) ?? null) - : selectedProviderCredentialId === CLAUDE_CODE_PROVIDER_ID - ? null - : activeProviderCredential; + : null; const selectedProviderModel = selectedProviderCredential ? selectedProviderModels[selectedProviderCredential.id] || selectedProviderCredential.model @@ -299,11 +285,6 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { }, [modelPickerOpen]); useEffect(() => { - if (!selectedProviderCredentialId && activeProviderCredential?.id) { - setSelectedProviderCredentialId(activeProviderCredential.id); - return; - } - if ( selectedProviderCredentialId && selectedProviderCredentialId !== CLAUDE_CODE_PROVIDER_ID && @@ -311,12 +292,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { (credential) => credential.id === selectedProviderCredentialId, ) ) { - setSelectedProviderCredentialId( - activeProviderCredential?.id ?? CLAUDE_CODE_PROVIDER_ID, - ); + setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID); } }, [ - activeProviderCredential?.id, openAiCredentials, selectedProviderCredentialId, setSelectedProviderCredentialId, @@ -352,7 +330,6 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const nextCredential = remainingCredentials[0] ?? null; if (nextCredential) { setSelectedProviderCredentialId(nextCredential.id); - void setActiveApiCredential(nextCredential.id); } else { setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID); } @@ -368,7 +345,6 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { openAiCredentials, selectedProviderCredential?.id, selectedProviderCredentialId, - setActiveApiCredential, setSelectedProviderCredentialId, ], ); @@ -1268,7 +1244,6 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const selectCredential = () => { if (isDeleting) return; setSelectedProviderCredentialId(credential.id); - void setActiveApiCredential(credential.id); }; return ( @@ -1484,7 +1459,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { onCancel={() => setProviderSetupOpen(false)} onSaved={() => { setProviderSetupOpen(false); - setSelectedProviderCredentialId(null); + const setupState = useClaudeSetupStore.getState(); + const lastCredential = + setupState.openAiCredentials[ + setupState.openAiCredentials.length - 1 + ]; + setSelectedProviderCredentialId( + setupState.activeOpenAiCredentialId ?? + lastCredential?.id ?? + CLAUDE_CODE_PROVIDER_ID, + ); setProviderModelOptions({}); setProviderModelError(null); }} @@ -1800,7 +1784,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { className="size-8 rounded-full" onClick={ isStreaming && !hasInput - ? () => void cancelExecution() + ? () => void cancelExecution(activeTabId) : handleSend } disabled={!isStreaming && !hasInput} diff --git a/apps/desktop/src/components/claude-setup.tsx b/apps/desktop/src/components/claude-setup.tsx index 48540bb..bbc8b59 100644 --- a/apps/desktop/src/components/claude-setup.tsx +++ b/apps/desktop/src/components/claude-setup.tsx @@ -85,16 +85,16 @@ const OPENAI_COMPATIBLE_PRESETS: OpenAICompatiblePreset[] = [ { id: "qwen", label: "Qwen", - baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", model: "", - note: "Alibaba Cloud Model Studio endpoint.", + note: "Qwen Anthropic-compatible endpoint for Claude Code.", }, { id: "deepseek", label: "DeepSeek", - baseUrl: "https://api.deepseek.com", + baseUrl: "https://api.deepseek.com/anthropic", model: "", - note: "DeepSeek OpenAI-compatible endpoint.", + note: "DeepSeek Anthropic-compatible endpoint for Claude Code.", }, { id: "moonshot", @@ -159,10 +159,71 @@ const CLAUDE_PROVIDER_CARDS: ModelProviderCard[] = [ ]; const OPENAI_DEFAULT_PRESET_ID = OPENAI_PROVIDER_CARDS[0]?.id ?? "openai"; +const DEEPSEEK_ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic"; +const QWEN_ANTHROPIC_BASE_URL = "https://dashscope.aliyuncs.com/apps/anthropic"; + +function deepseekOrigin(url: string) { + const trimmed = url.trim(); + const match = trimmed.match(/^(https?:\/\/api\.deepseek\.com)(?:\/|$)/i); + return match?.[1] ?? null; +} + +function qwenOrigin(url: string) { + const trimmed = url.trim(); + const match = trimmed.match( + /^(https?:\/\/dashscope(?:-intl)?\.aliyuncs\.com)(?:\/|$)/i, + ); + return match?.[1] ?? null; +} + +function canonicalOpenAiCompatibleBaseUrl( + url: string, + presetId?: string | null, +) { + const trimmed = url.trim(); + const origin = deepseekOrigin(trimmed); + if ( + origin && + (presetId === "deepseek" || !trimmed.toLowerCase().includes("/anthropic")) + ) { + const lower = trimmed.toLowerCase(); + const anthropicIndex = lower.indexOf("/anthropic"); + if (anthropicIndex >= 0) { + return `${trimmed.slice(0, anthropicIndex)}/anthropic`; + } + return `${origin}/anthropic`; + } + + const qwenBaseOrigin = qwenOrigin(trimmed); + if ( + qwenBaseOrigin && + (presetId === "qwen" || + trimmed.toLowerCase().includes("/apps/anthropic") || + trimmed.toLowerCase().includes("/compatible-mode/") || + normalizeOriginOnlyUrl(trimmed) === + normalizeOriginOnlyUrl(qwenBaseOrigin)) + ) { + const lower = trimmed.toLowerCase(); + const anthropicIndex = lower.indexOf("/apps/anthropic"); + if (anthropicIndex >= 0) { + return `${trimmed.slice(0, anthropicIndex)}/apps/anthropic`; + } + return `${qwenBaseOrigin}/apps/anthropic`; + } + + return trimmed; +} + +function normalizeOriginOnlyUrl(value: string) { + return value.trim().replace(/\/+$/, "").toLowerCase(); +} + +function isNativeAnthropicPreset(cardId?: string | null) { + return cardId === "deepseek" || cardId === "qwen"; +} function normalizePresetBaseUrl(url: string) { - return url - .trim() + return canonicalOpenAiCompatibleBaseUrl(url) .replace(/\/chat\/completions$/i, "") .replace(/\/+$/, "") .toLowerCase(); @@ -461,13 +522,17 @@ export function ClaudeSetup({ selectedProvider: "claude-code" | "openai-compatible" = provider, credentialLabel?: string, ) => { + const savedBaseUrl = + selectedProvider === "openai-compatible" + ? canonicalOpenAiCompatibleBaseUrl(baseUrl, providerPreset) + : baseUrl.trim(); const savedPreset = selectedProvider === "openai-compatible" - ? openAiPresetIdForBaseUrl(baseUrl) + ? openAiPresetIdForBaseUrl(savedBaseUrl) : "anthropic-direct"; const success = await saveApiKey( apiKey, - baseUrl, + savedBaseUrl, selectedProvider, model, credentialLabel, @@ -495,14 +560,17 @@ export function ClaudeSetup({ }; const beginProviderEdit = (isDirectProvider: boolean) => { + const nextBaseUrl = isDirectProvider + ? canonicalOpenAiCompatibleBaseUrl(providerBaseUrl || "") + : ""; setProvider(isDirectProvider ? "openai-compatible" : "claude-code"); setProviderPreset( isDirectProvider - ? openAiPresetIdForBaseUrl(providerBaseUrl) + ? openAiPresetIdForBaseUrl(nextBaseUrl) : "anthropic-direct", ); setApiKey(""); - setBaseUrl(isDirectProvider ? providerBaseUrl || "" : ""); + setBaseUrl(nextBaseUrl); setModel(isDirectProvider ? providerModel || "" : ""); setModelOptions([]); setModelFetchError(null); @@ -676,14 +744,20 @@ export function ClaudeSetup({

{selectedProvider === "openai-compatible" - ? "Use either the API root or a full /chat/completions URL." + ? activeCardId === "deepseek" + ? "DeepSeek runs through its native Anthropic-compatible Claude Code route." + : activeCardId === "qwen" + ? "Qwen runs through its native Anthropic-compatible Claude Code route." + : "Use either the API root or a full /chat/completions URL." : "Leave blank for Anthropic direct API."}

@@ -789,7 +876,11 @@ export function ClaudeSetup({

)}

- Fetches the provider's real /models list when available. + {activeCardId === "deepseek" + ? "Fetches DeepSeek models from the matching provider model endpoint." + : activeCardId === "qwen" + ? "Fetches Qwen models from the matching DashScope model endpoint." + : "Fetches the provider's real /models list when available."}

)} diff --git a/apps/desktop/src/hooks/use-claude-events.ts b/apps/desktop/src/hooks/use-claude-events.ts index d706a55..eb099a6 100644 --- a/apps/desktop/src/hooks/use-claude-events.ts +++ b/apps/desktop/src/hooks/use-claude-events.ts @@ -84,11 +84,10 @@ export function useClaudeEvents() { hasTexChangesRef.current.set(tab.id, false); cancelledForAskRef.current.set(tab.id, false); lastErrorRef.current.delete(tab.id); - const providerId = - useClaudeChatStore.getState().selectedProviderCredentialId; + const providerKey = tab.sessionProviderKey ?? tab.providerKey; directProviderTabRef.current.set( tab.id, - !!providerId && providerId !== CLAUDE_CODE_PROVIDER_ID, + !!providerKey && providerKey !== CLAUDE_CODE_PROVIDER_ID, ); msgCountRef.current.set(tab.id, 0); streamStartTimeRef.current.delete(tab.id); diff --git a/apps/desktop/src/stores/claude-chat-store.ts b/apps/desktop/src/stores/claude-chat-store.ts index 55fcda8..a2ffadf 100644 --- a/apps/desktop/src/stores/claude-chat-store.ts +++ b/apps/desktop/src/stores/claude-chat-store.ts @@ -12,7 +12,7 @@ export const SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY = function providerSelectionStorage(): Storage | null { try { - return globalThis.localStorage ?? null; + return globalThis.sessionStorage ?? null; } catch { return null; } @@ -120,7 +120,10 @@ export interface TabState { id: string; title: string; sessionId: string | null; + /** Provider currently selected in the tab UI. */ providerKey: string | null; + /** Provider that last executed this session, used for safe resume/switching. */ + sessionProviderKey: string | null; messages: ClaudeStreamMessage[]; isStreaming: boolean; error: string | null; @@ -144,11 +147,14 @@ const TAB_FIELDS = [ ] as const; function makeDefaultTab(id: string): TabState { + const selectedCredentialId = + loadSelectedProviderCredentialId() ?? CLAUDE_CODE_PROVIDER_ID; return { id, title: "New Chat", sessionId: null, - providerKey: null, + providerKey: providerKeyForSelectedCredential(selectedCredentialId), + sessionProviderKey: null, messages: [], isStreaming: false, error: null, @@ -168,6 +174,12 @@ function providerSessionKey(providerCredentialId: string | null): string { : CLAUDE_CODE_PROVIDER_ID; } +function providerKeyForSelectedCredential(credentialId: string | null): string { + return credentialId && credentialId !== CLAUDE_CODE_PROVIDER_ID + ? providerSessionKey(credentialId) + : CLAUDE_CODE_PROVIDER_ID; +} + function providerCredentialIdFromSessionKey( providerKey: string | null, ): string | null | undefined { @@ -179,6 +191,11 @@ function providerCredentialIdFromSessionKey( : undefined; } +function selectedCredentialForProviderKey(providerKey: string | null) { + const credentialId = providerCredentialIdFromSessionKey(providerKey); + return credentialId === undefined ? null : credentialId; +} + function inferProviderKeyFromHistory(history: any[]): string | null { const init = history.find( (entry) => entry?.type === "system" && entry?.subtype === "init", @@ -593,7 +610,7 @@ interface ClaudeChatState { clearQueuedGuidance: (tabId: string) => void; consumeTemporaryFilePaths: (tabId: string) => string[]; forceQueuedGuidanceNow: (tabId: string, guidanceId?: string) => Promise; - cancelExecution: () => Promise; + cancelExecution: (tabId?: string) => Promise; clearMessages: () => void; newSession: () => void; resumeSession: (sessionId: string, title?: string) => Promise; @@ -634,10 +651,19 @@ export const useClaudeChatStore = create()((set, get) => ({ selectedModel: "opus", setSelectedModel: (model) => set({ selectedModel: model }), - selectedProviderCredentialId: loadSelectedProviderCredentialId(), + selectedProviderCredentialId: + loadSelectedProviderCredentialId() ?? CLAUDE_CODE_PROVIDER_ID, setSelectedProviderCredentialId: (credentialId) => { persistSelectedProviderCredentialId(credentialId); - set({ selectedProviderCredentialId: credentialId }); + const providerKey = providerKeyForSelectedCredential( + credentialId ?? CLAUDE_CODE_PROVIDER_ID, + ); + set((state) => ({ + selectedProviderCredentialId: credentialId, + tabs: state.tabs.map((tab) => + tab.id === state.activeTabId ? { ...tab, providerKey } : tab, + ), + })); }, selectedProviderModels: {}, setSelectedProviderModel: (credentialId, model) => @@ -687,17 +713,15 @@ export const useClaudeChatStore = create()((set, get) => ({ const activeTab = state.tabs.find((t) => t.id === activeTabId); if (!activeTab || activeTab.isStreaming) return; - const { - selectedModel, - effortLevel, - selectedProviderCredentialId, - selectedProviderModels, - } = state; + const { selectedModel, effortLevel, selectedProviderModels } = state; const sessionId = activeTab.sessionId; + const tabSelectedProviderCredentialId = + selectedCredentialForProviderKey(activeTab.providerKey) ?? + state.selectedProviderCredentialId; let providerCredentialId = - selectedProviderCredentialId && - selectedProviderCredentialId !== CLAUDE_CODE_PROVIDER_ID - ? selectedProviderCredentialId + tabSelectedProviderCredentialId && + tabSelectedProviderCredentialId !== CLAUDE_CODE_PROVIDER_ID + ? tabSelectedProviderCredentialId : null; if (options?.preserveTabProvider && activeTab.providerKey) { @@ -715,7 +739,7 @@ export const useClaudeChatStore = create()((set, get) => ({ ? selectedProviderModels[providerCredentialId] || null : null; const requestProviderKey = providerSessionKey(providerCredentialId); - const previousProviderKey = activeTab?.providerKey ?? null; + const previousProviderKey = activeTab?.sessionProviderKey ?? null; const providerChanged = !!sessionId && !!previousProviderKey && @@ -790,6 +814,7 @@ export const useClaudeChatStore = create()((set, get) => ({ messages: [...(currentTab?.messages ?? []), userMessage], sessionId: resumeSessionId, providerKey: requestProviderKey, + sessionProviderKey: requestProviderKey, isStreaming: true, error: null, pendingTemporaryFilePaths: temporaryFilePaths, @@ -1064,14 +1089,11 @@ export const useClaudeChatStore = create()((set, get) => ({ } }, - cancelExecution: async () => { - const { activeTabId } = get(); + cancelExecution: async (tabId) => { + const activeTabId = tabId ?? get().activeTabId; + const tab = get().tabs.find((t) => t.id === activeTabId); + if (!tab?.isStreaming) return; set({ _cancelledByUser: true }); - try { - await invoke("cancel_claude_execution", { tabId: activeTabId }); - } catch { - // ignore - } set((s) => applyTabUpdate(s, activeTabId, { isStreaming: false, @@ -1080,6 +1102,11 @@ export const useClaudeChatStore = create()((set, get) => ({ forcedQueuedGuidanceId: null, }), ); + try { + await invoke("cancel_claude_execution", { tabId: activeTabId }); + } catch { + // The UI has already moved to a stopped state; stale output is ignored. + } }, clearMessages: () => { @@ -1103,7 +1130,12 @@ export const useClaudeChatStore = create()((set, get) => ({ const activeTab = tabs.find((t) => t.id === activeTabId); if (activeTab?.isStreaming) { const id = nextTabId(); - const newTab = makeDefaultTab(id); + const newTab = { + ...makeDefaultTab(id), + providerKey: + activeTab.providerKey ?? + providerKeyForSelectedCredential(get().selectedProviderCredentialId), + }; set({ tabs: [...tabs, newTab], activeTabId: id, @@ -1113,6 +1145,9 @@ export const useClaudeChatStore = create()((set, get) => ({ error: newTab.error, totalInputTokens: newTab.totalInputTokens, totalOutputTokens: newTab.totalOutputTokens, + selectedProviderCredentialId: selectedCredentialForProviderKey( + newTab.providerKey, + ), }); return; } @@ -1121,7 +1156,10 @@ export const useClaudeChatStore = create()((set, get) => ({ applyTabUpdate(s, activeTabId, { messages: [], sessionId: null, - providerKey: null, + providerKey: + activeTab?.providerKey ?? + providerKeyForSelectedCredential(s.selectedProviderCredentialId), + sessionProviderKey: null, error: null, isStreaming: false, totalInputTokens: 0, @@ -1148,6 +1186,10 @@ export const useClaudeChatStore = create()((set, get) => ({ const nextTabs = tabs.map((tab) => tab.id === existingTab.id ? { ...tab, title: nextTitle } : tab, ); + const nextSelectedProviderCredentialId = + selectedCredentialForProviderKey(existingTab.providerKey) ?? + CLAUDE_CODE_PROVIDER_ID; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); set({ tabs: nextTabs, activeTabId: existingTab.id, @@ -1157,13 +1199,21 @@ export const useClaudeChatStore = create()((set, get) => ({ error: existingTab.error, totalInputTokens: existingTab.totalInputTokens, totalOutputTokens: existingTab.totalOutputTokens, + selectedProviderCredentialId: nextSelectedProviderCredentialId, }); if (existingTab.isStreaming) return; } else { const activeTab = tabs.find((tab) => tab.id === activeTabId); if (activeTab?.isStreaming) { const id = nextTabId(); - const newTab = makeDefaultTab(id); + const newTab = { + ...makeDefaultTab(id), + providerKey: + activeTab.providerKey ?? + providerKeyForSelectedCredential( + get().selectedProviderCredentialId, + ), + }; tabs = [...tabs, newTab]; activeTabId = id; set({ @@ -1175,6 +1225,9 @@ export const useClaudeChatStore = create()((set, get) => ({ error: newTab.error, totalInputTokens: newTab.totalInputTokens, totalOutputTokens: newTab.totalOutputTokens, + selectedProviderCredentialId: selectedCredentialForProviderKey( + newTab.providerKey, + ), }); } } @@ -1187,6 +1240,7 @@ export const useClaudeChatStore = create()((set, get) => ({ messages: [], sessionId, providerKey: null, + sessionProviderKey: null, error: null, isStreaming: false, totalInputTokens: 0, @@ -1220,20 +1274,29 @@ export const useClaudeChatStore = create()((set, get) => ({ const providerKey = inferProviderKeyFromHistory(history); const selectedProviderCredentialId = providerCredentialIdFromSessionKey(providerKey); - if (selectedProviderCredentialId !== undefined) { - persistSelectedProviderCredentialId(selectedProviderCredentialId); - } + const nextSelectedProviderCredentialId = + selectedProviderCredentialId === undefined + ? CLAUDE_CODE_PROVIDER_ID + : selectedProviderCredentialId; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); set((s) => ({ ...applyTabUpdate(s, activeTabId, { messages, - providerKey, + providerKey: + providerKey ?? + providerKeyForSelectedCredential( + nextSelectedProviderCredentialId, + ), + sessionProviderKey: + providerKey ?? + providerKeyForSelectedCredential( + nextSelectedProviderCredentialId, + ), title: sessionTitle ?? titleForMessages(rawMessages) ?? "New Chat", totalInputTokens: totals.inputTokens, totalOutputTokens: totals.outputTokens, }), - ...(selectedProviderCredentialId !== undefined - ? { selectedProviderCredentialId } - : {}), + selectedProviderCredentialId: nextSelectedProviderCredentialId, })); } catch (err) { log.error("Failed to load session history", { error: String(err) }); @@ -1246,7 +1309,14 @@ export const useClaudeChatStore = create()((set, get) => ({ createTab: () => { log.debug("Creating new tab"); const id = nextTabId(); - const newTab = makeDefaultTab(id); + const state = get(); + const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); + const newTab = { + ...makeDefaultTab(id), + providerKey: + activeTab?.providerKey ?? + providerKeyForSelectedCredential(state.selectedProviderCredentialId), + }; set((s) => ({ tabs: [...s.tabs, newTab], activeTabId: id, @@ -1257,6 +1327,9 @@ export const useClaudeChatStore = create()((set, get) => ({ error: newTab.error, totalInputTokens: newTab.totalInputTokens, totalOutputTokens: newTab.totalOutputTokens, + selectedProviderCredentialId: selectedCredentialForProviderKey( + newTab.providerKey, + ), })); return id; }, @@ -1278,6 +1351,10 @@ export const useClaudeChatStore = create()((set, get) => ({ // Switch to adjacent tab const newIdx = Math.min(idx, newTabs.length - 1); const newActive = newTabs[newIdx]; + const nextSelectedProviderCredentialId = + selectedCredentialForProviderKey(newActive.providerKey) ?? + CLAUDE_CODE_PROVIDER_ID; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); set({ tabs: newTabs, activeTabId: newActive.id, @@ -1288,6 +1365,7 @@ export const useClaudeChatStore = create()((set, get) => ({ error: newActive.error, totalInputTokens: newActive.totalInputTokens, totalOutputTokens: newActive.totalOutputTokens, + selectedProviderCredentialId: nextSelectedProviderCredentialId, }); } else { set({ tabs: newTabs }); @@ -1299,6 +1377,10 @@ export const useClaudeChatStore = create()((set, get) => ({ if (tabId === state.activeTabId) return; const targetTab = state.tabs.find((t) => t.id === tabId); if (!targetTab) return; + const nextSelectedProviderCredentialId = + selectedCredentialForProviderKey(targetTab.providerKey) ?? + CLAUDE_CODE_PROVIDER_ID; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); // Project the target tab's fields to top-level set({ @@ -1309,6 +1391,7 @@ export const useClaudeChatStore = create()((set, get) => ({ error: targetTab.error, totalInputTokens: targetTab.totalInputTokens, totalOutputTokens: targetTab.totalOutputTokens, + selectedProviderCredentialId: nextSelectedProviderCredentialId, }); }, diff --git a/apps/desktop/src/stores/claude-setup-store.ts b/apps/desktop/src/stores/claude-setup-store.ts index 177e44f..b67f820 100644 --- a/apps/desktop/src/stores/claude-setup-store.ts +++ b/apps/desktop/src/stores/claude-setup-store.ts @@ -118,6 +118,41 @@ const STEP_ORDER_INSTALL = [ ]; const STEP_ORDER_LOGIN = ["opening-browser", "waiting-auth", "complete"]; +function canonicalOpenAiCompatibleBaseUrl(url: string) { + const trimmed = url.trim(); + const lower = trimmed.toLowerCase(); + const deepseekMatch = trimmed.match( + /^(https?:\/\/api\.deepseek\.com)(?:\/|$)/i, + ); + const deepseekOrigin = deepseekMatch?.[1]; + if (deepseekOrigin && !lower.includes("/anthropic")) { + return `${deepseekOrigin}/anthropic`; + } + if (deepseekOrigin) { + const anthropicIndex = lower.indexOf("/anthropic"); + return `${trimmed.slice(0, anthropicIndex)}/anthropic`; + } + + const qwenMatch = trimmed.match( + /^(https?:\/\/dashscope(?:-intl)?\.aliyuncs\.com)(?:\/|$)/i, + ); + const qwenOrigin = qwenMatch?.[1]; + if ( + qwenOrigin && + (lower.includes("/apps/anthropic") || + lower.includes("/compatible-mode/") || + trimmed.replace(/\/+$/, "").toLowerCase() === qwenOrigin.toLowerCase()) + ) { + const anthropicIndex = lower.indexOf("/apps/anthropic"); + if (anthropicIndex >= 0) { + return `${trimmed.slice(0, anthropicIndex)}/apps/anthropic`; + } + return `${qwenOrigin}/apps/anthropic`; + } + + return trimmed; +} + function advanceSteps( steps: StepInfo[], targetId: string, @@ -298,7 +333,11 @@ export const useClaudeSetupStore = create((set, get) => ({ credentialLabel?: string, ) => { const key = apiKey.trim(); - const url = baseUrl?.trim() ?? ""; + const rawUrl = baseUrl?.trim() ?? ""; + const url = + provider === "openai-compatible" + ? canonicalOpenAiCompatibleBaseUrl(rawUrl) + : rawUrl; const modelName = model?.trim() ?? ""; if (provider !== "openai-compatible" && !key) { set({ error: "API key is empty" });