From 9fd46663c81ffe9424ada84d6296c733fabc1e1b Mon Sep 17 00:00:00 2001 From: Majid Ali <67096621+Majidalee1@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:08:08 +0500 Subject: [PATCH 1/2] refactor: extract shared model conversion utilities Introduce src/models.ts so the V1 plugin and the upcoming V2 entrypoint share one source of truth for loading models.json and converting entries into their runtime-specific shapes. - loadModels(): reads models.json once, typed as ModelEntry[] - toConfigKey(): stable short key derivation (preserves prior behavior) - toV1ModelConfig(): legacy { config } hook model shape - toV2ModelDraft()/toV2ModelMap(): OpenCode V2 catalog model drafts plugin.ts now delegates to the shared module; behavior is unchanged and covered by tests. --- plugin.ts | 52 +++++-------------- src/models.ts | 102 ++++++++++++++++++++++++++++++++++++++ tests/unit/models.test.ts | 100 +++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 40 deletions(-) create mode 100644 src/models.ts create mode 100644 tests/unit/models.test.ts diff --git a/plugin.ts b/plugin.ts index 655e127..f36ec6e 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,30 +1,14 @@ -import { readFileSync } from "fs" -import { join, dirname } from "path" -import { fileURLToPath } from "url" - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -interface ModelEntry { - id: string - name: string - tier: "premium" | "open-source" - reasoning: boolean - tool_call: boolean - cost: { input: number; output: number; cache_read?: number; cache_write?: number } - limit: { context: number; output: number } -} - -function loadModels(): ModelEntry[] { - const modelsPath = join(__dirname, "models.json") - return JSON.parse(readFileSync(modelsPath, "utf-8")) -} - -function toConfigKey(id: string): string { - const slashIdx = id.indexOf("/") - const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id - return short.toLowerCase() -} - +import { loadModels, toConfigKey, toV1ModelConfig } from "./src/models.js" + +/** + * Legacy OpenCode V1 plugin entrypoint. + * + * The default export is a factory returning a `{ config, auth }` hook object, + * which the V1 runtime uses to inject the `provider.commandcode` block and + * register the API-key auth method. This entrypoint is kept for V1 users. + * + * @deprecated OpenCode V2 uses the `./v2` entrypoint instead. + */ export default async function commandcodePlugin() { return { config: async (config: Record) => { @@ -43,19 +27,7 @@ export default async function commandcodePlugin() { const models = loadModels() const modelsObj: Record = {} for (const entry of models) { - const key = toConfigKey(entry.id) - const costObj: Record = { input: entry.cost.input, output: entry.cost.output } - if (entry.cost.cache_read !== undefined) costObj.cache_read = entry.cost.cache_read - if (entry.cost.cache_write !== undefined) costObj.cache_write = entry.cost.cache_write - - modelsObj[key] = { - id: entry.id, - name: entry.name, - reasoning: entry.reasoning, - tool_call: entry.tool_call, - cost: costObj, - limit: entry.limit, - } + modelsObj[toConfigKey(entry.id)] = toV1ModelConfig(entry) } cc.models = modelsObj } diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..bf888f4 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,102 @@ +import { readFileSync } from "fs" +import { join, dirname } from "path" +import { fileURLToPath } from "url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export interface ModelEntry { + id: string + name: string + tier: "premium" | "open-source" + reasoning: boolean + tool_call: boolean + cost: { + input: number + output: number + cache_read?: number + cache_write?: number + } + limit: { context: number; output: number } +} + +export function loadModels(): ModelEntry[] { + const modelsPath = join(__dirname, "..", "models.json") + return JSON.parse(readFileSync(modelsPath, "utf-8")) +} + +/** + * The config key used for a model is the part of its id after the first `/` + * (if any), lowercased. Command Code routes the full `id` upstream, so the + * full `modelID` is preserved separately. + */ +export function toConfigKey(id: string): string { + const slashIdx = id.indexOf("/") + const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id + return short.toLowerCase() +} + +export interface V1ModelConfig { + id: string + name: string + reasoning: boolean + tool_call: boolean + cost: Record + limit: { context: number; output: number } +} + +/** Model shape consumed by the legacy (V1) `config` hook. */ +export function toV1ModelConfig(entry: ModelEntry): V1ModelConfig { + const cost: Record = { input: entry.cost.input, output: entry.cost.output } + if (entry.cost.cache_read !== undefined) cost.cache_read = entry.cost.cache_read + if (entry.cost.cache_write !== undefined) cost.cache_write = entry.cost.cache_write + return { + id: entry.id, + name: entry.name, + reasoning: entry.reasoning, + tool_call: entry.tool_call, + cost, + limit: entry.limit, + } +} + +export interface V2ModelDraft { + modelID: string + name: string + capabilities: { tools: boolean; input: string[]; output: string[] } + limit: { context: number; output: number } + cost: { + input: number + output: number + cache?: { read: number; write: number } + } +} + +/** Model draft consumed by the OpenCode V2 `ctx.catalog` API. */ +export function toV2ModelDraft(entry: ModelEntry): V2ModelDraft { + const draft: V2ModelDraft = { + modelID: entry.id, + name: entry.name, + capabilities: { + tools: entry.tool_call, + input: ["text"], + output: ["text"], + }, + limit: entry.limit, + cost: { input: entry.cost.input, output: entry.cost.output }, + } + if (entry.cost.cache_read !== undefined) { + draft.cost.cache = { + read: entry.cost.cache_read, + write: entry.cost.cache_write ?? 0, + } + } + return draft +} + +export function toV2ModelMap(entries: ModelEntry[]): Record { + const map: Record = {} + for (const entry of entries) { + map[toConfigKey(entry.id)] = toV2ModelDraft(entry) + } + return map +} diff --git a/tests/unit/models.test.ts b/tests/unit/models.test.ts new file mode 100644 index 0000000..9f25780 --- /dev/null +++ b/tests/unit/models.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test" +import { loadModels, toConfigKey, toV1ModelConfig, toV2ModelDraft, toV2ModelMap } from "../../src/models.js" + +test("loadModels reads all entries from models.json", () => { + const models = loadModels() + expect(models.length).toBeGreaterThan(0) + for (const m of models) { + expect(m.id).toBeTruthy() + expect(m.name).toBeTruthy() + expect(m.limit.context).toBeGreaterThan(0) + expect(m.limit.output).toBeGreaterThan(0) + } +}) + +test("toConfigKey strips a provider prefix", () => { + expect(toConfigKey("deepseek/deepseek-v4-flash")).toBe("deepseek-v4-flash") +}) + +test("toConfigKey lowercases", () => { + expect(toConfigKey("zai-org/GLM-5")).toBe("glm-5") +}) + +test("toConfigKey keeps unprefixed ids unchanged (lowercased)", () => { + expect(toConfigKey("gpt-5.5")).toBe("gpt-5.5") +}) + +test("toV1ModelConfig maps a model entry to the V1 shape", () => { + const entry = { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + tier: "premium" as const, + reasoning: false, + tool_call: true, + cost: { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, + limit: { context: 200000, output: 8192 }, + } + expect(toV1ModelConfig(entry)).toEqual({ + id: entry.id, + name: entry.name, + reasoning: false, + tool_call: true, + cost: { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, + limit: entry.limit, + }) +}) + +test("toV1ModelConfig omits cache fields when absent", () => { + const entry = { + id: "glm-5", + name: "GLM-5", + tier: "open-source" as const, + reasoning: false, + tool_call: true, + cost: { input: 0.95, output: 3.15 }, + limit: { context: 200000, output: 131072 }, + } + expect(toV1ModelConfig(entry).cost).toEqual({ input: 0.95, output: 3.15 }) +}) + +test("toV2ModelDraft maps a model entry to the V2 catalog shape", () => { + const entry = { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + tier: "premium" as const, + reasoning: false, + tool_call: true, + cost: { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, + limit: { context: 200000, output: 8192 }, + } + expect(toV2ModelDraft(entry)).toEqual({ + modelID: entry.id, + name: entry.name, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + limit: entry.limit, + cost: { input: 1, output: 5, cache: { read: 0.1, write: 1.25 } }, + }) +}) + +test("toV2ModelDraft omits cache when not declared", () => { + const entry = { + id: "glm-5", + name: "GLM-5", + tier: "open-source" as const, + reasoning: false, + tool_call: true, + cost: { input: 0.95, output: 3.15 }, + limit: { context: 200000, output: 131072 }, + } + expect(toV2ModelDraft(entry).cost).toEqual({ input: 0.95, output: 3.15 }) +}) + +test("toV2ModelMap keys by config key and preserves modelID", () => { + const models = loadModels() + const map = toV2ModelMap(models) + const keys = Object.keys(map) + expect(keys.length).toBe(models.length) + expect(map["deepseek-v4-flash"]).toBeDefined() + expect(map["deepseek-v4-flash"].modelID).toBe("deepseek/deepseek-v4-flash") + expect(map["gpt-5.5"].modelID).toBe("gpt-5.5") +}) From 5a6d854ed164d10686b9c68825e560d6cf8c19b6 Mon Sep 17 00:00:00 2001 From: Majid Ali <67096621+Majidalee1@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:09:15 +0500 Subject: [PATCH 2/2] feat: add OpenCode 2 (V2) plugin entrypoint OpenCode 2 loads plugins from a module's default export as { id, setup }, and requires provider/model registration through the catalog transform API. The legacy plugin.ts factory (V1 only) fails to load in V2 with a SchemaError. Add a new ./v2 entrypoint that exports { id, setup } and registers the commandcode provider plus all models from models.json via ctx.catalog.transform. Users can now opt in with: plugins: ["commandcode-go-opencode-provider/v2"] No provider block or hand-written model list is needed. - v2.ts: V2 plugin default export ({ id, setup }) - package.json: expose ./v2, include v2.ts in files, bump to 0.5.0 - tsconfig.json: include v2.ts - tests/unit/v2.test.ts: coverage for provider/model registration --- package.json | 7 ++- tests/unit/v2.test.ts | 122 ++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- v2.ts | 67 +++++++++++++++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 tests/unit/v2.test.ts create mode 100644 v2.ts diff --git a/package.json b/package.json index a96ec87..f1d8b78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commandcode-go-opencode-provider", - "version": "0.4.0", + "version": "0.5.0", "author": "Brent Weatherall", "repository": { "type": "git", @@ -18,6 +18,9 @@ }, "./server": { "import": "./plugin.ts" + }, + "./v2": { + "import": "./v2.ts" } }, "bugs": { @@ -25,7 +28,7 @@ }, "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code", "engines": { "bun": ">=1.0.0" }, - "files": ["index.ts", "plugin.ts", "models.json", "src/"], + "files": ["index.ts", "plugin.ts", "v2.ts", "models.json", "src/"], "homepage": "https://github.com/brent-weatherall/opencode-commandcode-provider#readme", "keywords": [ "opencode", diff --git a/tests/unit/v2.test.ts b/tests/unit/v2.test.ts new file mode 100644 index 0000000..4e944bf --- /dev/null +++ b/tests/unit/v2.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test" +import plugin, { id } from "../../v2.js" +import { loadModels, toConfigKey, toV2ModelDraft } from "../../src/models.js" + +type Draft = Record + +function makeCatalog() { + const providers: Record = {} + const models: Record = {} + return { + providers, + models, + catalog: { + async transform(transform: (catalog: unknown) => void) { + transform({ + provider: { + update(providerID: string, update: (draft: Draft) => void) { + providers[providerID] ??= {} + update(providers[providerID]!) + }, + }, + model: { + update(providerID: string, modelID: string, update: (draft: Draft) => void) { + const key = `${providerID}/${modelID}` + models[key] ??= {} + update(models[key]!) + }, + }, + }) + }, + }, + } +} + +test("exports a stable plugin id", () => { + expect(id).toBe("commandcode-go-opencode-provider") + expect(plugin.id).toBe("commandcode-go-opencode-provider") +}) + +test("plugin has a setup function", () => { + expect(typeof plugin.setup).toBe("function") +}) + +test("setup registers the commandcode provider", async () => { + const { catalog, providers } = makeCatalog() + await plugin.setup({ catalog } as never) + + const provider = providers["commandcode"] + expect(provider).toBeDefined() + expect(provider.name).toBe("Command Code") + expect(provider.package).toBe("aisdk:commandcode-go-opencode-provider") + expect(provider.settings).toEqual({ baseURL: "https://api.commandcode.ai" }) +}) + +test("setup preserves user settings overrides", async () => { + const { catalog, providers } = makeCatalog() + providers["commandcode"] = { + settings: { baseURL: "https://proxy.example.com", custom: 1 }, + } + await plugin.setup({ catalog } as never) + + const provider = providers["commandcode"] + expect(provider.settings).toEqual({ + baseURL: "https://proxy.example.com", + custom: 1, + }) +}) + +test("setup registers every model from models.json", async () => { + const { catalog, models } = makeCatalog() + await plugin.setup({ catalog } as never) + + const entries = loadModels() + expect(Object.keys(models)).toHaveLength(entries.length) + + for (const entry of entries) { + const key = `commandcode/${toConfigKey(entry.id)}` + const model = models[key] + expect(model, `model ${entry.id} should be registered`).toBeDefined() + expect(model.modelID).toBe(entry.id) + expect(model.name).toBe(entry.name) + expect(model.capabilities).toEqual({ + tools: entry.tool_call, + input: ["text"], + output: ["text"], + }) + expect(model.limit).toEqual(entry.limit) + } +}) + +test("model drafts match toV2ModelDraft", async () => { + const { catalog, models } = makeCatalog() + await plugin.setup({ catalog } as never) + + const entries = loadModels() + for (const entry of entries) { + const key = `commandcode/${toConfigKey(entry.id)}` + expect(models[key]).toEqual(toV2ModelDraft(entry)) + } +}) + +test("models include cache pricing when declared", async () => { + const { catalog, models } = makeCatalog() + await plugin.setup({ catalog } as never) + + const key = `commandcode/claude-haiku-4-5-20251001` + const model = models[key] + expect(model.cost).toEqual({ + input: 1, + output: 5, + cache: { read: 0.1, write: 1.25 }, + }) +}) + +test("models omit cache when not declared", async () => { + const { catalog, models } = makeCatalog() + await plugin.setup({ catalog } as never) + + const key = `commandcode/glm-5` + const model = models[key] + expect(model.cost).toEqual({ input: 0.95, output: 3.15 }) +}) diff --git a/tsconfig.json b/tsconfig.json index 2099597..516b274 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "outDir": "dist", "declaration": true }, - "include": ["index.ts", "plugin.ts", "src/**/*.ts"], + "include": ["index.ts", "plugin.ts", "v2.ts", "src/**/*.ts"], "exclude": ["scripts/", "tests/"] } diff --git a/v2.ts b/v2.ts new file mode 100644 index 0000000..f3f37f7 --- /dev/null +++ b/v2.ts @@ -0,0 +1,67 @@ +import { loadModels, toConfigKey, toV2ModelDraft } from "./src/models.js" + +export const id = "commandcode-go-opencode-provider" + +export interface CatalogDraft { + provider: { + update(providerID: string, update: (draft: Record) => void): void + } + model: { + update( + providerID: string, + modelID: string, + update: (draft: Record) => void, + ): void + } +} + +export interface V2PluginContext { + catalog: { + transform( + transform: (catalog: CatalogDraft) => void | Promise, + ): Promise + } +} + +/** + * OpenCode V2 plugin entrypoint. + * + * V2 loads a plugin from the module's default export, which must be an object + * with a unique `id` and a `setup` function. `setup` registers the Command + * Code provider and its model catalog through `ctx.catalog.transform`, so V2 + * users get auto-discovery with no hand-written model list: + * + * ```json + * { + * "plugins": ["commandcode-go-opencode-provider/v2"] + * } + * ``` + */ +export default { + id, + setup: async (ctx: V2PluginContext): Promise => { + await ctx.catalog.transform((catalog) => { + catalog.provider.update("commandcode", (draft) => { + draft.name = "Command Code" + draft.package = "aisdk:commandcode-go-opencode-provider" + draft.settings = { + baseURL: "https://api.commandcode.ai", + ...(typeof draft.settings === "object" && draft.settings !== null + ? (draft.settings as Record) + : {}), + } + }) + + for (const entry of loadModels()) { + catalog.model.update("commandcode", toConfigKey(entry.id), (draft) => { + const model = toV2ModelDraft(entry) + draft.modelID = model.modelID + draft.name = model.name + draft.capabilities = model.capabilities + draft.limit = model.limit + draft.cost = model.cost + }) + } + }) + }, +}