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
35 changes: 31 additions & 4 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/plugin/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
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",
Expand Down
25 changes: 14 additions & 11 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
// 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: {},
Expand Down Expand Up @@ -183,11 +185,11 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// 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: {},
}
Expand All @@ -197,11 +199,11 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// 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,
Expand Down Expand Up @@ -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 ?? {}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
65 changes: 57 additions & 8 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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 {}")
},
})

Expand Down
Loading