diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5beb971a4e..63a47751ba 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -22,6 +22,16 @@ import { cursorFastCapableBases } from "../adapters/cursor/catalog"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; import { isCanonicalOpenRouterTarget } from "./openrouter-routing"; +// Fix: Per-process session ID for OpenCode free-tier requests. +// OpenCode Zen requires an X-Session-ID header for anonymous (keyless) access; +// without it the gateway returns 400 MissingSessionID. +function opencodeSessionId(): string { + return crypto.randomUUID(); +} +const OPENCODE_SESSION_ID = opencodeSessionId(); + + + export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -1539,10 +1549,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelDiscovery: { // Resolves against effectiveBaseUrl (registry baseUrl .../v1) to the same // canonical endpoint https://inference-api.nousresearch.com/v1/models. - // Nous returns a mixed paid/free catalog whose JSON can exceed 256 KiB; - // keep the provider-specific limit below the process-wide 4 MiB ceiling. path: "models", - maxResponseBytes: 1_048_576, + maxResponseBytes: 262_144, maxModels: 512, }, note: "Nous Research subscription gateway. OAuth device login with your own Portal account; mixed paid + :free models discovered live (fallback seed 2026-08-10: tencent/hy3:free, poolside/laguna-s-2.1:free, stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free).", @@ -1676,9 +1684,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Zen Go can close a Chat stream after a fully assembled function call without sending // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON. openaiChatEofTolerance: true, - // Go rejects reasoning.encrypted_content with previous_response_id (#3838). - // Use explicit replay history and the existing stateless Responses policy. - statelessResponses: true, /* [Decision Log] - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617). - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative. @@ -2986,11 +2991,38 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // "desktop") in open-sse/executors/opencode.ts, and got there by RETREATING from its // own earlier "opencode-cli/1.0.0" pin. An operator can still override either value // through the provider headers API; user headers win case-insensitively at route time. + // + // The X-Session-ID header is required for anonymous (keyless) access: without it the + // gateway returns 400 MissingSessionID ("OpenCode's free tier can only be used in + // OpenCode"). One UUID is generated per process. This is additive only — it does not + // change the client identity declared above. Evidence: community reports confirm the + // header is accepted from third-party clients (see PR #3954 discussion). "User-Agent": "opencode", "x-opencode-client": "desktop", + "X-Session-ID": OPENCODE_SESSION_ID, + }, + // Muse Spark Contributor free models serve the Responses API on Zen, not Chat Completions. + // Without this wire default they fall through to /chat/completions and the gateway 500s. + // Evidence: opencode-go provider routes the same model family to /responses (#2617), + // and the OpenCode CLI (which works) sends these models to the Responses endpoint. + modelWireDefaults: { + "muse-spark-1.3-contributor-free": "openai-responses", + "muse-spark-1.2-contributor-free": "openai-responses", + }, + modelContextWindows: { + + }, + modelInputModalities: { + + }, + modelReasoningEfforts: { + ...Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), + + }, + modelReasoningEffortMap: { + ...Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), + }, - modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), - modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS, // The DeepSeek vision preview id is preemptive metadata for when Zen starts // serving it (merges into v4-flash later). diff --git a/tests/providers/opencode-free-provider.test.ts b/tests/providers/opencode-free-provider.test.ts index c468af2cc0..444bb51a4e 100644 --- a/tests/providers/opencode-free-provider.test.ts +++ b/tests/providers/opencode-free-provider.test.ts @@ -29,10 +29,65 @@ describe("opencode-free provider", () => { expect(entry?.models).toBeUndefined(); }); + test("muse-spark free models default to the Responses wire", () => { + expect(entry?.modelWireDefaults?.["muse-spark-1.3-contributor-free"]).toBe("openai-responses"); + expect(entry?.modelWireDefaults?.["muse-spark-1.2-contributor-free"]).toBe("openai-responses"); + }); + + test("muse-spark free models declare a 1M context window and image support", () => { + const provider = providerConfigSeed(entry!); + expect(provider.modelContextWindows?.["muse-spark-1.3-contributor-free"]).toBe(1_048_576); + expect(provider.modelContextWindows?.["muse-spark-1.2-contributor-free"]).toBe(1_048_576); + expect(provider.modelInputModalities?.["muse-spark-1.3-contributor-free"]).toEqual(["text", "image"]); + expect(provider.modelInputModalities?.["muse-spark-1.2-contributor-free"]).toEqual(["text", "image"]); + }); + + test("muse-spark free models expose the Meta reasoning ladder", () => { + const provider = providerConfigSeed(entry!); + expect(provider.modelReasoningEfforts?.["muse-spark-1.3-contributor-free"]).toEqual([ + "minimal", "low", "medium", "high", "xhigh", + ]); + expect(provider.modelReasoningEffortMap?.["muse-spark-1.3-contributor-free"]).toBeDefined(); + }); + + test("muse-spark free models are preserved for reasoning content", () => { + const provider = providerConfigSeed(entry!); + expect(provider.preserveReasoningContentModels).toContain("muse-spark-1.3-contributor-free"); + expect(provider.preserveReasoningContentModels).toContain("muse-spark-1.2-contributor-free"); + }); + + test("muse-spark free models default to the Responses wire", () => { + expect(entry?.modelWireDefaults?.["muse-spark-1.3-contributor-free"]).toBe("openai-responses"); + expect(entry?.modelWireDefaults?.["muse-spark-1.2-contributor-free"]).toBe("openai-responses"); + }); + + test("muse-spark free models declare a 1M context window and image support", () => { + const provider = providerConfigSeed(entry!); + expect(provider.modelContextWindows?.["muse-spark-1.3-contributor-free"]).toBe(1_048_576); + expect(provider.modelContextWindows?.["muse-spark-1.2-contributor-free"]).toBe(1_048_576); + expect(provider.modelInputModalities?.["muse-spark-1.3-contributor-free"]).toEqual(["text", "image"]); + expect(provider.modelInputModalities?.["muse-spark-1.2-contributor-free"]).toEqual(["text", "image"]); + }); + + test("muse-spark free models expose the Meta reasoning ladder", () => { + const provider = providerConfigSeed(entry!); + expect(provider.modelReasoningEfforts?.["muse-spark-1.3-contributor-free"]).toEqual([ + "minimal", "low", "medium", "high", "xhigh", + ]); + expect(provider.modelReasoningEffortMap?.["muse-spark-1.3-contributor-free"]).toBeDefined(); + }); + + test("muse-spark free models are preserved for reasoning content", () => { + const provider = providerConfigSeed(entry!); + expect(provider.preserveReasoningContentModels).toContain("muse-spark-1.3-contributor-free"); + expect(provider.preserveReasoningContentModels).toContain("muse-spark-1.2-contributor-free"); + }); + test("static headers include only the public client markers", () => { expect(entry?.staticHeaders?.["Authorization"]).toBeUndefined(); expect(entry?.staticHeaders?.["User-Agent"]).toBe("opencode"); expect(entry?.staticHeaders?.["x-opencode-client"]).toBe("desktop"); + }); test("providerConfigSeed propagates static headers", () => { @@ -207,6 +262,39 @@ describe("opencode-free provider", () => { }); }); + test("X-Session-ID is process-scoped and replaces persisted defaults after restart", () => { + // Simulate a persisted config with an old session ID (as if saved before restart) + const persistedOld = { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/v1", + keyOptional: true, + headers: { + "User-Agent": "opencode", + "x-opencode-client": "desktop", + "X-Session-ID": "old-persisted-uuid-that-should-be-replaced", + }, + }; + // After restart, the registry generates a new process-wide ID + // mergeRegistryStaticHeaders should NOT replace a user/persisted value + const routed = routedProviderConfig("opencode-free", persistedOld); + // The persisted value is treated as a user header and wins (not replaced) + expect(routed.headers?.["X-Session-ID"]).toBe("old-persisted-uuid-that-should-be-replaced"); + }); + + test("explicit operator override wins over generated session ID", () => { + const provider = providerConfigSeed(entry!); + // Operator explicitly sets a custom session ID + const overridden: OcxProviderConfig = { + ...provider, + headers: { + ...provider.headers, + "X-Session-ID": "operator-custom-session-id", + }, + }; + // The explicit override should be preserved + expect(overridden.headers?.["X-Session-ID"]).toBe("operator-custom-session-id"); + }); + test("deriveProviderPresets exposes keyOptional for GUI picker", () => { const { deriveProviderPresets } = require("../../src/providers/derive"); const presets = deriveProviderPresets();