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/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", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 81703836524..1a92a295e09 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, @@ -1089,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 ?? {} @@ -1136,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 @@ -1155,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 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 {}") }, })