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({
@@ -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" });