diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 19e17b5148..dbd64a3b18 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -55,7 +55,6 @@ import { useTheme } from "@/composables/useTheme"; import { copyToClipboard } from "@/lib/common/clipboard"; import { clearDebugLogs as clearStoredDebugLogs, downloadDebugLogs, getDebugLogBundleText } from "@/lib/backend/debugLog"; import { - aiListModels, aiTestConnection, checkMcpServerStatus, installMcpServer, @@ -2339,6 +2338,7 @@ const aiTestErrorCategoryKeys: Record = { tokenLimit: "ai.testErrorTokenLimit", safety: "ai.testErrorSafety", emptyResponse: "ai.testErrorEmptyResponse", + modelDiscoveryUnsupported: "ai.modelListUnsupported", network: "ai.testErrorNetwork", unknown: "ai.testErrorUnknown", }; @@ -2389,7 +2389,9 @@ watch(aiIsCliProvider, (isCliProvider) => { if (isCliProvider) void ensureCliMcpStatus(); }); const aiRequiresApiKey = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value].requiresApiKey); -const aiSupportsAuthMethod = computed(() => aiEditProvider.value === "claude" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value)); +const aiUsesConfigurableAnthropicAuth = computed(() => aiEditProvider.value === "claude" || aiEditProvider.value === "anthropic-compatible" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value)); +const aiUsesCompatibleAnthropicApi = computed(() => aiEditProvider.value === "anthropic-compatible" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value)); +const aiSupportsAuthMethod = computed(() => aiUsesConfigurableAnthropicAuth.value); const aiCredentialLabel = computed(() => (aiSupportsAuthMethod.value && aiEditAuthMethod.value === "bearer" ? "Auth Token" : "API Key")); const aiCredentialPlaceholder = computed(() => { if (!aiRequiresApiKey.value) return "Optional"; @@ -2397,7 +2399,7 @@ const aiCredentialPlaceholder = computed(() => { return ""; }); const aiEndpointPlaceholder = computed(() => { - if (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value) { + if (aiUsesCompatibleAnthropicApi.value) { return "https://api.example.com/v1/messages"; } if (aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom") { @@ -2406,7 +2408,7 @@ const aiEndpointPlaceholder = computed(() => { return "https://api.openai.com/v1"; }); const aiEndpointHint = computed(() => { - if (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value) { + if (aiUsesCompatibleAnthropicApi.value) { return t("ai.anthropicMessagesHint"); } if (aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom") { @@ -2669,14 +2671,12 @@ async function aiTestConn() { aiTestErrorCopied.value = false; try { const config = currentAiEditConfig(); - if (aiIsCliProvider.value) { - const result = await aiTestConnection(config); - aiTestLatency.value = result.latencyMs ?? null; - } else { - const startedAt = performance.now(); - await aiListModels(config); - aiTestLatency.value = Math.round(performance.now() - startedAt); + const activeModel = settingsStore.activeModel; + if (aiEditConfigId.value && activeModel?.configId === aiEditConfigId.value) { + config.model = activeModel.modelId; } + const result = await aiTestConnection(config); + aiTestLatency.value = result.latencyMs ?? null; aiTestResult.value = "success"; } catch (e: any) { aiTestResult.value = "error"; diff --git a/apps/desktop/src/components/icons/AiProviderLogo.vue b/apps/desktop/src/components/icons/AiProviderLogo.vue index 87e922d954..dc2e4d4cd8 100644 --- a/apps/desktop/src/components/icons/AiProviderLogo.vue +++ b/apps/desktop/src/components/icons/AiProviderLogo.vue @@ -21,7 +21,7 @@ watch( }, ); -const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible"); +const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible"); const localIconUrl = computed(() => { if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg"); return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : ""; diff --git a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts index f0975d4358..1f1a12827e 100644 --- a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts @@ -12,13 +12,19 @@ describe("orderAiConfigsForDisplay", () => { const configs: TestConfig[] = [ { id: "claude-code-1", provider: "claude-code-cli" }, { id: "claude", provider: "claude" }, + { id: "anthropic-compatible", provider: "anthropic-compatible" }, { id: "openai", provider: "openai" }, + { id: "gemini", provider: "gemini" }, + { id: "deepseek", provider: "deepseek" }, + { id: "qwen", provider: "qwen" }, + { id: "ollama", provider: "ollama" }, + { id: "openai-compatible", provider: "openai-compatible" }, { id: "codex", provider: "codex-cli" }, { id: "pi", provider: "pi-agent-cli" }, { id: "custom", provider: "custom" }, ]; - expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "claude-code-1", "codex", "pi", "custom"]); + expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "pi", "custom"]); }); it("preserves creation order for configs from the same provider", () => { diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index d27902b200..e47b75c3e3 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -180,6 +180,16 @@ export const AI_PROVIDER_PRESETS: Record = { authMethod: "bearer", requiresApiKey: false, }, + "anthropic-compatible": { + label: "Anthropic Compatible", + iconSlug: "anthropic", + provider: "anthropic-compatible", + endpoint: "", + model: "", + apiStyle: "anthropic-messages", + authMethod: "bearer", + requiresApiKey: false, + }, "openai-compatible": { label: "OpenAI Compatible", iconSlug: "openai", diff --git a/apps/desktop/src/types/ai.ts b/apps/desktop/src/types/ai.ts index cdf3b5ea61..af8dfa9205 100644 --- a/apps/desktop/src/types/ai.ts +++ b/apps/desktop/src/types/ai.ts @@ -1,4 +1,4 @@ -export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "custom"; +export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "custom"; export type AiApiStyle = "completions" | "responses" | "anthropic-messages"; export type AiAuthMethod = "api-key" | "bearer"; export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 4d5fa4697d..1b222d6101 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -63,6 +63,8 @@ pub const AGENT_CANCELLED_ERROR: &str = "Agent loop cancelled"; pub enum AiProvider { #[serde(alias = "anthropic")] Claude, + #[serde(rename = "anthropic-compatible")] + AnthropicCompatible, Openai, Gemini, Deepseek, @@ -83,6 +85,7 @@ impl AiProvider { pub fn as_str(&self) -> &'static str { match self { AiProvider::Claude => "claude", + AiProvider::AnthropicCompatible => "anthropic-compatible", AiProvider::Openai => "openai", AiProvider::Gemini => "gemini", AiProvider::Deepseek => "deepseek", @@ -602,6 +605,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String { } } AiProvider::Claude + | AiProvider::AnthropicCompatible | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli @@ -610,7 +614,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String { } pub fn uses_anthropic_messages_api(config: &AiConfig) -> bool { - matches!(config.provider, AiProvider::Claude) + matches!(config.provider, AiProvider::Claude | AiProvider::AnthropicCompatible) || matches!(config.provider, AiProvider::Custom) && config.api_style == AiApiStyle::AnthropicMessages } @@ -672,6 +676,29 @@ pub fn stream_data_payload(line: &str) -> Option<&str> { None } +fn stream_event_name(line: &str) -> Option<&str> { + line.trim().strip_prefix("event:").map(str::trim).filter(|event| !event.is_empty()) +} + +fn anthropic_stream_error(event_name: Option<&str>, event: &serde_json::Value) -> Option { + if event_name != Some("error") && event["type"].as_str() != Some("error") { + return None; + } + + let error = event.get("error").unwrap_or(event); + let error_type = error["type"].as_str().filter(|value| !value.trim().is_empty()); + let message = + error["message"].as_str().or_else(|| event["message"].as_str()).filter(|value| !value.trim().is_empty()); + let detail = match (error_type, message) { + (Some(error_type), Some(message)) => format!("{error_type}: {message}"), + (Some(error_type), None) => error_type.to_string(), + (None, Some(message)) => message.to_string(), + (None, None) => truncate_diagnostic(&event.to_string(), 500), + }; + let category = classify_error(&detail); + Some(format!("[{category}] Anthropic stream error ({detail})")) +} + fn drain_next_stream_line(buffer: &mut Vec) -> Result, String> { let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') else { return Ok(None); @@ -1195,7 +1222,7 @@ fn parse_dynamic_effort_capability( fn decorate_model_capabilities(config: &AiConfig, models: &mut [AiModelInfo]) { for model in models { - if model.effort_capability.is_none() { + if matches!(config.provider, AiProvider::AnthropicCompatible) || model.effort_capability.is_none() { model.effort_capability = crate::ai_effort::static_effort_capability(config, &model.id); } } @@ -1223,20 +1250,54 @@ fn parse_gemini_model_list_response(data: &serde_json::Value) -> Result Result, String> { - let res = client - .get(resolve_model_list_endpoint(config)?) - .headers(claude_headers(config)?) - .send() - .await - .map_err(|e| format!("Claude model list request failed: {e}"))?; + let endpoint = resolve_model_list_endpoint(config)?; + let headers = claude_headers(config)?; + let mut after_id: Option = None; + let mut seen_cursors = HashSet::new(); + let mut seen_models = HashSet::new(); + let mut models = Vec::new(); - let status = res.status(); - let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - if !status.is_success() { - return Err(extract_error(&data).unwrap_or_else(|| format!("Claude model list API error: {status}"))); + loop { + let mut request = client.get(&endpoint).headers(headers.clone()); + if let Some(after_id) = after_id.as_deref() { + request = request.query(&[("after_id", after_id)]); + } + let res = request.send().await.map_err(|e| format!("Claude model list request failed: {e}"))?; + + let status = res.status(); + if !status.is_success() { + if matches!(config.provider, AiProvider::AnthropicCompatible | AiProvider::Custom) + && matches!(status, reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED) + { + return Err(format!( + "[modelDiscoveryUnsupported] The provider does not expose a model list at {endpoint}. Save the provider and enter a model ID manually." + )); + } + return Err(categorized_http_error(res, "Claude model list", &config.api_key).await); + } + + let data: serde_json::Value = + res.json().await.map_err(|e| format!("Claude model list returned invalid JSON (HTTP {status}): {e}"))?; + for model in parse_model_list_response(&data)? { + if seen_models.insert(model.id.clone()) { + models.push(model); + } + } + + if data["has_more"].as_bool() != Some(true) { + break; + } + let next_cursor = data["last_id"] + .as_str() + .filter(|cursor| !cursor.trim().is_empty()) + .ok_or_else(|| "Claude model list response has_more=true but last_id is missing".to_string())?; + if !seen_cursors.insert(next_cursor.to_string()) { + return Err(format!("Claude model list returned repeated last_id cursor: {next_cursor}")); + } + after_id = Some(next_cursor.to_string()); } - parse_model_list_response(&data) + Ok(models) } async fn list_gemini_models(client: &reqwest::Client, config: &AiConfig) -> Result, String> { @@ -1392,7 +1453,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str validate_model_list_config(config)?; let client = build_ai_http_client(config, 30)?; match config.provider { - AiProvider::Claude => list_claude_models(&client, config).await?, + AiProvider::Claude | AiProvider::AnthropicCompatible => list_claude_models(&client, config).await?, AiProvider::Gemini => list_gemini_models(&client, config).await?, AiProvider::Ollama => { let models = list_openai_compatible_models(&client, config).await?; @@ -1774,6 +1835,7 @@ fn collect_gemini_safety_ratings(ratings: Option<&[serde_json::Value]>, output: fn probe_stream_payload( data: &str, + event_name: Option<&str>, is_claude: bool, is_gemini: bool, diagnostics: &mut StreamProbeDiagnostics, @@ -1786,6 +1848,11 @@ fn probe_stream_payload( let parsed: serde_json::Value = serde_json::from_str(data) .map_err(|e| format!("AI stream JSON parse error: {e}; payload={}", truncate_diagnostic(data, 240)))?; diagnostics.json_events += 1; + if is_claude { + if let Some(error) = anthropic_stream_error(event_name, &parsed) { + return Err(error); + } + } if is_gemini { diagnostics.observe_gemini(&parsed); } @@ -1876,18 +1943,29 @@ async fn measure_first_stream_chunk( ) -> Result<(u64, String), String> { let mut buf = Vec::new(); let mut diagnostics = StreamProbeDiagnostics::default(); + let mut event_name: Option = None; while let Some(chunk) = byte_stream.next().await { let chunk = chunk.map_err(|e| format!("stream read error: {}", e.without_url()))?; diagnostics.bytes_received += chunk.len(); buf.extend_from_slice(&chunk); while let Some(line) = drain_next_stream_line(&mut buf)? { + if line.trim().is_empty() { + event_name = None; + continue; + } + if let Some(name) = stream_event_name(&line) { + event_name = Some(name.to_string()); + continue; + } let Some(data) = stream_data_payload(&line) else { continue }; if data == "[DONE]" { diagnostics.data_events += 1; return Err(diagnostics.empty_stream_error(is_gemini)); } - if let Some(text) = probe_stream_payload(data, is_claude, is_gemini, &mut diagnostics)? { + if let Some(text) = + probe_stream_payload(data, event_name.as_deref(), is_claude, is_gemini, &mut diagnostics)? + { let latency = start.elapsed().as_millis() as u64; return Ok((latency, text)); } @@ -1897,7 +1975,9 @@ async fn measure_first_stream_chunk( if !buf.is_empty() { let line = String::from_utf8(buf).map_err(|e| format!("AI stream returned invalid UTF-8: {e}"))?; if let Some(data) = stream_data_payload(&line) { - if let Some(text) = probe_stream_payload(data, is_claude, is_gemini, &mut diagnostics)? { + if let Some(text) = + probe_stream_payload(data, event_name.as_deref(), is_claude, is_gemini, &mut diagnostics)? + { let latency = start.elapsed().as_millis() as u64; return Ok((latency, text)); } @@ -1936,6 +2016,19 @@ pub async fn test_connection_core(config: &AiConfig) -> Result Result { - let body = json!({ - "model": claude_http_model(&model), - "max_tokens": 16, - "system": CLAUDE_DEFAULT_SYSTEM, - "messages": [{ "role": "user", "content": TEST_PROMPT }], - "stream": true, - }); - let headers = claude_headers(&config_inner)?; - let res = client - .post(&endpoint) - .headers(headers) - .json(&body) - .send() - .await - .map_err(|e| format!("Claude request failed: {e}"))?; - if !res.status().is_success() { - return Err(categorized_http_error(res, "Claude", &api_key).await); - } - res.bytes_stream() + let byte_stream = if is_claude { + let body = json!({ + "model": claude_http_model(&model), + "max_tokens": 16, + "system": CLAUDE_DEFAULT_SYSTEM, + "messages": [{ "role": "user", "content": TEST_PROMPT }], + "stream": true, + }); + let headers = claude_headers(&config_inner)?; + let res = client + .post(&endpoint) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("Claude request failed: {e}"))?; + if !res.status().is_success() { + return Err(categorized_http_error(res, "Claude", &api_key).await); } - AiProvider::Gemini => { - let res = client - .post(&gemini_ep) - .header(CONTENT_TYPE, "application/json") - .query(&[("key", api_key.as_str()), ("alt", "sse")]) - .json(&json!({ - "contents": [{ "parts": [{ "text": TEST_PROMPT }], "role": "user" }], - "generationConfig": { "maxOutputTokens": 256 }, - })) - .send() - .await - .map_err(|e| format_transport_error("Gemini", e))?; - if !res.status().is_success() { - return Err(categorized_http_error(res, "Gemini", &api_key).await); + res.bytes_stream() + } else { + match provider { + AiProvider::Gemini => { + let res = client + .post(&gemini_ep) + .header(CONTENT_TYPE, "application/json") + .query(&[("key", api_key.as_str()), ("alt", "sse")]) + .json(&json!({ + "contents": [{ "parts": [{ "text": TEST_PROMPT }], "role": "user" }], + "generationConfig": { "maxOutputTokens": 256 }, + })) + .send() + .await + .map_err(|e| format_transport_error("Gemini", e))?; + if !res.status().is_success() { + return Err(categorized_http_error(res, "Gemini", &api_key).await); + } + res.bytes_stream() } - res.bytes_stream() - } - AiProvider::Custom if is_claude => { - let body = json!({ - "model": claude_http_model(&model), - "max_tokens": 16, - "system": CLAUDE_DEFAULT_SYSTEM, - "messages": [{ "role": "user", "content": TEST_PROMPT }], - "stream": true, - }); - let headers = claude_headers(&config_inner)?; - let res = client - .post(&endpoint) - .headers(headers) - .json(&body) - .send() - .await - .map_err(|e| format!("Claude request failed: {e}"))?; - if !res.status().is_success() { - return Err(categorized_http_error(res, "Claude", &api_key).await); + AiProvider::Claude | AiProvider::AnthropicCompatible => unreachable!(), + _ => { + let mut body_obj = if api_style == AiApiStyle::Responses { + json!({ + "model": &model, + "input": [{ "role": "user", "content": TEST_PROMPT }], + "max_output_tokens": 16, + "stream": true, + }) + } else { + let messages = vec![json!({ "role": "user", "content": TEST_PROMPT })]; + let mut body = json!({ + "model": &model, + "messages": messages, + "stream": true, + }); + set_chat_completion_token_limit(&mut body, &config_inner, 16); + body + }; + if api_style != AiApiStyle::Responses { + apply_chat_completion_thinking_toggle(&mut body_obj, &config_inner); + } + let headers = maybe_bearer_headers(&config_inner)?; + let res = client + .post(&endpoint) + .headers(headers) + .json(&body_obj) + .send() + .await + .map_err(|e| format!("AI request failed: {e}"))?; + if !res.status().is_success() { + return Err(categorized_http_error(res, "AI", &api_key).await); + } + res.bytes_stream() } - res.bytes_stream() - } - _ => { - let mut body_obj = if api_style == AiApiStyle::Responses { - json!({ - "model": &model, - "input": [{ "role": "user", "content": TEST_PROMPT }], - "max_output_tokens": 16, - "stream": true, - }) - } else { - let messages = vec![json!({ "role": "user", "content": TEST_PROMPT })]; - let mut body = json!({ - "model": &model, - "messages": messages, - "stream": true, - }); - set_chat_completion_token_limit(&mut body, &config_inner, 16); - body - }; - if api_style != AiApiStyle::Responses { - apply_chat_completion_thinking_toggle(&mut body_obj, &config_inner); - } - let headers = maybe_bearer_headers(&config_inner)?; - let res = client - .post(&endpoint) - .headers(headers) - .json(&body_obj) - .send() - .await - .map_err(|e| format!("AI request failed: {e}"))?; - if !res.status().is_success() { - return Err(categorized_http_error(res, "AI", &api_key).await); - } - res.bytes_stream() } }; @@ -2068,7 +2142,12 @@ pub async fn test_connection_core(config: &AiConfig) -> Result { let category = classify_error(&e); - Err(format!("[{category}] {e}")) + let category_prefix = format!("[{category}]"); + if e.starts_with(&category_prefix) { + Err(e) + } else { + Err(format!("{category_prefix} {e}")) + } } } } @@ -2088,6 +2167,8 @@ fn classify_error(msg: &str) -> &'static str { || lower.contains("incorrect api key") || lower.contains("api key not valid") || lower.contains("api_key_invalid") + || lower.contains("authentication_error") + || lower.contains("permission_error") { "auth" } else if lower.contains("404") @@ -2095,6 +2176,7 @@ fn classify_error(msg: &str) -> &'static str { || lower.contains("model not found") || lower.contains("model does not exist") || lower.contains("not supported for generatecontent") + || lower.contains("not_found_error") { "modelNotFound" } else if lower.contains("429") @@ -2102,11 +2184,15 @@ fn classify_error(msg: &str) -> &'static str { || lower.contains("too many requests") || lower.contains("resource_exhausted") || lower.contains("quota exceeded") + || lower.contains("rate_limit_error") { "rateLimit" } else if lower.contains("timeout") || lower.contains("timed out") || lower.contains("504") { "timeout" - } else if lower.contains("finishreason=max_tokens") || lower.contains("thinking tokens") { + } else if lower.contains("finishreason=max_tokens") + || lower.contains("thinking tokens") + || lower.contains("request_too_large") + { "tokenLimit" } else if lower.contains("safety filter") || lower.contains("prompt was blocked") { "safety" @@ -2121,6 +2207,8 @@ fn classify_error(msg: &str) -> &'static str { || lower.contains("resolve") || lower.contains("502") || lower.contains("503") + || lower.contains("api_error") + || lower.contains("overloaded_error") { "network" } else { @@ -2294,8 +2382,11 @@ pub async fn complete(request: &AiCompletionRequest) -> Result { let client = client.clone(); let request = request.clone(); async move { + if uses_anthropic_messages_api(&request.config) { + return call_claude(&client, request).await; + } + match request.config.provider { - AiProvider::Claude => call_claude(&client, request).await, AiProvider::Gemini => call_gemini(&client, request).await, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), AiProvider::Openai @@ -2310,14 +2401,13 @@ pub async fn complete(request: &AiCompletionRequest) -> Result { } } AiProvider::Custom => { - if uses_anthropic_messages_api(&request.config) { - call_claude(&client, request).await - } else if request.config.api_style == AiApiStyle::Responses { + if request.config.api_style == AiApiStyle::Responses { call_responses_api(&client, request).await } else { call_openai_compatible(&client, request).await } } + AiProvider::Claude | AiProvider::AnthropicCompatible => unreachable!(), } } }) @@ -2343,8 +2433,11 @@ pub async fn stream( let stream_timeout = if runtime_thinking_enabled(&request.config) { 600 } else { 120 }; let client = build_ai_http_client(&request.config, stream_timeout)?; + if uses_anthropic_messages_api(&request.config) { + return stream_claude(&client, session_id, request, cancelled, &on_chunk).await; + } + match request.config.provider { - AiProvider::Claude => stream_claude(&client, session_id, request, cancelled, &on_chunk).await, AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), AiProvider::Openai @@ -2359,14 +2452,13 @@ pub async fn stream( } } AiProvider::Custom => { - if uses_anthropic_messages_api(&request.config) { - stream_claude(&client, session_id, request, cancelled, &on_chunk).await - } else if request.config.api_style == AiApiStyle::Responses { + if request.config.api_style == AiApiStyle::Responses { stream_responses_api(&client, session_id, request, cancelled, &on_chunk).await } else { stream_openai(&client, session_id, request, cancelled, &on_chunk).await } } + AiProvider::Claude | AiProvider::AnthropicCompatible => unreachable!(), } } @@ -2411,6 +2503,7 @@ async fn stream_claude( let mut byte_stream = res.bytes_stream(); let mut buf = Vec::new(); + let mut event_name: Option = None; loop { tokio::select! { @@ -2421,6 +2514,14 @@ async fn stream_claude( let mut finished = false; while let Some(line) = drain_next_stream_line(&mut buf)? { + if line.trim().is_empty() { + event_name = None; + continue; + } + if let Some(name) = stream_event_name(&line) { + event_name = Some(name.to_string()); + continue; + } let Some(data) = stream_data_payload(&line) else { continue }; if data == "[DONE]" { finished = true; @@ -2428,6 +2529,9 @@ async fn stream_claude( } if let Ok(event) = serde_json::from_str::(data) { + if let Some(error) = anthropic_stream_error(event_name.as_deref(), &event) { + return Err(error); + } if let Some(text) = claude_stream_text(&event) { emitted.store(true, std::sync::atomic::Ordering::Relaxed); on_chunk(AiStreamChunk { @@ -2948,6 +3052,7 @@ async fn stream_claude_with_tools( let mut byte_stream = res.bytes_stream(); let mut buf = Vec::new(); + let mut event_name: Option = None; // Track the current content block index and type for tool_use blocks let mut current_block_index: Option = None; let mut current_block_type: Option = None; @@ -2962,6 +3067,14 @@ async fn stream_claude_with_tools( let mut finished = false; while let Some(line) = drain_next_stream_line(&mut buf)? { + if line.trim().is_empty() { + event_name = None; + continue; + } + if let Some(name) = stream_event_name(&line) { + event_name = Some(name.to_string()); + continue; + } let Some(data) = stream_data_payload(&line) else { continue }; if data == "[DONE]" { finished = true; @@ -2969,6 +3082,9 @@ async fn stream_claude_with_tools( } if let Ok(event) = serde_json::from_str::(data) { + if let Some(error) = anthropic_stream_error(event_name.as_deref(), &event) { + return Err(error); + } let event_type = event["type"].as_str().unwrap_or(""); match event_type { @@ -3585,36 +3701,31 @@ pub async fn stream_with_tools( let accumulator = Arc::new(std::sync::Mutex::new(StreamingToolCallAccumulator::new())); - let token_usage = match config.provider { - AiProvider::Claude => { - stream_claude_with_tools(&client, session_id, request, tools, cancelled, &|event| { - accumulator.lock().unwrap().process(event, &on_chunk); - }) - .await? - } - AiProvider::Gemini => { - stream_gemini_with_tools(&client, session_id, request, tools, cancelled, &|event| { - accumulator.lock().unwrap().process(event, &on_chunk); - }) - .await? - } - AiProvider::Custom if uses_anthropic_messages_api(config) => { - stream_claude_with_tools(&client, session_id, request, tools, cancelled, &|event| { - accumulator.lock().unwrap().process(event, &on_chunk); - }) - .await? - } - _ if config.api_style == AiApiStyle::Responses => { - stream_responses_with_tools(&client, session_id, request, tools, cancelled, &|event| { - accumulator.lock().unwrap().process(event, &on_chunk); - }) - .await? - } - _ => { - stream_openai_with_tools(&client, session_id, request, tools, cancelled, &|event| { - accumulator.lock().unwrap().process(event, &on_chunk); - }) - .await? + let token_usage = if uses_anthropic_messages_api(config) { + stream_claude_with_tools(&client, session_id, request, tools, cancelled, &|event| { + accumulator.lock().unwrap().process(event, &on_chunk); + }) + .await? + } else { + match config.provider { + AiProvider::Gemini => { + stream_gemini_with_tools(&client, session_id, request, tools, cancelled, &|event| { + accumulator.lock().unwrap().process(event, &on_chunk); + }) + .await? + } + _ if config.api_style == AiApiStyle::Responses => { + stream_responses_with_tools(&client, session_id, request, tools, cancelled, &|event| { + accumulator.lock().unwrap().process(event, &on_chunk); + }) + .await? + } + _ => { + stream_openai_with_tools(&client, session_id, request, tools, cancelled, &|event| { + accumulator.lock().unwrap().process(event, &on_chunk); + }) + .await? + } } }; @@ -3687,21 +3798,21 @@ mod tests { use super::{ apply_chat_completion_thinking_toggle, build_ai_http_client, build_gemini_contents, - build_responses_input_with_tools, call_claude, classify_error, claude_headers, claude_system_prompt, + build_responses_input_with_tools, call_claude, classify_error, claude_headers, claude_system_prompt, complete, drain_next_stream_line, emit_gemini_tool_call_part, emit_responses_function_call_item, format_transport_error, - gemini_text, is_kimi_model, is_retryable_error, maybe_bearer_headers, maybe_tag_retry_after, + gemini_text, is_kimi_model, is_retryable_error, list_models_core, maybe_bearer_headers, maybe_tag_retry_after, measure_first_stream_chunk, merge_global_max_retries, ollama_selected_model_tool_support, openai_response_text, openai_stream_reasoning, openai_stream_text, parse_dynamic_effort_capability, parse_gemini_model_list_response, parse_model_list_response, parse_retry_after, parse_retry_after_secs, resolve_endpoint, resolve_gemini_stream_endpoint, resolve_model_effort_core, resolve_model_list_endpoint, resolve_ollama_show_endpoint, responses_function_tool, responses_max_output_tokens, responses_stream_text, responses_text, responses_token_usage, retain_ollama_completion_models, retry_after_secs, - set_chat_completion_token_limit, stream_claude, stream_claude_with_tools, stream_data_payload, stream_error, - stream_openai_with_tools, test_connection_core, uses_anthropic_messages_api, validate_config, - validate_model_list_config, with_retry, with_stream_retry, AiApiStyle, AiAuthMethod, AiCapabilitySource, - AiCompletionRequest, AiConfig, AiEffortCapability, AiEffortOption, AiEffortSelection, AiMessage, AiModelInfo, - AiProvider, AiReasoningLevel, StreamToolEvent, StreamingToolCallAccumulator, ToolCallRef, AUTHORIZATION, - CLAUDE_DEFAULT_SYSTEM, TEST_PROMPT, + set_chat_completion_token_limit, stream, stream_claude, stream_claude_with_tools, stream_data_payload, + stream_error, stream_openai_with_tools, stream_with_tools, test_connection_core, uses_anthropic_messages_api, + validate_config, validate_model_list_config, with_retry, with_stream_retry, AiApiStyle, AiAuthMethod, + AiCapabilitySource, AiCompletionRequest, AiConfig, AiEffortCapability, AiEffortOption, AiEffortSelection, + AiMessage, AiModelInfo, AiProvider, AiReasoningLevel, StreamToolEvent, StreamingToolCallAccumulator, + ToolCallRef, AUTHORIZATION, CLAUDE_DEFAULT_SYSTEM, TEST_PROMPT, }; struct CapturedJsonRequest { @@ -3785,6 +3896,131 @@ mod tests { (format!("http://{address}/test"), server) } + async fn spawn_get_response_server( + status: &'static str, + response_body: &'static str, + ) -> (String, tokio::task::JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = socket.read(&mut chunk).await.unwrap(); + assert!(read > 0, "request ended before headers were complete"); + request.extend_from_slice(&chunk[..read]); + } + let request = String::from_utf8(request).unwrap(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + request + }); + + (format!("http://{address}"), server) + } + + async fn spawn_paginated_model_server() -> (String, tokio::task::JoinHandle>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let responses = [ + r#"{"data":[{"id":"vendor/first"},{"id":"vendor/shared"}],"has_more":true,"last_id":"cursor-1"}"#, + r#"{"data":[{"id":"vendor/shared"},{"id":"vendor/second"}],"has_more":false,"last_id":"vendor/second"}"#, + ]; + let mut requests = Vec::new(); + + for response_body in responses { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let bytes_read = socket.read(&mut chunk).await.unwrap(); + assert!(bytes_read > 0, "request ended before headers were complete"); + request.extend_from_slice(&chunk[..bytes_read]); + } + requests.push(String::from_utf8(request).unwrap()); + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + } + + requests + }); + + (format!("http://{address}"), server) + } + + async fn spawn_model_discovery_and_stream_server() -> (String, tokio::task::JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn read_request(socket: &mut tokio::net::TcpStream) -> (String, Vec) { + let mut request = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end = loop { + if let Some(index) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + let read = socket.read(&mut chunk).await.unwrap(); + assert!(read > 0, "request ended before headers were complete"); + request.extend_from_slice(&chunk[..read]); + }; + let headers = String::from_utf8(request[..header_end].to_vec()).unwrap(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or_default(); + while request.len() < header_end + content_length { + let read = socket.read(&mut chunk).await.unwrap(); + assert!(read > 0, "request ended before body was complete"); + request.extend_from_slice(&chunk[..read]); + } + (headers, request[header_end..header_end + content_length].to_vec()) + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut model_socket, _) = listener.accept().await.unwrap(); + let (model_headers, _) = read_request(&mut model_socket).await; + assert!(model_headers.starts_with("GET /v1/models ")); + let model_body = r#"{"data":[{"id":"discovered-model"}]}"#; + let model_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{model_body}", + model_body.len() + ); + model_socket.write_all(model_response.as_bytes()).await.unwrap(); + + let (mut stream_socket, _) = listener.accept().await.unwrap(); + let (stream_headers, stream_body) = read_request(&mut stream_socket).await; + assert!(stream_headers.starts_with("POST /v1/messages ")); + let stream_response_body = + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"OK\"}}\n\n"; + let stream_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{stream_response_body}", + stream_response_body.len() + ); + stream_socket.write_all(stream_response.as_bytes()).await.unwrap(); + + CapturedJsonRequest { headers: stream_headers, body: serde_json::from_slice(&stream_body).unwrap() } + }); + + (format!("http://{address}"), server) + } + fn claude_http_test_request(endpoint: String) -> AiCompletionRequest { AiCompletionRequest { config: AiConfig { @@ -3821,6 +4057,15 @@ mod tests { } } + fn anthropic_compatible_test_request(endpoint: String) -> AiCompletionRequest { + let mut request = claude_http_test_request(endpoint); + request.config.provider = AiProvider::AnthropicCompatible; + request.config.auth_method = AiAuthMethod::Bearer; + request.config.api_style = AiApiStyle::AnthropicMessages; + request.config.model = "gateway-model".to_string(); + request + } + fn assert_claude_http_request(captured: CapturedJsonRequest) { assert_eq!(captured.body["model"], "claude-sonnet-4-6"); assert!(!captured.headers.to_ascii_lowercase().contains("anthropic-beta:")); @@ -3837,6 +4082,170 @@ mod tests { assert_claude_http_request(server.await.unwrap()); } + #[tokio::test] + async fn anthropic_compatible_completion_uses_messages_runtime_and_bearer_auth() { + let (endpoint, server) = + spawn_json_capture_server("application/json", r#"{"content":[{"type":"text","text":"compatible"}]}"#).await; + let request = anthropic_compatible_test_request(endpoint); + + assert_eq!(complete(&request).await.unwrap(), "compatible"); + + let captured = server.await.unwrap(); + let headers = captured.headers.to_ascii_lowercase(); + assert!(headers.contains("authorization: bearer secret")); + assert!(headers.contains("anthropic-version: 2023-06-01")); + assert!(!headers.contains("x-api-key:")); + assert_eq!(captured.body["model"], "gateway-model"); + } + + #[tokio::test] + async fn anthropic_compatible_connection_test_uses_anthropic_sse_and_api_key_auth() { + let response = concat!( + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"OK\"}}\n\n", + "data: [DONE]\n\n" + ); + let (endpoint, server) = spawn_json_capture_server("text/event-stream", response).await; + let mut config = anthropic_compatible_test_request(endpoint).config; + config.auth_method = AiAuthMethod::ApiKey; + + let result = test_connection_core(&config).await.unwrap(); + + assert!(result.success); + assert_eq!(result.model_used, "gateway-model"); + let captured = server.await.unwrap(); + let headers = captured.headers.to_ascii_lowercase(); + assert!(headers.contains("x-api-key: secret")); + assert!(!headers.contains("authorization: bearer")); + assert_eq!(captured.body["stream"], true); + } + + #[tokio::test] + async fn anthropic_compatible_connection_test_surfaces_stream_error_once() { + let response = concat!( + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n" + ); + let (endpoint, server) = spawn_json_capture_server("text/event-stream", response).await; + let mut config = anthropic_compatible_test_request(endpoint).config; + config.max_retries = Some(0); + + let error = test_connection_core(&config).await.unwrap_err(); + + assert!(error.starts_with("[network] Anthropic stream error")); + assert_eq!(error.matches("[network]").count(), 1); + assert!(error.contains("overloaded_error: Overloaded")); + server.await.unwrap(); + } + + #[tokio::test] + async fn anthropic_compatible_connection_test_discovers_a_model_before_messages() { + let (endpoint, server) = spawn_model_discovery_and_stream_server().await; + let mut config = anthropic_compatible_test_request(endpoint).config; + config.model.clear(); + + let result = test_connection_core(&config).await.unwrap(); + + assert!(result.success); + assert_eq!(result.model_used, "discovered-model"); + let captured = server.await.unwrap(); + assert_eq!(captured.body["model"], "discovered-model"); + assert_eq!(captured.body["stream"], true); + } + + #[tokio::test] + async fn anthropic_compatible_stream_reuses_anthropic_event_parsing() { + let response = concat!( + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"compatible stream\"}}\n\n", + "data: [DONE]\n\n" + ); + let (endpoint, server) = spawn_json_capture_server("text/event-stream", response).await; + let request = anthropic_compatible_test_request(endpoint); + let chunks = RefCell::new(Vec::new()); + + stream("compatible-session", &request, &Notify::new(), |chunk| chunks.borrow_mut().push(chunk)).await.unwrap(); + + let chunks = chunks.into_inner(); + assert_eq!(chunks[0].delta, "compatible stream"); + assert!(!chunks[0].done); + assert!(chunks.last().unwrap().done); + let captured = server.await.unwrap(); + assert_eq!(captured.body["model"], "gateway-model"); + } + + #[tokio::test] + async fn anthropic_compatible_stream_surfaces_sse_error_event() { + let response = concat!( + "event: error\n", + "data: {\"error\":{\"type\":\"rate_limit_error\",\"message\":\"Too many requests\"}}\n\n" + ); + let (endpoint, server) = spawn_json_capture_server("text/event-stream", response).await; + let mut request = anthropic_compatible_test_request(endpoint); + request.config.max_retries = Some(0); + let chunks = RefCell::new(Vec::new()); + + let error = stream("compatible-session", &request, &Notify::new(), |chunk| chunks.borrow_mut().push(chunk)) + .await + .unwrap_err(); + + assert!(error.starts_with("[rateLimit]")); + assert!(error.contains("rate_limit_error: Too many requests")); + assert!(chunks.into_inner().is_empty()); + server.await.unwrap(); + } + + #[tokio::test] + async fn anthropic_compatible_tool_stream_reuses_anthropic_tool_events() { + let response = concat!( + "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":4}}}\n\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"tool-1\",\"name\":\"get_tables\",\"input\":{}}}\n\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"schema\\\":\\\"public\\\"}\"}}\n\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":6}}\n\n", + "data: [DONE]\n\n" + ); + let (endpoint, server) = spawn_json_capture_server("text/event-stream", response).await; + let request = anthropic_compatible_test_request(endpoint); + let tools = [crate::agent_events::ToolDefinition { + name: "get_tables", + description: "List tables", + parameters: serde_json::json!({ + "type": "object", + "properties": { "schema": { "type": "string" } } + }), + read_only: true, + parallel_ok: true, + }]; + + let (calls, usage) = + stream_with_tools(&request.config, &request, "compatible-session", &tools, &Notify::new(), |_| {}) + .await + .unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].id, "tool-1"); + assert_eq!(calls[0].name, "get_tables"); + assert_eq!(calls[0].arguments["schema"], "public"); + assert_eq!(usage.unwrap().input_tokens, 4); + assert_eq!(server.await.unwrap().body["tools"][0]["name"], "get_tables"); + } + + #[tokio::test] + async fn anthropic_compatible_tool_stream_surfaces_json_error_event() { + let response = + "data: {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"invalid x-api-key\"}}\n\n"; + let (endpoint, server) = spawn_json_capture_server("text/event-stream", response).await; + let mut request = anthropic_compatible_test_request(endpoint); + request.config.max_retries = Some(0); + + let error = stream_with_tools(&request.config, &request, "compatible-session", &[], &Notify::new(), |_| {}) + .await + .unwrap_err(); + + assert!(error.starts_with("[auth]")); + assert!(error.contains("authentication_error: invalid x-api-key")); + server.await.unwrap(); + } + #[tokio::test] async fn claude_http_stream_strips_cli_context_suffix_without_beta_header() { let (endpoint, server) = spawn_json_capture_server("text/event-stream", "data: [DONE]\n\n").await; @@ -4276,7 +4685,9 @@ mod tests { pi_agent_cli_env: Default::default(), }; - for provider in [AiProvider::Ollama, AiProvider::OpenaiCompatible, AiProvider::Custom] { + for provider in + [AiProvider::Ollama, AiProvider::AnthropicCompatible, AiProvider::OpenaiCompatible, AiProvider::Custom] + { let config = AiConfig { provider, ..base.clone() }; assert!(validate_config(&config).is_ok()); assert!(validate_model_list_config(&config).is_ok()); @@ -4393,6 +4804,35 @@ mod tests { assert_eq!(resolve_model_list_endpoint(&kimi_coding).unwrap(), "https://api.kimi.com/coding/v1/models"); } + #[test] + fn anthropic_compatible_provider_uses_fixed_messages_endpoints_and_round_trips() { + let config = anthropic_compatible_test_request("https://gateway.example.com".to_string()).config; + + assert!(uses_anthropic_messages_api(&config)); + assert_eq!(resolve_endpoint(&config), "https://gateway.example.com/v1/messages"); + assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://gateway.example.com/v1/models"); + + let v1 = AiConfig { endpoint: "https://gateway.example.com/v1".to_string(), ..config.clone() }; + assert_eq!(resolve_endpoint(&v1), "https://gateway.example.com/v1/messages"); + + let nested = + AiConfig { endpoint: "https://gateway.example.com/anthropic/v1/messages".to_string(), ..config.clone() }; + assert_eq!(resolve_endpoint(&nested), "https://gateway.example.com/anthropic/v1/messages"); + assert_eq!(resolve_model_list_endpoint(&nested).unwrap(), "https://gateway.example.com/anthropic/v1/models"); + + let dashscope = + AiConfig { endpoint: "https://dashscope-intl.aliyuncs.com/apps/anthropic".to_string(), ..config.clone() }; + assert_eq!(resolve_endpoint(&dashscope), "https://dashscope-intl.aliyuncs.com/apps/anthropic/v1/messages"); + assert_eq!( + resolve_model_list_endpoint(&dashscope).unwrap(), + "https://dashscope-intl.aliyuncs.com/apps/anthropic/v1/models" + ); + + let provider_json = serde_json::to_string(&AiProvider::AnthropicCompatible).unwrap(); + assert_eq!(provider_json, r#""anthropic-compatible""#); + assert!(matches!(serde_json::from_str::(&provider_json).unwrap(), AiProvider::AnthropicCompatible)); + } + #[test] fn auto_adds_v1_to_openai_compatible_endpoints() { // Endpoint without /v1 — auto add @@ -5114,6 +5554,84 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn anthropic_compatible_lists_unknown_models_without_filtering() { + let response_body = r#"{"data":[{"id":"vendor/future-model","display_name":"Future Model","capabilities":{"effort":{"low":{"supported":true},"high":{"supported":true}}}}]}"#; + let (origin, server) = spawn_get_response_server("200 OK", response_body).await; + let config = anthropic_compatible_test_request(format!("{origin}/v1/messages")).config; + + let models = list_models_core(&config).await.unwrap(); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "vendor/future-model"); + assert_eq!(models[0].display_name.as_deref(), Some("Future Model")); + assert!(matches!( + models[0].effort_capability, + Some(AiEffortCapability::FreeText { source: AiCapabilitySource::Custom, .. }) + )); + let request = server.await.unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /v1/models ")); + assert!(request.contains("authorization: bearer secret")); + } + + #[tokio::test] + async fn anthropic_compatible_model_list_paginates_and_deduplicates() { + let (origin, server) = spawn_paginated_model_server().await; + let config = anthropic_compatible_test_request(format!("{origin}/v1/messages")).config; + + let models = list_models_core(&config).await.unwrap(); + + assert_eq!( + models.iter().map(|model| model.id.as_str()).collect::>(), + ["vendor/first", "vendor/shared", "vendor/second",] + ); + let requests = server.await.unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests[0].starts_with("GET /v1/models ")); + assert!(requests[1].starts_with("GET /v1/models?after_id=cursor-1 ")); + } + + #[tokio::test] + async fn anthropic_compatible_model_list_uses_free_text_effort() { + let response_body = r#"{"data":[{"id":"vendor/future-model","display_name":"Future Model"}]}"#; + let (origin, server) = spawn_get_response_server("200 OK", response_body).await; + let config = anthropic_compatible_test_request(format!("{origin}/v1/messages")).config; + + let models = list_models_core(&config).await.unwrap(); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "vendor/future-model"); + assert!(matches!( + models[0].effort_capability, + Some(AiEffortCapability::FreeText { source: AiCapabilitySource::Custom, .. }) + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn anthropic_compatible_reports_optional_model_discovery_as_unsupported() { + let (origin, server) = spawn_get_response_server("404 Not Found", "not found").await; + let mut config = anthropic_compatible_test_request(format!("{origin}/v1/messages")).config; + config.model.clear(); + + let error = test_connection_core(&config).await.unwrap_err(); + + assert!(error.starts_with("[modelDiscoveryUnsupported]")); + assert!(error.contains("/v1/models")); + assert!(error.contains("enter a model ID manually")); + let request = server.await.unwrap(); + assert!(request.starts_with("GET /v1/models ")); + } + + #[tokio::test] + async fn anthropic_compatible_effort_is_free_text_without_model_detail_request() { + let config = anthropic_compatible_test_request("http://127.0.0.1:1/v1/messages".to_string()).config; + + let capability = resolve_model_effort_core(&config, "vendor/model").await.unwrap(); + + assert!(matches!(capability, AiEffortCapability::FreeText { source: AiCapabilitySource::Custom, .. })); + } + #[test] fn omits_extra_body_for_kimi_test_connection_body() { let config = AiConfig { @@ -5883,6 +6401,7 @@ mod tests { fn merge_global_max_retries_sets_for_api_providers() { for provider in &[ AiProvider::Claude, + AiProvider::AnthropicCompatible, AiProvider::Openai, AiProvider::OpenaiCompatible, AiProvider::Custom, diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs index d317a77f73..8caff3036e 100644 --- a/crates/dbx-core/src/ai_effort.rs +++ b/crates/dbx-core/src/ai_effort.rs @@ -76,7 +76,7 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option deepseek_capability(&model, source), AiProvider::Qwen => qwen_capability(&model, source), AiProvider::Ollama => ollama_capability(&model, source), - AiProvider::OpenaiCompatible | AiProvider::Custom => { + AiProvider::AnthropicCompatible | AiProvider::OpenaiCompatible | AiProvider::Custom => { Some(AiEffortCapability::FreeText { placeholder: None, source: AiCapabilitySource::Custom }) } AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => None, @@ -178,6 +178,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> { AiProvider::Qwen => Some(QWEN_THINKING_DOCS), AiProvider::Ollama => Some(OLLAMA_THINKING_DOCS), AiProvider::Claude + | AiProvider::AnthropicCompatible | AiProvider::OpenaiCompatible | AiProvider::CodexCli | AiProvider::ClaudeCodeCli @@ -235,7 +236,7 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) { }; match config.provider { - AiProvider::Claude => apply_claude_effort(object, selection), + AiProvider::Claude | AiProvider::AnthropicCompatible => apply_claude_effort(object, selection), AiProvider::Gemini => apply_gemini_effort(object, &config.model, selection), AiProvider::Deepseek => apply_deepseek_effort(object, selection), AiProvider::Qwen => apply_qwen_effort(object, selection), @@ -404,7 +405,7 @@ mod tests { #[test] fn free_text_effort_uses_the_translated_frontend_placeholder() { - for provider in [AiProvider::OpenaiCompatible, AiProvider::Custom] { + for provider in [AiProvider::AnthropicCompatible, AiProvider::OpenaiCompatible, AiProvider::Custom] { let capability = static_effort_capability(&config(provider, "custom-model"), "custom-model").unwrap(); let AiEffortCapability::FreeText { placeholder, .. } = capability else { panic!("expected free-text capability"); @@ -598,6 +599,19 @@ mod tests { assert_eq!(body["output_config"]["effort"], "high"); } + #[test] + fn anthropic_compatible_accepts_only_free_text_effort() { + let mut config = config(AiProvider::AnthropicCompatible, "gateway-model"); + config.runtime_effort = Some(AiEffortSelection::Enum("provider-high".to_string())); + assert!(validate_runtime_effort(&config).is_err()); + + config.runtime_effort = Some(AiEffortSelection::Text("custom-level".to_string())); + assert!(validate_runtime_effort(&config).is_ok()); + let mut body = json!({}); + apply_runtime_effort(&mut body, &config); + assert_eq!(body["output_config"]["effort"], "custom-level"); + } + #[test] fn validates_custom_effort_text_bounds() { let mut config = config(AiProvider::Custom, "custom-model"); diff --git a/crates/dbx-core/src/ai_model_filter.rs b/crates/dbx-core/src/ai_model_filter.rs index bae2b8db1e..e30ea1b2bd 100644 --- a/crates/dbx-core/src/ai_model_filter.rs +++ b/crates/dbx-core/src/ai_model_filter.rs @@ -89,6 +89,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st AiProvider::Gemini => !is_gemini_non_assistant_model(&model), AiProvider::Qwen => !is_qwen_non_assistant_model(&model), AiProvider::Claude + | AiProvider::AnthropicCompatible | AiProvider::Deepseek | AiProvider::Ollama | AiProvider::OpenaiCompatible @@ -197,7 +198,7 @@ mod tests { #[test] fn generic_providers_do_not_filter_unknown_model_taxonomies() { - for provider in [AiProvider::OpenaiCompatible, AiProvider::Custom] { + for provider in [AiProvider::AnthropicCompatible, AiProvider::OpenaiCompatible, AiProvider::Custom] { for model in ["text-embedding-private-chat", "company/image-reasoner", "future-model"] { assert!(model_is_assistant_compatible(&provider, model), "{}:{model}", provider.as_str()); } diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 58d82c4f0c..05177eecfb 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -5153,6 +5153,32 @@ mod tests { std::fs::remove_file(&db).ok(); } + #[tokio::test] + async fn anthropic_compatible_ai_config_roundtrip() { + let db = temp_db_path("anthropic-compatible-ai-roundtrip"); + let storage = Storage::open(&db).await.unwrap(); + + let mut cfg = make_ai_config("anthropic-compatible", true); + cfg.config.provider = AiProvider::AnthropicCompatible; + cfg.config.api_key = String::new(); + cfg.config.auth_method = AiAuthMethod::Bearer; + cfg.config.endpoint = "https://gateway.example.com/anthropic/v1/messages".to_string(); + cfg.config.model = "vendor/future-model".to_string(); + cfg.config.api_style = AiApiStyle::AnthropicMessages; + storage.save_ai_config_item(&cfg).await.unwrap(); + + let loaded = storage.load_ai_configs().await.unwrap(); + assert_eq!(loaded.len(), 1); + assert!(matches!(loaded[0].config.provider, AiProvider::AnthropicCompatible)); + assert_eq!(loaded[0].config.auth_method, AiAuthMethod::Bearer); + assert_eq!(loaded[0].config.api_style, AiApiStyle::AnthropicMessages); + assert_eq!(loaded[0].config.endpoint, "https://gateway.example.com/anthropic/v1/messages"); + assert_eq!(loaded[0].config.model, "vendor/future-model"); + assert!(loaded[0].config.api_key.is_empty()); + + std::fs::remove_file(&db).ok(); + } + #[tokio::test] async fn ai_config_only_one_default() { let db = temp_db_path("ai-one-default"); diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index 8fd167ac9b..de967325af 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -535,6 +535,7 @@ mod tests { fn allows_other_providers_single() { for provider in &[ AiProvider::Claude, + AiProvider::AnthropicCompatible, AiProvider::Openai, AiProvider::OpenaiCompatible, AiProvider::Custom, diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index dc3bcc4047..d0ad555e14 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -502,6 +502,10 @@ test("AI provider presets include common hosted and local providers", () => { assert.equal(AI_PROVIDER_PRESETS.ollama.endpoint, "http://localhost:11434/v1"); assert.equal(AI_PROVIDER_PRESETS.ollama.requiresApiKey, false); assert.equal(AI_PROVIDER_PRESETS.claude.authMethod, "api-key"); + assert.equal(AI_PROVIDER_PRESETS["anthropic-compatible"].apiStyle, "anthropic-messages"); + assert.equal(AI_PROVIDER_PRESETS["anthropic-compatible"].authMethod, "bearer"); + assert.equal(AI_PROVIDER_PRESETS["anthropic-compatible"].requiresApiKey, false); + assert.equal(AI_PROVIDER_PRESETS["anthropic-compatible"].iconSlug, "anthropic"); assert.equal(AI_PROVIDER_PRESETS.openai.authMethod, "bearer"); assert.equal(AI_PROVIDER_PRESETS.openai.iconSlug, "openai"); assert.equal(AI_PROVIDER_PRESETS.deepseek.iconSlug, "deepseek"); @@ -511,6 +515,7 @@ test("AI provider presets include common hosted and local providers", () => { assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi"); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].requiresApiKey, false); + assert.equal(Object.keys(AI_PROVIDER_PRESETS).indexOf("anthropic-compatible") + 1, Object.keys(AI_PROVIDER_PRESETS).indexOf("openai-compatible")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));