Skip to content

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

Description

@whp233

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

Summary / 摘要

Custom provider (kind = "openai-compatible") currently only supports Chat Completions wire. 配置 wire = "responses" / "anthropic" 被忽略,始终走 ChatCompletions,导致需要 Responses 或 Anthropic Messages 的模型(如 muse-spark-1.2 on opencode.ai/zen/v1)无法在 custom provider 下工作。

Custom provider (kind="openai-compatible") only speaks Chat Completions, ignoring wire setting. Models requiring Responses / Anthropic wire (e.g. muse-spark-1.2 via opencode.ai/zen/v1) cannot be used as custom.

Reproduction / 复现

Minimal config.toml:

[providers.myspark]
kind = "openai-compatible"
base_url = "https://opencode.ai/zen/v1"
api_key_env = "OPENCODE_API_KEY"  # or OPENCODE_ZEN_API_KEY
model = "muse-spark-1.2"
wire = "responses"   # 期望走 OpenAI Responses;改成 "anthropic" / "messages" 同理
# wire = "anthropic"

[providers.myspark2]
kind = "openai-compatible"
base_url = "https://opencode.ai/zen/v1"
api_key_env = "OPENCODE_API_KEY"
model = "muse-spark-1.2"
wire = "anthropic"

Steps:

  1. codewhale --provider myspark (or set as default)
  2. Send any message / codewhale doctor --verbose
  3. Observe request goes to /v1/chat/completions instead of /responses or /v1/messages

Expected / 期望

  • wire = "responses" (also response, openai-responses) → WireFormat::Responses, requests hit /responses (OpenAI Responses API, like openai_codex).
  • wire = "anthropic" / "messages" / "anthropic-messages"WireFormat::AnthropicMessages, requests hit /v1/messages.
  • wire omitted / "openai" / "chat" / "chat_completions"WireFormat::ChatCompletions (default, backward compatible).
  • Case-insensitive, trims whitespace.

Actual / 实际

Always WireFormat::ChatCompletions regardless of wire value. 对 opencode.ai/zen/v1 + muse-spark-1.2 这类 Responses-Anthropic hybrid 网关,请求 400/404 或 unsupported wire,因为服务端期望 Responses/Anthropic payload 而收到 Chat Completions schema.

Root cause / 根因

crates/config/src/provider.rs:1651-1653:

impl Provider for Custom {
    fn wire_policy(&self) -> WirePolicy {
        WirePolicy::Fixed(WireFormat::ChatCompletions) // ← fixed, ignores config
    }
}
  • Custom::wire_policy() is Fixed(ChatCompletions), so WirePolicy::resolve() never consults providers.<name>.wire.
  • crates/tui/src/config.rs & crates/tui/src/client.rs resolve wire_format via provider.wire_policy() + provider_wire_format_for_config(), but Custom never reaches the ModelAware / wire_config_prefers_anthropic path. providers.<name>.wire is parsed & stored in lib.rs (ProviderConfigToml::wire) yet never read for Custom.
  • Other built-ins (e.g. OpencodeZenModelAware, OpenaiCodexFixed(Responses), AnthropicFixed(AnthropicMessages)) demonstrate the wire abstraction already exists; Custom is the outlier.

Proposed fix / 修复建议

Branch: fix/custom-wire-responses (or main with following changes)

  1. crates/config/src/provider.rsCustom::wire_policy
    Make Custom respect wire field: either ModelAware or Fixed with override via wire string. Suggested mapping (reuse WirePolicy::resolve keys):

    • responsesResponses
    • anthropic / messages / anthropic_messages / anthropic-messagesAnthropicMessages
    • chat / chat_completions / openai / empty → ChatCompletions (default)
  2. crates/tui/src/config.rsprovider_wire_format_for_config / wire_config_prefers_anthropic
    Ensure ApiProvider::Custom path reads config.wire (like dual-wire vendors do). Normalize aliases (wire, wire_format, api_style already aliased in config_document.rs).

  3. crates/tui/src/client.rs — dispatch & validation
    No wire-specific hardcoding for Custom; rely on resolved wire_format for match self.wire_format { ChatCompletions | Responses | AnthropicMessages } → correct body builder / endpoint. Keep validate_route / limits intact.

Backward compat: 无 wire 时保持 ChatCompletions,不影响现有 custom 用户。

Workaround / 临时绕过

Reuse the existing Responses-fixed built-in provider openai_codex + env override (since Custom is blocked):

[providers.openai_codex]
# base_url/model 留空,由 env 覆盖,避免写死官方 URL
export OPENAI_CODEX_BASE_URL="https://opencode.ai/zen/v1"
export OPENAI_CODEX_API_KEY="$OPENCODE_API_KEY"   # or OPENCODE_ZEN_API_KEY
# 可选:指定默认模型
export OPENAI_CODEX_MODEL="muse-spark-1.2"
codewhale --provider openai_codex --model muse-spark-1.2

或直接改 env 启动:

$env:OPENAI_CODEX_BASE_URL="https://opencode.ai/zen/v1"
$env:OPENAI_CODEX_API_KEY=$env:OPENCODE_API_KEY
codewhale --provider openai_codex

原理:OpenaiCodex::wire_policy = Fixed(Responses) 天然走 /responses,绕过 Custom 的 Fixed-Chat 限制。For Anthropic wire, use providers.anthropic similarly if gateway supports it.

缺点:占用 openai_codex 槽位,不能同时配多个 custom Responses 网关;env 覆盖对多路由不友好。

Related files / 相关文件

  • crates/config/src/provider.rsCustom struct & wire_policy() (root cause)
  • crates/tui/src/config.rsprovider_wire_format_for_config(), wire_config_prefers_anthropic(), ProviderConfigToml::wire handling
  • crates/tui/src/client.rsprovider_default_wire_format(), DeepSeekClient::wire_format dispatch (ChatCompletions / Responses / AnthropicMessages)
  • crates/config/src/lib.rsProviderKind::Custom, ProviderConfigToml, wire field definition

Environment

  • CodeWhale: main @ a0341b5a4 (pre-fix) / branch fix/custom-wire-responses
  • Provider: kind = "openai-compatible" (Custom) → opencode.ai/zen/v1 / muse-spark-1.2
  • OS: Windows 10 (also repro on Linux/macOS)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestresponses-apiResponses API integration, streaming, or provider protocol issues

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions