-
Notifications
You must be signed in to change notification settings - Fork 3.5k
fix(custom): wire = responses|anthropic for openai-compatible + opencode-zen muse-spark (rescue of #5716) #5719
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fca11af
c39cd8e
a89c3dc
20ac186
ba7673f
1434e7e
9bf4c6e
45eaadc
c213698
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -417,8 +417,23 @@ impl RouteResolver { | |
| // Aggregators, local runtimes, and custom OpenAI-compatible | ||
| // endpoints legitimately accept arbitrary / prefixed ids verbatim. | ||
| ProviderClass::Aggregator | ProviderClass::LocalOrCustom => { | ||
| let _ = provider_kind; | ||
| if require_catalog_match { | ||
| // Opencode Zen serves Muse Spark exclusively over Responses. | ||
| // Handle any future muse-spark variant (e.g. -free suffix) | ||
| // even when no exact bundled offering exists — fail open to | ||
| // responses rather than failing closed to "unproven". | ||
| if provider_kind == ProviderKind::OpencodeZen | ||
| && raw.to_ascii_lowercase().contains("muse-spark") | ||
|
Comment on lines
+425
to
+426
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| { | ||
| return Ok(ResolvedOffering { | ||
| wire_model_id: WireModelId::from(raw), | ||
| canonical_model: None, | ||
| endpoint_key: "responses".to_string(), | ||
| limits: RouteLimits::default(), | ||
| capabilities: RouteCapabilities::default(), | ||
| pricing: PricingSku::UnknownOrStale, | ||
| }); | ||
| } | ||
| return Err(RouteError::UnsupportedModelProtocol { | ||
| provider: provider_id.clone(), | ||
| model: raw.to_string(), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1196,8 +1196,23 @@ impl DeepSeekClient { | |
| validate_route(api_provider, &default_model).map_err(anyhow::Error::msg)?; | ||
| } | ||
| let (api_key, codex_account_id) = if api_provider == ApiProvider::OpenaiCodex { | ||
| let credentials = config.codex_credentials()?; | ||
| (credentials.access_token, credentials.account_id) | ||
| // The official endpoint requires Codex OAuth credentials. A custom | ||
| // endpoint prefers its own configured key, but an explicit | ||
| // `OPENAI_CODEX_ACCESS_TOKEN` still wins (`codex_credentials` | ||
| // checks env before enforcing the official-endpoint consent | ||
| // grant), so existing token-plus-custom-base-url setups keep | ||
| // working. Only when no env token exists does the custom endpoint | ||
| // fall back to the generic provider-scoped key resolver. | ||
| match config.codex_credentials() { | ||
| Ok(credentials) => (credentials.access_token, credentials.account_id), | ||
| Err(error) => { | ||
| if config.provider_uses_custom_endpoint(ApiProvider::OpenaiCodex) { | ||
| (config.deepseek_api_key()?, None) | ||
| } else { | ||
| return Err(error); | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| (config.deepseek_api_key()?, None) | ||
| }; | ||
|
|
@@ -1687,17 +1702,17 @@ fn provider_default_wire_format(api_provider: ApiProvider) -> WireFormat { | |
|
|
||
| /// Resolve the wire dialect for a dual-protocol vendor. | ||
| /// | ||
| /// Power-user toggle: `providers.<id>.wire = "openai" | "anthropic"`. | ||
| /// Legacy dialect kinds (`*Anthropic`) still force Messages. Everyone else | ||
| /// keeps the descriptor's fixed policy (or Chat Completions). | ||
| /// Power-user toggle: `providers.<id>.wire = "openai" | "anthropic" | "responses"`. | ||
| /// Legacy dialect kinds (`*Anthropic`) still force Messages. Custom providers | ||
| /// honor `wire = "responses" | "anthropic" | "chat"` per-config (see | ||
| /// `crates/config/src/provider.rs:Custom`). Everyone else keeps the descriptor's | ||
| /// fixed policy (or Chat Completions). | ||
| fn provider_wire_format_for_config( | ||
| api_provider: ApiProvider, | ||
| config: Option<&crate::config::Config>, | ||
| ) -> WireFormat { | ||
| let catalog = api_provider.catalog_identity(); | ||
| let wire = config | ||
| .and_then(|cfg| cfg.provider_config_for(catalog)) | ||
| .and_then(|entry| entry.wire.as_deref()); | ||
| let wire = config.and_then(|cfg| cfg.provider_wire_dialect(catalog)); | ||
| let prefers_anthropic = matches!( | ||
| api_provider, | ||
| ApiProvider::DeepseekAnthropic | ||
|
|
@@ -1722,6 +1737,22 @@ fn provider_wire_format_for_config( | |
| return WireFormat::AnthropicMessages; | ||
| } | ||
|
|
||
| // Custom providers honor `wire = "anthropic"` / `wire = "responses"` explicitly. | ||
| // The static `Custom::wire_policy()` remains `Chat` as a safe default; the | ||
| // per-config override lives here (and in `provider_capability`) so existing | ||
| // `[providers.<name>]` tables gain the three-way switch without changing the | ||
| // provider registry trait. Supported aliases: | ||
| // anthropic: "anthropic" | "messages" | "claude" | "anthropic-messages" | ... | ||
| // responses: "responses" | "responses-api" | "openai-responses" | "openai_responses" | ... | ||
| if api_provider == ApiProvider::Custom { | ||
| if wire_config_prefers_anthropic(wire) { | ||
| return WireFormat::AnthropicMessages; | ||
| } | ||
| if wire_config_prefers_responses(wire) { | ||
| return WireFormat::Responses; | ||
| } | ||
|
Comment on lines
+1747
to
+1753
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Custom wire selection lost during dispatch With custom Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| api_provider | ||
| .kind() | ||
| .and_then(|kind| { | ||
|
|
@@ -1754,6 +1785,24 @@ fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool { | |
| ) | ||
| } | ||
|
|
||
| fn wire_config_prefers_responses(wire: Option<&str>) -> bool { | ||
| let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { | ||
| return false; | ||
| }; | ||
| let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); | ||
| matches!( | ||
| normalized.as_str(), | ||
| "responses" | ||
| | "responses-api" | ||
| | "openai-responses" | ||
| | "openai-responses-api" | ||
| | "response" | ||
| | "response-api" | ||
| | "openai-responses-compat" | ||
| | "responses-compat" | ||
| ) | ||
| } | ||
|
|
||
| fn api_provider_skips_models_probe(api_provider: ApiProvider) -> bool { | ||
| matches!(api_provider, ApiProvider::DeepseekAnthropic) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -628,6 +628,53 @@ pub enum RequestPayloadMode { | |
| /// in the API payload (after normalization / provider-specific mapping). | ||
| #[must_use] | ||
| pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability { | ||
| provider_capability_with_wire(provider, resolved_model, None) | ||
| } | ||
|
|
||
| /// Wire-aware variant of [`provider_capability`] that respects | ||
| /// `wire = "responses" | "anthropic" | "chat"` for `Custom` providers. | ||
| /// | ||
| /// Built-ins keep their fixed policy; `Custom` defaults to `Chat` when `wire` | ||
| /// is absent so existing configs stay compatible. Mirrors | ||
| /// `crates/tui/src/client.rs::provider_wire_format_for_config` and the | ||
| /// `Custom` comment in `crates/config/src/provider.rs`. | ||
| #[must_use] | ||
| pub fn provider_capability_with_wire( | ||
| provider: ApiProvider, | ||
| resolved_model: &str, | ||
| wire: Option<&str>, | ||
| ) -> ProviderCapability { | ||
| // Custom wire overrides must be checked before the generic fallback so | ||
| // `[providers.<name>] wire = "responses"` / `"anthropic"` is honored. | ||
| if provider == ApiProvider::Custom { | ||
| if wire_config_prefers_anthropic(wire) { | ||
| return ProviderCapability { | ||
| provider, | ||
| resolved_model: resolved_model.to_string(), | ||
| context_window: crate::models::context_window_for_model(resolved_model) | ||
| .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS), | ||
| max_output: crate::models::max_output_tokens_for_model(resolved_model), | ||
| thinking_supported: crate::models::model_supports_reasoning(resolved_model), | ||
| cache_telemetry_supported: false, | ||
| request_payload_mode: RequestPayloadMode::AnthropicMessages, | ||
| alias_deprecation: None, | ||
| }; | ||
| } | ||
| if wire_config_prefers_responses(wire) { | ||
| return ProviderCapability { | ||
| provider, | ||
| resolved_model: resolved_model.to_string(), | ||
| context_window: crate::models::context_window_for_model(resolved_model) | ||
| .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS), | ||
| max_output: crate::models::max_output_tokens_for_model(resolved_model), | ||
| thinking_supported: crate::models::model_supports_reasoning(resolved_model), | ||
| cache_telemetry_supported: false, | ||
| request_payload_mode: RequestPayloadMode::Responses, | ||
| alias_deprecation: None, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| if matches!( | ||
| provider, | ||
| ApiProvider::Anthropic | ApiProvider::MinimaxAnthropic | ApiProvider::Openmodel | ||
|
|
@@ -5466,6 +5513,20 @@ impl Config { | |
| || (identity_is_literal_custom(identity) && self.uses_legacy_literal_custom_route()) | ||
| } | ||
|
|
||
| /// Trimmed, non-empty `wire` dialect preference for `provider`'s config | ||
| /// table (`[providers.<name>] wire = "responses" | "anthropic" | "chat"`). | ||
| /// | ||
| /// Single source for the client wire resolver and the capability reporter | ||
| /// so the two cannot drift. `None` means "no preference" — the provider's | ||
| /// static policy applies. | ||
| pub(crate) fn provider_wire_dialect(&self, provider: ApiProvider) -> Option<&str> { | ||
| self.provider_config_for(provider)? | ||
| .wire | ||
| .as_deref() | ||
| .map(str::trim) | ||
| .filter(|value| !value.is_empty()) | ||
| } | ||
|
|
||
| pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> { | ||
| let providers = self.providers.as_ref()?; | ||
| // The custom provider's config lives in the flatten map, keyed by the | ||
|
|
@@ -9497,6 +9558,24 @@ fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool { | |
| ) | ||
| } | ||
|
|
||
| fn wire_config_prefers_responses(wire: Option<&str>) -> bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reuse/simplification (still open from the previous review pass): |
||
| let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { | ||
| return false; | ||
| }; | ||
| let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); | ||
| matches!( | ||
| normalized.as_str(), | ||
| "responses" | ||
| | "responses-api" | ||
| | "openai-responses" | ||
| | "openai-responses-api" | ||
| | "response" | ||
| | "response-api" | ||
| | "openai-responses-compat" | ||
| | "responses-compat" | ||
| ) | ||
| } | ||
|
|
||
| fn modelstudio_mode_is_coding_plan(provider: ApiProvider, mode: Option<&str>) -> bool { | ||
| if matches!( | ||
| provider, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tests: this substring fallback (
raw.to_ascii_lowercase().contains("muse-spark"), scoped correctly toProviderKind::OpencodeZen) andwire_config_prefers_responses's alias table are new protocol-routing logic with no accompanying unit test.resolver.rsalready has a#[cfg(test)]module (line 637) — a cheap addition there asserting an unlistedmuse-spark-*variant onOpencodeZenresolves toendpoint_key == "responses"(and that the same raw string on a differentProviderKinddoes not fall into this branch) would guard the fallback against silent regressions, e.g. if the match condition is ever loosened to another provider.