From 54a74dfa048a60fb5af4d9d1fdedfe5191352380 Mon Sep 17 00:00:00 2001 From: snailoniu Date: Fri, 17 Jul 2026 10:43:49 +0800 Subject: [PATCH 01/12] refactor(routes): support model-aware wire policies Represent provider wire selection as either fixed or model-aware. Resolve model-aware protocols from catalog endpoint keys and fail closed when an endpoint does not prove a supported request shape.\n\nExisting providers remain fixed-policy routes, so this creates the Zen routing seam without changing current provider behavior. Signed-off-by: snailoniu --- crates/app-server/src/chat_completions.rs | 10 ++- crates/config/src/provider.rs | 87 ++++++++++++++++---- crates/config/src/route/conformance_tests.rs | 5 +- crates/config/src/route/descriptor.rs | 16 ++-- crates/config/src/route/errors.rs | 19 +++++ crates/config/src/route/resolver.rs | 11 ++- crates/config/src/route/tests.rs | 22 +++-- crates/config/src/tests.rs | 17 +++- crates/tui/src/main.rs | 17 ++-- crates/tui/src/tui/provider_picker.rs | 7 +- 10 files changed, 163 insertions(+), 48 deletions(-) diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index ce383bbb24..f65593f003 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -19,7 +19,7 @@ use codewhale_config::{ ConfigToml, ProviderKind, auth_mode_disables_api_key, is_upstream_auth_header, provider::WireFormat, provider_base_url_is_official, provider_preserves_custom_base_url_model, - route::{LogicalModelRef, RouteError, RouteRequest, RouteResolver}, + route::{LogicalModelRef, ProviderId, RouteError, RouteRequest, RouteResolver}, }; use serde_json::Value; @@ -144,7 +144,13 @@ 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 = provider_meta.wire_policy().fixed().ok_or_else(|| { + RouteError::UnsupportedModelProtocol { + provider: ProviderId::from(provider_kind.as_str()), + model: model.clone(), + endpoint_key: "unresolved".to_string(), + } + })?; Ok(ResolvedModelEndpoint { provider: provider_kind, diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 9de5223879..a4698290ee 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -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, @@ -521,8 +558,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 +846,8 @@ impl Provider for OpenaiCodex { ] } - fn wire(&self) -> WireFormat { - WireFormat::Responses + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::Responses) } } @@ -846,8 +883,8 @@ impl Provider for Anthropic { "anthropic" } - fn wire(&self) -> WireFormat { - WireFormat::AnthropicMessages + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::AnthropicMessages) } } @@ -887,8 +924,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 +1005,8 @@ impl Provider for MinimaxAnthropic { ] } - fn wire(&self) -> WireFormat { - WireFormat::AnthropicMessages + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::AnthropicMessages) } } @@ -1112,8 +1149,8 @@ impl Provider for Custom { "custom" } - fn wire(&self) -> WireFormat { - WireFormat::ChatCompletions + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::ChatCompletions) } } @@ -1435,6 +1472,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/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/resolver.rs b/crates/config/src/route/resolver.rs index 5b78afdef1..feab73b36b 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -208,13 +208,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 +257,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 diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 2bdd8a2b08..070aabe6e4 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -264,8 +264,10 @@ 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(); + // Every currently shipped provider has a concrete default protocol. + let _: RequestProtocol = d + .protocol_for_endpoint("chat") + .expect("current provider protocol"); } } @@ -274,9 +276,9 @@ fn descriptor_protocol_matches_provider_wire() { for kind in ProviderKind::ALL { let d = ProviderDescriptor::for_kind(kind); 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, @@ -286,7 +288,11 @@ fn descriptor_protocol_matches_provider_wire() { | ProviderKind::Openmodel => RequestProtocol::AnthropicMessages, _ => RequestProtocol::ChatCompletions, }; - assert_eq!(d.protocol(), expected, "{kind:?} protocol mismatch"); + assert_eq!( + d.protocol_for_endpoint("chat"), + Some(expected), + "{kind:?} protocol mismatch" + ); } } @@ -972,7 +978,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 a69aaff7eb..13233b7bcd 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -3887,7 +3887,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, @@ -3930,7 +3933,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, @@ -4004,7 +4010,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#" @@ -4281,7 +4290,7 @@ fn provider_metadata_defaults_match_runtime_helpers() { | ProviderKind::Openmodel => provider::WireFormat::AnthropicMessages, _ => provider::WireFormat::ChatCompletions, }; - assert_eq!(provider.wire(), expected_wire); + assert_eq!(provider.wire_policy().fixed(), Some(expected_wire)); } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 98af410564..34738cd744 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -6283,14 +6283,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/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 37fa8c3fc3..2012f49c57 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -607,10 +607,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()), From 2947bba495266eeb9cb33dbc244c3ad0bd88b89b Mon Sep 17 00:00:00 2001 From: snailoniu Date: Fri, 17 Jul 2026 10:59:19 +0800 Subject: [PATCH 02/12] feat(providers): add OpenCode Zen configuration Register OpenCode Zen across config, CLI, secrets, and TUI surfaces with provider-scoped API key guidance. Keep its wire policy model-aware and fail closed until a catalog offering proves the selected model protocol. Signed-off-by: snailoniu --- crates/cli/src/lib.rs | 21 +++++ crates/config/src/lib.rs | 24 ++++++ crates/config/src/provider.rs | 52 +++++++++++- crates/config/src/provider_defaults.rs | 6 ++ crates/config/src/provider_kind.rs | 8 ++ crates/config/src/route/conformance_tests.rs | 22 +++++ crates/config/src/route/resolver.rs | 46 ++++++++-- crates/config/src/route/tests.rs | 41 +++++++-- crates/config/src/tests.rs | 89 ++++++++++++++++++-- crates/secrets/src/lib.rs | 24 ++++++ crates/tui/src/client.rs | 6 +- crates/tui/src/config.rs | 50 ++++++++++- crates/tui/src/config/models.rs | 2 + crates/tui/src/config/tests.rs | 13 +++ crates/tui/src/config_persistence.rs | 1 + crates/tui/src/tui/ui.rs | 1 + 16 files changed, 378 insertions(+), 28 deletions(-) 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 ba7dabb4ca..fec255c21d 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", @@ -457,6 +466,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, @@ -498,6 +508,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, @@ -2476,6 +2487,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(), @@ -3388,6 +3400,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, @@ -3430,6 +3443,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, @@ -5150,6 +5164,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, @@ -5422,6 +5438,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()) @@ -5504,6 +5526,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(), @@ -5539,6 +5562,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 a4698290ee..7a7ab2b69b 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -22,6 +22,7 @@ use super::{ 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_OPENCODE_ZEN_BASE_URL, DEFAULT_OPENCODE_ZEN_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, @@ -370,6 +371,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/"), @@ -1058,6 +1065,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, @@ -1186,12 +1234,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, @@ -1224,6 +1273,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 36] = [ &SAKANA, &LONGCAT, &OPENCODE_GO, + &OPENCODE_ZEN, &META, &XAI, &TELECOMJS, 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..398ebb3a00 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", @@ -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 1ec903a2cf..c88ebdc392 100644 --- a/crates/config/src/route/conformance_tests.rs +++ b/crates/config/src/route/conformance_tests.rs @@ -11,6 +11,7 @@ use super::descriptor::ProviderDescriptor; use super::ids::{LogicalModelRef, ProviderId}; use super::resolver::{RouteRequest, RouteResolver}; use crate::ProviderKind; +use crate::provider::WirePolicy; fn none_request(kind: ProviderKind) -> RouteRequest { RouteRequest { @@ -69,6 +70,16 @@ fn every_provider_kind_resolves_its_default_route() { let bundled = bundled_offerings(); for &kind in ProviderKind::all() { let descriptor = ProviderDescriptor::for_kind(kind); + if descriptor.wire_policy() == WirePolicy::ModelAware { + let error = resolver + .resolve(&none_request(kind)) + .expect_err("model-aware provider without catalog proof must fail closed"); + assert!(matches!( + error, + super::RouteError::UnsupportedModelProtocol { .. } + )); + continue; + } let candidate = resolver.resolve(&none_request(kind)).unwrap_or_else(|err| { panic!("{kind:?}: default (None selector) route must resolve, got {err:?}") }); @@ -110,6 +121,7 @@ fn every_provider_kind_resolves_its_default_route() { fn every_provider_kind_resolves_the_auto_selector() { let resolver = RouteResolver::new(); for &kind in ProviderKind::all() { + let descriptor = ProviderDescriptor::for_kind(kind); let request = RouteRequest { explicit_provider: Some(kind), model_selector: Some(LogicalModelRef::from("auto")), @@ -117,6 +129,16 @@ fn every_provider_kind_resolves_the_auto_selector() { base_url_override: None, limit_overrides: Vec::new(), }; + if descriptor.wire_policy() == WirePolicy::ModelAware { + let error = resolver + .resolve(&request) + .expect_err("model-aware provider without catalog proof must fail closed"); + assert!(matches!( + error, + super::RouteError::UnsupportedModelProtocol { .. } + )); + continue; + } let candidate = resolver .resolve(&request) .unwrap_or_else(|err| panic!("{kind:?}: `auto` must resolve, got {err:?}")); diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index feab73b36b..19b5eafd01 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. @@ -276,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 @@ -310,6 +322,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 => { diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 070aabe6e4..4c98e74b1f 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -264,10 +264,11 @@ fn descriptor_for_every_kind_has_nonempty_transport_facts() { !d.default_wire_model().as_str().is_empty(), "{kind:?} default_wire_model empty" ); - // Every currently shipped provider has a concrete default protocol. - let _: RequestProtocol = d - .protocol_for_endpoint("chat") - .expect("current provider protocol"); + if d.wire_policy().fixed().is_some() { + let _: RequestProtocol = d + .protocol_for_endpoint("chat") + .expect("fixed provider protocol"); + } } } @@ -275,22 +276,39 @@ 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_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_for_endpoint("chat"), - Some(expected), + expected, "{kind:?} protocol mismatch" ); } @@ -973,6 +991,13 @@ fn resolver_protocol_matches_descriptor_for_every_provider() { // the whole provider set. let default_wire = ProviderDescriptor::for_kind(kind).default_wire_model(); let request = req(Some(kind), Some(default_wire.as_str())); + if kind == ProviderKind::OpencodeZen { + let error = r + .resolve(&request) + .expect_err("Zen without a catalog offering must fail closed"); + assert!(matches!(error, RouteError::UnsupportedModelProtocol { .. })); + continue; + } let out = r .resolve(&request) .unwrap_or_else(|e| panic!("{kind:?} should resolve its own default: {e}")); diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 13233b7bcd..3f596199b9 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -877,6 +877,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, @@ -909,6 +913,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"), @@ -1034,6 +1042,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"); @@ -1182,6 +1194,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( @@ -4098,7 +4114,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#" @@ -4140,6 +4159,65 @@ model = "glm-5.2" assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); } +#[test] +fn opencode_zen_configures_model_aware_provider_and_fails_closed_without_catalog_proof() { + 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 error = 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, + }) + .expect_err("Zen model without catalog protocol proof must fail closed"); + assert!( + matches!( + error, + crate::route::RouteError::UnsupportedModelProtocol { .. } + ), + "unexpected error: {error:?}" + ); +} + #[test] fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() { let _lock = env_lock(); @@ -4283,14 +4361,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_policy().fixed(), Some(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/src/client.rs b/crates/tui/src/client.rs index 8e0672a747..20da9c4fde 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -2582,7 +2582,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 +2680,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 +2758,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Stepfun => {} ApiProvider::Sakana => {} ApiProvider::LongCat => {} - ApiProvider::OpencodeGo => {} + ApiProvider::OpencodeGo | ApiProvider::OpencodeZen => {} ApiProvider::Meta => {} ApiProvider::Xai => {} }, diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index c3b7d77c64..c3b646a6bd 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/tui/ui.rs b/crates/tui/src/tui/ui.rs index 476df8f67d..eecc3c4e74 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -14992,6 +14992,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, From 306698bba42e391a4889f52e80ccaa6792a504d6 Mon Sep 17 00:00:00 2001 From: snailoniu Date: Fri, 17 Jul 2026 11:02:52 +0800 Subject: [PATCH 03/12] feat(routes): map OpenCode Zen model protocols Route the documented Zen catalog across Responses, Anthropic Messages, and Chat Completions by model. Normalize the official opencode/model selector and reject Gemini or unknown models until their request protocol is supported and proven. Signed-off-by: snailoniu --- crates/config/src/route/conformance_tests.rs | 22 ---- crates/config/src/route/offering.rs | 104 +++++++++++++++++-- crates/config/src/route/resolver.rs | 11 +- crates/config/src/route/tests.rs | 83 +++++++++++++-- crates/config/src/tests.rs | 15 +-- 5 files changed, 182 insertions(+), 53 deletions(-) diff --git a/crates/config/src/route/conformance_tests.rs b/crates/config/src/route/conformance_tests.rs index c88ebdc392..1ec903a2cf 100644 --- a/crates/config/src/route/conformance_tests.rs +++ b/crates/config/src/route/conformance_tests.rs @@ -11,7 +11,6 @@ use super::descriptor::ProviderDescriptor; use super::ids::{LogicalModelRef, ProviderId}; use super::resolver::{RouteRequest, RouteResolver}; use crate::ProviderKind; -use crate::provider::WirePolicy; fn none_request(kind: ProviderKind) -> RouteRequest { RouteRequest { @@ -70,16 +69,6 @@ fn every_provider_kind_resolves_its_default_route() { let bundled = bundled_offerings(); for &kind in ProviderKind::all() { let descriptor = ProviderDescriptor::for_kind(kind); - if descriptor.wire_policy() == WirePolicy::ModelAware { - let error = resolver - .resolve(&none_request(kind)) - .expect_err("model-aware provider without catalog proof must fail closed"); - assert!(matches!( - error, - super::RouteError::UnsupportedModelProtocol { .. } - )); - continue; - } let candidate = resolver.resolve(&none_request(kind)).unwrap_or_else(|err| { panic!("{kind:?}: default (None selector) route must resolve, got {err:?}") }); @@ -121,7 +110,6 @@ fn every_provider_kind_resolves_its_default_route() { fn every_provider_kind_resolves_the_auto_selector() { let resolver = RouteResolver::new(); for &kind in ProviderKind::all() { - let descriptor = ProviderDescriptor::for_kind(kind); let request = RouteRequest { explicit_provider: Some(kind), model_selector: Some(LogicalModelRef::from("auto")), @@ -129,16 +117,6 @@ fn every_provider_kind_resolves_the_auto_selector() { base_url_override: None, limit_overrides: Vec::new(), }; - if descriptor.wire_policy() == WirePolicy::ModelAware { - let error = resolver - .resolve(&request) - .expect_err("model-aware provider without catalog proof must fail closed"); - assert!(matches!( - error, - super::RouteError::UnsupportedModelProtocol { .. } - )); - continue; - } let candidate = resolver .resolve(&request) .unwrap_or_else(|err| panic!("{kind:?}: `auto` must resolve, got {err:?}")); diff --git a/crates/config/src/route/offering.rs b/crates/config/src/route/offering.rs index 10ba04f3a4..e79bf58065 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,96 @@ 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(), + pricing: PricingSku::UnknownOrStale, + }) + }) + .collect() } diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 19b5eafd01..3d2226db77 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -301,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) }; @@ -430,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 4c98e74b1f..7fac5a17f2 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -901,6 +901,82 @@ 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, + }; + 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(); @@ -991,13 +1067,6 @@ fn resolver_protocol_matches_descriptor_for_every_provider() { // the whole provider set. let default_wire = ProviderDescriptor::for_kind(kind).default_wire_model(); let request = req(Some(kind), Some(default_wire.as_str())); - if kind == ProviderKind::OpencodeZen { - let error = r - .resolve(&request) - .expect_err("Zen without a catalog offering must fail closed"); - assert!(matches!(error, RouteError::UnsupportedModelProtocol { .. })); - continue; - } let out = r .resolve(&request) .unwrap_or_else(|e| panic!("{kind:?} should resolve its own default: {e}")); diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 3f596199b9..fdd4da577a 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -4160,7 +4160,7 @@ model = "glm-5.2" } #[test] -fn opencode_zen_configures_model_aware_provider_and_fails_closed_without_catalog_proof() { +fn opencode_zen_configures_model_aware_provider_with_catalog_proof() { for alias in [ "opencode-zen", "opencode_zen", @@ -4201,21 +4201,16 @@ model = "gpt-5.5" assert_eq!(resolved.api_key.as_deref(), Some("zen-config-key")); let resolver = crate::route::RouteResolver::new(); - let error = resolver + 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, }) - .expect_err("Zen model without catalog protocol proof must fail closed"); - assert!( - matches!( - error, - crate::route::RouteError::UnsupportedModelProtocol { .. } - ), - "unexpected error: {error:?}" - ); + .expect("documented Zen model must resolve"); + assert_eq!(route.protocol, crate::route::RequestProtocol::Responses); + assert_eq!(route.endpoint.endpoint_key, "responses"); } #[test] From cc6da8f36aeef322e96a00eb799d579b85557201 Mon Sep 17 00:00:00 2001 From: snailoniu Date: Fri, 17 Jul 2026 11:16:07 +0800 Subject: [PATCH 04/12] feat(client): dispatch OpenCode Zen by model protocol Bind clients to the resolver-proven request protocol and send Zen models through Responses, Anthropic Messages, or Chat Completions as appropriate. Keep Zen on bearer authentication, exclude Codex OAuth headers, and fail closed when a request changes protocol or uses an unproven FIM route. Signed-off-by: snailoniu --- crates/app-server/src/chat_completions.rs | 42 ++- crates/tui/src/client.rs | 323 +++++++++++++++++++--- crates/tui/src/client/responses.rs | 20 +- 3 files changed, 337 insertions(+), 48 deletions(-) diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index f65593f003..7852758326 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -19,7 +19,7 @@ use codewhale_config::{ ConfigToml, ProviderKind, auth_mode_disables_api_key, is_upstream_auth_header, provider::WireFormat, provider_base_url_is_official, provider_preserves_custom_base_url_model, - route::{LogicalModelRef, ProviderId, RouteError, RouteRequest, RouteResolver}, + route::{LogicalModelRef, RouteError, RouteRequest, RouteResolver}, }; use serde_json::Value; @@ -144,13 +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_policy().fixed().ok_or_else(|| { - RouteError::UnsupportedModelProtocol { - provider: ProviderId::from(provider_kind.as_str()), - model: model.clone(), - endpoint_key: "unresolved".to_string(), - } - })?; + let wire_format = route.protocol; Ok(ResolvedModelEndpoint { provider: provider_kind, @@ -225,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) } @@ -932,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/tui/src/client.rs b/crates/tui/src/client.rs index 20da9c4fde..0be9c4ba68 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -21,7 +21,9 @@ 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 +956,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 +980,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 +993,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 +1066,7 @@ impl DeepSeekClient { &http_headers, api_provider, &base_url, + wire_format, auth_disabled, false, )?; @@ -1062,18 +1078,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 +1156,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 +1194,7 @@ impl DeepSeekClient { extra_headers, api_provider, base_url, + provider_default_wire_format(api_provider), false, false, ) @@ -1169,6 +1205,7 @@ impl DeepSeekClient { extra_headers: &HashMap, api_provider: ApiProvider, base_url: &str, + wire_format: WireFormat, auth_disabled: bool, force_http1: bool, ) -> Result { @@ -1177,6 +1214,7 @@ impl DeepSeekClient { extra_headers, api_provider, base_url, + wire_format, auth_disabled, )?; let mut builder = crate::tls::reqwest_client_builder() @@ -1218,6 +1256,7 @@ impl DeepSeekClient { extra_headers, ApiProvider::Deepseek, crate::config::DEFAULT_DEEPSEEK_BASE_URL, + WireFormat::ChatCompletions, false, ) } @@ -1229,7 +1268,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 +1285,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 +1301,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,8 +1321,11 @@ 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) - && api_provider != ApiProvider::Openmodel + && uses_anthropic_messages + && !matches!( + api_provider, + ApiProvider::Openmodel | ApiProvider::OpencodeZen + ) { Some(HeaderName::from_static("x-api-key")) } else if !api_key.is_empty() @@ -1327,6 +1385,23 @@ fn api_provider_uses_anthropic_messages(api_provider: ApiProvider) -> bool { ) } +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 { matches!(api_provider, ApiProvider::DeepseekAnthropic) } @@ -1354,8 +1429,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 +1623,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 +2205,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 +2218,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?, @@ -2846,10 +2935,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 +3801,171 @@ 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}" + ); + } + } + + #[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_uses_bearer_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_bearer_without_codex_headers(&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", diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index df7ffd20a6..67dbb84256 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -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()) }) From 90177b905021c577a77a88b5fca845d81ec36f20 Mon Sep 17 00:00:00 2001 From: snailoniu Date: Fri, 17 Jul 2026 11:20:19 +0800 Subject: [PATCH 05/12] docs(providers): document OpenCode Zen routes Document Zen as a separate API-key provider with model-scoped Responses, Messages, and Chat Completions groups. Record the unsupported Gemini boundary and teach the provider registry drift check about the manual model-aware provider implementation. Signed-off-by: snailoniu --- CHANGELOG.md | 4 ++++ config.example.toml | 18 ++++++++++++-- crates/tui/CHANGELOG.md | 4 ++++ docs/CONFIGURATION.md | 1 + docs/PROVIDERS.md | 38 ++++++++++++++++++++++++++---- scripts/check-provider-registry.py | 5 ++-- 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d31597ff4..a184927017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,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 031fc16d82..fd253f9391 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) @@ -593,6 +593,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/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 1a96ced736..a1e43ab3f0 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -199,6 +199,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/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 06a790cbb0..93d3d1a942 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..718bac2436 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,37 @@ 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`. All use the Zen API key with Bearer auth; the route never uses 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 + +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/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})\"', From eeb8d34085bc1354556dde6b7fd79801d042b3da Mon Sep 17 00:00:00 2001 From: snailoniu Date: Fri, 17 Jul 2026 11:58:14 +0800 Subject: [PATCH 06/12] fix(client): authenticate Zen Messages requests OpenCode Zen routes Anthropic models through the Messages API, which expects the native x-api-key header. Sending Zen keys as Authorization bearer tokens produced upstream 401 Missing API key errors for Claude models. Refresh the request-shape test to lock the Zen Messages auth dialect while keeping bearer auth for Zen Responses and Chat Completions routes. Signed-off-by: snailoniu --- crates/tui/src/client.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 0be9c4ba68..91d57d971c 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1322,10 +1322,7 @@ fn build_default_headers( None } else if !api_key.is_empty() && uses_anthropic_messages - && !matches!( - api_provider, - ApiProvider::Openmodel | ApiProvider::OpencodeZen - ) + && api_provider != ApiProvider::Openmodel { Some(HeaderName::from_static("x-api-key")) } else if !api_key.is_empty() @@ -3843,6 +3840,26 @@ mod tests { } } + 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; @@ -3877,7 +3894,7 @@ mod tests { } #[tokio::test] - async fn opencode_zen_messages_request_uses_bearer_anthropic_route() { + 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")) @@ -3904,7 +3921,7 @@ mod tests { let requests = server.received_requests().await.expect("recorded request"); assert_eq!(requests.len(), 1); - assert_zen_bearer_without_codex_headers(&requests[0]); + assert_zen_messages_api_key_without_bearer(&requests[0]); assert_eq!( requests[0] .headers From 709f5810c0bfdf0e89fe32771b17a9de57d6d540 Mon Sep 17 00:00:00 2001 From: snailoniu Date: Fri, 17 Jul 2026 13:17:39 +0800 Subject: [PATCH 07/12] test(client): track DeepSeek FIM contract error The model-aware route guard now reports that the Anthropic-compatible route has no proven FIM wire contract. Keep the local-failure test aligned with that fail-closed behavior and continue asserting that no HTTP request is made. Signed-off-by: snailoniu --- crates/tui/src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 91d57d971c..2f5982913a 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -5242,7 +5242,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(), From d1fd0e3dab5e9b7b2023b9fcfeb7f7179ffb80b1 Mon Sep 17 00:00:00 2001 From: snailoniu Date: Sun, 26 Jul 2026 17:30:26 +0800 Subject: [PATCH 08/12] fix(provider): finish OpenCode Zen rebase Align the rebased provider with current route candidate accessors, offering capabilities, registry counts, and Responses URL construction. Rename custom-provider test fixtures now that opencode_zen is a built-in identity, and remove the superseded protocol helper. Signed-off-by: snailoniu --- crates/app-server/src/chat_completions.rs | 2 +- crates/config/src/provider.rs | 23 ++++++------ crates/config/src/provider_kind.rs | 2 +- crates/config/src/route/offering.rs | 1 + crates/config/src/route/tests.rs | 15 ++++---- crates/config/src/tests.rs | 43 ++++++++++++----------- crates/tui/src/client.rs | 14 +------- crates/tui/src/client/responses.rs | 2 +- docs/PROVIDERS.md | 4 +++ 9 files changed, 50 insertions(+), 56 deletions(-) diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index 7852758326..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 = route.protocol; + let wire_format = route.protocol(); Ok(ResolvedModelEndpoint { provider: provider_kind, diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 7a7ab2b69b..6e423cbb6c 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -16,18 +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_OPENCODE_ZEN_BASE_URL, DEFAULT_OPENCODE_ZEN_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. diff --git a/crates/config/src/provider_kind.rs b/crates/config/src/provider_kind.rs index 398ebb3a00..3110852b82 100644 --- a/crates/config/src/provider_kind.rs +++ b/crates/config/src/provider_kind.rs @@ -154,7 +154,7 @@ pub enum ProviderKind { } impl ProviderKind { - pub const ALL: [Self; 36] = [ + pub const ALL: [Self; 37] = [ Self::Deepseek, Self::DeepseekAnthropic, Self::NvidiaNim, diff --git a/crates/config/src/route/offering.rs b/crates/config/src/route/offering.rs index e79bf58065..95936430b3 100644 --- a/crates/config/src/route/offering.rs +++ b/crates/config/src/route/offering.rs @@ -162,6 +162,7 @@ pub fn bundled_offerings() -> Vec { endpoint_key: endpoint_key.to_string(), default_for_provider: *model == "gpt-5.5", limits: RouteLimits::default(), + capabilities: RouteCapabilities::default(), pricing: PricingSku::UnknownOrStale, }) }) diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 7fac5a17f2..9b4fecf547 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -932,10 +932,10 @@ fn opencode_zen_resolver_selects_protocol_from_documented_model_catalog() { 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}"); + 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}"); } } } @@ -943,8 +943,8 @@ fn opencode_zen_resolver_selects_protocol_from_documented_model_catalog() { 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); + assert_eq!(automatic.wire_model_id().as_str(), "gpt-5.5"); + assert_eq!(automatic.protocol(), RequestProtocol::Responses); } #[test] @@ -965,6 +965,7 @@ fn opencode_zen_resolver_fails_closed_for_unproven_protocols() { model_selector: Some(LogicalModelRef::from(model)), saved_provider_model: None, base_url_override, + limit_overrides: Vec::new(), }; assert!( matches!( @@ -1073,7 +1074,7 @@ fn resolver_protocol_matches_descriptor_for_every_provider() { assert_eq!( out.protocol(), ProviderDescriptor::for_kind(kind) - .protocol_for_endpoint(&out.endpoint.endpoint_key) + .protocol_for_endpoint(&out.endpoint().endpoint_key) .expect("resolved endpoint protocol"), "{kind:?} candidate protocol must match descriptor" ); diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index fdd4da577a..9cc0bca7de 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -2972,12 +2972,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"); @@ -2992,7 +2992,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] @@ -3782,23 +3782,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 @@ -3806,7 +3806,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 @@ -3814,7 +3814,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 @@ -3825,7 +3825,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}"); @@ -3833,18 +3833,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"); @@ -3852,7 +3852,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")); } } @@ -4207,10 +4207,11 @@ model = "gpt-5.5" 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"); + assert_eq!(route.protocol(), crate::route::RequestProtocol::Responses); + assert_eq!(route.endpoint().endpoint_key, "responses"); } #[test] diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 2f5982913a..84b854c300 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -21,9 +21,7 @@ use codewhale_config::catalog::{ ProviderCatalogCache, ProviderCatalogDelta, base_url_fingerprint, now_unix, }; use codewhale_config::provider::WireFormat; -use codewhale_config::route::{ - LogicalModelRef, ReadyRouteCandidate, RouteRequest, RouteResolver, -}; +use codewhale_config::route::{LogicalModelRef, ReadyRouteCandidate, RouteRequest, RouteResolver}; use codewhale_config::{auth_mode_disables_api_key, is_upstream_auth_header}; use crate::config::{ @@ -1372,16 +1370,6 @@ 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() diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 67dbb84256..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, }; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 718bac2436..238b1d8e30 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -350,6 +350,10 @@ Kimi remains API-key-only; external consent for Kimi is rejected. ### 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: From 10fca7f2f9cdf8dc7ea8340efd8ff16c3029042b Mon Sep 17 00:00:00 2001 From: snailoniu Date: Sun, 26 Jul 2026 21:13:26 +0800 Subject: [PATCH 09/12] fix(tui): keep admission acceptance logging off stderr Route the opt-in resource-admission test skip notice through tracing so the all-targets Clippy gate respects the TUI module's print_stderr deny policy. Signed-off-by: snailoniu --- crates/tui/src/tools/resource_admission.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" ); From 19e47a6a21b20ddcd2a989be1dd25e0514b7775d Mon Sep 17 00:00:00 2001 From: snailoniu Date: Sun, 26 Jul 2026 21:36:28 +0800 Subject: [PATCH 10/12] docs(providers): clarify OpenCode Zen auth dialects State explicitly that Zen Responses and Chat Completions use Bearer authorization while Anthropic Messages uses x-api-key, matching the request-shape implementation and detailed protocol section. Signed-off-by: snailoniu --- docs/PROVIDERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 238b1d8e30..6df1bfc6b1 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -343,7 +343,7 @@ 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`. All use the Zen API key with Bearer auth; the route never uses 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. | +| `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`. | From fbbedbe1c8a07e7cec7203660ad28d76c89fd4ee Mon Sep 17 00:00:00 2001 From: snailoniu Date: Mon, 27 Jul 2026 07:24:42 +0800 Subject: [PATCH 11/12] test(config): isolate OpenCode Zen provider resolution Serialize the model-aware provider test with other environment-mutating config tests and clear runtime provider overrides so parallel runs cannot leak a sibling test's provider selection. Signed-off-by: snailoniu --- crates/config/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 9cc0bca7de..6c1364832f 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -4161,6 +4161,8 @@ model = "glm-5.2" #[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", From c3647fea29280cefa58238e018ee66ce688ccca0 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sun, 26 Jul 2026 16:48:46 -0700 Subject: [PATCH 12/12] fix(web): map OpenCode Zen provider facts Keep the public provider facts and source-candidate count aligned with the new ApiProvider variant so the hosted web gate remains truthful. Verified: npm run check:facts; npm run check:docs; npm test (137/137); npm run lint; npm run build. --- docs/public-surface-facts.json | 2 +- web/lib/facts-drift.ts | 1 + web/lib/facts.generated.ts | 7 ++++++- web/scripts/facts-lib.mjs | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/public-surface-facts.json b/docs/public-surface-facts.json index 88adfcc487..ea7a69f3c7 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": 68, "sandboxBackends": [ "seatbelt (macOS, when available)", 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 c9fc587dd6..42dff9eb5c 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-26T10:15:23.806Z", + "generatedAt": "2026-07-26T23:47:32.882Z", "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" },