From 014c50e9880e11bf29c3d3656bd153bd8aa6bb03 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 4 Mar 2026 14:14:17 -0700 Subject: [PATCH 1/4] fix(provider): add fallback for sdk.responses undefined When switching between models (e.g., Claude to ChatGPT), the SDK may be created from @ai-sdk/openai-compatible which does not have a .responses() method. This causes TypeError: sdk.responses is not a function. Added fallback checks to openai, azure, and azure-cognitive-services custom loaders to use sdk.languageModel() when sdk.responses is undefined. Fixes model switching crashes. --- packages/opencode/src/provider/provider.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 81703836524..3c0182a054e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -154,6 +154,8 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { + // Fallback to languageModel if responses is not available (e.g., @ai-sdk/openai-compatible) + if (sdk.responses === undefined) return sdk.languageModel(modelID) return sdk.responses(modelID) }, options: {}, @@ -183,11 +185,11 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { + // Fallback to languageModel if responses/chat are not available if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) + return sdk.chat ? sdk.chat(modelID) : sdk.languageModel(modelID) } + return sdk.responses ? sdk.responses(modelID) : sdk.languageModel(modelID) }, options: {}, } @@ -197,11 +199,11 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { + // Fallback to languageModel if responses/chat are not available if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) + return sdk.chat ? sdk.chat(modelID) : sdk.languageModel(modelID) } + return sdk.responses ? sdk.responses(modelID) : sdk.languageModel(modelID) }, options: { baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, From c626ca92e26cc7672a7a5a92d5836ae9b90507ed Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 4 Mar 2026 14:52:36 -0700 Subject: [PATCH 2/4] fix: prevent Codex plugin from corrupting Anthropic SDK cache The Codex plugin's custom fetch wrapper was mutating the global provider.options object, which caused subsequent Anthropic model loads to fail with 'Bad Request'. This happened because: 1. User switches to Codex (OpenAI) - custom fetch added to options 2. options.fetch mutation persists in provider.options 3. User switches to Anthropic - SDK cache key includes mutated options 4. Anthropic SDK loads with corrupted options, causing API errors Fix: Create a local copy (sdkOptions) before adding the fetch wrapper, preserving the original provider.options for other provider instances. Fixes: Model switching from Codex to Anthropic causes API errors --- packages/opencode/src/provider/provider.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3c0182a054e..1a92a295e09 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1091,9 +1091,10 @@ export namespace Provider { const existing = s.sdk.get(key) if (existing) return existing - const customFetch = options["fetch"] - - options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + // Create a copy to avoid mutating provider.options + const sdkOptions = { ...options } + const customFetch = sdkOptions["fetch"] + sdkOptions["fetch"] = async (input: any, init?: BunFetchRequestInit) => { // Preserve custom fetch if it exists, wrap it with timeout logic const fetchFn = customFetch ?? fetch const opts = init ?? {} @@ -1138,7 +1139,7 @@ export namespace Provider { log.info("using bundled provider", { providerID: model.providerID, pkg: model.api.npm }) const loaded = bundledFn({ name: model.providerID, - ...options, + ...sdkOptions, }) s.sdk.set(key, loaded) return loaded as SDK @@ -1157,7 +1158,7 @@ export namespace Provider { const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] const loaded = fn({ name: model.providerID, - ...options, + ...sdkOptions, }) s.sdk.set(key, loaded) return loaded as SDK From 84f1a02be67f4225a531b71cb2ffc0a17bc52808 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 4 Mar 2026 14:54:37 -0700 Subject: [PATCH 3/4] fix(codex): use correct OpenAI API baseURL instead of ChatGPT backend --- packages/opencode/src/plugin/codex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 56931b2ed62..aef114a9135 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -377,7 +377,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { providerID: "openai", api: { id: "gpt-5.3-codex", - url: "https://chatgpt.com/backend-api/codex", + url: "https://api.openai.com/v1", npm: "@ai-sdk/openai", }, name: "GPT-5.3 Codex", From 59b42e26ee957f7e8149c1a452a0283c1b5878b1 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Thu, 5 Mar 2026 09:27:56 -0700 Subject: [PATCH 4/4] fix(config): use package.json name for file:// plugin identity Previously, file:// plugins were deduplicated by filename only, causing collisions when multiple plugins used standard entry points like index.js. Example: file:///path/to/plugin-a/dist/index.js -> 'index' (COLLISION) file:///path/to/plugin-b/dist/index.js -> 'index' (COLLISION) This change: - First checks for package.json 'name' field (up to 5 dirs up) - Falls back to full canonical URL (via realpathSync) if no package.json - Properly deduplicates file:// plugins with their npm counterparts This matches Node.js ESM loader semantics and handles: - Multiple plugins with same filename in different directories - npm package + local file:// version of same plugin (deduplicates correctly) - Symlinks resolved to canonical paths - Cross-platform support (Windows + Unix) Fixes #8759, #11159, #14304 --- packages/opencode/src/config/config.ts | 35 +++++++++-- packages/opencode/test/config/config.test.ts | 65 +++++++++++++++++--- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28c5b239a41..4fdce3fc4ea 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -24,7 +24,7 @@ import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" -import { constants, existsSync } from "fs" +import { constants, existsSync, readFileSync, realpathSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" @@ -462,19 +462,46 @@ export namespace Config { return plugins } + function findPackageJsonName(filePath: string): string | undefined { + let dir = path.dirname(filePath) + const root = path.parse(dir).root + + for (let i = 0; i < 5 && dir !== root; i++) { + const pkgPath = path.join(dir, "package.json") + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) + if (pkg.name && typeof pkg.name === "string") { + return pkg.name + } + } catch {} + } + dir = path.dirname(dir) + } + return undefined + } + /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: extracts filename without extension + * - For file:// URLs: uses package.json name if available, otherwise full canonical URL * - For npm packages: extracts package name without version * * @example - * getPluginName("file:///path/to/plugin/foo.js") // "foo" + * getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode" * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ export function getPluginName(plugin: string): string { if (plugin.startsWith("file://")) { - return path.parse(new URL(plugin).pathname).name + const filePath = fileURLToPath(plugin) + const pkgName = findPackageJsonName(filePath) + if (pkgName) return pkgName + + try { + return pathToFileURL(realpathSync(filePath)).href + } catch { + return plugin + } } const lastAt = plugin.lastIndexOf("@") if (lastAt > 0) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 40ab97449fb..0cc5466131e 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1601,10 +1601,38 @@ test("wellknown URL with trailing slash is normalized", async () => { }) describe("getPluginName", () => { - test("extracts name from file:// URL", () => { - expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo") - expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar") - expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") + test("file:// URL without package.json returns full canonical URL", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "standalone.js"), "export default {}") + }, + }) + const url = `file://${tmp.path}/standalone.js` + const result = Config.getPluginName(url) + expect(result.startsWith("file://")).toBe(true) + expect(result).toContain("standalone.js") + }) + + test("file:// URL with package.json returns package name", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "package.json"), JSON.stringify({ name: "my-plugin" })) + await Filesystem.write(path.join(dir, "dist", "index.js"), "export default {}") + }, + }) + const url = `file://${tmp.path}/dist/index.js` + expect(Config.getPluginName(url)).toBe("my-plugin") + }) + + test("file:// URL with scoped package.json returns scoped name", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "package.json"), JSON.stringify({ name: "@scope/pkg" })) + await Filesystem.write(path.join(dir, "index.js"), "export default {}") + }, + }) + const url = `file://${tmp.path}/index.js` + expect(Config.getPluginName(url)).toBe("@scope/pkg") }) test("extracts name from npm package with version", () => { @@ -1637,13 +1665,33 @@ describe("deduplicatePlugins", () => { expect(result.length).toBe(3) }) - test("prefers local file over npm package with same name", () => { - const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"] + test("file:// plugin with package.json dedupes with npm package of same name", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "package.json"), JSON.stringify({ name: "oh-my-opencode" })) + await Filesystem.write(path.join(dir, "index.js"), "export default {}") + }, + }) + const plugins = ["oh-my-opencode@2.4.3", `file://${tmp.path}/index.js`] const result = Config.deduplicatePlugins(plugins) expect(result.length).toBe(1) - expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js") + expect(result[0]).toBe(`file://${tmp.path}/index.js`) + }) + + test("file:// plugins without package.json do not collide on filename", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "plugin-a", "index.js"), "export default {}") + await Filesystem.write(path.join(dir, "plugin-b", "index.js"), "export default {}") + }, + }) + const plugins = [`file://${tmp.path}/plugin-a/index.js`, `file://${tmp.path}/plugin-b/index.js`] + + const result = Config.deduplicatePlugins(plugins) + + expect(result.length).toBe(2) }) test("preserves order of remaining plugins", () => { @@ -1670,7 +1718,8 @@ describe("deduplicatePlugins", () => { }), ) - await Filesystem.write(path.join(pluginDir, "my-plugin.js"), "export default {}") + await Filesystem.write(path.join(pluginDir, "package.json"), JSON.stringify({ name: "my-plugin" })) + await Filesystem.write(path.join(pluginDir, "index.js"), "export default {}") }, })