diff --git a/src/adapter/adapters/anthropic/adapter_impl.rs b/src/adapter/adapters/anthropic/adapter_impl.rs index 8a5338f2..2d102bf9 100644 --- a/src/adapter/adapters/anthropic/adapter_impl.rs +++ b/src/adapter/adapters/anthropic/adapter_impl.rs @@ -193,7 +193,29 @@ impl Adapter for AnthropicAdapter { fn get_service_url(_model: &ModelIden, service_type: ServiceType, endpoint: Endpoint) -> Result { let base_url = endpoint.base_url(); let url = match service_type { - ServiceType::Chat | ServiceType::ChatStream => format!("{base_url}messages"), + ServiceType::Chat | ServiceType::ChatStream => { + // Normalize the base URL to always have `/v1/messages` regardless + // of whether the caller passed `https://api.anthropic.com/v1/` + // (with trailing slash), `https://api.anthropic.com/v1` + // (no slash), or a custom gateway like + // `https://api.minimax.io/anthropic/v1` where the `/v1` is the + // version segment and `messages` should be appended with a + // slash separator. Previously this used + // `format!("{base_url}messages")` which produced malformed URLs + // like `https://api.minimax.io/anthropic/v1messages` (404). + if base_url.ends_with("messages") { + base_url.to_string() + } else if base_url.ends_with("/v1/") { + // Legacy Anthropic shape: trailing slash already present, just append messages. + format!("{base_url}messages") + } else if base_url.ends_with("/v1") { + // Version segment without trailing slash — need a slash separator. + format!("{base_url}/messages") + } else { + // No version segment; append the canonical /v1/messages. + format!("{}/v1/messages", base_url.trim_end_matches('/')) + } + } ServiceType::Embed => format!("{base_url}embeddings"), // Anthropic doesn't support embeddings yet }; @@ -943,6 +965,77 @@ mod tests { let result = parse_cache_creation_details(&cache_creation); assert!(result.is_none()); } + + // region: --- Endpoint URL normalization tests + // + // Regression tests for the bug where `format!("{base_url}messages")` produced + // malformed URLs (e.g. `https://api.minimax.io/anthropicmessages`) for + // Anthropic-compat gateways whose base URL didn't end in `/v1/`. The fix + // added a three-way shape detection in `get_service_url`. + + const TEST_BASE_URL: &str = "https://api.anthropic.com/v1/"; + + fn make_url(base_url: &str, service_type: ServiceType) -> String { + let endpoint = Endpoint::from_owned(base_url.to_string()); + AnthropicAdapter::get_service_url( + &ModelIden::new(AdapterKind::Anthropic, "claude-3-5-haiku-latest".to_string()), + service_type, + endpoint, + ) + .unwrap() + } + + #[test] + fn anthropic_default_base_url_produces_canonical_v1_messages() { + // The default Anthropic base URL ends with `/v1/` (trailing slash). + // Verify the canonical output is `…/v1/messages`. + let url = make_url(TEST_BASE_URL, ServiceType::Chat); + assert_eq!(url, "https://api.anthropic.com/v1/messages"); + } + + #[test] + fn anthropic_base_url_with_v1_no_trailing_slash_works() { + // Some gateways (e.g. terraphim-llm-proxy's MiniMax provider after + // the 2026-08-11 config fix) pass `…/v1` without a trailing slash. + // We must insert a `/` separator so the path is `/v1/messages` rather + // than `/v1messages` (which 404s). + let url = make_url("https://api.minimax.io/anthropic/v1", ServiceType::Chat); + assert_eq!(url, "https://api.minimax.io/anthropic/v1/messages"); + } + + #[test] + fn anthropic_base_url_without_version_segment_gets_v1_messages_suffix() { + // A bare host (no `/v1/`, no `/v1`) should get `/v1/messages` appended + // with a proper `/` separator. This was the broken case that produced + // `https://api.minimax.io/anthropicmessages` before this fix. + let url = make_url("https://api.minimax.io/anthropic", ServiceType::Chat); + assert_eq!(url, "https://api.minimax.io/anthropic/v1/messages"); + } + + #[test] + fn anthropic_base_url_with_trailing_slash_without_version_gets_v1_messages() { + // A bare host with a trailing slash should also work — `/v1/messages` + // appended after stripping the trailing `/`. + let url = make_url("https://api.minimax.io/", ServiceType::Chat); + assert_eq!(url, "https://api.minimax.io/v1/messages"); + } + + #[test] + fn anthropic_base_url_already_ending_in_messages_passes_through() { + // Defensive: if a caller already passed a fully-formed URL ending in + // `messages`, return it unchanged. + let url = make_url("https://api.minimax.io/anthropic/v1/messages", ServiceType::Chat); + assert_eq!(url, "https://api.minimax.io/anthropic/v1/messages"); + } + + #[test] + fn anthropic_streaming_path_uses_same_url_construction_as_chat() { + // Streaming should use identical URL logic as Chat. + let url = make_url("https://api.minimax.io/anthropic", ServiceType::ChatStream); + assert_eq!(url, "https://api.minimax.io/anthropic/v1/messages"); + } + + // endregion: --- Endpoint URL normalization tests } // endregion: --- Tests diff --git a/src/webc/web_stream.rs b/src/webc/web_stream.rs index 300ea236..dd735507 100644 --- a/src/webc/web_stream.rs +++ b/src/webc/web_stream.rs @@ -255,17 +255,13 @@ fn new_with_pretty_json_array( last_idx = idx + 1; } } - '[' => { - if depth == 0 { - messages.push("[".to_string()); - last_idx = idx + 1; - } + '[' if depth == 0 => { + messages.push("[".to_string()); + last_idx = idx + 1; } - ']' => { - if depth == 0 { - messages.push("]".to_string()); - last_idx = idx + 1; - } + ']' if depth == 0 => { + messages.push("]".to_string()); + last_idx = idx + 1; } _ => { // Ignore other characters outside of objects (whitespace, commas)