Skip to content
Merged
12 changes: 10 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ max_subagents = 10 # optional (default 64, clamped to 1-128)
# model = "deepseek-v4-pro"

# OpenCode Zen (https://opencode.ai/docs/zen/)
# Model-aware gateway: GPT models use Responses, Claude/Qwen use Anthropic
# Model-aware gateway: GPT/Muse Spark 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.
Expand All @@ -774,9 +774,17 @@ max_subagents = 10 # optional (default 64, clamped to 1-128)
[providers.opencode_zen]
# api_key = "YOUR_OPENCODE_ZEN_API_KEY"
# base_url = "https://opencode.ai/zen/v1"
# model = "gpt-5.5" # Responses default
# model = "gpt-5.5" # Responses
# model = "muse-spark-1.2-contributor-free" # Responses (free tier, auto-routed to Responses — no wire needed)
# model = "claude-sonnet-4-6" # Anthropic Messages example
# model = "deepseek-v4-pro" # Chat Completions example
# Custom gateway equivalent (when not using the opencode_zen provider):
# [providers.my_opencode]
# kind = "openai-compatible"
# base_url = "https://opencode.ai/zen/v1"
# model = "muse-spark-1.2-contributor-free"
# wire = "responses"
# api_key_env = "OPENCODE_ZEN_API_KEY"

# Meta Model API / Muse Spark (https://developer.meta.com/ai/)
# OpenAI-compatible Chat Completions route.
Expand Down
7 changes: 7 additions & 0 deletions crates/config/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,13 @@ impl Provider for Custom {
}

fn wire_policy(&self) -> WirePolicy {
// Static default remains Chat Completions for backward compatibility.
// Per-config `wire = "responses" | "anthropic" | "chat"` overrides are
// honored in `crates/tui/src/client.rs::provider_wire_format_for_config`
// and `crates/tui/src/config.rs::provider_capability`, which read
// `ProviderConfig::wire` for the `Custom` catalog identity. This keeps
// the `Provider` trait `Fixed` while giving custom endpoints the same
// three-way switch (`responses` / `anthropic` / `chat`) as built-ins.
WirePolicy::Fixed(WireFormat::ChatCompletions)
}
}
Expand Down
5 changes: 5 additions & 0 deletions crates/config/src/route/offering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ pub(crate) const OPENCODE_ZEN_RESPONSES_MODELS: &[&str] = &[
"gpt-5",
"gpt-5-codex",
"gpt-5-nano",
// Muse Spark via OpenCode Zen gateway — Responses-only (reported
// 2026-08-29: muse-spark-1.2-contributor-free rejects Chat Completions).
"muse-spark-1.2",
"muse-spark-1.2-contributor",
"muse-spark-1.2-contributor-free",
];

pub(crate) const OPENCODE_ZEN_MESSAGES_MODELS: &[&str] = &[
Expand Down
17 changes: 16 additions & 1 deletion crates/config/src/route/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown
Contributor

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 to ProviderKind::OpencodeZen) and wire_config_prefers_responses's alias table are new protocol-routing logic with no accompanying unit test. resolver.rs already has a #[cfg(test)] module (line 637) — a cheap addition there asserting an unlisted muse-spark-* variant on OpencodeZen resolves to endpoint_key == "responses" (and that the same raw string on a different ProviderKind does not fall into this branch) would guard the fallback against silent regressions, e.g. if the match condition is ever loosened to another provider.

Comment on lines +425 to +426

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Muse fallback weakens closed routing

The contains("muse-spark") fallback accepts unrelated selectors containing that text. Confirm the closed Zen roster permits this broad forward-compatible boundary.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

{
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(),
Expand Down
65 changes: 57 additions & 8 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
Expand Down Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Custom wire selection lost during dispatch

With custom wire set, provider_wire_format_for_config honors it only during direct construction. Turn dispatch rebuilds from a Chat candidate and sends the wrong protocol.

Prompt for agents
Custom wire overrides are applied only in crates/tui/src/client.rs provider_wire_format_for_config. Runtime turns resolve ProviderKind::Custom through crates/config/src/route/resolver.rs, whose descriptor remains Fixed(ChatCompletions), and DeepSeekClient::from_candidate then binds candidate.protocol(), discarding wire = responses or anthropic. Represent the selected custom dialect in the executable route candidate, or apply one consistent override when candidates are created and consumed. Ensure normal turn dispatch, route preflight, model rebinding, and doctor capability reporting all use the same effective protocol. Add focused coverage that sends a normal engine turn for named custom providers using Responses and Anthropic wires.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

api_provider
.kind()
.and_then(|kind| {
Expand Down Expand Up @@ -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)
}
Expand Down
79 changes: 79 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -9497,6 +9558,24 @@ fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool {
)
}

fn wire_config_prefers_responses(wire: Option<&str>) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reuse/simplification (still open from the previous review pass): wire_config_prefers_responses is defined identically here and in crates/tui/src/client.rs:1788 (both files are in the same codewhale-tui crate, so there's no crate-boundary reason for the split). This mirrors the pre-existing duplication of wire_config_prefers_anthropic between the same two files. provider_wire_dialect() (added in this PR) now gives a single reader for the raw wire string, but the alias-matching tables themselves can still drift independently between the two copies — e.g. adding "response-api" in one and not the other would silently produce different behavior for the client vs. the capability report. Consider making config.rs's copies the single definition (pub(crate)) and having client.rs call them.

Fix this →

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,
Expand Down
9 changes: 8 additions & 1 deletion crates/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6977,7 +6977,14 @@ fn provider_capability_report(config: &Config) -> serde_json::Value {
let resolved_model = route
.as_ref()
.map_or(configured_model.as_str(), |route| route.model.as_str());
let cap = crate::config::provider_capability(provider, resolved_model);
// Wire-aware so a custom provider's `wire = "responses" | "anthropic"`
// reports the payload mode the client will actually speak instead of the
// static Chat default.
let cap = crate::config::provider_capability_with_wire(
provider,
resolved_model,
config.provider_wire_dialect(provider),
);
let route_profile = route.as_ref().map(|route| {
crate::model_profile::resolved_capability_profile_for_route(
provider,
Expand Down
Loading
Loading