From 9a6718529763b74ddd27f794e95610ed2a4b6b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:31:25 +0330 Subject: [PATCH] fix(cli): support OpenCode V2 config format in setup-opencode (#11070) (#11079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked the value commit (55da60f5) onto the current tip, dropping the stale base-red sync commits. Focused tests: opencode-v2-config-11070 + merge-provider-guard + config-dir-single-source 12/12; mutation coverage gate no-drift (new test registered in tap.testFiles by the PR itself). Fixes #11070 — setup-opencode now emits both V1 and V2 blocks. Thank you @rqzbeh! --- src/shared/services/opencodeConfig.ts | 42 +++++++++++++++++---- stryker.conf.json | 1 + tests/unit/opencode-v2-config-11070.test.ts | 40 ++++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/unit/opencode-v2-config-11070.test.ts diff --git a/src/shared/services/opencodeConfig.ts b/src/shared/services/opencodeConfig.ts index a4bbd65591..a91ca5069c 100644 --- a/src/shared/services/opencodeConfig.ts +++ b/src/shared/services/opencodeConfig.ts @@ -84,11 +84,29 @@ export const buildOpenCodeProviderConfig = ({ }; }; +export const buildOpenCodeV2ProviderConfig = ( + input: OpenCodeConfigInput +): Record => { + const v1Config = buildOpenCodeProviderConfig(input); + return { + name: v1Config.name, + package: "@opencode-ai/ai/providers/openai-compatible", + settings: { + baseURL: v1Config.options.baseURL, + apiKey: v1Config.options.apiKey, + }, + models: v1Config.models, + }; +}; + export const buildOpenCodeConfigDocument = (input: OpenCodeConfigInput) => ({ $schema: "https://opencode.ai/config.json", provider: { omniroute: buildOpenCodeProviderConfig(input), }, + providers: { + omniroute: buildOpenCodeV2ProviderConfig(input), + }, }); export const mergeOpenCodeConfig = ( @@ -100,18 +118,18 @@ export const mergeOpenCodeConfig = ( ? existingConfig : {}; - // Same guard as the root above, one level down. Spreading a non-object here - // does not throw, it splays the value into index keys: an existing - // `"provider": ["a", "b"]` merged to `{"0": "a", "1": "b", omniroute: ... }` - // and a string was exploded one character per key. mergeOpenCodeConfigText - // refuses the same input outright, so the two disagreed on what to do with a - // malformed config. const existingProvider = (safeConfig as Record).provider; const safeProvider = existingProvider && typeof existingProvider === "object" && !Array.isArray(existingProvider) ? (existingProvider as Record) : {}; + const existingProviders = (safeConfig as Record).providers; + const safeProviders = + existingProviders && typeof existingProviders === "object" && !Array.isArray(existingProviders) + ? (existingProviders as Record) + : {}; + return { ...safeConfig, $schema: safeConfig.$schema || "https://opencode.ai/config.json", @@ -119,6 +137,10 @@ export const mergeOpenCodeConfig = ( ...safeProvider, omniroute: buildOpenCodeProviderConfig(input), }, + providers: { + ...safeProviders, + omniroute: buildOpenCodeV2ProviderConfig(input), + }, }; }; @@ -127,6 +149,7 @@ export const mergeOpenCodeConfigText = ( input: OpenCodeConfigInput ) => { const providerConfig = buildOpenCodeProviderConfig(input); + const v2ProviderConfig = buildOpenCodeV2ProviderConfig(input); const content = typeof existingText === "string" ? existingText : ""; const trimmedContent = content.trim(); @@ -161,6 +184,11 @@ export const mergeOpenCodeConfigText = ( const providerEdits = modify(nextText, ["provider", "omniroute"], providerConfig, { formattingOptions: { insertSpaces: true, tabSize: 2 }, }); + nextText = applyEdits(nextText, providerEdits); - return applyEdits(nextText, providerEdits); + const v2ProviderEdits = modify(nextText, ["providers", "omniroute"], v2ProviderConfig, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + + return applyEdits(nextText, v2ProviderEdits); }; diff --git a/stryker.conf.json b/stryker.conf.json index 240e8419e5..a39b4b3a17 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -283,6 +283,7 @@ "tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts", "tests/unit/openapi-security-tiers.test.ts", "tests/unit/opencode-autocombo-search-pair.test.ts", + "tests/unit/opencode-v2-config-11070.test.ts", "tests/unit/openrouter-free-model-credits-exhausted.test.ts", "tests/unit/openrouter-passthrough-models.test.ts", "tests/unit/openrouter-quota-6842.test.ts", diff --git a/tests/unit/opencode-v2-config-11070.test.ts b/tests/unit/opencode-v2-config-11070.test.ts new file mode 100644 index 0000000000..71bd8a9537 --- /dev/null +++ b/tests/unit/opencode-v2-config-11070.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const opencodeConfig = await import("../../src/shared/services/opencodeConfig.ts"); + +test("buildOpenCodeConfigDocument includes both V1 (provider) and V2 (providers) definitions", () => { + const doc = opencodeConfig.buildOpenCodeConfigDocument({ + baseUrl: "http://localhost:20128/v1", + apiKey: "{env:OMNIROUTE_API_KEY}", + models: ["auto/best-coding"], + }); + + assert.ok(doc.provider?.omniroute, "V1 provider.omniroute must be present"); + assert.equal(doc.provider.omniroute.npm, "@ai-sdk/openai-compatible"); + assert.equal(doc.provider.omniroute.options.baseURL, "http://localhost:20128/v1"); + + assert.ok(doc.providers?.omniroute, "V2 providers.omniroute must be present"); + assert.equal(doc.providers.omniroute.package, "@opencode-ai/ai/providers/openai-compatible"); + assert.equal(doc.providers.omniroute.settings.baseURL, "http://localhost:20128/v1"); + assert.equal(doc.providers.omniroute.settings.apiKey, "{env:OMNIROUTE_API_KEY}"); + assert.ok(doc.providers.omniroute.models["auto/best-coding"].limit, "V2 model limit must be present"); +}); + +test("mergeOpenCodeConfig preserves existing properties and updates both provider and providers", () => { + const existing = { + $schema: "https://opencode.ai/config.json", + customField: "keep-me", + }; + + const merged = opencodeConfig.mergeOpenCodeConfig(existing, { + baseUrl: "http://localhost:20128/v1", + apiKey: "sk_test_key", + models: ["auto/best-coding"], + }); + + assert.equal(merged.customField, "keep-me"); + assert.ok(merged.provider?.omniroute); + assert.ok(merged.providers?.omniroute); + assert.equal(merged.providers.omniroute.settings.apiKey, "sk_test_key"); +});