From 036b602b6a5dfd3db503b335a0f3fddbe230cdda Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 11 Aug 2026 12:02:56 +0100 Subject: [PATCH 1/3] fix(adapter): handle Anthropic-compat gateways without /v1/ in base URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic adapter's get_service_url used `format!("{base_url}messages")`, which concatenates the base URL with `messages` without any slash separator. This works for the default Anthropic base URL (`https://api.anthropic.com/v1/`) because the trailing slash happens to align with the message path. It produces malformed URLs for Anthropic-compat gateways whose base URL doesn't end in /v1/ — e.g. terraphim-llm-proxy's MiniMax provider at `https://api.minimax.io/anthropic` was producing `https://api.minimax.io/anthropicmessages` (404). This affects both Chat and ChatStream service types in the same way, so streaming requests to MiniMax (and any other non-Anthropic-but-compat provider) fail with 404 today. Fix the URL construction to handle three shapes: - base_url ends in `messages` → pass through unchanged - base_url ends in `/v1/` or `/v1` → append `messages` (legacy Anthropic behavior) - base_url is anything else → append `/v1/messages` with proper slash Add 6 unit tests covering all shapes and confirming streaming == chat URL construction. Verified locally via `cargo test --lib` (71/71 pass). Discovered while validating terraphim-llm-proxy's MiniMax highspeed offerings on 2026-08-11; see terraphim-llm-proxy#18 (Anthropic route) and the gitea #19 config PR for related context. Streaming was the remaining gap after PR #19 landed. --- .../adapters/anthropic/adapter_impl.rs | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/src/adapter/adapters/anthropic/adapter_impl.rs b/src/adapter/adapters/anthropic/adapter_impl.rs index 8a5338f2..33ca28e1 100644 --- a/src/adapter/adapters/anthropic/adapter_impl.rs +++ b/src/adapter/adapters/anthropic/adapter_impl.rs @@ -193,7 +193,26 @@ 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` where the `/v1/messages` + // suffix isn't part of the host. Previously this used + // `format!("{base_url}messages")` which produced malformed URLs + // like `https://api.minimax.io/anthropicmessages` for + // Anthropic-compat gateways without `/v1/` in their base URL. + if base_url.ends_with("messages") { + base_url.to_string() + } else if base_url.ends_with("/v1/") || base_url.ends_with("/v1") { + // Already includes the /v1 version segment; just append messages. + 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 +962,75 @@ 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. + let url = make_url("https://api.minimax.io/anthropic/v1", ServiceType::Chat); + assert_eq!(url, "https://api.minimax.io/anthropic/v1messages"); + } + + #[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 From 632ba935acf72aa81bedda641cffc944f14c71be Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 11 Aug 2026 13:16:23 +0100 Subject: [PATCH 2/3] fix(adapter): insert slash between /v1 and messages for compat gateways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used `format!("{base_url}messages")` when base_url ended in `/v1`, which produces `https://api.minimax.io/anthropic/v1messages` — no slash separator, 404. Real-world gateways like the terraphim-llm-proxy MiniMax provider use `https://api.minimax.io/anthropic/v1` as the base URL, and the actual upstream endpoint is `/anthropic/v1/messages`. Split the `/v1` arm into two: - base_url ends with `/v1/` → legacy Anthropic shape, append `messages` - base_url ends with `/v1` → insert `/`, append `messages` Update the regression test to assert `/v1/messages` for the no-trailing-slash case. This is a follow-up to PR #7 (terraphim/rust-genai#7) — the streaming build I tested locally produced 404 because of this. All 13 anthropic unit tests pass. --- .../adapters/anthropic/adapter_impl.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/adapter/adapters/anthropic/adapter_impl.rs b/src/adapter/adapters/anthropic/adapter_impl.rs index 33ca28e1..2d102bf9 100644 --- a/src/adapter/adapters/anthropic/adapter_impl.rs +++ b/src/adapter/adapters/anthropic/adapter_impl.rs @@ -198,16 +198,19 @@ impl Adapter for AnthropicAdapter { // 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` where the `/v1/messages` - // suffix isn't part of the host. Previously this used + // `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/anthropicmessages` for - // Anthropic-compat gateways without `/v1/` in their base URL. + // 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/") || base_url.ends_with("/v1") { - // Already includes the /v1 version segment; just append messages. + } 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('/')) @@ -994,8 +997,10 @@ mod tests { 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/v1messages"); + assert_eq!(url, "https://api.minimax.io/anthropic/v1/messages"); } #[test] From cc9d8f23252945276ce3170cfc6ce3e13e66dc8a Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 11 Aug 2026 14:03:02 +0100 Subject: [PATCH 3/3] style: collapse nested if inside match arm (clippy 1.96) Clippy 1.96 added the `clippy::collapsible_match` lint which flags `match arm { if cond { body } }` and suggests converting to `match arm if cond => { body }`. Apply to two arms in `src/webc/web_stream.rs` (`'['` and `']'`) that both contain `if depth == 0`. No behavior change. Required to keep CI passing under `-D warnings` once stable Rust ships 1.96. PR #7 (anthropic endpoint URL normalization) hits this lint and fails the Lint/Build/Test job without this fix. --- src/webc/web_stream.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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)