diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de3b60bb7..2a0a87c302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -204,6 +204,10 @@ surface. of this narrow route until Codewhale supports per-model wire selection (#1481 by @seanthefuturegorilla; implementation harvested from PR #773 by @zhangweiii and PR #1050 by @sternelee). +- Add OpenCode Zen as a separate model-aware API-key provider. Its curated + model catalog selects Responses, Anthropic Messages, or Chat Completions per + model; unsupported Gemini and unknown models fail closed, and Zen missing + credentials never fall through to ChatGPT/Codex OAuth guidance. - Add TelecomJS TokenHub as a first-class Chat Completions provider with `[providers.telecomjs]`, `TELECOMJS_API_KEY`, and a key-scoped live `/v1/models` refresh. Models.dev and provider-specific catalogs remain in diff --git a/config.example.toml b/config.example.toml index a280620713..92783ba38a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -17,11 +17,11 @@ # `--provider fireworks`, `--provider siliconflow`, `--provider siliconflow-CN`, # `/provider arcee`, `/provider moonshot`, `/provider qianfan`, `/provider sglang`, `/provider vllm`, # `/provider ollama`, `/provider huggingface`, `/provider stepfun`, `/provider openmodel`, -# `/provider opencode-go`, `/provider meta`, `/provider xai`) toggle without having to re-enter keys. Top-level +# `/provider opencode-go`, `/provider opencode-zen`, `/provider meta`, `/provider xai`) toggle without having to re-enter keys. Top-level # `api_key` / `base_url` are # still read as DeepSeek defaults when `[providers.deepseek]` is absent # (backward compatibility). -provider = "deepseek" # deepseek | deepseek-cn | deepseek-anthropic | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | zai | stepfun | minimax | sglang | vllm | ollama | huggingface | together | qianfan | openai-codex | anthropic | openmodel | deepinfra | sakana | longcat | opencode-go | meta | xai +provider = "deepseek" # deepseek | deepseek-cn | deepseek-anthropic | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | zai | stepfun | minimax | sglang | vllm | ollama | huggingface | together | qianfan | openai-codex | anthropic | openmodel | deepinfra | sakana | longcat | opencode-go | opencode-zen | meta | xai api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty base_url = "https://api.deepseek.com/beta" # provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com) @@ -599,6 +599,20 @@ max_subagents = 10 # optional (1-20) # base_url = "https://opencode.ai/zen/go/v1" # model = "deepseek-v4-pro" +# OpenCode Zen (https://opencode.ai/docs/zen/) +# Model-aware gateway: GPT models use Responses, Claude/Qwen use Anthropic +# Messages, and DeepSeek/MiniMax/GLM/Kimi/Grok/free models use Chat Completions. +# Gemini uses a Google-specific protocol that Codewhale does not implement and +# therefore fails closed instead of being sent with the wrong request shape. +# Env vars: OPENCODE_ZEN_API_KEY (preferred), OPENCODE_API_KEY, +# OPENCODE_ZEN_BASE_URL, OPENCODE_ZEN_MODEL +[providers.opencode_zen] +# api_key = "YOUR_OPENCODE_ZEN_API_KEY" +# base_url = "https://opencode.ai/zen/v1" +# model = "gpt-5.5" # Responses default +# model = "claude-sonnet-4-6" # Anthropic Messages example +# model = "deepseek-v4-pro" # Chat Completions example + # Meta Model API / Muse Spark (https://developer.meta.com/ai/) # OpenAI-compatible Chat Completions route. # Provider aliases: meta, meta-ai, meta-model-api, muse, muse-spark diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index ce383bbb24..e1c8b1c3d9 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -144,7 +144,7 @@ fn resolve_endpoint( let insecure_skip_tls_verify = provider_cfg.insecure_skip_tls_verify.unwrap_or(false); - let wire_format = provider_meta.wire(); + let wire_format = route.protocol(); Ok(ResolvedModelEndpoint { provider: provider_kind, @@ -219,7 +219,11 @@ fn provider_base_url(config: &ConfigToml, provider: ProviderKind) -> String { fn endpoint_preserves_raw_model_ids(provider: ProviderKind, base_url: &str) -> bool { matches!( provider, - ProviderKind::Custom | ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang + ProviderKind::Custom + | ProviderKind::Ollama + | ProviderKind::Vllm + | ProviderKind::Sglang + | ProviderKind::OpencodeZen ) || provider_preserves_custom_base_url_model(provider, base_url) } @@ -926,6 +930,32 @@ api_key = {provider_api_key:?} } } + #[test] + fn opencode_zen_app_route_uses_the_resolved_model_protocol() { + let config = ConfigToml { + provider: ProviderKind::OpencodeZen, + ..ConfigToml::default() + }; + let registry = ModelRegistry::default(); + + for (model, expected) in [ + ("gpt-5.5", WireFormat::Responses), + ("claude-sonnet-4-6", WireFormat::AnthropicMessages), + ("deepseek-v4-pro", WireFormat::ChatCompletions), + ] { + let endpoint = resolve_endpoint(&config, ®istry, Some(model)) + .unwrap_or_else(|error| panic!("{model} should resolve: {error}")); + assert_eq!(endpoint.provider, ProviderKind::OpencodeZen); + assert_eq!(endpoint.model, model); + assert_eq!(endpoint.wire_format, expected); + } + + assert!(matches!( + resolve_endpoint(&config, ®istry, Some("gemini-3.1-pro")), + Err(RouteError::UnsupportedModelProtocol { .. }) + )); + } + #[test] fn root_auth_and_headers_do_not_bleed_across_inferred_providers() { let mut config = ConfigToml { diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 11c3e7cd94..1b4cc16891 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -75,6 +75,13 @@ enum ProviderArg { LongCat, #[value(alias = "opencode_go", alias = "opencodego")] OpencodeGo, + #[value( + alias = "opencode_zen", + alias = "opencodezen", + alias = "zen", + alias = "opencode" + )] + OpencodeZen, #[value( alias = "meta-ai", alias = "meta_ai", @@ -120,6 +127,7 @@ impl From for ProviderKind { ProviderArg::Sakana => ProviderKind::Sakana, ProviderArg::LongCat => ProviderKind::LongCat, ProviderArg::OpencodeGo => ProviderKind::OpencodeGo, + ProviderArg::OpencodeZen => ProviderKind::OpencodeZen, ProviderArg::Meta => ProviderKind::Meta, ProviderArg::Xai => ProviderKind::Xai, } @@ -5328,6 +5336,19 @@ mod tests { } } + #[test] + fn opencode_zen_provider_aliases_parse_as_builtin() { + for alias in [ + "opencode-zen", + "opencode_zen", + "opencodezen", + "zen", + "opencode", + ] { + assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeZen)); + } + } + #[test] fn raw_provider_ids_remain_restricted_to_exec_and_fleet() { let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 49f7e70f1a..447320f503 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -315,6 +315,15 @@ pub struct ProvidersToml { alias = "opencodego" )] pub opencode_go: ProviderConfigToml, + #[serde( + default, + skip_serializing_if = "ProviderConfigToml::is_empty", + alias = "opencode-zen", + alias = "opencodezen", + alias = "zen", + alias = "opencode" + )] + pub opencode_zen: ProviderConfigToml, #[serde( default, skip_serializing_if = "ProviderConfigToml::is_empty", @@ -462,6 +471,7 @@ impl ProvidersToml { ProviderKind::Sakana => &self.sakana, ProviderKind::LongCat => &self.longcat, ProviderKind::OpencodeGo => &self.opencode_go, + ProviderKind::OpencodeZen => &self.opencode_zen, ProviderKind::Meta => &self.meta, ProviderKind::Xai => &self.xai, ProviderKind::Telecomjs => &self.telecomjs, @@ -503,6 +513,7 @@ impl ProvidersToml { ProviderKind::Sakana => &mut self.sakana, ProviderKind::LongCat => &mut self.longcat, ProviderKind::OpencodeGo => &mut self.opencode_go, + ProviderKind::OpencodeZen => &mut self.opencode_zen, ProviderKind::Meta => &mut self.meta, ProviderKind::Xai => &mut self.xai, ProviderKind::Telecomjs => &mut self.telecomjs, @@ -2481,6 +2492,7 @@ impl ConfigToml { ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(), ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(), ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(), + ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(), ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(), ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(), ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(), @@ -3393,6 +3405,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Sakana => DEFAULT_SAKANA_MODEL, ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL, ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL, + ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL, ProviderKind::Meta => DEFAULT_META_MODEL, ProviderKind::Xai => DEFAULT_XAI_MODEL, ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL, @@ -3435,6 +3448,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL, ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL, ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL, + ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL, ProviderKind::Meta => DEFAULT_META_BASE_URL, ProviderKind::Xai => DEFAULT_XAI_BASE_URL, ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL, @@ -5239,6 +5253,8 @@ struct EnvRuntimeOverrides { longcat_model: Option, opencode_go_base_url: Option, opencode_go_model: Option, + opencode_zen_base_url: Option, + opencode_zen_model: Option, meta_base_url: Option, meta_model: Option, xai_base_url: Option, @@ -5511,6 +5527,12 @@ impl EnvRuntimeOverrides { opencode_go_model: std::env::var("OPENCODE_GO_MODEL") .ok() .filter(|v| !v.trim().is_empty()), + opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL") + .ok() + .filter(|v| !v.trim().is_empty()), meta_base_url: std::env::var("META_MODEL_API_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()) @@ -5593,6 +5615,7 @@ impl EnvRuntimeOverrides { ProviderKind::Sakana => self.sakana_base_url.clone(), ProviderKind::LongCat => self.longcat_base_url.clone(), ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(), + ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(), ProviderKind::Meta => self.meta_base_url.clone(), ProviderKind::Xai => self.xai_base_url.clone(), ProviderKind::Telecomjs => self.telecomjs_base_url.clone(), @@ -5628,6 +5651,7 @@ impl EnvRuntimeOverrides { ProviderKind::Sakana => self.sakana_model.clone(), ProviderKind::LongCat => self.longcat_model.clone(), ProviderKind::OpencodeGo => self.opencode_go_model.clone(), + ProviderKind::OpencodeZen => self.opencode_zen_model.clone(), ProviderKind::Meta => self.meta_model.clone(), ProviderKind::Xai => self.xai_model.clone(), ProviderKind::Telecomjs => self.telecomjs_model.clone(), diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 9de5223879..6e423cbb6c 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -16,17 +16,17 @@ use super::{ DEFAULT_NOVITA_MODEL, DEFAULT_NVIDIA_NIM_BASE_URL, DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_CODEX_BASE_URL, DEFAULT_OPENAI_CODEX_MODEL, DEFAULT_OPENAI_MODEL, - DEFAULT_OPENCODE_GO_BASE_URL, DEFAULT_OPENCODE_GO_MODEL, DEFAULT_OPENMODEL_BASE_URL, - DEFAULT_OPENMODEL_MODEL, DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL, - DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL, - DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL, - DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL, - DEFAULT_STEPFUN_MODEL, DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL, - DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, - DEFAULT_VOLCENGINE_BASE_URL, DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, - DEFAULT_WANJIE_ARK_MODEL, DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL, - DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL, - DEFAULT_ZAI_MODEL, ProviderKind, + DEFAULT_OPENCODE_GO_BASE_URL, DEFAULT_OPENCODE_GO_MODEL, DEFAULT_OPENCODE_ZEN_BASE_URL, + DEFAULT_OPENCODE_ZEN_MODEL, DEFAULT_OPENMODEL_BASE_URL, DEFAULT_OPENMODEL_MODEL, + DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL, DEFAULT_QIANFAN_BASE_URL, + DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL, DEFAULT_SGLANG_BASE_URL, + DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL, DEFAULT_SILICONFLOW_CN_BASE_URL, + DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_MODEL, + DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL, DEFAULT_TOGETHER_BASE_URL, + DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL, + DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL, + DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL, DEFAULT_XIAOMI_MIMO_BASE_URL, + DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL, ProviderKind, }; /// Wire protocol spoken by a provider. @@ -74,6 +74,43 @@ impl CredentialAcquisition { } } +/// How a provider selects its request wire format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WirePolicy { + /// Every model served by the provider uses the same wire format. + Fixed(WireFormat), + /// The provider catalog selects a wire format per model/endpoint. + ModelAware, +} + +impl WirePolicy { + /// Return the fixed format, or `None` for model-aware providers. + #[must_use] + pub const fn fixed(self) -> Option { + match self { + Self::Fixed(format) => Some(format), + Self::ModelAware => None, + } + } + + /// Resolve a concrete format from an offering endpoint key. + #[must_use] + pub fn resolve(self, endpoint_key: &str) -> Option { + if let Self::Fixed(format) = self { + return Some(format); + } + + match endpoint_key.trim().to_ascii_lowercase().as_str() { + "chat" | "chat_completions" | "chat-completions" => Some(WireFormat::ChatCompletions), + "responses" => Some(WireFormat::Responses), + "messages" | "anthropic_messages" | "anthropic-messages" => { + Some(WireFormat::AnthropicMessages) + } + _ => None, + } + } +} + /// Canonical, non-secret help for configuring one provider. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CredentialHelp { @@ -126,9 +163,9 @@ pub trait Provider: Send + Sync { &[] } - /// Wire format used by the provider. - fn wire(&self) -> WireFormat { - WireFormat::ChatCompletions + /// Policy used to select the request wire format. + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::ChatCompletions) } /// Credential acquisition metadata shared by onboarding, setup, diagnostics, @@ -333,6 +370,12 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp { docs_url: Some("https://opencode.ai/docs/go/"), guidance: "Create or copy an OpenCode Go subscription key from OpenCode Zen.", }, + ProviderKind::OpencodeZen => CredentialHelp { + acquisition: ApiKey, + credential_url: Some("https://opencode.ai/zen/"), + docs_url: Some("https://opencode.ai/docs/zen/"), + guidance: "Create or copy an OpenCode Zen API key from OpenCode Zen.", + }, ProviderKind::Meta => CredentialHelp { acquisition: ApiKey, credential_url: Some("https://developer.meta.com/ai/"), @@ -521,8 +564,8 @@ impl Provider for DeepseekAnthropic { &["deepseek_anthropic", "deepseek-claude", "deepseek_claude"] } - fn wire(&self) -> WireFormat { - WireFormat::AnthropicMessages + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::AnthropicMessages) } } provider!( @@ -809,8 +852,8 @@ impl Provider for OpenaiCodex { ] } - fn wire(&self) -> WireFormat { - WireFormat::Responses + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::Responses) } } @@ -846,8 +889,8 @@ impl Provider for Anthropic { "anthropic" } - fn wire(&self) -> WireFormat { - WireFormat::AnthropicMessages + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::AnthropicMessages) } } @@ -887,8 +930,8 @@ impl Provider for Openmodel { &["open-model", "open_model"] } - fn wire(&self) -> WireFormat { - WireFormat::AnthropicMessages + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::AnthropicMessages) } } @@ -968,8 +1011,8 @@ impl Provider for MinimaxAnthropic { ] } - fn wire(&self) -> WireFormat { - WireFormat::AnthropicMessages + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::AnthropicMessages) } } @@ -1021,6 +1064,47 @@ provider!( aliases: ["opencode_go", "opencodego"] ); +/// OpenCode Zen gateway with a model-scoped wire protocol. +pub struct OpencodeZen; + +impl Provider for OpencodeZen { + fn id(&self) -> &'static str { + "opencode-zen" + } + + fn kind(&self) -> ProviderKind { + ProviderKind::OpencodeZen + } + + fn display_name(&self) -> &'static str { + "OpenCode Zen" + } + + fn default_base_url(&self) -> &'static str { + DEFAULT_OPENCODE_ZEN_BASE_URL + } + + fn default_model(&self) -> &'static str { + DEFAULT_OPENCODE_ZEN_MODEL + } + + fn env_vars(&self) -> &'static [&'static str] { + &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"] + } + + fn provider_config_key(&self) -> &'static str { + "opencode_zen" + } + + fn aliases(&self) -> &'static [&'static str] { + &["opencode_zen", "opencodezen", "zen", "opencode"] + } + + fn wire_policy(&self) -> WirePolicy { + WirePolicy::ModelAware + } +} + provider!( Meta, Meta, @@ -1112,8 +1196,8 @@ impl Provider for Custom { "custom" } - fn wire(&self) -> WireFormat { - WireFormat::ChatCompletions + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::ChatCompletions) } } @@ -1149,12 +1233,13 @@ static DEEPINFRA: Deepinfra = Deepinfra; static SAKANA: Sakana = Sakana; static LONGCAT: LongCat = LongCat; static OPENCODE_GO: OpencodeGo = OpencodeGo; +static OPENCODE_ZEN: OpencodeZen = OpencodeZen; static META: Meta = Meta; static XAI: Xai = Xai; static TELECOMJS: Telecomjs = Telecomjs; static CUSTOM: Custom = Custom; -static PROVIDER_REGISTRY: [&dyn Provider; 36] = [ +static PROVIDER_REGISTRY: [&dyn Provider; 37] = [ &DEEPSEEK, &DEEPSEEK_ANTHROPIC, &NVIDIA_NIM, @@ -1187,6 +1272,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 36] = [ &SAKANA, &LONGCAT, &OPENCODE_GO, + &OPENCODE_ZEN, &META, &XAI, &TELECOMJS, @@ -1435,6 +1521,26 @@ mod tests { ); } + #[test] + fn model_aware_wire_policy_resolves_only_supported_endpoint_keys() { + let policy = WirePolicy::ModelAware; + assert_eq!(policy.resolve("chat"), Some(WireFormat::ChatCompletions)); + assert_eq!(policy.resolve("responses"), Some(WireFormat::Responses)); + assert_eq!( + policy.resolve("messages"), + Some(WireFormat::AnthropicMessages) + ); + assert_eq!(policy.resolve("models/gemini-3.1-pro"), None); + assert_eq!(policy.resolve(""), None); + } + + #[test] + fn fixed_wire_policy_ignores_catalog_endpoint_keys() { + let policy = WirePolicy::Fixed(WireFormat::Responses); + assert_eq!(policy.resolve("chat"), Some(WireFormat::Responses)); + assert_eq!(policy.resolve("unknown"), Some(WireFormat::Responses)); + } + #[test] fn display_order_is_alphabetical_by_display_name() { let display = providers_sorted_for_display(); diff --git a/crates/config/src/provider_defaults.rs b/crates/config/src/provider_defaults.rs index 36a1641c63..9d27a14b13 100644 --- a/crates/config/src/provider_defaults.rs +++ b/crates/config/src/provider_defaults.rs @@ -149,6 +149,12 @@ pub(crate) const OPENCODE_GO_KIMI_K2_6_MODEL: &str = "kimi-k2.6"; pub(crate) const OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL: &str = "deepseek-v4-flash"; pub(crate) const OPENCODE_GO_MIMO_V2_5_MODEL: &str = "mimo-v2.5"; pub(crate) const OPENCODE_GO_MIMO_V2_5_PRO_MODEL: &str = "mimo-v2.5-pro"; + +// OpenCode Zen is a model-aware gateway. The default is a documented +// Responses model, but every executable route must still obtain its protocol +// from a provider-scoped catalog offering. +pub(crate) const DEFAULT_OPENCODE_ZEN_MODEL: &str = "gpt-5.5"; +pub(crate) const DEFAULT_OPENCODE_ZEN_BASE_URL: &str = "https://opencode.ai/zen/v1"; // Meta Model API / Muse Spark defaults pub(crate) const DEFAULT_META_MODEL: &str = "muse-spark-1.1"; pub(crate) const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1"; diff --git a/crates/config/src/provider_kind.rs b/crates/config/src/provider_kind.rs index 232487f7c6..3110852b82 100644 --- a/crates/config/src/provider_kind.rs +++ b/crates/config/src/provider_kind.rs @@ -113,6 +113,13 @@ pub enum ProviderKind { LongCat, #[serde(alias = "opencode_go", alias = "opencodego")] OpencodeGo, + #[serde( + alias = "opencode_zen", + alias = "opencodezen", + alias = "zen", + alias = "opencode" + )] + OpencodeZen, #[serde( alias = "meta-ai", alias = "meta_ai", @@ -147,7 +154,7 @@ pub enum ProviderKind { } impl ProviderKind { - pub const ALL: [Self; 36] = [ + pub const ALL: [Self; 37] = [ Self::Deepseek, Self::DeepseekAnthropic, Self::NvidiaNim, @@ -180,6 +187,7 @@ impl ProviderKind { Self::Sakana, Self::LongCat, Self::OpencodeGo, + Self::OpencodeZen, Self::Meta, Self::Xai, Self::Telecomjs, diff --git a/crates/config/src/route/conformance_tests.rs b/crates/config/src/route/conformance_tests.rs index a6ee358dcf..1ec903a2cf 100644 --- a/crates/config/src/route/conformance_tests.rs +++ b/crates/config/src/route/conformance_tests.rs @@ -57,8 +57,9 @@ fn every_provider_kind_has_a_wellformed_descriptor() { ); } - // The wire protocol accessor must not panic for any kind. - let _ = descriptor.protocol(); + // Current fixed providers resolve a concrete protocol for their + // default endpoint key. + let _ = descriptor.protocol_for_endpoint("chat"); } } diff --git a/crates/config/src/route/descriptor.rs b/crates/config/src/route/descriptor.rs index 72fdea957d..331b333df5 100644 --- a/crates/config/src/route/descriptor.rs +++ b/crates/config/src/route/descriptor.rs @@ -10,7 +10,7 @@ //! inside a `Serialize` struct; serialize the resolved facts instead. use crate::ProviderKind; -use crate::provider::{self, Provider}; +use crate::provider::{self, Provider, WirePolicy}; use super::RequestProtocol; use super::ids::{ProviderId, WireModelId}; @@ -60,10 +60,16 @@ impl ProviderDescriptor { self.inner.env_vars() } - /// Selected wire protocol for this provider. + /// Policy used to select this provider's wire protocol. #[must_use] - pub fn protocol(&self) -> RequestProtocol { - self.inner.wire() + pub fn wire_policy(&self) -> WirePolicy { + self.inner.wire_policy() + } + + /// Resolve the concrete protocol for an offering endpoint key. + #[must_use] + pub fn protocol_for_endpoint(&self, endpoint_key: &str) -> Option { + self.wire_policy().resolve(endpoint_key) } } @@ -72,7 +78,7 @@ impl std::fmt::Debug for ProviderDescriptor { f.debug_struct("ProviderDescriptor") .field("kind", &self.kind) .field("id", &self.inner.id()) - .field("protocol", &self.inner.wire()) + .field("wire_policy", &self.inner.wire_policy()) .finish() } } diff --git a/crates/config/src/route/errors.rs b/crates/config/src/route/errors.rs index 0a052418e4..fbf3a93d23 100644 --- a/crates/config/src/route/errors.rs +++ b/crates/config/src/route/errors.rs @@ -23,6 +23,16 @@ pub enum RouteError { /// The foreign model selector that was rejected. model: String, }, + /// A model-aware provider did not prove a supported request protocol for + /// the selected model/endpoint. + UnsupportedModelProtocol { + /// Provider whose catalog row was incomplete or unsupported. + provider: ProviderId, + /// Selected provider-owned model id. + model: String, + /// Catalog endpoint key, when one was present. + endpoint_key: String, + }, } impl fmt::Display for RouteError { @@ -43,6 +53,15 @@ impl fmt::Display for RouteError { "model {model:?} is not served by direct provider {}", provider.as_str() ), + Self::UnsupportedModelProtocol { + provider, + model, + endpoint_key, + } => write!( + f, + "model {model:?} on provider {} has unsupported or unproven endpoint {endpoint_key:?}", + provider.as_str() + ), } } } diff --git a/crates/config/src/route/offering.rs b/crates/config/src/route/offering.rs index 10ba04f3a4..95936430b3 100644 --- a/crates/config/src/route/offering.rs +++ b/crates/config/src/route/offering.rs @@ -6,11 +6,10 @@ //! by multiple providers under DIFFERENT wire ids (some aggregator-prefixed), //! and a prefix never implies provider ownership. //! -//! The hand-curated seed table is gone (#4139 / #3830 P1): catalog-derived -//! offerings from [`crate::catalog::bundled_catalog_offerings`] are the single -//! bundled source of truth. [`bundled_offerings`] remains as an empty seam so -//! the resolver can still prepend curated overrides later without reintroducing -//! a parallel seed list. +//! Catalog-derived offerings from [`crate::catalog::bundled_catalog_offerings`] +//! remain the general bundled source of truth. [`bundled_offerings`] contains +//! only transport facts that Models.dev cannot express, such as a single +//! provider routing different models over different wire protocols. use serde::{Deserialize, Serialize}; @@ -75,13 +74,97 @@ pub struct ProviderModelOffering { pub pricing: PricingSku, } -/// Return the bundled offering seam as owned [`ProviderModelOffering`] rows. +// Transport snapshot verified against https://opencode.ai/docs/zen on +// 2026-07-17. Gemini rows are intentionally absent because they use Google's +// model-specific wire protocol, which CodeWhale does not currently implement. +pub(crate) const OPENCODE_ZEN_RESPONSES_MODELS: &[&str] = &[ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.1", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5", + "gpt-5-codex", + "gpt-5-nano", +]; + +pub(crate) const OPENCODE_ZEN_MESSAGES_MODELS: &[&str] = &[ + "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4-5", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-sonnet-4-5", + "claude-haiku-4-5", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-plus", + "qwen3.5-plus", +]; + +pub(crate) const OPENCODE_ZEN_CHAT_MODELS: &[&str] = &[ + "deepseek-v4-pro", + "deepseek-v4-flash", + "minimax-m3", + "minimax-m2.7", + "minimax-m2.5", + "glm-5.2", + "glm-5.1", + "glm-5", + "kimi-k2.5", + "kimi-k2.6", + "kimi-k2.7-code", + "grok-4.5", + "grok-build-0.1", + "big-pickle", + "mimo-v2.5-free", + "north-mini-code-free", + "nemotron-3-ultra-free", + "deepseek-v4-flash-free", +]; + +/// Return curated provider/model transport facts as owned offering rows. /// -/// Empty by design: every former hand-seed row is covered by the bundled -/// Models.dev catalog ([`crate::catalog::bundled_catalog_offerings`]), which -/// carries the same canonical-model joins via `base_model` plus honest limits -/// and pricing the old seeds lacked (#4139 / #3830 P1 OFFERING_SEEDS dedupe). +/// OpenCode Zen's official catalog serves models over three protocol families. +/// These rows intentionally carry no inferred limits, pricing, or canonical +/// identity: their sole claim is the documented wire model and endpoint key. #[must_use] pub fn bundled_offerings() -> Vec { - Vec::new() + let provider = ProviderId::from("opencode-zen"); + let groups = [ + ("responses", OPENCODE_ZEN_RESPONSES_MODELS), + ("messages", OPENCODE_ZEN_MESSAGES_MODELS), + ("chat", OPENCODE_ZEN_CHAT_MODELS), + ]; + + groups + .into_iter() + .flat_map(|(endpoint_key, models)| { + let provider = provider.clone(); + models.iter().map(move |model| ProviderModelOffering { + provider: provider.clone(), + canonical_model: None, + wire_model_id: WireModelId::from(*model), + endpoint_key: endpoint_key.to_string(), + default_for_provider: *model == "gpt-5.5", + limits: RouteLimits::default(), + capabilities: RouteCapabilities::default(), + pricing: PricingSku::UnknownOrStale, + }) + }) + .collect() } diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 5b78afdef1..3d2226db77 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -37,6 +37,7 @@ use super::errors::RouteError; use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId}; use super::offering::{ProviderModelOffering, RouteLimits, bundled_offerings}; use crate::catalog::{CatalogOffering, bundled_catalog_offerings}; +use crate::provider::WirePolicy; use crate::{ProviderKind, opencode_go_chat_model_id, provider_preserves_custom_base_url_model}; /// A request to resolve into an executable route. @@ -188,17 +189,27 @@ impl RouteResolver { } else { classify(provider_kind) }; + let model_aware = descriptor.wire_policy() == WirePolicy::ModelAware; let mut selected = if is_auto { - default_offering.map_or_else( - || { - // No offering in hand on the default branch: capability - // and pricing facts are honestly unknown. - ResolvedOffering::unknown(descriptor.default_wire_model()) - }, - ResolvedOffering::from_offering, - ) + match default_offering { + None if model_aware => { + return Err(RouteError::UnsupportedModelProtocol { + provider: provider_id.clone(), + model: descriptor.default_wire_model().as_str().to_string(), + endpoint_key: "unproven".to_string(), + }); + } + None => ResolvedOffering::unknown(descriptor.default_wire_model()), + Some(offering) => ResolvedOffering::from_offering(offering), + } } else { - self.scope_selector(provider_kind, &provider_id, &logical_model, class)? + self.scope_selector( + provider_kind, + &provider_id, + &logical_model, + class, + model_aware, + )? }; if custom_endpoint { // A documented first-party server tool is an endpoint-owned fact. @@ -208,13 +219,20 @@ impl RouteResolver { super::capabilities::CapabilityState::Unknown; } + let protocol = descriptor + .protocol_for_endpoint(&selected.endpoint_key) + .ok_or_else(|| RouteError::UnsupportedModelProtocol { + provider: provider_id.clone(), + model: selected.wire_model_id.as_str().to_string(), + endpoint_key: selected.endpoint_key.clone(), + })?; let endpoint = ResolvedEndpoint { base_url: req .base_url_override .clone() .unwrap_or_else(|| descriptor.default_base_url().to_string()), endpoint_key: selected.endpoint_key, - protocol: descriptor.protocol(), + protocol, }; // Advisory validation (#1519): a non-loopback `http://` endpoint sends @@ -250,7 +268,7 @@ impl RouteResolver { // The resolver never inspects credentials: auth is honestly // `Unresolved` at resolution time, not a claimed `Missing`. ResolvedAuthSource::Unresolved, - descriptor.protocol(), + protocol, limits, selected.capabilities, // #3085: honest pricing projected from the matched offering (the @@ -269,6 +287,7 @@ impl RouteResolver { provider_id: &ProviderId, logical_model: &LogicalModelRef, class: ProviderClass, + require_catalog_match: bool, ) -> Result { // OpenCode Go publishes one combined model roster across two wire // protocols. Codewhale's provider is deliberately Chat Completions @@ -282,6 +301,12 @@ impl RouteResolver { model: logical_model.raw().to_string(), } })? + } else if provider_kind == ProviderKind::OpencodeZen { + logical_model + .raw() + .strip_prefix("opencode/") + .or_else(|| logical_model.raw().strip_prefix("opencode-zen/")) + .unwrap_or_else(|| logical_model.raw()) } else { provider_scoped_wire_alias(provider_kind, logical_model.raw(), class) }; @@ -303,6 +328,22 @@ impl RouteResolver { } } + if require_catalog_match { + return Err(RouteError::UnsupportedModelProtocol { + provider: provider_id.clone(), + model: raw.to_string(), + endpoint_key: "unproven".to_string(), + }); + } + + if require_catalog_match { + return Err(RouteError::UnsupportedModelProtocol { + provider: provider_id.clone(), + model: raw.to_string(), + endpoint_key: "unproven".to_string(), + }); + } + // No catalog match. Apply class-specific pass-through rules. match class { ProviderClass::StrictDirect => { @@ -395,9 +436,8 @@ fn provider_scoped_wire_alias( /// Build the default resolver offerings from the bundled Models.dev asset. /// -/// [`bundled_offerings`] is an empty override seam (#4139): when it later gains -/// curated rows again, those win a `(provider, wire id)` collision over the -/// asset. Today the asset is the sole bundled source of truth. +/// Curated transport rows win a `(provider, wire id)` collision over the asset; +/// all other offerings continue to come from Models.dev. fn default_offerings() -> Vec { let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); let mut out = Vec::new(); diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 2bdd8a2b08..9b4fecf547 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -264,8 +264,11 @@ fn descriptor_for_every_kind_has_nonempty_transport_facts() { !d.default_wire_model().as_str().is_empty(), "{kind:?} default_wire_model empty" ); - // protocol() always yields a RequestProtocol; calling it must not panic. - let _: RequestProtocol = d.protocol(); + if d.wire_policy().fixed().is_some() { + let _: RequestProtocol = d + .protocol_for_endpoint("chat") + .expect("fixed provider protocol"); + } } } @@ -273,20 +276,41 @@ fn descriptor_for_every_kind_has_nonempty_transport_facts() { fn descriptor_protocol_matches_provider_wire() { for kind in ProviderKind::ALL { let d = ProviderDescriptor::for_kind(kind); + if kind == ProviderKind::OpencodeZen { + assert_eq!(d.wire_policy(), crate::provider::WirePolicy::ModelAware); + assert_eq!( + d.protocol_for_endpoint("chat"), + Some(RequestProtocol::ChatCompletions) + ); + assert_eq!( + d.protocol_for_endpoint("responses"), + Some(RequestProtocol::Responses) + ); + assert_eq!( + d.protocol_for_endpoint("messages"), + Some(RequestProtocol::AnthropicMessages) + ); + assert_eq!(d.protocol_for_endpoint("models/gemini"), None); + continue; + } assert_eq!( - d.protocol(), - kind.provider().wire(), - "{kind:?} protocol must equal provider().wire()" + d.protocol_for_endpoint("chat"), + kind.provider().wire_policy().fixed(), + "{kind:?} protocol must equal the provider wire policy" ); let expected = match kind { - ProviderKind::OpenaiCodex => RequestProtocol::Responses, + ProviderKind::OpenaiCodex => Some(RequestProtocol::Responses), ProviderKind::DeepseekAnthropic | ProviderKind::Anthropic | ProviderKind::MinimaxAnthropic - | ProviderKind::Openmodel => RequestProtocol::AnthropicMessages, - _ => RequestProtocol::ChatCompletions, + | ProviderKind::Openmodel => Some(RequestProtocol::AnthropicMessages), + _ => Some(RequestProtocol::ChatCompletions), }; - assert_eq!(d.protocol(), expected, "{kind:?} protocol mismatch"); + assert_eq!( + d.protocol_for_endpoint("chat"), + expected, + "{kind:?} protocol mismatch" + ); } } @@ -877,6 +901,83 @@ fn opencode_go_resolver_rejects_messages_models_even_on_custom_base_urls() { } } +#[test] +fn opencode_zen_resolver_selects_protocol_from_documented_model_catalog() { + use super::offering::{ + OPENCODE_ZEN_CHAT_MODELS, OPENCODE_ZEN_MESSAGES_MODELS, OPENCODE_ZEN_RESPONSES_MODELS, + }; + + let resolver = RouteResolver::new(); + let groups = [ + ( + OPENCODE_ZEN_RESPONSES_MODELS, + "responses", + RequestProtocol::Responses, + ), + ( + OPENCODE_ZEN_MESSAGES_MODELS, + "messages", + RequestProtocol::AnthropicMessages, + ), + ( + OPENCODE_ZEN_CHAT_MODELS, + "chat", + RequestProtocol::ChatCompletions, + ), + ]; + + for (models, endpoint_key, protocol) in groups { + for model in models { + for requested in [model.to_string(), format!("opencode/{model}")] { + let route = resolver + .resolve(&req(Some(ProviderKind::OpencodeZen), Some(&requested))) + .unwrap_or_else(|error| panic!("{requested} should resolve: {error}")); + assert_eq!(route.provider_kind(), ProviderKind::OpencodeZen); + assert_eq!(route.wire_model_id().as_str(), *model, "{requested}"); + assert_eq!(route.endpoint().endpoint_key, endpoint_key, "{requested}"); + assert_eq!(route.protocol(), protocol, "{requested}"); + } + } + } + + let automatic = resolver + .resolve(&req(Some(ProviderKind::OpencodeZen), Some("auto"))) + .expect("OpenCode Zen auto should resolve to its documented default"); + assert_eq!(automatic.wire_model_id().as_str(), "gpt-5.5"); + assert_eq!(automatic.protocol(), RequestProtocol::Responses); +} + +#[test] +fn opencode_zen_resolver_fails_closed_for_unproven_protocols() { + let resolver = RouteResolver::new(); + + for model in [ + "gemini-3.1-pro", + "opencode/gemini-3.5-flash", + "unknown-model", + ] { + for base_url_override in [ + None, + Some("https://zen-gateway.example.test/v1".to_string()), + ] { + let request = RouteRequest { + explicit_provider: Some(ProviderKind::OpencodeZen), + model_selector: Some(LogicalModelRef::from(model)), + saved_provider_model: None, + base_url_override, + limit_overrides: Vec::new(), + }; + assert!( + matches!( + resolver.resolve(&request), + Err(RouteError::UnsupportedModelProtocol { .. }) + ), + "{model} must fail closed without a supported protocol mapping" + ); + } + } +} + #[test] fn resolver_deepseek_none_selector_uses_default_wire_id() { let r = RouteResolver::new(); @@ -972,7 +1073,9 @@ fn resolver_protocol_matches_descriptor_for_every_provider() { .unwrap_or_else(|e| panic!("{kind:?} should resolve its own default: {e}")); assert_eq!( out.protocol(), - ProviderDescriptor::for_kind(kind).protocol(), + ProviderDescriptor::for_kind(kind) + .protocol_for_endpoint(&out.endpoint().endpoint_key) + .expect("resolved endpoint protocol"), "{kind:?} candidate protocol must match descriptor" ); assert_eq!( diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 7ca7891e58..9e7b06c5b5 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -994,6 +994,10 @@ struct EnvGuard { opencode_go_api_key: Option, opencode_go_base_url: Option, opencode_go_model: Option, + opencode_zen_api_key: Option, + opencode_api_key: Option, + opencode_zen_base_url: Option, + opencode_zen_model: Option, meta_model_api_key: Option, model_api_key: Option, meta_model_api_base_url: Option, @@ -1026,6 +1030,10 @@ impl EnvGuard { opencode_go_api_key: env::var_os("OPENCODE_GO_API_KEY"), opencode_go_base_url: env::var_os("OPENCODE_GO_BASE_URL"), opencode_go_model: env::var_os("OPENCODE_GO_MODEL"), + opencode_zen_api_key: env::var_os("OPENCODE_ZEN_API_KEY"), + opencode_api_key: env::var_os("OPENCODE_API_KEY"), + opencode_zen_base_url: env::var_os("OPENCODE_ZEN_BASE_URL"), + opencode_zen_model: env::var_os("OPENCODE_ZEN_MODEL"), meta_model_api_key: env::var_os("META_MODEL_API_KEY"), model_api_key: env::var_os("MODEL_API_KEY"), meta_model_api_base_url: env::var_os("META_MODEL_API_BASE_URL"), @@ -1151,6 +1159,10 @@ impl EnvGuard { env::remove_var("OPENCODE_GO_API_KEY"); env::remove_var("OPENCODE_GO_BASE_URL"); env::remove_var("OPENCODE_GO_MODEL"); + env::remove_var("OPENCODE_ZEN_API_KEY"); + env::remove_var("OPENCODE_API_KEY"); + env::remove_var("OPENCODE_ZEN_BASE_URL"); + env::remove_var("OPENCODE_ZEN_MODEL"); env::remove_var("META_MODEL_API_KEY"); env::remove_var("MODEL_API_KEY"); env::remove_var("META_MODEL_API_BASE_URL"); @@ -1299,6 +1311,10 @@ impl Drop for EnvGuard { Self::restore_var("OPENCODE_GO_API_KEY", self.opencode_go_api_key.take()); Self::restore_var("OPENCODE_GO_BASE_URL", self.opencode_go_base_url.take()); Self::restore_var("OPENCODE_GO_MODEL", self.opencode_go_model.take()); + Self::restore_var("OPENCODE_ZEN_API_KEY", self.opencode_zen_api_key.take()); + Self::restore_var("OPENCODE_API_KEY", self.opencode_api_key.take()); + Self::restore_var("OPENCODE_ZEN_BASE_URL", self.opencode_zen_base_url.take()); + Self::restore_var("OPENCODE_ZEN_MODEL", self.opencode_zen_model.take()); Self::restore_var("META_MODEL_API_KEY", self.meta_model_api_key.take()); Self::restore_var("MODEL_API_KEY", self.model_api_key.take()); Self::restore_var( @@ -3073,12 +3089,12 @@ fn load_project_config_keeps_unknown_provider_names_strict() { fs::create_dir_all(&config_dir).expect("mkdir project config"); fs::write( config_dir.join(CONFIG_FILE_NAME), - r#"provider = "opencode_zen" + r#"provider = "acme_zen_gateway" model = "must-not-apply" -[providers.opencode_zen] +[providers.acme_zen_gateway] kind = "openai-compatible" -base_url = "https://opencode.example/v1" +base_url = "https://acme.example/v1" "#, ) .expect("write project config"); @@ -3093,7 +3109,7 @@ base_url = "https://opencode.example/v1" .map(|(path, reason)| (path.to_path_buf(), reason.to_string())) .expect("unknown provider must report why the config was rejected"); assert!(path.ends_with(CONFIG_FILE_NAME), "{path:?}"); - assert!(reason.contains("opencode_zen"), "{reason}"); + assert!(reason.contains("acme_zen_gateway"), "{reason}"); } #[test] @@ -3883,23 +3899,23 @@ fn config_store_preserves_named_custom_provider_identity_across_typed_dispatch_r fs::write( &path, r#"# written by the TUI custom-provider flow -provider = "opencode_zen" +provider = "acme_zen_gateway" -[providers.opencode_zen] +[providers.acme_zen_gateway] kind = "openai-compatible" -base_url = "https://opencode.example/v1" +base_url = "https://acme.example/v1" model = "deepseek-v4-flash-free" -api_key_env = "OPENCODE_ZEN_API_KEY" +api_key_env = "ACME_ZEN_GATEWAY_API_KEY" "#, ) .expect("custom provider fixture"); let mut store = ConfigStore::load(Some(path.clone())).expect("dispatcher config should load"); assert_eq!(store.config.provider, ProviderKind::Custom); - assert_eq!(store.config.provider_id(), "opencode_zen"); + assert_eq!(store.config.provider_id(), "acme_zen_gateway"); assert_eq!( store.config.get_value("provider").as_deref(), - Some("opencode_zen") + Some("acme_zen_gateway") ); assert_eq!( store @@ -3907,7 +3923,7 @@ api_key_env = "OPENCODE_ZEN_API_KEY" .list_values() .get("provider") .map(String::as_str), - Some("opencode_zen") + Some("acme_zen_gateway") ); let resolved = store @@ -3915,7 +3931,7 @@ api_key_env = "OPENCODE_ZEN_API_KEY" .resolve_runtime_options(&CliRuntimeOverrides::default()); assert_eq!(resolved.provider, ProviderKind::Custom); assert_eq!(resolved.provider_source, ProviderSource::Config); - assert_eq!(resolved.base_url, "https://opencode.example/v1"); + assert_eq!(resolved.base_url, "https://acme.example/v1"); assert_eq!(resolved.model, "deepseek-v4-flash-free"); store @@ -3926,7 +3942,7 @@ api_key_env = "OPENCODE_ZEN_API_KEY" .rendered_body() .expect("render custom provider config"); assert!( - rendered.contains("provider = \"opencode_zen\""), + rendered.contains("provider = \"acme_zen_gateway\""), "{rendered}" ); assert!(!rendered.contains("provider = \"custom\""), "{rendered}"); @@ -3934,18 +3950,18 @@ api_key_env = "OPENCODE_ZEN_API_KEY" store.save().expect("save custom provider config"); let reloaded = ConfigStore::load(Some(path)).expect("reload custom provider config"); - assert_eq!(reloaded.config.provider_id(), "opencode_zen"); + assert_eq!(reloaded.config.provider_id(), "acme_zen_gateway"); } #[test] fn named_custom_root_provider_requires_a_matching_openai_compatible_table() { for body in [ - "provider = \"opencode_zen\"\n", - r#"provider = "opencode_zen" + "provider = \"acme_zen_gateway\"\n", + r#"provider = "acme_zen_gateway" -[providers.opencode_zen] +[providers.acme_zen_gateway] kind = "anthropic-messages" -base_url = "https://opencode.example/v1" +base_url = "https://acme.example/v1" "#, ] { let dir = tempfile::tempdir().expect("tempdir"); @@ -3953,7 +3969,7 @@ base_url = "https://opencode.example/v1" fs::write(&path, body).expect("invalid custom provider fixture"); let err = ConfigStore::load(Some(path)).expect_err("invalid custom route should fail"); let message = format!("{err:#}"); - assert!(message.contains("opencode_zen"), "{message}"); + assert!(message.contains("acme_zen_gateway"), "{message}"); assert!(message.contains("openai-compatible") || message.contains("matching")); } } @@ -4004,7 +4020,10 @@ fn deepseek_anthropic_route_defaults_to_anthropic_endpoint() { DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL ); assert_eq!(provider.env_vars(), &["DEEPSEEK_API_KEY"]); - assert_eq!(provider.wire(), provider::WireFormat::AnthropicMessages); + assert_eq!( + provider.wire_policy().fixed(), + Some(provider::WireFormat::AnthropicMessages) + ); let config = ConfigToml { provider: ProviderKind::DeepseekAnthropic, @@ -4047,7 +4066,10 @@ fn openmodel_route_defaults_to_messages_endpoint() { assert_eq!(provider.default_model(), DEFAULT_OPENMODEL_MODEL); assert_eq!(provider.default_base_url(), DEFAULT_OPENMODEL_BASE_URL); assert_eq!(provider.env_vars(), &["OPENMODEL_API_KEY"]); - assert_eq!(provider.wire(), provider::WireFormat::AnthropicMessages); + assert_eq!( + provider.wire_policy().fixed(), + Some(provider::WireFormat::AnthropicMessages) + ); let config = ConfigToml { provider: ProviderKind::Openmodel, @@ -4121,7 +4143,10 @@ fn opencode_go_resolves_current_chat_completions_route() { assert_eq!(metadata.default_base_url(), DEFAULT_OPENCODE_GO_BASE_URL); assert_eq!(metadata.default_model(), DEFAULT_OPENCODE_GO_MODEL); assert_eq!(metadata.env_vars(), &["OPENCODE_GO_API_KEY"]); - assert_eq!(metadata.wire(), provider::WireFormat::ChatCompletions); + assert_eq!( + metadata.wire_policy().fixed(), + Some(provider::WireFormat::ChatCompletions) + ); let config: ConfigToml = toml::from_str( r#" @@ -4206,7 +4231,10 @@ fn telecomjs_resolves_key_scoped_chat_completions_route() { assert_eq!(metadata.default_base_url(), DEFAULT_TELECOMJS_BASE_URL); assert_eq!(metadata.default_model(), DEFAULT_TELECOMJS_MODEL); assert_eq!(metadata.env_vars(), &["TELECOMJS_API_KEY"]); - assert_eq!(metadata.wire(), provider::WireFormat::ChatCompletions); + assert_eq!( + metadata.wire_policy(), + provider::WirePolicy::Fixed(provider::WireFormat::ChatCompletions) + ); let config: ConfigToml = toml::from_str( r#" @@ -4248,6 +4276,63 @@ model = "glm-5.2" assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); } +#[test] +fn opencode_zen_configures_model_aware_provider_with_catalog_proof() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + for alias in [ + "opencode-zen", + "opencode_zen", + "opencodezen", + "zen", + "opencode", + ] { + assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::OpencodeZen)); + } + + let metadata = provider::resolve_provider("opencode_zen").expect("Zen provider metadata"); + assert_eq!(metadata.id(), "opencode-zen"); + assert_eq!(metadata.display_name(), "OpenCode Zen"); + assert_eq!(metadata.provider_config_key(), "opencode_zen"); + assert_eq!(metadata.default_base_url(), DEFAULT_OPENCODE_ZEN_BASE_URL); + assert_eq!(metadata.default_model(), DEFAULT_OPENCODE_ZEN_MODEL); + assert_eq!( + metadata.env_vars(), + &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"] + ); + assert_eq!(metadata.wire_policy(), provider::WirePolicy::ModelAware); + + let config: ConfigToml = toml::from_str( + r#" +provider = "opencode-zen" + +[providers.opencode_zen] +api_key = "zen-config-key" +base_url = "https://zen-gateway.example/v1" +model = "gpt-5.5" +"#, + ) + .expect("OpenCode Zen provider table"); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.provider, ProviderKind::OpencodeZen); + assert_eq!(resolved.base_url, "https://zen-gateway.example/v1"); + assert_eq!(resolved.model, "gpt-5.5"); + assert_eq!(resolved.api_key.as_deref(), Some("zen-config-key")); + + let resolver = crate::route::RouteResolver::new(); + let route = resolver + .resolve(&crate::route::RouteRequest { + explicit_provider: Some(ProviderKind::OpencodeZen), + model_selector: Some(crate::route::LogicalModelRef::from("gpt-5.5")), + saved_provider_model: None, + base_url_override: None, + limit_overrides: Vec::new(), + }) + .expect("documented Zen model must resolve"); + assert_eq!(route.protocol(), crate::route::RequestProtocol::Responses); + assert_eq!(route.endpoint().endpoint_key, "responses"); +} + #[test] fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() { let _lock = env_lock(); @@ -4391,14 +4476,15 @@ fn provider_metadata_defaults_match_runtime_helpers() { // Anthropic-compatible routes speak the native Messages API; every // other built-in provider is OpenAI-compatible Chat Completions. let expected_wire = match kind { - ProviderKind::OpenaiCodex => provider::WireFormat::Responses, + ProviderKind::OpencodeZen => None, + ProviderKind::OpenaiCodex => Some(provider::WireFormat::Responses), ProviderKind::Anthropic | ProviderKind::DeepseekAnthropic | ProviderKind::MinimaxAnthropic - | ProviderKind::Openmodel => provider::WireFormat::AnthropicMessages, - _ => provider::WireFormat::ChatCompletions, + | ProviderKind::Openmodel => Some(provider::WireFormat::AnthropicMessages), + _ => Some(provider::WireFormat::ChatCompletions), }; - assert_eq!(provider.wire(), expected_wire); + assert_eq!(provider.wire_policy().fixed(), expected_wire); } } diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 429618cbb2..9cf27ee02b 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -1083,6 +1083,9 @@ pub fn env_for(name: &str) -> Option { "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"], "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"], "opencode-go" | "opencode_go" | "opencodego" => &["OPENCODE_GO_API_KEY"], + "opencode-zen" | "opencode_zen" | "opencodezen" | "zen" | "opencode" => { + &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"] + } "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse" | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"], "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"], @@ -1144,6 +1147,8 @@ mod tests { "SAKANA_API_KEY", "LONGCAT_API_KEY", "OPENCODE_GO_API_KEY", + "OPENCODE_ZEN_API_KEY", + "OPENCODE_API_KEY", "META_MODEL_API_KEY", "MODEL_API_KEY", "XAI_API_KEY", @@ -1649,6 +1654,25 @@ mod tests { clear_known_envs(); } + #[test] + fn opencode_zen_env_aliases_resolve() { + let _guard = env_lock(); + clear_known_envs(); + unsafe { std::env::set_var("OPENCODE_ZEN_API_KEY", "zen-key") }; + + for alias in [ + "opencode-zen", + "opencode_zen", + "opencodezen", + "zen", + "opencode", + ] { + assert_eq!(env_for(alias).as_deref(), Some("zen-key"), "{alias}"); + } + + clear_known_envs(); + } + #[test] fn meta_model_api_env_aliases_resolve() { let _guard = env_lock(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 7026393d9f..41d79a9913 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -204,6 +204,10 @@ surface. of this narrow route until Codewhale supports per-model wire selection (#1481 by @seanthefuturegorilla; implementation harvested from PR #773 by @zhangweiii and PR #1050 by @sternelee). +- Add OpenCode Zen as a separate model-aware API-key provider. Its curated + model catalog selects Responses, Anthropic Messages, or Chat Completions per + model; unsupported Gemini and unknown models fail closed, and Zen missing + credentials never fall through to ChatGPT/Codex OAuth guidance. - Add TelecomJS TokenHub as a first-class Chat Completions provider with `[providers.telecomjs]`, `TELECOMJS_API_KEY`, and a key-scoped live `/v1/models` refresh. Models.dev and provider-specific catalogs remain in diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 8e0672a747..84b854c300 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -21,7 +21,7 @@ use codewhale_config::catalog::{ ProviderCatalogCache, ProviderCatalogDelta, base_url_fingerprint, now_unix, }; use codewhale_config::provider::WireFormat; -use codewhale_config::route::ReadyRouteCandidate; +use codewhale_config::route::{LogicalModelRef, ReadyRouteCandidate, RouteRequest, RouteResolver}; use codewhale_config::{auth_mode_disables_api_key, is_upstream_auth_header}; use crate::config::{ @@ -954,7 +954,18 @@ fn add_extra_root_certs( impl DeepSeekClient { /// Create a DeepSeek client from CLI configuration. pub fn new(config: &Config) -> Result { - Self::from_parts(config.deepseek_base_url(), config.default_model(), config) + let api_provider = config.api_provider(); + if api_provider == ApiProvider::OpencodeZen { + let route = crate::route_runtime::resolve_runtime_route(config, api_provider, None) + .map_err(anyhow::Error::msg)?; + return Self::from_candidate(&route.config, &route.candidate); + } + Self::from_parts( + config.deepseek_base_url(), + config.default_model(), + provider_default_wire_format(api_provider), + config, + ) } /// Create a DeepSeek client whose transport is bound to a runtime-resolved @@ -967,15 +978,12 @@ impl DeepSeekClient { /// an auth-source *class*), so the API key and provider are still read from /// `config`. pub fn from_candidate(config: &Config, candidate: &ReadyRouteCandidate) -> Result { - let mut client = Self::from_parts( + Self::from_parts( candidate.endpoint().base_url.clone(), candidate.wire_model_id().as_str().to_string(), + candidate.protocol(), config, - )?; - // #185: dispatch the wire format from the resolved route offering, - // not from the provider enum. - client.wire_format = candidate.protocol(); - Ok(client) + ) } /// Shared constructor body for [`Self::new`] and [`Self::from_candidate`]. @@ -983,7 +991,12 @@ impl DeepSeekClient { /// `base_url` and `default_model` are the only inputs that differ between /// the two entry points; everything else (auth, provider, retry, headers, /// timeouts) is derived from `config` so the two paths cannot drift. - fn from_parts(base_url: String, default_model: String, config: &Config) -> Result { + fn from_parts( + base_url: String, + default_model: String, + wire_format: WireFormat, + config: &Config, + ) -> Result { let api_provider = config.api_provider(); if api_provider == ApiProvider::OpencodeGo { validate_route(api_provider, &default_model).map_err(anyhow::Error::msg)?; @@ -1051,6 +1064,7 @@ impl DeepSeekClient { &http_headers, api_provider, &base_url, + wire_format, auth_disabled, false, )?; @@ -1062,18 +1076,11 @@ impl DeepSeekClient { &http_headers, api_provider, &base_url, + wire_format, auth_disabled, true, )?; - let wire_format = if api_provider == ApiProvider::OpenaiCodex { - WireFormat::Responses - } else if api_provider_uses_anthropic_messages(api_provider) { - WireFormat::AnthropicMessages - } else { - WireFormat::ChatCompletions - }; - Ok(Self { http_client, http1_client, @@ -1147,6 +1154,32 @@ impl DeepSeekClient { redact_model_bound_text(text, &self.model_bound_secret_values) } + fn bind_request_to_protocol(&self, mut request: MessageRequest) -> Result { + if self.api_provider != ApiProvider::OpencodeZen { + return Ok(request); + } + + let candidate = RouteResolver::new() + .resolve(&RouteRequest { + explicit_provider: ApiProvider::OpencodeZen.kind(), + model_selector: Some(LogicalModelRef::from(request.model.as_str())), + saved_provider_model: None, + base_url_override: Some(self.base_url.clone()), + limit_overrides: Vec::new(), + }) + .map_err(anyhow::Error::msg)?; + if candidate.protocol() != self.wire_format { + bail!( + "OpenCode Zen model {:?} uses {:?}, but this client is bound to {:?}; resolve a new model route before sending", + request.model, + candidate.protocol(), + self.wire_format + ); + } + request.model = candidate.wire_model_id().as_str().to_string(); + Ok(request) + } + #[cfg(test)] fn build_http_client( api_key: &str, @@ -1159,6 +1192,7 @@ impl DeepSeekClient { extra_headers, api_provider, base_url, + provider_default_wire_format(api_provider), false, false, ) @@ -1169,6 +1203,7 @@ impl DeepSeekClient { extra_headers: &HashMap, api_provider: ApiProvider, base_url: &str, + wire_format: WireFormat, auth_disabled: bool, force_http1: bool, ) -> Result { @@ -1177,6 +1212,7 @@ impl DeepSeekClient { extra_headers, api_provider, base_url, + wire_format, auth_disabled, )?; let mut builder = crate::tls::reqwest_client_builder() @@ -1218,6 +1254,7 @@ impl DeepSeekClient { extra_headers, ApiProvider::Deepseek, crate::config::DEFAULT_DEEPSEEK_BASE_URL, + WireFormat::ChatCompletions, false, ) } @@ -1229,7 +1266,14 @@ impl DeepSeekClient { api_provider: ApiProvider, base_url: &str, ) -> Result { - build_default_headers(api_key, extra_headers, api_provider, base_url, false) + build_default_headers( + api_key, + extra_headers, + api_provider, + base_url, + provider_default_wire_format(api_provider), + false, + ) } #[cfg(test)] @@ -1239,7 +1283,14 @@ impl DeepSeekClient { api_provider: ApiProvider, base_url: &str, ) -> Result { - build_default_headers(api_key, extra_headers, api_provider, base_url, true) + build_default_headers( + api_key, + extra_headers, + api_provider, + base_url, + provider_default_wire_format(api_provider), + true, + ) } } @@ -1248,12 +1299,14 @@ fn build_default_headers( extra_headers: &HashMap, api_provider: ApiProvider, base_url: &str, + wire_format: WireFormat, auth_disabled: bool, ) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); let api_key = api_key.trim(); - if api_provider_uses_anthropic_messages(api_provider) { + let uses_anthropic_messages = wire_format == WireFormat::AnthropicMessages; + if uses_anthropic_messages { // #3014: most Messages API routes authenticate with `x-api-key`. // OpenModel also supports Bearer auth for Messages, and its `/models` // endpoint requires it, so the header chooser below keeps OpenModel on @@ -1266,7 +1319,7 @@ fn build_default_headers( let auth_header_name = if auth_disabled { None } else if !api_key.is_empty() - && api_provider_uses_anthropic_messages(api_provider) + && uses_anthropic_messages && api_provider != ApiProvider::Openmodel { Some(HeaderName::from_static("x-api-key")) @@ -1317,14 +1370,21 @@ fn is_auth_dialect_header(header_name: &HeaderName) -> bool { || header_name == HeaderName::from_static("x-api-key") } -fn api_provider_uses_anthropic_messages(api_provider: ApiProvider) -> bool { - matches!( - api_provider, - ApiProvider::Anthropic - | ApiProvider::DeepseekAnthropic - | ApiProvider::MinimaxAnthropic - | ApiProvider::Openmodel - ) +fn provider_default_wire_format(api_provider: ApiProvider) -> WireFormat { + api_provider + .kind() + .and_then(|kind| { + codewhale_config::provider::provider_for_kind(kind) + .wire_policy() + .fixed() + }) + .unwrap_or_else(|| { + if api_provider == ApiProvider::OpencodeZen { + WireFormat::Responses + } else { + WireFormat::ChatCompletions + } + }) } fn api_provider_skips_models_probe(api_provider: ApiProvider) -> bool { @@ -1354,8 +1414,15 @@ pub async fn verify_provider_api_key( // way; accept the key optimistically (same as health_check). return Ok(()); } - let headers = build_default_headers(api_key, &Default::default(), provider, base_url, false) - .map_err(|err| format!("failed to build auth headers: {err:#}"))?; + let headers = build_default_headers( + api_key, + &Default::default(), + provider, + base_url, + provider_default_wire_format(provider), + false, + ) + .map_err(|err| format!("failed to build auth headers: {err:#}"))?; let client = crate::tls::reqwest_client_builder() .default_headers(headers) .user_agent(concat!( @@ -1541,10 +1608,17 @@ impl DeepSeekClient { target_language: &str, ) -> Result { let model = wire_model_for_provider_route(self.api_provider, &self.base_url, model); - if self.wire_format == WireFormat::AnthropicMessages { - let response = self - .handle_anthropic_message(translation_message_request(text, model, target_language)) - .await?; + if self.wire_format != WireFormat::ChatCompletions { + let request = self.bind_request_to_protocol(translation_message_request( + text, + model, + target_language, + ))?; + let response = match self.wire_format { + WireFormat::Responses => self.handle_responses_message(request).await?, + WireFormat::AnthropicMessages => self.handle_anthropic_message(request).await?, + WireFormat::ChatCompletions => unreachable!(), + }; return translation_text_from_response(&response); } @@ -2116,7 +2190,7 @@ impl LlmClient for DeepSeekClient { async fn create_message(&self, request: MessageRequest) -> Result { let _permit = self.acquire_provider_request_permit().await; - let request = self.prepare_model_bound_request(request); + let request = self.bind_request_to_protocol(self.prepare_model_bound_request(request))?; match self.wire_format { WireFormat::Responses => self.handle_responses_message(request).await, WireFormat::AnthropicMessages => self.handle_anthropic_message(request).await, @@ -2129,7 +2203,7 @@ impl LlmClient for DeepSeekClient { request: MessageRequest, ) -> Result { let permit = self.acquire_provider_request_permit().await; - let request = self.prepare_model_bound_request(request); + let request = self.bind_request_to_protocol(self.prepare_model_bound_request(request))?; let stream = match self.wire_format { WireFormat::Responses => self.handle_responses_stream(request).await?, WireFormat::AnthropicMessages => self.handle_anthropic_stream(request).await?, @@ -2582,7 +2656,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Stepfun => {} ApiProvider::Sakana => {} ApiProvider::LongCat => {} - ApiProvider::OpencodeGo => {} + ApiProvider::OpencodeGo | ApiProvider::OpencodeZen => {} ApiProvider::Meta => {} ApiProvider::Xai => {} }, @@ -2680,7 +2754,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Stepfun => {} ApiProvider::Sakana => {} ApiProvider::LongCat => {} - ApiProvider::OpencodeGo => {} + ApiProvider::OpencodeGo | ApiProvider::OpencodeZen => {} ApiProvider::Meta => {} ApiProvider::Xai => {} }, @@ -2758,7 +2832,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Stepfun => {} ApiProvider::Sakana => {} ApiProvider::LongCat => {} - ApiProvider::OpencodeGo => {} + ApiProvider::OpencodeGo | ApiProvider::OpencodeZen => {} ApiProvider::Meta => {} ApiProvider::Xai => {} }, @@ -2846,10 +2920,13 @@ impl DeepSeekClient { suffix: &str, max_tokens: u32, ) -> anyhow::Result { - if self.wire_format == WireFormat::AnthropicMessages { + if self.api_provider == ApiProvider::OpencodeZen + || self.wire_format != WireFormat::ChatCompletions + { bail!( - "FIM completion is not supported for {} because it uses the Anthropic Messages protocol", - self.api_provider.display_name() + "FIM completion is not supported for {} because the route has no proven FIM wire contract ({:?})", + self.api_provider.display_name(), + self.wire_format ); } let url = api_url_with_suffix(&self.base_url, "beta/completions", None); @@ -3709,6 +3786,191 @@ mod tests { assert_kimi_code_captures_exact_general_child_catalog().await; } + fn opencode_zen_client(server: &MockServer, model: &str) -> DeepSeekClient { + let config = Config { + provider: Some("opencode-zen".to_string()), + providers: Some(ProvidersConfig { + opencode_zen: ProviderConfig { + api_key: Some("zen-test-key".to_string()), + base_url: Some(server.uri()), + model: Some(model.to_string()), + ..ProviderConfig::default() + }, + ..ProvidersConfig::default() + }), + ..Config::default() + }; + DeepSeekClient::new(&config).expect("OpenCode Zen client should resolve its model route") + } + + fn minimal_zen_request(model: &str) -> MessageRequest { + translation_message_request("hello", model.to_string(), "English") + } + + fn assert_zen_bearer_without_codex_headers(request: &wiremock::Request) { + assert_eq!( + request + .headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer zen-test-key") + ); + for forbidden in [ + "openai-beta", + "originator", + "chatgpt-account-id", + "x-api-key", + ] { + assert!( + request.headers.get(forbidden).is_none(), + "Zen request must not include {forbidden}" + ); + } + } + + fn assert_zen_messages_api_key_without_bearer(request: &wiremock::Request) { + assert_eq!( + request + .headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + Some("zen-test-key") + ); + assert!( + request.headers.get(AUTHORIZATION).is_none(), + "Zen Messages request must not include Authorization" + ); + for forbidden in ["openai-beta", "originator", "chatgpt-account-id"] { + assert!( + request.headers.get(forbidden).is_none(), + "Zen request must not include {forbidden}" + ); + } + } + + #[tokio::test] + async fn opencode_zen_responses_request_uses_responses_route_without_oauth_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string("data: [DONE]\n\n"), + ) + .expect(1) + .mount(&server) + .await; + + let client = opencode_zen_client(&server, "gpt-5.5"); + assert_eq!(client.wire_format, WireFormat::Responses); + let mut stream = client + .create_message_stream(minimal_zen_request("gpt-5.5")) + .await + .expect("Zen Responses request should start"); + while let Some(event) = stream.next().await { + event.expect("Zen Responses stream event"); + } + + let requests = server.received_requests().await.expect("recorded request"); + assert_eq!(requests.len(), 1); + assert_zen_bearer_without_codex_headers(&requests[0]); + let body: Value = serde_json::from_slice(&requests[0].body).expect("Responses JSON body"); + assert_eq!(body.get("model").and_then(Value::as_str), Some("gpt-5.5")); + assert!(body.get("input").is_some(), "Responses body: {body}"); + assert!(body.get("messages").is_none(), "Responses body: {body}"); + } + + #[tokio::test] + async fn opencode_zen_messages_request_shape_uses_api_key_anthropic_route() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_zen", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + "model": "claude-sonnet-4-6", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": {"input_tokens": 1, "output_tokens": 1} + }))) + .expect(1) + .mount(&server) + .await; + + let client = opencode_zen_client(&server, "claude-sonnet-4-6"); + assert_eq!(client.wire_format, WireFormat::AnthropicMessages); + client + .create_message(minimal_zen_request("claude-sonnet-4-6")) + .await + .expect("Zen Messages request should succeed"); + + let requests = server.received_requests().await.expect("recorded request"); + assert_eq!(requests.len(), 1); + assert_zen_messages_api_key_without_bearer(&requests[0]); + assert_eq!( + requests[0] + .headers + .get("anthropic-version") + .and_then(|value| value.to_str().ok()), + Some("2023-06-01") + ); + } + + #[tokio::test] + async fn opencode_zen_chat_request_uses_chat_completions_route() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl_zen", + "object": "chat.completion", + "model": "deepseek-v4-pro", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + }))) + .expect(1) + .mount(&server) + .await; + + let client = opencode_zen_client(&server, "deepseek-v4-pro"); + assert_eq!(client.wire_format, WireFormat::ChatCompletions); + client + .create_message(minimal_zen_request("deepseek-v4-pro")) + .await + .expect("Zen Chat Completions request should succeed"); + + let requests = server.received_requests().await.expect("recorded request"); + assert_eq!(requests.len(), 1); + assert_zen_bearer_without_codex_headers(&requests[0]); + assert!(requests[0].headers.get("anthropic-version").is_none()); + } + + #[tokio::test] + async fn opencode_zen_client_fails_closed_when_request_model_changes_protocol() { + let server = MockServer::start().await; + let client = opencode_zen_client(&server, "gpt-5.5"); + + let error = client + .create_message(minimal_zen_request("claude-sonnet-4-6")) + .await + .expect_err("a Responses-bound client must not send a Messages model"); + assert!(format!("{error:#}").contains("resolve a new model route")); + assert!( + server + .received_requests() + .await + .expect("recorded requests") + .is_empty() + ); + } + const CONFIG_SECRET_SENTINELS: [&str; 8] = [ "deepseek-config-secret-001", "arcee-config-secret-002", @@ -4968,7 +5230,7 @@ mod tests { message.contains("FIM completion is not supported"), "{message}" ); - assert!(message.contains("Anthropic Messages protocol"), "{message}"); + assert!(message.contains("no proven FIM wire contract"), "{message}"); let requests = server.received_requests().await.expect("recorded requests"); assert!( requests.is_empty(), diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index df7ffd20a6..179676098e 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -19,7 +19,7 @@ use crate::models::{ use crate::tools::schema_sanitize; use super::{ - DeepSeekClient, ERROR_BODY_MAX_BYTES, bounded_error_text, from_api_tool_name, + DeepSeekClient, ERROR_BODY_MAX_BYTES, api_url, bounded_error_text, from_api_tool_name, system_to_instructions, to_api_tool_name, }; @@ -83,7 +83,12 @@ impl DeepSeekClient { request: MessageRequest, ) -> Result { let body = build_responses_body(&request); - let url = format!("{}{}", self.base_url, CODEX_RESPONSES_PATH); + let is_codex = self.api_provider == crate::config::ApiProvider::OpenaiCodex; + let url = if is_codex { + format!("{}{}", self.base_url, CODEX_RESPONSES_PATH) + } else { + api_url(&self.base_url, "responses") + }; // The bearer Authorization header is already installed as a default // header on both the dual and the HTTP/1.1 twin client (resolved from @@ -117,11 +122,14 @@ impl DeepSeekClient { let mut builder = client .post(&url) .header("Content-Type", "application/json") - .header("Accept", "text/event-stream") - .header("OpenAI-Beta", "responses=experimental") - .header("originator", "codex_cli_rs"); - if let Some(account_id) = &account_id { - builder = builder.header("chatgpt-account-id", account_id); + .header("Accept", "text/event-stream"); + if is_codex { + builder = builder + .header("OpenAI-Beta", "responses=experimental") + .header("originator", "codex_cli_rs"); + if let Some(account_id) = &account_id { + builder = builder.header("chatgpt-account-id", account_id); + } } builder.body(request_body.clone()) }) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 86255c0cf8..46950e6cfb 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -70,6 +70,7 @@ pub enum ApiProvider { Sakana, LongCat, OpencodeGo, + OpencodeZen, Meta, Xai, /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway. @@ -229,7 +230,7 @@ impl ApiProvider { /// `ApiProvider` discriminant → `ProviderKind` lookup. /// Index 1 is `None` for the legacy `DeepseekCN` variant. - const KIND_LOOKUP: [Option; 37] = [ + const KIND_LOOKUP: [Option; 38] = [ Some(codewhale_config::ProviderKind::Deepseek), None, // DeepseekCN Some(codewhale_config::ProviderKind::DeepseekAnthropic), @@ -263,6 +264,7 @@ impl ApiProvider { Some(codewhale_config::ProviderKind::Sakana), Some(codewhale_config::ProviderKind::LongCat), Some(codewhale_config::ProviderKind::OpencodeGo), + Some(codewhale_config::ProviderKind::OpencodeZen), Some(codewhale_config::ProviderKind::Meta), Some(codewhale_config::ProviderKind::Xai), Some(codewhale_config::ProviderKind::Telecomjs), @@ -270,7 +272,7 @@ impl ApiProvider { ]; /// `ProviderKind` discriminant → `ApiProvider` lookup. - const FROM_KIND_LOOKUP: [Self; 36] = [ + const FROM_KIND_LOOKUP: [Self; 37] = [ Self::Deepseek, Self::DeepseekAnthropic, Self::NvidiaNim, @@ -303,6 +305,7 @@ impl ApiProvider { Self::Sakana, Self::LongCat, Self::OpencodeGo, + Self::OpencodeZen, Self::Meta, Self::Xai, Self::Telecomjs, @@ -402,6 +405,10 @@ fn subagent_provider_key_matches(key: &str, provider: ApiProvider) -> bool { ApiProvider::OpencodeGo => { matches!(normalized.as_str(), "opencode_go" | "opencodego") } + ApiProvider::OpencodeZen => matches!( + normalized.as_str(), + "opencode_zen" | "opencodezen" | "zen" | "opencode" + ), ApiProvider::Meta => matches!( normalized.as_str(), "meta" | "meta_ai" | "meta_model_api" | "muse" | "muse_spark" @@ -1308,6 +1315,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Sakana => vec![DEFAULT_SAKANA_MODEL, SAKANA_FUGU_ULTRA_MODEL], ApiProvider::LongCat => vec![DEFAULT_LONGCAT_MODEL], ApiProvider::OpencodeGo => OPENCODE_GO_CHAT_MODELS.to_vec(), + ApiProvider::OpencodeZen => vec![DEFAULT_OPENCODE_ZEN_MODEL], ApiProvider::Meta => vec![DEFAULT_META_MODEL], ApiProvider::Xai => vec![ DEFAULT_XAI_MODEL, @@ -2909,6 +2917,14 @@ pub struct ProvidersConfig { pub longcat: ProviderConfig, #[serde(default, alias = "opencode-go", alias = "opencodego")] pub opencode_go: ProviderConfig, + #[serde( + default, + alias = "opencode-zen", + alias = "opencodezen", + alias = "zen", + alias = "opencode" + )] + pub opencode_zen: ProviderConfig, #[serde( default, alias = "meta-ai", @@ -2981,6 +2997,7 @@ impl ProvidersConfig { ("providers.minimax_anthropic", &self.minimax_anthropic), ("providers.sakana", &self.sakana), ("providers.opencode_go", &self.opencode_go), + ("providers.opencode_zen", &self.opencode_zen), ("providers.meta", &self.meta), ("providers.xai", &self.xai), ]; @@ -4277,6 +4294,7 @@ impl Config { ApiProvider::Sakana => &providers.sakana, ApiProvider::LongCat => &providers.longcat, ApiProvider::OpencodeGo => &providers.opencode_go, + ApiProvider::OpencodeZen => &providers.opencode_zen, ApiProvider::Meta => &providers.meta, ApiProvider::Xai => &providers.xai, ApiProvider::Telecomjs => &providers.telecomjs, @@ -4343,6 +4361,7 @@ impl Config { ApiProvider::Sakana => &mut providers.sakana, ApiProvider::LongCat => &mut providers.longcat, ApiProvider::OpencodeGo => &mut providers.opencode_go, + ApiProvider::OpencodeZen => &mut providers.opencode_zen, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, ApiProvider::Telecomjs => &mut providers.telecomjs, @@ -4677,6 +4696,7 @@ impl Config { ApiProvider::Sakana => DEFAULT_SAKANA_MODEL, ApiProvider::LongCat => DEFAULT_LONGCAT_MODEL, ApiProvider::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL, + ApiProvider::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL, ApiProvider::Meta => DEFAULT_META_MODEL, ApiProvider::Xai => DEFAULT_XAI_MODEL, ApiProvider::Telecomjs => DEFAULT_TELECOMJS_MODEL, @@ -4736,6 +4756,7 @@ impl Config { | ApiProvider::Sakana | ApiProvider::LongCat | ApiProvider::OpencodeGo + | ApiProvider::OpencodeZen | ApiProvider::Meta | ApiProvider::Xai | ApiProvider::Telecomjs => None, @@ -4802,6 +4823,7 @@ impl Config { ApiProvider::Sakana => DEFAULT_SAKANA_BASE_URL, ApiProvider::LongCat => DEFAULT_LONGCAT_BASE_URL, ApiProvider::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL, + ApiProvider::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL, ApiProvider::Meta => DEFAULT_META_BASE_URL, ApiProvider::Xai => DEFAULT_XAI_BASE_URL, ApiProvider::Telecomjs => DEFAULT_TELECOMJS_BASE_URL, @@ -5293,6 +5315,9 @@ impl Config { ApiProvider::Anthropic | ApiProvider::Openmodel => { anyhow::bail!("{}", missing_provider_api_key_message(provider)?) } + ApiProvider::OpencodeZen => { + anyhow::bail!("{}", missing_provider_api_key_message(provider)?) + } ApiProvider::OpenaiCodex => anyhow::bail!("{}", crate::oauth::missing_auth_message()), ApiProvider::Xai => { // Prefer OAuth guidance when auth_mode requests it or Grok CLI @@ -6198,6 +6223,7 @@ fn provider_env_base_url_override(provider: ApiProvider) -> Option { ApiProvider::Xai => &["XAI_BASE_URL"], ApiProvider::Telecomjs => &["TELECOMJS_BASE_URL"], ApiProvider::OpencodeGo => &["OPENCODE_GO_BASE_URL"], + ApiProvider::OpencodeZen => &["OPENCODE_ZEN_BASE_URL"], ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic @@ -6487,6 +6513,13 @@ fn apply_env_overrides_unlocked(config: &mut Config) { .opencode_go .base_url = Some(value); } + ApiProvider::OpencodeZen => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .opencode_zen + .base_url = Some(value); + } ApiProvider::Meta => { config .providers @@ -6776,6 +6809,7 @@ fn apply_env_overrides_unlocked(config: &mut Config) { ApiProvider::Sakana => &mut providers.sakana, ApiProvider::LongCat => &mut providers.longcat, ApiProvider::OpencodeGo => &mut providers.opencode_go, + ApiProvider::OpencodeZen => &mut providers.opencode_zen, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, ApiProvider::Telecomjs => &mut providers.telecomjs, @@ -6973,6 +7007,16 @@ fn apply_env_overrides_unlocked(config: &mut Config) { .telecomjs .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::OpencodeZen) + && let Ok(value) = std::env::var("OPENCODE_ZEN_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .opencode_zen + .model = Some(value); + } if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL") .ok() .or_else(|| { @@ -7048,6 +7092,7 @@ fn apply_env_overrides_unlocked(config: &mut Config) { ApiProvider::Sakana => &mut providers.sakana, ApiProvider::LongCat => &mut providers.longcat, ApiProvider::OpencodeGo => &mut providers.opencode_go, + ApiProvider::OpencodeZen => &mut providers.opencode_zen, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, ApiProvider::Telecomjs => &mut providers.telecomjs, @@ -8071,6 +8116,7 @@ fn merge_providers( sakana: merge_provider_config(base.sakana, override_cfg.sakana), longcat: merge_provider_config(base.longcat, override_cfg.longcat), opencode_go: merge_provider_config(base.opencode_go, override_cfg.opencode_go), + opencode_zen: merge_provider_config(base.opencode_zen, override_cfg.opencode_zen), meta: merge_provider_config(base.meta, override_cfg.meta), xai: merge_provider_config(base.xai, override_cfg.xai), telecomjs: merge_provider_config(base.telecomjs, override_cfg.telecomjs), diff --git a/crates/tui/src/config/models.rs b/crates/tui/src/config/models.rs index 58c307347e..b2c8c0081d 100644 --- a/crates/tui/src/config/models.rs +++ b/crates/tui/src/config/models.rs @@ -188,6 +188,8 @@ pub const DEFAULT_LONGCAT_BASE_URL: &str = "https://api.longcat.chat/openai/v1"; pub const DEFAULT_OPENCODE_GO_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_OPENCODE_GO_BASE_URL: &str = "https://opencode.ai/zen/go/v1"; pub use codewhale_config::OPENCODE_GO_CHAT_MODELS; +pub const DEFAULT_OPENCODE_ZEN_MODEL: &str = "gpt-5.5"; +pub const DEFAULT_OPENCODE_ZEN_BASE_URL: &str = "https://opencode.ai/zen/v1"; pub const DEFAULT_META_MODEL: &str = "muse-spark-1.1"; pub const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1"; pub const DEFAULT_XAI_MODEL: &str = "grok-4.5"; diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 1dc3c6f134..4135153099 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -245,6 +245,19 @@ fn missing_provider_api_key_message_uses_provider_metadata() -> Result<()> { Ok(()) } +#[test] +fn opencode_zen_missing_credentials_never_mentions_codex_oauth() -> Result<()> { + let message = missing_provider_api_key_message(ApiProvider::OpencodeZen)?; + assert!(message.contains("OpenCode Zen API key not found")); + assert!(message.contains("OPENCODE_ZEN_API_KEY")); + assert!(message.contains("OPENCODE_API_KEY")); + assert!(message.contains("[providers.opencode_zen]")); + assert!(!message.contains("codex login")); + assert!(!message.contains("ChatGPT")); + assert!(!message.contains("auth.json")); + Ok(()) +} + // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in. #[test] fn allow_shell_defaults_to_false_when_unset() { diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs index 0602f081f0..9f84b4eaa8 100644 --- a/crates/tui/src/config_persistence.rs +++ b/crates/tui/src/config_persistence.rs @@ -301,6 +301,7 @@ fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static ApiProvider::Sakana => Ok("sakana"), ApiProvider::LongCat => Ok("longcat"), ApiProvider::OpencodeGo => Ok("opencode_go"), + ApiProvider::OpencodeZen => Ok("opencode_zen"), ApiProvider::Meta => Ok("meta"), ApiProvider::Xai => Ok("xai"), ApiProvider::Telecomjs => Ok("telecomjs"), diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index fd88e2b1e3..90917606a2 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -6344,14 +6344,17 @@ fn doctor_provider_source(config: &Config) -> &'static str { } fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str { - match provider + let policy = provider .metadata() - .map(|metadata| metadata.wire()) - .unwrap_or(codewhale_config::provider::WireFormat::ChatCompletions) - { - codewhale_config::provider::WireFormat::ChatCompletions => "chat_completions", - codewhale_config::provider::WireFormat::Responses => "responses", - codewhale_config::provider::WireFormat::AnthropicMessages => "anthropic_messages", + .map(|metadata| metadata.wire_policy()) + .unwrap_or(codewhale_config::provider::WirePolicy::Fixed( + codewhale_config::provider::WireFormat::ChatCompletions, + )); + match policy.fixed() { + Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions", + Some(codewhale_config::provider::WireFormat::Responses) => "responses", + Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages", + None => "model_aware", } } diff --git a/crates/tui/src/tools/resource_admission.rs b/crates/tui/src/tools/resource_admission.rs index a43952b5df..730c593288 100644 --- a/crates/tui/src/tools/resource_admission.rs +++ b/crates/tui/src/tools/resource_admission.rs @@ -541,7 +541,7 @@ mod tests { #[tokio::test] async fn real_cargo_command_is_admitted_and_released() { if std::env::var_os("CODEWHALE_RESOURCE_ADMISSION_RUST_ACCEPTANCE").is_none() { - eprintln!( + tracing::info!( "skipping opt-in real-rust admission acceptance; \ set CODEWHALE_RESOURCE_ADMISSION_RUST_ACCEPTANCE=1 to run" ); diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 57e5f71c5d..ad1cd96090 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -617,10 +617,9 @@ impl ProviderDashboardRow { vec![ provider .metadata() - .map(|metadata| protocol_label(metadata.wire()).to_string()) - .unwrap_or_else(|| { - protocol_label(WireFormat::ChatCompletions).to_string() - }), + .and_then(|metadata| metadata.wire_policy().fixed()) + .map(|protocol| protocol_label(protocol).to_string()) + .unwrap_or_else(|| "model-aware".to_string()), ], ProviderDefaultRoute { logical_model: configured_model.unwrap_or_else(|| "invalid".to_string()), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 8853284500..e880f04078 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -15120,6 +15120,7 @@ fn mirror_saved_api_key_in_config(config: &mut Config, provider: ApiProvider, ap ApiProvider::Sakana => &mut providers.sakana, ApiProvider::LongCat => &mut providers.longcat, ApiProvider::OpencodeGo => &mut providers.opencode_go, + ApiProvider::OpencodeZen => &mut providers.opencode_zen, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, ApiProvider::Telecomjs => &mut providers.telecomjs, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b45fb2c98f..38fe8c1157 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1281,6 +1281,7 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) - `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `telecomjs`, or `xai`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = "token-plan-cn"` for China and `mode = "token-plan-ams"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets ChatGPT/Codex OAuth; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go Chat Completions route at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route. +- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.5`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model is resolved through the curated Zen catalog: GPT uses Responses, Claude/Qwen use Anthropic Messages, and the documented DeepSeek/MiniMax/GLM/Kimi/Grok/free rows use Chat Completions. Gemini and unknown models fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog). - `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. - `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = "api_key"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = "kimi-for-coding"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = "kimi_oauth"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index e6dcd355e6..6df1bfc6b1 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -33,7 +33,7 @@ The canonical provider IDs are: `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, -`opencode-go`, `meta`, `telecomjs`, and `xai`. +`opencode-go`, `opencode-zen`, `meta`, `telecomjs`, and `xai`. Use any of these surfaces to select a provider: @@ -113,6 +113,7 @@ the listed provider env vars. | `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` | | `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` | | `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` | +| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` | | `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` | | `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` | | `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` | @@ -120,8 +121,9 @@ the listed provider env vars. Default base URLs and models for each route are listed in the shipped provider table below. The wire protocol values above are derived from `crates/config/src/provider.rs`: `ChatCompletions` is the default, -`openai-codex` overrides to `Responses`, and `deepseek-anthropic`, -`anthropic`, plus `openmodel` override to `AnthropicMessages`. +`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and +`openmodel` override to `AnthropicMessages`; and `opencode-zen` resolves the +protocol from the selected model's curated offering. ## Auth And Env Rules @@ -253,7 +255,8 @@ configuration path instead of guessing a vendor page. | `sglang`, `vllm`, `ollama` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. | | `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) | | `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) | -| `opencode-go` | [OpenCode Zen](https://opencode.ai/zen/) | +| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) | +| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) | | `meta` | [Meta Model API](https://developer.meta.com/ai/) | | `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) | | `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. | @@ -340,10 +343,41 @@ Kimi remains API-key-only; external consent for Kimi is rejected. | `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. | | `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. | | `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. | +| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.5` (default); current documented GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. GPT rows use `/responses`; Claude and Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, Grok, and the listed free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini currently fails closed because its model-specific Google wire protocol is not implemented. Unknown models also fail closed until their protocol is present in the curated catalog. | | `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.1` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.1 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. | | `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. | | `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.5` (default), `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. | +### OpenCode Zen protocol catalog + +Zen Responses and Chat Completions requests authenticate with Bearer +`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these +routes add ChatGPT/Codex OAuth headers. + +The bundled Zen transport snapshot follows the [official endpoint +table](https://opencode.ai/docs/zen/) and is intentionally explicit: + +- Responses: `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, + `gpt-5.5-pro`, `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, + `gpt-5.3-codex`, `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, + `gpt-5.1`, `gpt-5.1-codex`, `gpt-5.1-codex-max`, + `gpt-5.1-codex-mini`, `gpt-5`, `gpt-5-codex`, `gpt-5-nano`. +- Anthropic Messages: `claude-fable-5`, `claude-opus-4-8`, + `claude-opus-4-7`, `claude-opus-4-6`, `claude-opus-4-5`, + `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`, + `claude-haiku-4-5`, `qwen3.7-max`, `qwen3.7-plus`, `qwen3.6-plus`, + `qwen3.5-plus`. +- Chat Completions: `deepseek-v4-pro`, `deepseek-v4-flash`, `minimax-m3`, + `minimax-m2.7`, `minimax-m2.5`, `glm-5.2`, `glm-5.1`, `glm-5`, + `kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `grok-4.5`, + `grok-build-0.1`, `big-pickle`, `mimo-v2.5-free`, + `north-mini-code-free`, `nemotron-3-ultra-free`, + `deepseek-v4-flash-free`. + +Gemini entries are excluded because the official table assigns them Google's +model-specific protocol. A catalog miss never falls back to another Zen wire +shape, including when a custom Zen base URL is configured. + ### Hugging Face Provider vs MCP vs Hub Codewhale's `huggingface` provider ID is only the OpenAI-compatible chat diff --git a/docs/public-surface-facts.json b/docs/public-surface-facts.json index 4a4d305a5a..f1cd7b20df 100644 --- a/docs/public-surface-facts.json +++ b/docs/public-surface-facts.json @@ -15,7 +15,7 @@ }, "sourceCandidate": { "version": "0.9.1", - "providerCount": 35, + "providerCount": 36, "toolCount": 70, "sandboxBackends": [ "seatbelt (macOS, when available)", diff --git a/scripts/check-provider-registry.py b/scripts/check-provider-registry.py index 66aba03e01..55055a2292 100644 --- a/scripts/check-provider-registry.py +++ b/scripts/check-provider-registry.py @@ -143,14 +143,15 @@ def provider_kind_ids(config_rs: str) -> dict[str, str]: provider_rs, ) ids: dict[str, str] = {variant: provider_id for variant, provider_id in pairs} - # OpenaiCodex, Anthropic, DeepseekAnthropic, and MinimaxAnthropic use - # manual impls rather than the provider!() macro. + # Providers with non-fixed wire policy or custom auth behavior use manual + # impls rather than the provider!() macro. for variant_name, id_literal in [ ("DeepseekAnthropic", "deepseek-anthropic"), ("OpenaiCodex", "openai-codex"), ("Anthropic", "anthropic"), ("Openmodel", "openmodel"), ("MinimaxAnthropic", "minimax-anthropic"), + ("OpencodeZen", "opencode-zen"), ]: match = re.search( rf'impl\s+Provider\s+for\s+{variant_name}.*?fn\s+id.*?\"({id_literal})\"', diff --git a/web/lib/facts-drift.ts b/web/lib/facts-drift.ts index e5bf05a363..395c217edc 100644 --- a/web/lib/facts-drift.ts +++ b/web/lib/facts-drift.ts @@ -128,6 +128,7 @@ function deriveProvidersFromConfig(cfg: string): ProviderFact[] { Qianfan: { id: "qianfan", label: "Baidu Qianfan", env: "QIANFAN_API_KEY / BAIDU_QIANFAN_API_KEY" }, OpenaiCodex: { id: "openai-codex", label: "OpenAI Codex", env: "ChatGPT/Codex OAuth via `codex login` (OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN override)" }, OpencodeGo: { id: "opencode-go", label: "OpenCode Go", env: "OPENCODE_GO_API_KEY" }, + OpencodeZen: { id: "opencode-zen", label: "OpenCode Zen", env: "OPENCODE_ZEN_API_KEY / OPENCODE_API_KEY" }, Anthropic: { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, Zai: { id: "zai", label: "Z.ai", env: "ZAI_API_KEY / Z_AI_API_KEY" }, Stepfun: { id: "stepfun", label: "StepFun", env: "STEPFUN_API_KEY / STEP_API_KEY" }, diff --git a/web/lib/facts.generated.ts b/web/lib/facts.generated.ts index 1c1eca9fec..2bc7e9f109 100644 --- a/web/lib/facts.generated.ts +++ b/web/lib/facts.generated.ts @@ -27,7 +27,7 @@ export interface RepoFacts { } export const FACTS: RepoFacts = { - "generatedAt": "2026-07-26T12:21:33.617Z", + "generatedAt": "2026-07-26T23:54:26.919Z", "sourceRevision": null, "sourceCommittedAt": null, "version": "0.9.1", @@ -216,6 +216,11 @@ export const FACTS: RepoFacts = { "label": "OpenCode Go", "env": "OPENCODE_GO_API_KEY" }, + { + "id": "opencode-zen", + "label": "OpenCode Zen", + "env": "OPENCODE_ZEN_API_KEY / OPENCODE_API_KEY" + }, { "id": "meta", "label": "Meta Model API", diff --git a/web/scripts/facts-lib.mjs b/web/scripts/facts-lib.mjs index 4636c96858..f54f8ff923 100644 --- a/web/scripts/facts-lib.mjs +++ b/web/scripts/facts-lib.mjs @@ -86,6 +86,7 @@ const PROVIDER_LABEL_MAP = { Qianfan: { id: "qianfan", label: "Baidu Qianfan", env: "QIANFAN_API_KEY / BAIDU_QIANFAN_API_KEY" }, OpenaiCodex: { id: "openai-codex", label: "OpenAI Codex", env: "ChatGPT/Codex OAuth via `codex login` (OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN override)" }, OpencodeGo: { id: "opencode-go", label: "OpenCode Go", env: "OPENCODE_GO_API_KEY" }, + OpencodeZen: { id: "opencode-zen", label: "OpenCode Zen", env: "OPENCODE_ZEN_API_KEY / OPENCODE_API_KEY" }, Anthropic: { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, Zai: { id: "zai", label: "Z.ai", env: "ZAI_API_KEY / Z_AI_API_KEY" }, Stepfun: { id: "stepfun", label: "StepFun", env: "STEPFUN_API_KEY / STEP_API_KEY" },