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.
This commit is contained in:
Gerard-Devlin 2026-06-11 18:22:03 +08:00
parent 792ecd6064
commit c7b8a4575a
17 changed files with 1573 additions and 174 deletions

View file

@ -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<String>,
pub(crate) model_transformers: Vec<String>,
}
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!({

View file

@ -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<Value, String> {
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::<Vec<_>>();
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<Value>, Option<V
(text.join("\n\n"), tool_calls, thinking)
}
fn flatten_tool_result_content(content: &Value) -> String {
tool_result_content_to_openai(content).0
}
fn tool_result_content_to_openai(content: &Value) -> (String, Vec<Value>) {
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::<Vec<_>>();
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]

View file

@ -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]

View file

@ -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(),
}
}

View file

@ -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::<Value>(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::<Value>(&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(&quote_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<String>, 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<String> {
serde_json::from_str::<Value>(value)
.ok()
.or_else(|| serde_yaml::from_str::<Value>(value).ok())
.and_then(canonical_tool_arguments)
}
fn canonical_tool_arguments(value: Value) -> Option<String> {
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<String> {
Some(output)
}
fn repair_single_quoted_json(value: &str) -> Option<String> {
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::<Vec<_>>();
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<String> {
let mut output = String::with_capacity(value.len() + 8);
let chars = value.chars().collect::<Vec<_>>();
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<char> {
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"), "{}");

View file

@ -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<String>,
}
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<String> {
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::<Vec<_>>()
})
.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());
}
}

View file

@ -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<String>,
model_transformers: HashMap<String, Vec<String>>,
}
#[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<String>,
#[serde(default)]
model_transformers: HashMap<String, Vec<String>>,
}
#[derive(Clone, Debug, serde::Serialize)]
@ -263,6 +269,36 @@ fn normalize_model(value: Option<&str>) -> Result<Option<String>, String> {
Ok(Some(clean))
}
fn normalized_transformer_names(values: &[String]) -> Vec<String> {
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<String, Vec<String>>,
) -> HashMap<String, Vec<String>> {
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::<Vec<_>>().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::<Vec<_>>()
.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::<Vec<_>>()
.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<String>,
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<String>,
effort_level: Option<String>,
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<String>,
effort_level: Option<String>,
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<String> {
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<String> {
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<String> {
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<String> {
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"

View file

@ -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<Mutex<HashMap<String, Child>>>,
@ -281,13 +284,14 @@ pub async fn stop_claude_process(
let process_key = process_key(&window_label, &tab_id);
let claude_state = window.state::<ClaudeProcessState>();
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) {

View file

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

View file

@ -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", () => {

View file

@ -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(

View file

@ -45,6 +45,7 @@ function resetStores() {
title: "New Chat",
sessionId: null,
providerKey: null,
sessionProviderKey: null,
messages: [],
isStreaming: false,
error: null,

View file

@ -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}

View file

@ -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({
<div className="min-w-0 space-y-1.5">
<Label htmlFor="anthropic-base-url" className="text-xs">
Base URL
{isNativeAnthropicPreset(activeCardId)
? "Base URL (Anthropic)"
: "Base URL"}
</Label>
<Input
id="anthropic-base-url"
type="url"
placeholder={
selectedProvider === "openai-compatible"
? "https://api.deepseek.com or https://dashscope.aliyuncs.com/compatible-mode/v1"
? activeCardId === "deepseek"
? DEEPSEEK_ANTHROPIC_BASE_URL
: activeCardId === "qwen"
? QWEN_ANTHROPIC_BASE_URL
: "https://dashscope.aliyuncs.com/compatible-mode/v1"
: "https://mg.aid.pub/claude-proxy"
}
value={baseUrl}
@ -698,6 +772,15 @@ export function ClaudeSetup({
if (selectedProvider === "openai-compatible") {
if (matchingPreset) {
setProviderPreset(matchingPreset);
if (isNativeAnthropicPreset(matchingPreset)) {
const canonicalUrl = canonicalOpenAiCompatibleBaseUrl(
nextUrl,
matchingPreset,
);
if (canonicalUrl !== nextUrl.trim()) {
setBaseUrl(canonicalUrl);
}
}
}
} else {
if (matchingClaudePreset) {
@ -712,7 +795,11 @@ export function ClaudeSetup({
/>
<p className="text-[11px] text-muted-foreground">
{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."}
</p>
</div>
@ -789,7 +876,11 @@ export function ClaudeSetup({
</p>
)}
<p className="text-[11px] text-muted-foreground">
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."}
</p>
</div>
)}

View file

@ -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);

View file

@ -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<void>;
cancelExecution: () => Promise<void>;
cancelExecution: (tabId?: string) => Promise<void>;
clearMessages: () => void;
newSession: () => void;
resumeSession: (sessionId: string, title?: string) => Promise<void>;
@ -634,10 +651,19 @@ export const useClaudeChatStore = create<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((set, get) => ({
error: newTab.error,
totalInputTokens: newTab.totalInputTokens,
totalOutputTokens: newTab.totalOutputTokens,
selectedProviderCredentialId: selectedCredentialForProviderKey(
newTab.providerKey,
),
});
return;
}
@ -1121,7 +1156,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((set, get) => ({
error: newTab.error,
totalInputTokens: newTab.totalInputTokens,
totalOutputTokens: newTab.totalOutputTokens,
selectedProviderCredentialId: selectedCredentialForProviderKey(
newTab.providerKey,
),
});
}
}
@ -1187,6 +1240,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
messages: [],
sessionId,
providerKey: null,
sessionProviderKey: null,
error: null,
isStreaming: false,
totalInputTokens: 0,
@ -1220,20 +1274,29 @@ export const useClaudeChatStore = create<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((set, get) => ({
error: targetTab.error,
totalInputTokens: targetTab.totalInputTokens,
totalOutputTokens: targetTab.totalOutputTokens,
selectedProviderCredentialId: nextSelectedProviderCredentialId,
});
},

View file

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