Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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).",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
88 changes: 88 additions & 0 deletions tests/providers/opencode-free-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down
Loading