diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0f46490899b..45a3340944a 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -792,6 +792,7 @@ Qwen3.7 Plus/3.8 Max/3.8 Flash use Anthropic Messages. Legacy MiniMax M2.5 and Union Alpha also retain Messages. Models without a listed wire retain Chat. New native pins apply to the pinned Go destination with key authentication. Responses models still accept an explicit `modelAdapters` override. +When reading an older saved configuration, OpenCodex ignores obsolete Chat/Responses overrides for the newly pinned Gemini and Qwen models, along with their formerly valid Responses-only hosted-tool preferences. Other providers, accounts and routing remain available. Reads leave the file unchanged; a later explicit configuration edit saves the upgraded values. New overrides for these native models are still rejected. Gemini uses `low`, `medium` and `high` thinking levels; higher requested efforts clamp to `high`. OpenCode Go requires a stable session identifier for routing. OpenCodex sends diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e8fc0b03a5d..f8bd26a990d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -753,7 +753,7 @@ "config-account-thresholds.test.ts": "config", "config-blocked-model-redirects.test.ts": "config", "config-catalog-auto-refresh.test.ts": "config", - "config-commandcode-claude-pin.test.ts": "config", "config-opencode-zen-upgrade.test.ts": "config", + "config-commandcode-claude-pin.test.ts": "config", "config-opencode-zen-upgrade.test.ts": "config", "config-opencode-go-upgrade.test.ts": "config", "config-load-degrade.test.ts": "config", "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", diff --git a/src/config.ts b/src/config.ts index 8fc8841e9f4..bdd93e331b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -162,7 +162,7 @@ import { persistConfigUnlocked, readRawConfigJson } from "./config/persist-unloc import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./config/mutation-lock"; import { getDefaultConfig } from "./config/proxy-env"; import { configSchema } from "./config/schema/config-schema"; -import { sanitizeOpenCodeZenOverridesForLoad } from "./config/opencode-zen-upgrade"; +import { sanitizeOpenCodeOverridesForLoad } from "./config/opencode-wire-upgrade"; import { hardenExistingSecret, normalizeApiKeyIds, @@ -223,7 +223,7 @@ export function loadConfig(): OcxConfig { try { const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); - sanitizeOpenCodeZenOverridesForLoad(parsed); + sanitizeOpenCodeOverridesForLoad(parsed); sanitizeAliasesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); sanitizeModelDisplayNamesForLoad(parsed); diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 6509b7ab93a..bde08407844 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -21,7 +21,7 @@ import { isMissingPathError } from "./atomic-write"; import { getConfigDir, getConfigPath } from "./paths"; import { getDefaultConfig } from "./proxy-env"; import { salvageConfigCandidate } from "./salvage"; -import { sanitizeOpenCodeZenOverridesForLoad } from "./opencode-zen-upgrade"; +import { sanitizeOpenCodeOverridesForLoad } from "./opencode-wire-upgrade"; import { sanitizeReasoningPinsForLoad, sanitizeRetryOn429ForLoad, @@ -766,7 +766,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { try { const parsed = JSON.parse(raw.replace(/^\uFEFF/, "")); - sanitizeOpenCodeZenOverridesForLoad(parsed); + sanitizeOpenCodeOverridesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the // schema and send the caller a default-config fallback (the config command could then diff --git a/src/config/opencode-wire-upgrade.ts b/src/config/opencode-wire-upgrade.ts new file mode 100644 index 00000000000..68fbbc35d8f --- /dev/null +++ b/src/config/opencode-wire-upgrade.ts @@ -0,0 +1,54 @@ +import { OPENCODE_GO_LEGACY_NAME_PINS } from "../types/opencode-go"; +import { captureWireAdapterHardPins } from "../types/wire"; +import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; +import { sanitizeOpenCodeZenOverridesForLoad } from "./opencode-zen-upgrade"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : undefined; +} + +/** File reads alone repair overrides admitted before OpenCode's native-wire upgrade. */ +export function sanitizeOpenCodeOverridesForLoad(parsed: unknown): void { + sanitizeOpenCodeZenOverridesForLoad(parsed); + const providers = record(record(parsed)?.providers); + const provider = providers && Object.hasOwn(providers, "opencode-go") + ? record(providers["opencode-go"]) : undefined; + if (!provider) return; + // Use validation's destination/auth rules, including the Go preset's fixed URL. + const pins = captureWireAdapterHardPins("opencode-go", provider); + const newlyPinned = (model: string): boolean => Object.hasOwn(pins, model) + && !OPENCODE_GO_LEGACY_NAME_PINS.has(model); + const overrides = record(provider.modelAdapters) ?? {}; + const preferences = record(provider.modelPreferHostedTools); + let preferencesChanged = false; + if (preferences) { + const unsupported = declaredUnsupportedHostedTools(provider as { unsupportedHostedTools?: readonly string[] }); + for (const [model, tools] of Object.entries(preferences)) { + // Go's registry owns the old provider-wide Chat wire. Only an explicit + // Responses override could previously make these native models eligible. + if (!newlyPinned(model.trim()) || overrides[model] !== "openai-responses" + || !Array.isArray(tools) || tools.length === 0 || tools.some(tool => tool !== "image_generation") + || isHostedToolUnsupportedForModel(model, "image_generation", undefined, unsupported)) continue; + delete preferences[model]; + preferencesChanged = true; + } + if (preferencesChanged && Object.keys(preferences).length === 0) delete provider.modelPreferHostedTools; + } + let overridesChanged = false; + for (const [model, adapter] of Object.entries(overrides)) { + // Leave malformed or previously incompatible preferences visible on every pass. + if (preferences && Object.hasOwn(preferences, model)) continue; + if (newlyPinned(model.trim()) && (adapter === "openai-chat" || adapter === "openai-responses")) { + delete overrides[model]; + overridesChanged = true; + } + } + if (overridesChanged) { + if (Object.keys(overrides).length === 0) delete provider.modelAdapters; + console.warn("OpenCode Go native models now use their required APIs; obsolete Chat/Responses overrides were ignored while loading config.json."); + } + if (preferencesChanged) { + console.warn("OpenCode Go native models now use their required APIs; legacy Responses-only hosted-tool preferences were ignored while loading config.json."); + } +} diff --git a/structure/config.md b/structure/config.md index 08bc246ff56..34dbbefb924 100644 --- a/structure/config.md +++ b/structure/config.md @@ -434,7 +434,7 @@ capture/route time and explicit false or empty declarations retain their field-s `src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, -ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-zen-upgrade.ts` normalizes saved Gemini overrides and their Responses-only hosted-tool preferences on canonical keyed OpenCode Zen routes. Runtime and diagnostics-backed reads share this upgrade, preserving unrelated providers and accounts. Reads warn without rewriting the file; explicit edits persist the normalized configuration, and new writes still reject incompatible overrides and preferences. Other destinations, authentication modes and invalid override values retain normal validation. +ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-wire-upgrade.ts` shares the Zen upgrade in `src/config/opencode-zen-upgrade.ts` and normalizes saved Chat/Responses overrides for newly pinned Go Gemini and Qwen models, including previously valid Responses-only hosted-tool preferences. Go uses validation's historical fixed-destination and key-auth rules; its older Anthropic pins and overridable Responses defaults retain normal validation. Runtime and diagnostics-backed reads share this upgrade, preserving unrelated providers, accounts and routing. Reads warn without rewriting the file; explicit edits persist the normalized configuration, and new writes still reject incompatible overrides and preferences. Unsupported authentication modes and invalid override values retain normal validation. Provider `projectContext` accepts `"off"` or `"on"` only for native `command-code`; `src/config/schema/leaf-validators.ts` rejects other adapters on load, while `src/config/provider-validation.ts` and `src/server/auth-cors.ts` reject them on management writes. This editor-owned outbound-file field follows [provider and adapter selection](providers-and-adapters.md). The Google tool-schema policy uses a closed enum at this boundary. Unknown values fail config load, diff --git a/tests/config/config-opencode-go-upgrade.test.ts b/tests/config/config-opencode-go-upgrade.test.ts new file mode 100644 index 00000000000..034198b2c61 --- /dev/null +++ b/tests/config/config-opencode-go-upgrade.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { getConfigPath, getDefaultConfig, loadConfig, validateConfigCandidate } from "../../src/config"; +import { readConfigFileSnapshot } from "../../src/config/diagnostics"; +import { handleConfigCommand } from "../../src/cli/config-command"; +import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { sanitizeOpenCodeOverridesForLoad } from "../../src/config/opencode-wire-upgrade"; +import { OPENCODE_GO_LEGACY_NAME_PINS, OPENCODE_GO_MODEL_WIRES } from "../../src/types/opencode-go"; +import { pinnedWireAdapter } from "../../src/types/wire"; +import { createTempHome } from "../helpers/temp-home"; + +const NEW_PINS = ["gemini-4-argon-contributor", "qwen3.7-plus", "qwen3.8-max", "qwen3.8-flash"]; +const RESPONSES = Object.keys(OPENCODE_GO_MODEL_WIRES).filter(model => OPENCODE_GO_MODEL_WIRES[model] === "openai-responses"); + +function candidate(modelAdapters: unknown, extra: Record = {}) { + return { + ...getDefaultConfig(), + defaultProvider: "opencode-go", + providers: { + "opencode-go": { + adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", apiKey: "synthetic-go-upgrade-key", + note: "Saved Go provider", modelAdapters, ...extra, + }, + "saved-gateway": { + adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", apiKey: "synthetic-other-key", + modelAdapters: { "qwen3.7-plus": "openai-responses" }, + modelPreferHostedTools: { "qwen3.7-plus": ["image_generation"] }, + }, + }, + codexAccounts: [{ id: "saved-account", email: "fixture@example.test", alias: "Saved account", plan: "plus", isMain: false }], + codexAccountPriorities: { "saved-account": 25 }, + activeCodexAccountPinned: "saved-account", + pausedCodexAccountIds: ["saved-account"], + routingProfiles: { saved: { candidates: [{ provider: "saved-gateway", model: "qwen3.7-plus" }] } }, + }; +} + +function checkReaders(raw: ReturnType) { + const home = createTempHome("ocx-go-config-upgrade-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const bytes = Buffer.from("\uFEFF" + JSON.stringify(raw, null, 2) + "\r\n"); + writeFileSync(getConfigPath(), bytes); + const filesBefore = readdirSync(home.root).sort(); + const loaded = loadConfig(); + const snapshot = readConfigFileSnapshot(); + expect(snapshot.raw).toBe(bytes.toString()); + expect(snapshot.diagnostics).toMatchObject({ source: "file", error: null, config: loaded }); + expect(loaded.defaultProvider).toBe("opencode-go"); + expect(Object.keys(loaded.providers).sort()).toEqual(["opencode-go", "saved-gateway"]); + expect(loaded.providers["saved-gateway"]).toEqual(raw.providers["saved-gateway"]); + expect(loaded.providers["opencode-go"]).toMatchObject({ apiKey: "synthetic-go-upgrade-key", note: "Saved Go provider" }); + expect(loaded.codexAccounts).toEqual(raw.codexAccounts); + expect(loaded.codexAccountPriorities).toEqual(raw.codexAccountPriorities); + expect(loaded.activeCodexAccountPinned).toBe(raw.activeCodexAccountPinned); + expect(loaded.pausedCodexAccountIds).toEqual(raw.pausedCodexAccountIds); + expect(loaded.routingProfiles).toEqual(raw.routingProfiles); + for (const model of NEW_PINS) { + expect(pinnedWireAdapter("opencode-go", model, loaded.providers["opencode-go"])) + .toBe(OPENCODE_GO_MODEL_WIRES[model]); + } + expect(readFileSync(getConfigPath())).toEqual(bytes); + expect(readdirSync(home.root).sort()).toEqual(filesBefore); + return loaded.providers["opencode-go"]; + } finally { warn.mockRestore(); home.remove(); } +} + +describe("OpenCode Go saved native-wire upgrade", () => { + test("covers every newly pinned model while excluding historical pins and Responses defaults", () => { + expect(Object.entries(OPENCODE_GO_MODEL_WIRES) + .filter(([model, wire]) => wire !== "openai-responses" && !OPENCODE_GO_LEGACY_NAME_PINS.has(model)) + .map(([model]) => model)).toEqual(NEW_PINS); + }); + + for (const model of NEW_PINS) { + for (const adapter of ["openai-chat", "openai-responses"]) { + test(`load and diagnostics preserve state for old ${model} ${adapter} overrides`, () => { + for (const key of [model, ` ${model} `]) { + const raw = candidate({ [key]: adapter, ...Object.fromEntries(RESPONSES.map(id => [id, "openai-chat"])) }); + expect(checkReaders(raw).modelAdapters).toEqual(Object.fromEntries(RESPONSES.map(id => [id, "openai-chat"]))); + } + }); + } + test(`load and diagnostics migrate valid old ${model} hosted preferences`, () => { + for (const key of [model, ` ${model} `]) { + const raw = candidate({ [key]: "openai-responses", "gpt-6-luna": "openai-responses" }, { + modelPreferHostedTools: { [key]: ["image_generation"], "gpt-6-luna": ["image_generation"] }, + }); + const loaded = checkReaders(raw); + expect(loaded.modelAdapters).toEqual({ "gpt-6-luna": "openai-responses" }); + expect(loaded.modelPreferHostedTools).toEqual({ "gpt-6-luna": ["image_generation"] }); + } + }); + } + + test("removes empty override and hosted-preference maps in both readers", () => { + const raw = candidate({ "qwen3.7-plus": "openai-responses" }, { + modelPreferHostedTools: { "qwen3.7-plus": ["image_generation"] }, + }); + const loaded = checkReaders(raw); + expect(loaded.modelAdapters).toBeUndefined(); + expect(loaded.modelPreferHostedTools).toBeUndefined(); + }); + + test("an explicit config edit persists the upgrade and preserves the rest of the saved config", async () => { + const home = createTempHome("ocx-go-config-edit-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const out = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + const previousExit = process.exitCode; + try { + const raw = candidate({ "qwen3.7-plus": "openai-responses" }, { + authMode: "key", googleMode: "ai-studio", + modelPreferHostedTools: { "qwen3.7-plus": ["image_generation"] }, + }); + writeFileSync(getConfigPath(), JSON.stringify(raw)); + expect(await handleConfigCommand(["set", "port", "10201", "--json"])).toBe(0); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")); + expect(persisted).toMatchObject({ ...raw, port: 10201, providers: { + ...raw.providers, "opencode-go": { + adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", + apiKey: "synthetic-go-upgrade-key", note: "Saved Go provider", + authMode: "key", googleMode: "ai-studio", + }, + } }); + expect(persisted.providers["opencode-go"].modelAdapters).toBeUndefined(); + expect(persisted.providers["opencode-go"].modelPreferHostedTools).toBeUndefined(); + expect(readConfigFileSnapshot().diagnostics).toMatchObject({ source: "file", error: null }); + } finally { + warn.mockRestore(); out.mockRestore(); err.mockRestore(); process.exitCode = previousExit; + await flushConfigDirHardeningAndReaps(home.root); + home.remove(); + } + }); + + test.each(["https://opencode.ai/inference/go/v1", "https://gateway.example.test/v1"])( + "matches validation's historical Go URL pin for %s", baseUrl => { + expect(checkReaders(candidate({ "qwen3.8-max": "openai-chat" }, { baseUrl })).modelAdapters).toBeUndefined(); + }, + ); + + test("strict writes still reject all newly pinned overrides without mutating the candidate", () => { + for (const model of NEW_PINS) { + for (const adapter of ["openai-chat", "openai-responses"]) { + const raw = candidate({ [model]: adapter }); + const original = structuredClone(raw); + const result = validateConfigCandidate(raw); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("only speaks one wire"); + expect(raw).toEqual(original); + } + } + }); +}); + +describe("Go upgrade boundaries", () => { + test("leaves historical pins, unknown ids, case variants and invalid override values to validation", () => { + const retained = { ...Object.fromEntries([...OPENCODE_GO_LEGACY_NAME_PINS].map(model => [model, "openai-chat"])), + "qwen3.7-plus": "anthropic", "qwen3.8-max": " openai-chat", "qwen3.8-flash": 42, + "QWEN3.7-PLUS": "openai-chat", "gemini-future": "openai-responses", " ": "openai-chat" }; + const raw = candidate(retained); + const original = structuredClone(raw); + sanitizeOpenCodeOverridesForLoad(raw); + expect(raw).toEqual(original); + expect(validateConfigCandidate(raw).ok).toBe(false); + }); + + test("does not erase previously invalid or denied hosted preferences on repeated passes", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + for (const extra of [ + ...[null, "image_generation", [], ["web_search"], ["image_generation", 42]].map(value => ({ + modelPreferHostedTools: { "qwen3.7-plus": value }, + })), + { unsupportedHostedTools: ["image_gen"], modelPreferHostedTools: { "qwen3.7-plus": ["image_generation"] } }, + { modelAdapters: { "qwen3.7-plus": "openai-chat" }, modelPreferHostedTools: { "qwen3.7-plus": ["image_generation"] } }, + { modelAdapters: undefined, adapter: "openai-responses", modelPreferHostedTools: { "qwen3.7-plus": ["image_generation"] } }, + ]) { + const raw = candidate({ "qwen3.7-plus": "openai-responses" }, extra); + const original = structuredClone(raw); + sanitizeOpenCodeOverridesForLoad(raw); + sanitizeOpenCodeOverridesForLoad(raw); + expect(raw).toEqual(original); + expect(validateConfigCandidate(raw).ok).toBe(false); + } + } finally { warn.mockRestore(); } + }); + + test("unsupported auth and Google modes, other provider names and malformed maps stay untouched", () => { + for (const extra of [{ authMode: "oauth" }, { authMode: "forward" }, { authMode: "none" }, + { googleMode: "vertex" }, { googleMode: "cloud-code-assist" }, + ...[null, 42, "openai-chat", ["openai-responses"]].map(modelAdapters => ({ modelAdapters }))]) { + const raw = candidate({ "qwen3.7-plus": "openai-chat" }, extra); + const original = structuredClone(raw); + sanitizeOpenCodeOverridesForLoad(raw); + expect(raw).toEqual(original); + } + const raw = { providers: { "my-go": candidate({ "qwen3.7-plus": "openai-chat" }).providers["opencode-go"] } }; + const original = structuredClone(raw); + sanitizeOpenCodeOverridesForLoad(raw); + expect(raw).toEqual(original); + }); + + test("upgrade is idempotent and warning text contains no configured names or values", () => { + const raw = candidate({ "qwen3.7-plus": "openai-chat", "qwen3.8-max": "openai-responses" }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeOverridesForLoad(raw); + const once = structuredClone(raw); + sanitizeOpenCodeOverridesForLoad(raw); + expect(raw).toEqual(once); + expect(warn.mock.calls).toEqual([["OpenCode Go native models now use their required APIs; obsolete Chat/Responses overrides were ignored while loading config.json."]]); + } finally { warn.mockRestore(); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d9773ed3bb3..fcca881b055 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -575,7 +575,7 @@ "compatibility-manifest.test.ts": "codex-integration", "compatibility-provider-equivalence.test.ts": "routing", "compatibility-version.test.ts": "ci-workflows", "config-account-thresholds.test.ts": "config", "config-blocked-model-redirects.test.ts": "config", "config-catalog-auto-refresh.test.ts": "config", - "config-commandcode-claude-pin.test.ts": "config", "config-opencode-zen-upgrade.test.ts": "config", "config-load-degrade.test.ts": "config", + "config-commandcode-claude-pin.test.ts": "config", "config-opencode-zen-upgrade.test.ts": "config", "config-opencode-go-upgrade.test.ts": "config", "config-load-degrade.test.ts": "config", "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", "config-save-boundary.test.ts": "config",