diff --git a/config.example.toml b/config.example.toml index 284b6c1b77..6374754f5b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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. @@ -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. diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index e65e6d084c..096a0568be 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -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) } } diff --git a/crates/config/src/route/offering.rs b/crates/config/src/route/offering.rs index 375bbe44b7..14e71ee356 100644 --- a/crates/config/src/route/offering.rs +++ b/crates/config/src/route/offering.rs @@ -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] = &[ diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index ecb8ea62fc..4f488e1518 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -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") + { + 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(), diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 22959ce55a..75843efd8e 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -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..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..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.]` 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 +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) } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 851fe5c52c..ac7c6fd41c 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -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.] 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.] 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 { + 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, diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 661023e1e8..773fb0098b 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -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, diff --git a/scripts/opencode-chat2responses-proxy.mjs b/scripts/opencode-chat2responses-proxy.mjs new file mode 100644 index 0000000000..3591b60b31 --- /dev/null +++ b/scripts/opencode-chat2responses-proxy.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +/** + * opencode-chat2responses-proxy.mjs + * + * Minimal local proxy that exposes POST /v1/chat/completions (Chat API) + * but forwards as POST /v1/responses (Responses API) to opencode.ai/zen. + * + * Purpose: CodeWhale only spoke Chat Completions, but + * muse-spark-1.2-contributor-free on https://opencode.ai/zen/v1 only + * speaks Responses. This shim lets any Chat-only client use that model + * without modifying Rust code. + * + * Usage: + * node scripts/opencode-chat2responses-proxy.mjs + * # listens on http://127.0.0.1:8765 + * + * Then in CodeWhale config.toml: + * [providers.my_opencode] + * kind = "openai-compatible" + * base_url = "http://127.0.0.1:8765/v1" + * model = "muse-spark-1.2-contributor-free" + * api_key_env = "OPENCODE_ZEN_API_KEY" + * # proxy speaks chat to CodeWhale, responses to upstream + * + * Prefer the native fix (no proxy needed): + * [providers.opencode_zen] + * api_key_env = "OPENCODE_ZEN_API_KEY" + * base_url = "https://opencode.ai/zen/v1" + * model = "muse-spark-1.2-contributor-free" + * The bundled offering + resolver now correctly routes muse-spark over + * Responses (see crates/config/src/route/offering.rs). + */ + +import http from "node:http"; + +const LISTEN_PORT = Number(process.env.PROXY_PORT ?? 8765); +const UPSTREAM_BASE = process.env.UPSTREAM_BASE ?? "https://opencode.ai/zen/v1"; +const UPSTREAM_PATH = "/responses"; + +function chatToResponses(chatBody) { + const model = chatBody.model ?? "muse-spark-1.2-contributor-free"; + const messages = chatBody.messages ?? []; + const tools = chatBody.tools; + const sysMsgs = messages.filter((m) => m.role === "system"); + const instructions = + sysMsgs.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))).join("\n\n") || + "You are a helpful assistant."; + const input = []; + for (const m of messages) { + if (m.role === "system") continue; + if (m.role === "tool") { + input.push({ + type: "function_call_output", + call_id: m.tool_call_id ?? m.toolCallId ?? "call_unknown", + output: typeof m.content === "string" ? m.content : JSON.stringify(m.content), + }); + continue; + } + const content = typeof m.content === "string" ? [{ type: "input_text", text: m.content }] : m.content; + if (m.tool_calls || m.toolCalls) { + for (const tc of m.tool_calls ?? m.toolCalls ?? []) { + input.push({ + type: "function_call", + call_id: tc.id, + name: tc.function?.name ?? tc.name, + arguments: tc.function?.arguments ?? "{}", + }); + } + } + input.push({ + type: "message", + role: m.role === "assistant" ? "assistant" : "user", + content, + }); + } + const body = { + model, + stream: chatBody.stream ?? false, + store: false, + instructions, + input, + }; + if (chatBody.max_tokens) body.max_output_tokens = chatBody.max_tokens; + if (chatBody.temperature != null) body.temperature = chatBody.temperature; + if (chatBody.top_p != null) body.top_p = chatBody.top_p; + if (tools) { + body.tools = tools.map((t) => ({ + type: "function", + name: t.function.name, + description: t.function.description ?? "", + parameters: t.function.parameters ?? { type: "object", properties: {} }, + strict: false, + })); + body.tool_choice = "auto"; + } + return body; +} + +function translateResponsesSseToChat(responsesChunk, model) { + let out = ""; + const lines = responsesChunk.split("\n"); + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const payload = line.slice(5).trim(); + if (payload === "[DONE]") { + out += `data: [DONE]\n\n`; + continue; + } + try { + const evt = JSON.parse(payload); + const type = evt.type ?? ""; + if (type === "response.output_text.delta") { + const delta = evt.delta ?? evt.text ?? ""; + out += `data: ${JSON.stringify({ id: evt.response?.id ?? "chatcmpl-proxy", object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { content: delta }, finish_reason: null }] })}\n\n`; + } else if (type === "response.output_item.added" && evt.item?.type === "function_call") { + const item = evt.item; + out += `data: ${JSON.stringify({ id: evt.response?.id ?? "chatcmpl-proxy", object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: item.call_id, type: "function", function: { name: item.name, arguments: "" } }] }, finish_reason: null }] })}\n\n`; + } else if (type === "response.function_call_arguments.delta") { + out += `data: ${JSON.stringify({ id: evt.response?.id ?? "chatcmpl-proxy", object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: evt.delta ?? "" } }] }, finish_reason: null }] })}\n\n`; + } else if (type === "response.completed" || type === "response.incomplete") { + out += `data: ${JSON.stringify({ id: evt.response?.id ?? "chatcmpl-proxy", object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`; + } + } catch {} + } + return out; +} + +const server = http.createServer(async (req, res) => { + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, upstream: UPSTREAM_BASE })); + return; + } + if (req.method !== "POST" || !req.url?.includes("/chat/completions")) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "only POST /v1/chat/completions is proxied" })); + return; + } + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", async () => { + try { + const chatBody = JSON.parse(body || "{}"); + const model = chatBody.model ?? "muse-spark-1.2-contributor-free"; + const isStream = chatBody.stream === true; + const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") ?? process.env.OPENCODE_ZEN_API_KEY ?? ""; + const responsesBody = chatToResponses(chatBody); + const upstreamUrl = `${UPSTREAM_BASE}${UPSTREAM_PATH}`; + const headers = { + "content-type": "application/json", + accept: isStream ? "text/event-stream" : "application/json", + }; + if (apiKey) headers.authorization = `Bearer ${apiKey}`; + const upstreamRes = await fetch(upstreamUrl, { method: "POST", headers, body: JSON.stringify(responsesBody) }); + if (!upstreamRes.ok) { + const text = await upstreamRes.text(); + res.writeHead(upstreamRes.status, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: `upstream ${upstreamRes.status}`, body: text.slice(0, 4000) })); + return; + } + if (!isStream) { + const data = await upstreamRes.json(); + const outputText = data.output?.flatMap((item) => item.content ?? []).filter((c) => c.type === "output_text").map((c) => c.text).join("") ?? data.output_text ?? ""; + const chatRes = { + id: data.id ?? "chatcmpl-proxy", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message: { role: "assistant", content: outputText }, finish_reason: "stop" }], + usage: data.usage ? { prompt_tokens: data.usage.input_tokens, completion_tokens: data.usage.output_tokens, total_tokens: (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0) } : undefined, + }; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(chatRes)); + return; + } + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", "x-accel-buffering": "no" }); + const reader = upstreamRes.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buf.indexOf("\n\n")) !== -1) { + const chunk = buf.slice(0, idx + 2); + buf = buf.slice(idx + 2); + const translated = translateResponsesSseToChat(chunk, model); + if (translated) res.write(translated); + } + } + if (buf.trim()) { + const translated = translateResponsesSseToChat(buf, model); + if (translated) res.write(translated); + } + res.write(`data: [DONE]\n\n`); + res.end(); + } catch (e) { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: String(e?.message ?? e).slice(0, 2000) })); + } + }); +}); + +server.listen(LISTEN_PORT, "127.0.0.1", () => { + console.log(`[opencode-proxy] listening on http://127.0.0.1:${LISTEN_PORT}/v1/chat/completions -> ${UPSTREAM_BASE}${UPSTREAM_PATH}`); + console.log(`[opencode-proxy] health: http://127.0.0.1:${LISTEN_PORT}/health`); +});