From bf4f48e800fb535862c23a03a90ffc07a650738d Mon Sep 17 00:00:00 2001 From: Nikita Koynov <43469098+nkoynov@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:38:27 +0000 Subject: [PATCH] fix(opencode2): pin the aisdk package to the plugin's own version The OpenCode 2 inventory registered `package: "aisdk:cursor-opencode-provider"`. OpenCode resolves a bare npm name as `@latest`, and on 2.0.22 the language model is built from that spec: the built-in `opencode.provider.dynamic` plugin is registered before external plugins and its `sdk` hook installs and imports the package itself (anomalyco/opencode#42788), so this plugin's own `sdk` hook finds `event.sdk` already set. A user who pinned `cursor-opencode-provider@0.8.0` in `plugins` therefore ran the last published language model (`/npm/cursor-opencode-provider@latest/`), and that copy changed whenever OpenCode refreshed its package cache. Register `aisdk:cursor-opencode-provider@`, read from this package's own package.json (two levels above both src/opencode2 and dist/opencode2), and fall back to the bare name when that file is missing or describes another package. `CURSOR_OPENCODE2_DEV_ENTRY` still wins. Same fix as stablekernel/opencode-cursor#129. --- CHANGELOG.md | 4 ++++ docs/opencode-2.md | 2 +- src/opencode2/catalog.ts | 40 +++++++++++++++++++++++++++++------ test/plugin-opencode2.test.ts | 25 +++++++++++++++++++++- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10e282a..61bb4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- OpenCode 2.0 loads the AI SDK provider at the installed plugin's version instead of the latest npm release ([#41](https://github.com/oakimov/cursor-opencode-provider/pull/41) by [@nkoynov](https://github.com/nkoynov)) + ## [0.8.0] - 2026-10-04 ### Changed diff --git a/docs/opencode-2.md b/docs/opencode-2.md index c849a36..225f45b 100644 --- a/docs/opencode-2.md +++ b/docs/opencode-2.md @@ -26,7 +26,7 @@ Synced models set `time.released` to `0` (Cursor AvailableModels does not expose Pin a version if you want: `"cursor-opencode-provider@0.4.1/plugin/opencode2"`. -OpenCode 2.0 installs the published package into its host cache and loads the AI SDK entry from there (`aisdk:cursor-opencode-provider`). No extra env vars are required. +OpenCode 2.0 installs the published package into its host cache and loads the AI SDK entry from there (`aisdk:cursor-opencode-provider@`, pinned to the version of the plugin that registered it, so a pinned plugin never runs a newer language model). No extra env vars are required. ## Config isolation diff --git a/src/opencode2/catalog.ts b/src/opencode2/catalog.ts index 61c8f07..64ec9fc 100644 --- a/src/opencode2/catalog.ts +++ b/src/opencode2/catalog.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs" import { pathToFileURL } from "node:url" import { CURSOR_PROVIDER_ID } from "../shared.js" import { CURSOR_WIRE_MODEL_ID_KEY, type ModelInfo } from "../models.js" @@ -17,14 +18,18 @@ import type { ConnectionInfo, ModelVariantInfo, ProviderEditor } from "./types.j /** Integration id owning Cursor credentials. Matches the provider id. */ export const CURSOR_INTEGRATION_ID = CURSOR_PROVIDER_ID +const CURSOR_PACKAGE_NAME = "cursor-opencode-provider" + /** * `aisdk:` selects OpenCode 2.0's AI SDK path, which is what surfaces the * `aisdk.hook("sdk")` / `("language")` extension points we supply the provider - * through. The suffix is this package's npm name so the host's built-in - * fallback can still resolve it if our own hook is ever bypassed — that - * fallback runs `npm.add(pkg)` against the *published* registry into - * `/packages//node_modules/`, ignoring any local - * `file://` plugin path this process was loaded from. + * through. The suffix is this package's npm name for the host's built-in + * fallback, which runs `npm.add(pkg)` against the *published* registry, + * ignoring any local `file://` plugin path this process was loaded from. On + * OpenCode 2.0.22 that fallback runs before plugin `sdk` hooks + * (anomalyco/opencode#42788), so the language model always comes from this + * spec. It is pinned to this package's own version: OpenCode resolves a bare + * name as `@latest`, which would load whatever was published last. * * `CURSOR_OPENCODE2_DEV_ENTRY` overrides the suffix with an `aisdk:file://…` * spec instead, pointed at a local built entry file (e.g. `dist/index.js`, @@ -35,7 +40,30 @@ export const CURSOR_INTEGRATION_ID = CURSOR_PROVIDER_ID */ export const CURSOR_AISDK_PACKAGE = process.env.CURSOR_OPENCODE2_DEV_ENTRY ? `aisdk:${pathToFileURL(process.env.CURSOR_OPENCODE2_DEV_ENTRY).href}` - : "aisdk:cursor-opencode-provider" + : `aisdk:${ownPackageSpec()}` + +/** + * `cursor-opencode-provider@` from this package's own package.json + * (two levels up from both `src/opencode2/` and `dist/opencode2/`), or the + * bare name when that file is missing or does not describe this package. + */ +export function ownPackageSpec( + packageJsonUrl: URL = new URL("../../package.json", import.meta.url), +): string { + try { + const pkg = JSON.parse(readFileSync(packageJsonUrl, "utf8")) as { name?: unknown; version?: unknown } + if ( + pkg.name === CURSOR_PACKAGE_NAME + && typeof pkg.version === "string" + && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version) + ) { + return `${CURSOR_PACKAGE_NAME}@${pkg.version}` + } + } catch { + // Bundled or relocated copies have no package.json beside them. + } + return CURSOR_PACKAGE_NAME +} /** * Plain-object `Model.Info` equivalent used by `ctx.provider.transform`. diff --git a/test/plugin-opencode2.test.ts b/test/plugin-opencode2.test.ts index 72d7b7a..800ed31 100644 --- a/test/plugin-opencode2.test.ts +++ b/test/plugin-opencode2.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import { join, resolve } from "node:path" import plugin from "../src/plugin-opencode2.js" import { CursorPlugin } from "../src/plugin.js" -import { applyCursorProviderInventory, CURSOR_AISDK_PACKAGE } from "../src/opencode2/catalog.js" +import { applyCursorProviderInventory, CURSOR_AISDK_PACKAGE, ownPackageSpec } from "../src/opencode2/catalog.js" import { accessTokenFromCredential, applyCursorIntegration, @@ -159,6 +159,29 @@ describe("opencode2 provider inventory", () => { expect(provider!.activation).toBe("enabled") }) + test("pins the aisdk package to this package's own version", () => { + // A bare name is resolved as `@latest`, so the host would load a newer + // language model than the plugin the user pinned. + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) + if (!process.env.CURSOR_OPENCODE2_DEV_ENTRY) { + expect(CURSOR_AISDK_PACKAGE).toBe(`aisdk:cursor-opencode-provider@${pkg.version}`) + } + expect(ownPackageSpec()).toBe(`cursor-opencode-provider@${pkg.version}`) + }) + + test("falls back to the bare package name without a matching package.json", () => { + const dir = mkdtempSync(join(tmpdir(), "cursor-pin-")) + try { + expect(ownPackageSpec(new URL(`file://${dir}/missing.json`))).toBe("cursor-opencode-provider") + writeFileSync(join(dir, "other.json"), JSON.stringify({ name: "some-bundle", version: "1.2.3" })) + expect(ownPackageSpec(new URL(`file://${dir}/other.json`))).toBe("cursor-opencode-provider") + writeFileSync(join(dir, "range.json"), JSON.stringify({ name: "cursor-opencode-provider", version: "^0.8.0" })) + expect(ownPackageSpec(new URL(`file://${dir}/range.json`))).toBe("cursor-opencode-provider") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + test("maps a model into the 2.0 shape", () => { const { editor, models } = fakeProviderEditor() applyCursorProviderInventory(editor, [baseModel])