Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions _plans/__TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
36 changes: 35 additions & 1 deletion src/aisdk/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ impl Provider for Anthropic {
tools: &[Tool],
_headers: &HashMap<String, String>,
) -> Result<ProviderStream> {
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<serde_json::Value> = messages
.iter()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -812,6 +825,27 @@ fn anthropic_messages(messages: &[Message]) -> Vec<serde_json::Value> {
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::*;
Expand Down
24 changes: 1 addition & 23 deletions src/aisdk/providers/compatible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ impl Provider for OpenAICompatible {
_headers: &HashMap<String, String>,
) -> Result<ProviderStream> {
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)
Expand Down Expand Up @@ -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
}
45 changes: 45 additions & 0 deletions src/aisdk/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}
151 changes: 148 additions & 3 deletions src/aisdk/providers/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2274,7 +2282,7 @@ fn build_openai_messages(
strip_system: bool,
responses_lite: bool,
) -> Vec<serde_json::Value> {
messages
let items = messages
.iter()
.filter_map(|msg| {
if strip_system {
Expand Down Expand Up @@ -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<serde_json::Value>) -> Vec<serde_json::Value> {
// 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<String, usize> = HashMap::new();
let mut output_occurrences: HashMap<String, usize> = 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 {
Expand Down Expand Up @@ -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![
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading