From fca11af9de8a27cddc624af339ab06050fcb372a Mon Sep 17 00:00:00 2001 From: whp233 Date: Tue, 30 Jun 2026 23:52:46 +0800 Subject: [PATCH 1/7] ci: add Windows x64 build workflow on push to main --- .github/workflows/build-windows.yml | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/build-windows.yml diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000000..0669ac0e90 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,59 @@ +name: Build Windows + +on: + push: + branches: [main] + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + +permissions: + contents: read + actions: write + +concurrency: + group: build-windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-windows: + name: Build Windows x64 + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + targets: x86_64-pc-windows-msvc + + - uses: mozilla-actions/sccache-action@v0.0.10 + - name: Enable sccache + shell: bash + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" + echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" + + - uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + + - name: Build + shell: bash + run: cargo build --release --locked --target x86_64-pc-windows-msvc -p codewhale-cli -p codewhale-tui + + - name: Stage binaries + shell: bash + run: | + mkdir -p artifacts + cp target/x86_64-pc-windows-msvc/release/codewhale.exe artifacts/ + cp target/x86_64-pc-windows-msvc/release/codewhale-tui.exe artifacts/ + cp target/x86_64-pc-windows-msvc/release/codew.exe artifacts/ 2>/dev/null || true + + - uses: actions/upload-artifact@v7 + with: + name: codewhale-windows-x64 + path: artifacts/* + if-no-files-found: error From c39cd8e80252478149903ae1fd873c83b0490d86 Mon Sep 17 00:00:00 2001 From: whp233 Date: Tue, 30 Jun 2026 23:57:28 +0800 Subject: [PATCH 2/7] fix: trigger build-windows on any push to main --- .github/workflows/build-windows.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 0669ac0e90..8c370116c4 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -3,11 +3,7 @@ name: Build Windows on: push: branches: [main] - paths: - - 'crates/**' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' + workflow_dispatch: {} permissions: contents: read From a89c3dc498209949e2b36557a1ffd71cb9455300 Mon Sep 17 00:00:00 2001 From: whp233 Date: Sat, 29 Aug 2026 15:29:01 +0800 Subject: [PATCH 3/7] fix(custom): support wire = "responses" | "anthropic" | "chat" for kind="openai-compatible" Custom provider was fixed to ChatCompletions, ignoring providers..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. --- crates/config/src/provider.rs | 7 ++++ crates/tui/src/client.rs | 57 +++++++++++++++++++++++++----- crates/tui/src/config.rs | 65 +++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 9 deletions(-) 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/tui/src/client.rs b/crates/tui/src/client.rs index 1b62b708ab..ddb6448514 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -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..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>, @@ -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.]` 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) } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index baff0a0aad..d6ecaea747 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 @@ -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, From 20ac186e4daf89549c09b53e4bb5f2e30e15844f Mon Sep 17 00:00:00 2001 From: whp233 Date: Sat, 29 Aug 2026 18:36:52 +0800 Subject: [PATCH 4/7] fix(opencode-zen): route muse-spark over Responses API Muse Spark 1.2 contributor-free on https://opencode.ai/zen/v1 only supports POST /v1/responses (Responses API) and rejects Chat Completions. Previously the bundled offering roster and ModelAware resolver treated unknown muse-spark variants as chat or failed closed to unproven, so CodeWhale sent chat payloads that 404. - Add muse-spark-1.2, -contributor, -contributor-free to OPENCODE_ZEN_RESPONSES_MODELS (bundled_offerings) - Add resolver fallback: any muse-spark* under OpencodeZen resolves to endpoint_key responses even without exact catalog match - Update config.example.toml docs (GPT/Muse Spark -> Responses) and add muse-spark-1.2-contributor-free example - Add scripts/opencode-chat2responses-proxy.mjs as zero-Rust chat->responses shim for chat-only clients Custom gateways can already use wire="responses" (ff504585a); this fix makes the first-class opencode-zen provider work without hand-written wire config. --- config.example.toml | 12 +- crates/config/src/route/offering.rs | 5 + crates/config/src/route/resolver.rs | 16 ++ scripts/opencode-chat2responses-proxy.mjs | 208 ++++++++++++++++++++++ 4 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 scripts/opencode-chat2responses-proxy.mjs diff --git a/config.example.toml b/config.example.toml index a9af8261a2..f22e707f25 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/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..354249c6ff 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -419,6 +419,22 @@ impl RouteResolver { 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/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`); +}); From ba7673fbb6a32b18558ed5a02425817dc4d82b80 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Sat, 29 Aug 2026 11:26:06 -0700 Subject: [PATCH 5/7] fix(client): keep codex env-token auth working on custom endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5716 diverted OpenaiCodex credential resolution to the generic key resolver whenever provider_uses_custom_endpoint() is true, which dropped an explicit OPENAI_CODEX_ACCESS_TOKEN for custom-base-url setups. The shared-seam wiremock test proves the regression: the mock only answers Bearer test-token, so the request came back 404 on all three CI OSes (client::responses::tests::responses_stream_open_preserves_wire_headers_ through_shared_seam). The manual if-condition formatting also failed the Lint job's cargo fmt --check. Restore the pre-PR precedence by trying codex_credentials() first: env credentials still win on custom endpoints (codex_credentials checks env before the official-endpoint consent grant), the official endpoint keeps propagating OAuth errors, and only a custom endpoint with no env token falls back to deepseek_api_key() — preserving the contributor's goal of letting a custom endpoint authenticate with its own configured key. Signed-off-by: CodeWhale Bot --- crates/tui/src/client.rs | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index ddb6448514..c6926bf779 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1195,15 +1195,27 @@ 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 - && !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 (api_key, codex_account_id) = if api_provider == ApiProvider::OpenaiCodex { + // 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) + }; let model_bound_secret_values = Arc::new(configured_model_bound_secret_values(config, &api_key)); validate_base_url_security(&base_url)?; @@ -1700,9 +1712,7 @@ fn provider_wire_format_for_config( 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 @@ -1790,7 +1800,7 @@ fn wire_config_prefers_responses(wire: Option<&str>) -> bool { | "response-api" | "openai-responses-compat" | "responses-compat" - ) || normalized.contains("responses") + ) } fn api_provider_skips_models_probe(api_provider: ApiProvider) -> bool { From 1434e7eea1640a3340c929c9f5fdbec41235214c Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Sat, 29 Aug 2026 11:26:16 -0700 Subject: [PATCH 6/7] refactor(tui): route wire-dialect reads through one Config helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire= feature read providers..wire in two places (client wire resolution and the capability reporter), and provider_capability_with_ wire was exported but never called with a real value — a parallel entry point that reported Chat for custom providers the client actually speaks Responses/Messages to. - Add Config::provider_wire_dialect() as the single trimmed, non-empty wire reader; use it in provider_wire_format_for_config and the doctor capability report (provider_capability_with_wire). - Drop the over-broad '|| normalized.contains("responses")' from wire_config_prefers_responses in both modules: every listed alias except the singular 'response'/'response-api' spellings already contains the substring, so the fallback only admitted unintended values like 'not-responses'. - Remove the vestigial 'let _ = provider_kind;' marker in the resolver arm that now genuinely uses provider_kind. Signed-off-by: CodeWhale Bot --- crates/config/src/route/resolver.rs | 1 - crates/tui/src/config.rs | 16 +++++++++++++++- crates/tui/src/lib.rs | 9 ++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 354249c6ff..4f488e1518 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -417,7 +417,6 @@ 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) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index d6ecaea747..3fef78f424 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -5458,6 +5458,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 @@ -9445,7 +9459,7 @@ fn wire_config_prefers_responses(wire: Option<&str>) -> bool { | "response-api" | "openai-responses-compat" | "responses-compat" - ) || normalized.contains("responses") + ) } fn modelstudio_mode_is_coding_plan(provider: ApiProvider, mode: Option<&str>) -> bool { diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index ea13392f2e..f5a8530d8e 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -6970,7 +6970,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, From 9bf4c6ed98476464dfd83fce455909521ba10708 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Sat, 29 Aug 2026 11:26:17 -0700 Subject: [PATCH 7/7] revert(ci): drop contributor-added build-windows workflow The PR added a Build Windows x64 workflow triggering on every push to main. That build is already covered: release-artifacts.yml builds both x86_64-pc-windows-msvc and aarch64-pc-windows-msvc release binaries, nightly.yml rebuilds them nightly, and ci.yml runs the full test matrix on windows-latest. A fourth always-on Windows build only spends CI minutes on every main push and grants the job an actions:write permission it does not need. Contributor CI-workflow additions are outside this feature's scope; restoring main's tree (no such file). Signed-off-by: CodeWhale Bot --- .github/workflows/build-windows.yml | 55 ----------------------------- 1 file changed, 55 deletions(-) delete mode 100644 .github/workflows/build-windows.yml diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml deleted file mode 100644 index 8c370116c4..0000000000 --- a/.github/workflows/build-windows.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Build Windows - -on: - push: - branches: [main] - workflow_dispatch: {} - -permissions: - contents: read - actions: write - -concurrency: - group: build-windows-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-windows: - name: Build Windows x64 - runs-on: windows-latest - steps: - - uses: actions/checkout@v7 - - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - targets: x86_64-pc-windows-msvc - - - uses: mozilla-actions/sccache-action@v0.0.10 - - name: Enable sccache - shell: bash - run: | - echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" - echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" - - - uses: Swatinem/rust-cache@v2 - with: - cache-bin: false - - - name: Build - shell: bash - run: cargo build --release --locked --target x86_64-pc-windows-msvc -p codewhale-cli -p codewhale-tui - - - name: Stage binaries - shell: bash - run: | - mkdir -p artifacts - cp target/x86_64-pc-windows-msvc/release/codewhale.exe artifacts/ - cp target/x86_64-pc-windows-msvc/release/codewhale-tui.exe artifacts/ - cp target/x86_64-pc-windows-msvc/release/codew.exe artifacts/ 2>/dev/null || true - - - uses: actions/upload-artifact@v7 - with: - name: codewhale-windows-x64 - path: artifacts/* - if-no-files-found: error