From 0f8839adb4666a35c102e507d544c514a928800d Mon Sep 17 00:00:00 2001 From: AlexMikhalev Date: Mon, 3 Nov 2025 13:40:08 +0000 Subject: [PATCH 1/2] fix: Clean up adapter imports and remove duplicate ZAi references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed import paths for adapters after module reorganization - Removed remaining duplicate ZAi adapter references - Added missing openrouter module export - Ensured consistent adapter import structure 🤖 Generated with [terraphim.ai](https://terraphim.ai) Co-Authored-By: Claude --- src/adapter/adapter_kind.rs | 4 ++-- src/adapter/adapters/mod.rs | 1 + src/adapter/dispatcher.rs | 16 +++------------- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/adapter/adapter_kind.rs b/src/adapter/adapter_kind.rs index c439446f..7c2c882e 100644 --- a/src/adapter/adapter_kind.rs +++ b/src/adapter/adapter_kind.rs @@ -1,5 +1,3 @@ -use crate::adapter::adapters::together::TogetherAdapter; -use crate::adapter::adapters::zai::{self, ZaiAdapter}; use crate::adapter::anthropic::AnthropicAdapter; use crate::adapter::cerebras::CerebrasAdapter; use crate::adapter::cohere::CohereAdapter; @@ -10,7 +8,9 @@ use crate::adapter::groq::{self, GroqAdapter}; use crate::adapter::nebius::NebiusAdapter; use crate::adapter::openai::OpenAIAdapter; use crate::adapter::openrouter::OpenRouterAdapter; +use crate::adapter::together::TogetherAdapter; use crate::adapter::xai::XaiAdapter; +use crate::adapter::zai::{self, ZaiAdapter}; use crate::{ModelName, Result}; use derive_more::Display; use serde::{Deserialize, Serialize}; diff --git a/src/adapter/adapters/mod.rs b/src/adapter/adapters/mod.rs index 21a059c7..bc32c2d4 100644 --- a/src/adapter/adapters/mod.rs +++ b/src/adapter/adapters/mod.rs @@ -11,6 +11,7 @@ pub(super) mod nebius; pub(super) mod ollama; pub(super) mod openai; pub(super) mod openai_resp; +pub(super) mod openrouter; pub(super) mod together; pub(super) mod xai; pub(super) mod zai; diff --git a/src/adapter/dispatcher.rs b/src/adapter/dispatcher.rs index d08c2d0e..d9f9194a 100644 --- a/src/adapter/dispatcher.rs +++ b/src/adapter/dispatcher.rs @@ -1,19 +1,18 @@ -use super::groq::GroqAdapter; -use crate::adapter::adapters::together::TogetherAdapter; -use crate::adapter::adapters::zai::ZaiAdapter; use crate::adapter::anthropic::AnthropicAdapter; use crate::adapter::cerebras::CerebrasAdapter; use crate::adapter::cohere::CohereAdapter; use crate::adapter::deepseek::DeepSeekAdapter; use crate::adapter::fireworks::FireworksAdapter; use crate::adapter::gemini::GeminiAdapter; +use crate::adapter::groq::GroqAdapter; use crate::adapter::nebius::NebiusAdapter; use crate::adapter::ollama::OllamaAdapter; use crate::adapter::openai::OpenAIAdapter; use crate::adapter::openai_resp::OpenAIRespAdapter; use crate::adapter::openrouter::OpenRouterAdapter; - +use crate::adapter::together::TogetherAdapter; use crate::adapter::xai::XaiAdapter; +use crate::adapter::zai::ZaiAdapter; use crate::adapter::{Adapter, AdapterKind, ServiceType, WebRequestData}; use crate::chat::{ChatOptionsSet, ChatRequest, ChatResponse, ChatStreamResponse}; use crate::embed::{EmbedOptionsSet, EmbedRequest, EmbedResponse}; @@ -47,7 +46,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::default_endpoint(), AdapterKind::Ollama => OllamaAdapter::default_endpoint(), AdapterKind::Cerebras => CerebrasAdapter::default_endpoint(), - AdapterKind::ZAi => ZAiAdapter::default_endpoint(), AdapterKind::OpenRouter => Endpoint::from_static("https://openrouter.ai/api/v1/"), } } @@ -68,7 +66,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::default_auth(), AdapterKind::Ollama => OllamaAdapter::default_auth(), AdapterKind::Cerebras => CerebrasAdapter::default_auth(), - AdapterKind::ZAi => ZAiAdapter::default_auth(), AdapterKind::OpenRouter => AuthData::from_env(OpenRouterAdapter::API_KEY_DEFAULT_ENV_NAME), } } @@ -89,7 +86,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::all_model_names(kind).await, AdapterKind::Ollama => OllamaAdapter::all_model_names(kind).await, AdapterKind::Cerebras => CerebrasAdapter::all_model_names(kind).await, - AdapterKind::ZAi => ZAiAdapter::all_model_names(kind).await, AdapterKind::OpenRouter => OpenRouterAdapter::all_model_names(kind).await, } } @@ -110,7 +106,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::get_service_url(model, service_type, endpoint), AdapterKind::Ollama => OllamaAdapter::get_service_url(model, service_type, endpoint), AdapterKind::Cerebras => CerebrasAdapter::get_service_url(model, service_type, endpoint), - AdapterKind::ZAi => ZAiAdapter::get_service_url(model, service_type, endpoint), AdapterKind::OpenRouter => OpenRouterAdapter::get_service_url(model, service_type, endpoint), } } @@ -143,7 +138,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::to_web_request_data(target, service_type, chat_req, options_set), AdapterKind::Ollama => OllamaAdapter::to_web_request_data(target, service_type, chat_req, options_set), AdapterKind::Cerebras => CerebrasAdapter::to_web_request_data(target, service_type, chat_req, options_set), - AdapterKind::ZAi => ZAiAdapter::to_web_request_data(target, service_type, chat_req, options_set), AdapterKind::OpenRouter => { OpenRouterAdapter::to_web_request_data(target, service_type, chat_req, options_set) } @@ -170,7 +164,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::to_chat_response(model_iden, web_response, options_set), AdapterKind::Ollama => OllamaAdapter::to_chat_response(model_iden, web_response, options_set), AdapterKind::Cerebras => CerebrasAdapter::to_chat_response(model_iden, web_response, options_set), - AdapterKind::ZAi => ZAiAdapter::to_chat_response(model_iden, web_response, options_set), AdapterKind::OpenRouter => OpenRouterAdapter::to_chat_response(model_iden, web_response, options_set), } } @@ -198,7 +191,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::to_chat_stream(model_iden, reqwest_builder, options_set), AdapterKind::Ollama => OllamaAdapter::to_chat_stream(model_iden, reqwest_builder, options_set), AdapterKind::Cerebras => CerebrasAdapter::to_chat_stream(model_iden, reqwest_builder, options_set), - AdapterKind::ZAi => ZAiAdapter::to_chat_stream(model_iden, reqwest_builder, options_set), AdapterKind::OpenRouter => OpenRouterAdapter::to_chat_stream(model_iden, reqwest_builder, options_set), } } @@ -227,7 +219,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::to_embed_request_data(target, embed_req, options_set), AdapterKind::Ollama => OllamaAdapter::to_embed_request_data(target, embed_req, options_set), AdapterKind::Cerebras => CerebrasAdapter::to_embed_request_data(target, embed_req, options_set), - AdapterKind::ZAi => ZAiAdapter::to_embed_request_data(target, embed_req, options_set), AdapterKind::OpenRouter => OpenRouterAdapter::to_embed_request_data(target, embed_req, options_set), } } @@ -255,7 +246,6 @@ impl AdapterDispatcher { AdapterKind::Cohere => CohereAdapter::to_embed_response(model_iden, web_response, options_set), AdapterKind::Ollama => OllamaAdapter::to_embed_response(model_iden, web_response, options_set), AdapterKind::Cerebras => CerebrasAdapter::to_embed_response(model_iden, web_response, options_set), - AdapterKind::ZAi => ZAiAdapter::to_embed_response(model_iden, web_response, options_set), AdapterKind::OpenRouter => OpenRouterAdapter::to_embed_response(model_iden, web_response, options_set), } } From 40db98a14318211c3104d9de1971e3a186b511fc Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 11 Aug 2026 11:59:55 +0100 Subject: [PATCH 2/2] 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`. 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 | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/src/adapter/adapters/anthropic/adapter_impl.rs b/src/adapter/adapters/anthropic/adapter_impl.rs index 505a3f31..8556b3a3 100644 --- a/src/adapter/adapters/anthropic/adapter_impl.rs +++ b/src/adapter/adapters/anthropic/adapter_impl.rs @@ -66,7 +66,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 }; @@ -620,3 +639,74 @@ struct AnthropicRequestParts { } // endregion: --- Support + +// region: --- Tests + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolver::Endpoint; + + // Mirrors `BASE_URL` from the Adapter impl — kept private there. + 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: --- Tests