Skip to content
Closed
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
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "commandcode-go-opencode-provider",
"version": "0.4.0",
"version": "0.5.0",
"author": "Brent Weatherall",
"repository": {
"type": "git",
Expand All @@ -18,14 +18,17 @@
},
"./server": {
"import": "./plugin.ts"
},
"./v2": {
"import": "./v2.ts"
}
},
"bugs": {
"url": "https://github.com/brent-weatherall/opencode-commandcode-provider/issues"
},
"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",
Expand Down
52 changes: 12 additions & 40 deletions plugin.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
Expand All @@ -43,19 +27,7 @@ export default async function commandcodePlugin() {
const models = loadModels()
const modelsObj: Record<string, unknown> = {}
for (const entry of models) {
const key = toConfigKey(entry.id)
const costObj: Record<string, number> = { 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
}
Expand Down
102 changes: 102 additions & 0 deletions src/models.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>
limit: { context: number; output: number }
}

/** Model shape consumed by the legacy (V1) `config` hook. */
export function toV1ModelConfig(entry: ModelEntry): V1ModelConfig {
const cost: Record<string, number> = { 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<string, V2ModelDraft> {
const map: Record<string, V2ModelDraft> = {}
for (const entry of entries) {
map[toConfigKey(entry.id)] = toV2ModelDraft(entry)
}
return map
}
100 changes: 100 additions & 0 deletions tests/unit/models.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
Loading