mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
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:
parent
792ecd6064
commit
c7b8a4575a
17 changed files with 1573 additions and 174 deletions
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("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<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"), "{}");
|
||||
|
|
|
|||
148
apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs
Normal file
148
apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ function resetStores() {
|
|||
title: "New Chat",
|
||||
sessionId: null,
|
||||
providerKey: null,
|
||||
sessionProviderKey: null,
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue