diff --git a/README.md b/README.md index cd60840..fa32ed8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,34 @@ Run `/models` to pick from available models: /models ``` +## OpenCode 2 (V2) + +OpenCode 2 (the `opencode2` CLI) uses a new plugin API. V2 loads a plugin from +the module's default export (`{ id, setup }`), which registers the Command Code +provider and its full model catalog through the catalog transform API. + +Add the plugin to your `opencode.json` or `opencode.jsonc`: + +```json +{ + "plugins": ["commandcode-go-opencode-provider/v2"] +} +``` + +No `provider` block or model list is required — the plugin registers all models +from [`models.json`](./models.json) automatically. Set your API key either via +`/connect`, or with the `COMMANDCODE_API_KEY` environment variable: + +```bash +COMMANDCODE_API_KEY=your-key opencode2 +``` + +Select a model with `/models`, for example `commandcode/deepseek-v4-flash`. + +> **Note for V1 users:** the `plugin`/`provider` configuration in the next +> section remains the entrypoint for the legacy `opencode` CLI and is +> unchanged. + ## Manual Configuration If you prefer to configure manually, add this to your `opencode.json`: @@ -109,6 +137,14 @@ For local testing, create `opencode.local.json` (gitignored) with `file://` path Run `opencode --config opencode.local.json` to test with your local build. +For OpenCode 2, use the `/v2` entrypoint with a `file://` path: + +```json +{ + "plugins": ["file:///path/to/commandcode-go-opencode-provider/v2"] +} +``` + ### Sync Models ```bash diff --git a/opencode.json b/opencode.json index ad18ef0..ae5b4b6 100644 --- a/opencode.json +++ b/opencode.json @@ -2,10 +2,9 @@ "$schema": "https://opencode.ai/config.json", "model": "commandcode/deepseek-v4-flash", "small_model": "commandcode/deepseek-v4-flash", - "plugin": ["commandcode-go-opencode-provider/server"], - "provider": { + "plugins": ["commandcode-go-opencode-provider/v2"], + "providers": { "commandcode": { - "npm": "commandcode-go-opencode-provider", "name": "Command Code", "env": ["COMMANDCODE_API_KEY"] } 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/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/src/stream.ts b/src/stream.ts index e4f85a3..535aa8d 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -101,8 +101,19 @@ function toStreamPart(event: RawEvent): LanguageModelV3StreamPart | null { modelId: event.modelId as string | undefined, } - case "error": - return { type: "error", error: event.error ?? event.message ?? "Unknown error" } + case "error": { + const raw = event.error ?? event.message ?? "Unknown error" + let text: string + if (typeof raw === "string") { + text = raw + } else if (typeof raw === "object" && raw !== null) { + const obj = raw as Record + text = typeof obj.message === "string" ? obj.message : JSON.stringify(raw) + } else { + text = JSON.stringify(raw) + } + return { type: "error", error: text } + } default: return null 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") +}) diff --git a/tests/unit/stream.test.ts b/tests/unit/stream.test.ts index ea36f73..02ba876 100644 --- a/tests/unit/stream.test.ts +++ b/tests/unit/stream.test.ts @@ -166,6 +166,38 @@ test("handles error events", async () => { expect(parts[0]).toMatchObject({ type: "error", error: "Something broke" }) }) +test("stringifies object error payloads instead of emitting [object Object]", async () => { + const body = streamFromChunks([ + sseEvent({ + type: "error", + error: { + type: "server_error", + message: "Service temporarily unavailable. Please try again shortly.", + statusCode: 503, + isRetryable: true, + }, + }), + ]) + const stream = parseStreamEvents(body) + const parts = await collectStream(stream) + expect(parts[0]).toMatchObject({ + type: "error", + error: "Service temporarily unavailable. Please try again shortly.", + }) + expect(parts[0].error).not.toContain("[object Object]") +}) + +test("falls back to JSON when object error has no message", async () => { + const body = streamFromChunks([ + sseEvent({ type: "error", error: { type: "server_error", statusCode: 503 } }), + ]) + const stream = parseStreamEvents(body) + const parts = await collectStream(stream) + expect(parts[0]).toMatchObject({ type: "error" }) + expect(parts[0].error).toContain("503") + expect(parts[0].error).not.toContain("[object Object]") +}) + test("handles response-metadata event", async () => { const body = streamFromChunks([sseEvent({ type: "response-metadata", id: "req-1", modelId: "model-v1" })]) const stream = parseStreamEvents(body) 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 + }) + } + }) + }, +}