diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index cdce5bec..b8e3d7c2 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -496,3 +496,5 @@ I think this is how the TUI works already anyway right? - [x] wanna add tinyfish and monid (free search apis) - [x] Bug: Fix the questions.. There's too much gap between 1-3 choices and the "Type your own answer". Because we always want type your own to be visible. Just amke them in the same container, no justification in the middle and if overflowing, just make it scroll the entire question dialog. + +- [ ] "default" thinking effort, make it dimmer when it's showing as what the default thinking effort is.. i.e. "medium" but default, make it dimmer. Only stay the same if it's explicit. diff --git a/src/aisdk/providers/anthropic.rs b/src/aisdk/providers/anthropic.rs index 24c0600e..e2c77271 100644 --- a/src/aisdk/providers/anthropic.rs +++ b/src/aisdk/providers/anthropic.rs @@ -95,7 +95,8 @@ impl Provider for Anthropic { tools: &[Tool], _headers: &HashMap, ) -> Result { - let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/')); + let base = self.base_url.trim_end_matches('/'); + let url = anthropic_messages_url(base); let system_prompts: Vec = messages .iter() @@ -733,6 +734,18 @@ fn anthropic_user_content(user: &crate::message::UserMessage) -> serde_json::Val /// Convert internal messages into Anthropic Messages API history. /// +/// Join the `/messages` endpoint onto a base URL without duplicating a +/// version segment: bases that already contain `/vN` (e.g. gateways ending +/// in `/v1`) get `/messages`, others get `/v1/messages`. +fn anthropic_messages_url(base: &str) -> String { + let base = base.trim_end_matches('/'); + if super::base_url_has_version_segment(base) { + format!("{base}/messages") + } else { + format!("{base}/v1/messages") + } +} + /// Adjacent `ToolCall`s are merged into one assistant message with multiple /// `tool_use` blocks; adjacent `ToolOutput`s become one user message with /// multiple `tool_result` blocks. Anthropic (and Kimi coding) reject @@ -812,6 +825,27 @@ fn anthropic_messages(messages: &[Message]) -> Vec { out } +#[cfg(test)] +mod url_tests { + use super::*; + + #[test] + fn messages_url_does_not_duplicate_version_segment() { + assert_eq!( + anthropic_messages_url("https://api.anthropic.com"), + "https://api.anthropic.com/v1/messages" + ); + assert_eq!( + anthropic_messages_url("https://gateway.example.com/v1"), + "https://gateway.example.com/v1/messages" + ); + assert_eq!( + anthropic_messages_url("https://gateway.example.com/v1/"), + "https://gateway.example.com/v1/messages" + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/aisdk/providers/compatible.rs b/src/aisdk/providers/compatible.rs index e151412d..c40a2345 100644 --- a/src/aisdk/providers/compatible.rs +++ b/src/aisdk/providers/compatible.rs @@ -116,7 +116,7 @@ impl Provider for OpenAICompatible { _headers: &HashMap, ) -> Result { let base = self.base_url.trim_end_matches('/'); - let url = if has_version_segment(base) { + let url = if super::base_url_has_version_segment(base) { format!("{}/chat/completions", base) } else { format!("{}/v1/chat/completions", base) @@ -977,25 +977,3 @@ fn is_sse_metadata_line(line: &str) -> bool { || line.starts_with("id:") || line.starts_with("retry:") } - -fn has_version_segment(base_url: &str) -> bool { - // Check if the URL path already contains a /vN segment (e.g., /v4, /v1) - if let Some(pos) = base_url.find("://") { - let after_scheme = &base_url[pos + 3..]; - if let Some(path_start) = after_scheme.find('/') { - let path = &after_scheme[path_start..]; - // Match /vN where N is one or more digits, followed by / or end of string - let bytes = path.as_bytes(); - for i in 0..bytes.len().saturating_sub(2) { - if bytes[i] == b'/' - && bytes[i + 1] == b'v' - && bytes[i + 2].is_ascii_digit() - && (i + 3 >= bytes.len() || bytes[i + 3] == b'/') - { - return true; - } - } - } - } - false -} diff --git a/src/aisdk/providers/mod.rs b/src/aisdk/providers/mod.rs index 5cf2e115..8469def4 100644 --- a/src/aisdk/providers/mod.rs +++ b/src/aisdk/providers/mod.rs @@ -11,3 +11,48 @@ pub use hosted_search::{ pub use anthropic::Anthropic; pub use compatible::OpenAICompatible; pub use openai::OpenAI; + +/// Returns true when a provider base URL already contains a `/vN` path segment +/// (e.g. `https://opencode.ai/zen/go/v1`, `.../v4`). Providers join their +/// endpoint path onto the base URL, so callers must not prepend another +/// version segment when one is already present (which produced +/// `/v1/v1/responses`-style 404s). +pub(crate) fn base_url_has_version_segment(base_url: &str) -> bool { + // Check if the URL path already contains a /vN segment (e.g., /v4, /v1) + if let Some(pos) = base_url.find("://") { + let after_scheme = &base_url[pos + 3..]; + if let Some(path_start) = after_scheme.find('/') { + let path = &after_scheme[path_start..]; + // Match /vN where N is one or more digits, followed by / or end of string + let bytes = path.as_bytes(); + for i in 0..bytes.len().saturating_sub(2) { + if bytes[i] == b'/' + && bytes[i + 1] == b'v' + && bytes[i + 2].is_ascii_digit() + && (i + 3 >= bytes.len() || bytes[i + 3] == b'/') + { + return true; + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_version_segment_in_base_url() { + assert!(base_url_has_version_segment( + "https://opencode.ai/zen/go/v1" + )); + assert!(base_url_has_version_segment( + "https://opencode.ai/zen/go/v1/" + )); + assert!(base_url_has_version_segment("http://localhost:11434/v1")); + assert!(!base_url_has_version_segment("https://api.openai.com")); + assert!(!base_url_has_version_segment("https://api.anthropic.com")); + } +} diff --git a/src/aisdk/providers/openai.rs b/src/aisdk/providers/openai.rs index 9ecaf401..4a3bddb1 100644 --- a/src/aisdk/providers/openai.rs +++ b/src/aisdk/providers/openai.rs @@ -191,7 +191,15 @@ impl OpenAIBuilder { let responses_path = { let trimmed = self.responses_path.trim(); if trimmed.is_empty() { - "/v1/responses".to_string() + // Gateways often ship base URLs that already include a version + // segment (e.g. https://opencode.ai/zen/go/v1). Appending the + // default `/v1/responses` there produces a duplicated + // `/v1/v1/...` path that 404s. + if super::base_url_has_version_segment(base_url.trim_end_matches('/')) { + "/responses".to_string() + } else { + "/v1/responses".to_string() + } } else if trimmed.starts_with('/') { trimmed.to_string() } else { @@ -2274,7 +2282,7 @@ fn build_openai_messages( strip_system: bool, responses_lite: bool, ) -> Vec { - messages + let items = messages .iter() .filter_map(|msg| { if strip_system { @@ -2332,7 +2340,67 @@ fn build_openai_messages( })), } }) - .collect() + .collect(); + dedupe_responses_call_ids(items) +} + +/// Rewrite duplicate `function_call` / `function_call_output` call ids in the +/// Responses `input` items. +/// +/// Some OpenAI-compatible relays mint per-request call ids (`call_1`, +/// `call_2`, ...) that restart on every request. The Responses API requires +/// every call_id to be unique across the conversation input, so a history that +/// reuses an id fails with "Duplicate function_call_output". Remap each +/// repeated call — and its matching output — to a unique id; ids are opaque. +fn dedupe_responses_call_ids(mut items: Vec) -> Vec { + // Occurrence k of a call_id pairs with occurrence k of its output, so + // track counts per call_id instead of assuming strict call/output order. + let mut call_occurrences: HashMap = HashMap::new(); + let mut output_occurrences: HashMap = HashMap::new(); + + for item in items.iter_mut() { + match item.get("type").and_then(|value| value.as_str()) { + Some("function_call") => { + let Some(call_id) = item + .get("call_id") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + continue; + }; + let occurrence = call_occurrences.entry(call_id.clone()).or_insert(0); + *occurrence += 1; + if *occurrence == 1 { + continue; + } + let new_id = format!("{call_id}_dup{occurrence}"); + item["call_id"] = serde_json::Value::String(new_id); + // The item id belongs to the original response; drop it so the + // rewritten pair cannot collide on item ids either. + if let Some(obj) = item.as_object_mut() { + obj.remove("id"); + } + } + Some("function_call_output") => { + let Some(call_id) = item + .get("call_id") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + continue; + }; + let occurrence = output_occurrences.entry(call_id.clone()).or_insert(0); + *occurrence += 1; + if *occurrence > 1 { + item["call_id"] = + serde_json::Value::String(format!("{call_id}_dup{occurrence}")); + } + } + _ => {} + } + } + + items } fn responses_lite_message(role: &str, text_type: &str, text: String) -> serde_json::Value { @@ -2436,6 +2504,45 @@ mod tests { assert!(provider.api_key.is_empty()); } + #[test] + fn default_responses_path_does_not_duplicate_v1_in_base_url() { + // Models.dev routes some gateway models (e.g. opencode-go / + // muse-spark-1.3-contributor) through `@ai-sdk/openai` while their + // base URL already ends in a version segment. The default Responses + // path must not append another `/v1` (which produced + // `/v1/v1/responses` 404s). + let provider = OpenAI::builder() + .base_url("https://opencode.ai/zen/go/v1") + .model_name("muse-spark-1.3-contributor") + .build() + .unwrap(); + assert_eq!(provider.responses_path, "/responses"); + + let provider = OpenAI::builder() + .base_url("https://api.openai.com") + .model_name("gpt-test") + .build() + .unwrap(); + assert_eq!(provider.responses_path, "/v1/responses"); + } + + #[test] + fn default_responses_path_handles_non_v1_version_segments() { + let provider = OpenAI::builder() + .base_url("https://gateway.example.com/v2") + .model_name("gpt-test") + .build() + .unwrap(); + assert_eq!(provider.responses_path, "/responses"); + + let provider = OpenAI::builder() + .base_url("https://gateway.example.com/v2/") + .model_name("gpt-test") + .build() + .unwrap(); + assert_eq!(provider.responses_path, "/responses"); + } + #[test] fn openai_messages_strip_unprefixed_response_item_ids() { let messages = vec![ @@ -2847,6 +2954,44 @@ mod tests { assert_eq!(input[1]["output"], "Replaced at line 7"); } + #[test] + fn dedupes_repeated_call_ids_across_turns_for_responses_input() { + // Relays that mint per-request ids (call_1, ...) restart numbering on + // every request; the stored history then repeats ids across turns. + let input = build_openai_messages( + &[ + Message::user("list files"), + Message::tool_call("call_1", "glob", "{}"), + Message::tool_output("call_1", "glob", "src/main.rs", false), + Message::tool_call("call_1", "read", "{}"), + Message::tool_output("call_1", "read", "fn main() {}", false), + Message::tool_call("call_1", "grep", "{}"), + Message::tool_output("call_1", "grep", "no matches", false), + ], + false, + false, + ); + + let call_ids: Vec<&str> = input + .iter() + .filter(|item| item.get("call_id").is_some()) + .map(|item| item["call_id"].as_str().expect("call_id")) + .collect(); + assert_eq!( + call_ids, + [ + "call_1", + "call_1", + "call_1_dup2", + "call_1_dup2", + "call_1_dup3", + "call_1_dup3" + ] + ); + // Rewritten calls must not keep a stale response item id. + assert!(input[3].get("id").is_none()); + } + #[test] fn serializes_tool_image_output_for_responses_input() { let input = build_openai_messages( diff --git a/src/model/extensions/mod.rs b/src/model/extensions/mod.rs index 2041b1b4..528e037e 100644 --- a/src/model/extensions/mod.rs +++ b/src/model/extensions/mod.rs @@ -303,15 +303,27 @@ fn merge_catalog( for (model_id, extension_model) in extension_models { let Some(existing) = provider.models.get_mut(model_id) else { - // Brand-new model: insert the extension spec as-is. - let Ok(model) = serde_json::from_value::(extension_model.clone()) else { - crate::emit_log!( - "Failed to deserialize catalog extension model {provider_id}/{model_id}" - ); - continue; - }; - provider.models.insert(model_id.clone(), model); - changed = true; + // Brand-new model: insert the extension spec as-is. New-model + // specs must be complete (not a patch fragment): inventing + // `id`/`name` for a `{"attachment": true}`-style fragment + // would fabricate a hollow entry with wrong defaults + // (tool_call=false, no limits/cost). Skip it so the next + // models.dev refresh or a completed spec resolves it instead + // of silently shipping a broken model. + match serde_json::from_value::(extension_model.clone()) { + Ok(model) => { + provider.models.insert(model_id.clone(), model); + changed = true; + } + Err(err) => { + crate::emit_log!( + "Skipping catalog extension model {}/{}: incomplete new-model spec ({})", + provider_id, + model_id, + err + ); + } + } continue; }; @@ -326,7 +338,9 @@ fn merge_catalog( } let Ok(model) = serde_json::from_value::(merged) else { crate::emit_log!( - "Failed to deserialize merged catalog extension model {provider_id}/{model_id}" + "Failed to deserialize merged catalog extension model {}/{}", + provider_id, + model_id ); continue; }; @@ -399,6 +413,39 @@ mod tests { assert!(models.get("kimi-k2.5-lightning").is_some()); } + #[test] + fn catalog_extensions_patch_fragment_for_unknown_model_is_skipped() { + // A patch-style fragment (no `id`/`name`) for a model absent from + // models.dev must NOT fabricate a hollow entry: that would ship + // wrong defaults (tool_call=false, no limits/cost). It is skipped + // until models.dev carries the model or the spec is completed. + let mut providers = HashMap::new(); + providers.insert( + "crof".to_string(), + Provider { + id: "crof".to_string(), + name: "Crof".to_string(), + api: String::new(), + doc: String::new(), + env: vec!["CROF_API_KEY".to_string()], + npm: "@ai-sdk/openai-compatible".to_string(), + models: HashMap::new(), + }, + ); + let mut extensions = serde_json::Map::new(); + extensions.insert( + "crof".to_string(), + serde_json::json!({ + "models": { + "kimi-k2.5-lightning": { "attachment": true } + } + }), + ); + + assert!(!merge_catalog(&mut providers, &extensions)); + assert!(!providers["crof"].models.contains_key("kimi-k2.5-lightning")); + } + #[test] fn catalog_extensions_add_xai_composer_to_existing_provider() { let mut providers = HashMap::new();