fix(custom): support wire = "responses" | "anthropic" | "chat" for kind="openai-compatible"

Custom provider was fixed to ChatCompletions, ignoring providers.<name>.wire.
Now honors per-config wire in both client::provider_wire_format_for_config
and config::provider_capability, keeping Custom::wire_policy default as Chat
for compat. Aliases: responses/openai-responses/responses-api -> Responses;
anthropic/messages/claude -> AnthropicMessages; default -> Chat.

Fixes custom muse-spark-1.2 on opencode.ai/zen/v1 needing Responses.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
whp233andClaude Opus 4.8 committed 2026-08-29 17:23:11 +08:00
1 parent 9cf3243285
commit ff504585a4
3 files changed
+120 -9

No files matched your search

+7
View File
@@ -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)
}
}
+48 -9
View File
@@ -1195,12 +1195,15 @@ impl DeepSeekClient {
if api_provider == ApiProvider::OpencodeGo {
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)
} else {
(config.deepseek_api_key()?, None)
};
let (api_key, codex_account_id) =
if api_provider == ApiProvider::OpenaiCodex
&& !config.provider_uses_custom_endpoint(ApiProvider::OpenaiCodex)
{
let credentials = config.codex_credentials()?;
(credentials.access_token, credentials.account_id)
} else {
(config.deepseek_api_key()?, None)
};
let model_bound_secret_values =
Arc::new(configured_model_bound_secret_values(config, &api_key));
validate_base_url_security(&base_url)?;
@@ -1687,9 +1690,11 @@ 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>,
@@ -1722,6 +1727,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;
}
}
api_provider
.kind()
.and_then(|kind| {
@@ -1754,6 +1775,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"
) || normalized.contains("responses")
}
fn api_provider_skips_models_probe(api_provider: ApiProvider) -> bool {
matches!(api_provider, ApiProvider::DeepseekAnthropic)
}
+65
View File
@@ -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
@@ -9383,6 +9430,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"
) || normalized.contains("responses")
}
fn modelstudio_mode_is_coding_plan(provider: ApiProvider, mode: Option<&str>) -> bool {
if matches!(
provider,