Skip to content
Merged
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export const SECRET_STATE_KEYS = [
"zaiApiKey",
"fireworksApiKey",
"vercelAiGatewayApiKey",
"opencodeGoApiKey",
"basetenApiKey",
] as const

Expand Down
11 changes: 11 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const dynamicProviders = [
"unbound",
"poe",
"deepseek",
"opencode-go",
] as const

export type DynamicProvider = (typeof dynamicProviders)[number]
Expand Down Expand Up @@ -399,6 +400,11 @@ const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({
vercelAiGatewayModelId: z.string().optional(),
})

const opencodeGoSchema = baseProviderSettingsSchema.extend({
opencodeGoApiKey: z.string().optional(),
opencodeGoModelId: z.string().optional(),
})

const basetenSchema = apiModelIdProviderModelSchema.extend({
basetenApiKey: z.string().optional(),
})
Expand Down Expand Up @@ -437,6 +443,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })),
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
opencodeGoSchema.merge(z.object({ apiProvider: z.literal("opencode-go") })),
defaultSchema,
])

Expand Down Expand Up @@ -471,6 +478,7 @@ export const providerSettingsSchema = z.object({
...fireworksSchema.shape,
...qwenCodeSchema.shape,
...vercelAiGatewaySchema.shape,
...opencodeGoSchema.shape,
...codebaseIndexProviderSchema.shape,
})

Expand Down Expand Up @@ -501,6 +509,7 @@ export const modelIdKeys = [
"unboundModelId",
"litellmModelId",
"vercelAiGatewayModelId",
"opencodeGoModelId",
] as const satisfies readonly (keyof ProviderSettings)[]

export type ModelIdKey = (typeof modelIdKeys)[number]
Expand Down Expand Up @@ -546,6 +555,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
zai: "apiModelId",
fireworks: "apiModelId",
"vercel-ai-gateway": "vercelAiGatewayModelId",
"opencode-go": "opencodeGoModelId",
}

/**
Expand Down Expand Up @@ -662,6 +672,7 @@ export const MODELS_BY_PROVIDER: Record<
requesty: { id: "requesty", label: "Requesty", models: [] },
unbound: { id: "unbound", label: "Unbound", models: [] },
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },
"opencode-go": { id: "opencode-go", label: "Opencode Go", models: [] },

// Local providers; models discovered from localhost endpoints.
lmstudio: { id: "lmstudio", label: "LM Studio", models: [] },
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export * from "./vertex.js"
export * from "./vscode-llm.js"
export * from "./xai.js"
export * from "./vercel-ai-gateway.js"
export * from "./opencode-go.js"
export * from "./zai.js"
export * from "./minimax.js"
export * from "./mimo.js"
Expand All @@ -46,6 +47,7 @@ import { vertexDefaultModelId } from "./vertex.js"
import { vscodeLlmDefaultModelId } from "./vscode-llm.js"
import { xaiDefaultModelId } from "./xai.js"
import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js"
import { opencodeGoDefaultModelId } from "./opencode-go.js"
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
import { minimaxDefaultModelId } from "./minimax.js"
import { mimoDefaultModelId } from "./mimo.js"
Expand Down Expand Up @@ -115,6 +117,8 @@ export function getProviderDefaultModelId(
return unboundDefaultModelId
case "vercel-ai-gateway":
return vercelAiGatewayDefaultModelId
case "opencode-go":
return opencodeGoDefaultModelId
case "anthropic":
case "gemini-cli":
case "fake-ai":
Expand Down
22 changes: 22 additions & 0 deletions packages/types/src/providers/opencode-go.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ModelInfo } from "../model.js"

// Opencode "Go" plan — OpenAI-compatible gateway.
// https://opencode.ai/docs/go/ · base URL: https://opencode.ai/zen/go/v1
//
// The full model list (and metadata) is fetched dynamically from
// `https://opencode.ai/zen/go/v1/models`, so models can be switched on the fly.
// The values below are only a fallback used before the live list resolves.
export const opencodeGoDefaultModelId = "glm-5.1"

export const opencodeGoDefaultModelInfo: ModelInfo = {
maxTokens: 32_768,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
// Pricing is intentionally omitted: ModelInfoView renders a `0` field as "$0.00 / 1M tokens"
// (implying the service is free), so we leave it unknown — consistent with the dynamically
// fetched models, which also leave price fields absent. See PR #319 review.
description: "Opencode Go plan model. Available models and metadata are resolved dynamically from /v1/models.",
}

export const OPENCODE_GO_DEFAULT_TEMPERATURE = 0
3 changes: 3 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
ZAiHandler,
FireworksHandler,
VercelAiGatewayHandler,
OpencodeGoHandler,
MiniMaxHandler,
MimoHandler,
BasetenHandler,
Expand Down Expand Up @@ -176,6 +177,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new FireworksHandler(options)
case "vercel-ai-gateway":
return new VercelAiGatewayHandler(options)
case "opencode-go":
return new OpencodeGoHandler(options)
case "minimax":
return new MiniMaxHandler(options)
case "baseten":
Expand Down
177 changes: 177 additions & 0 deletions src/api/providers/__tests__/opencode-go.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// npx vitest run src/api/providers/__tests__/opencode-go.spec.ts

// Mock vscode first to avoid import errors
vitest.mock("vscode", () => ({}))

import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"

import { opencodeGoDefaultModelId } from "@roo-code/types"

import { OpencodeGoHandler } from "../opencode-go"
import { ApiHandlerOptions } from "../../../shared/api"

vitest.mock("openai")
vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
vitest.mock("../fetchers/modelCache", () => ({
getModels: vitest.fn().mockImplementation(() =>
Promise.resolve({
"glm-5.1": {
maxTokens: 32768,
contextWindow: 200000,
supportsImages: false,
supportsPromptCache: false,
description: "GLM 5.1",
},
}),
),
getModelsFromCache: vitest.fn().mockReturnValue(undefined),
}))

const mockCreate = vitest.fn()

;(OpenAI as any).mockImplementation(() => ({
chat: { completions: { create: mockCreate } },
}))

describe("OpencodeGoHandler", () => {
const mockOptions: ApiHandlerOptions = {
opencodeGoApiKey: "test-key",
opencodeGoModelId: "glm-5.1",
}

beforeEach(() => {
vitest.clearAllMocks()
mockCreate.mockClear()
})

it("initializes the OpenAI client with the Opencode Go base URL and key", () => {
const handler = new OpencodeGoHandler(mockOptions)
expect(handler).toBeInstanceOf(OpencodeGoHandler)
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://opencode.ai/zen/go/v1",
apiKey: "test-key",
}),
)
})

describe("fetchModel", () => {
it("returns the configured model info", async () => {
const handler = new OpencodeGoHandler(mockOptions)
const result = await handler.fetchModel()
expect(result.id).toBe("glm-5.1")
expect(result.info.maxTokens).toBe(32768)
expect(result.info.contextWindow).toBe(200000)
expect(result.info.supportsPromptCache).toBe(false)
})

it("falls back to the default model id when none is configured", async () => {
const handler = new OpencodeGoHandler({ opencodeGoApiKey: "test-key" })
const result = await handler.fetchModel()
expect(result.id).toBe(opencodeGoDefaultModelId)
})
})

describe("createMessage", () => {
beforeEach(() => {
mockCreate.mockImplementation(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
content: "Hello",
reasoning_content: "thinking…",
tool_calls: [
{
index: 0,
id: "call_1",
function: { name: "read_file", arguments: '{"path":' },
},
],
},
index: 0,
},
],
usage: null,
}
yield {
choices: [{ delta: {}, index: 0 }],
usage: {
prompt_tokens: 12,
completion_tokens: 7,
total_tokens: 19,
prompt_tokens_details: { cached_tokens: 4 },
},
}
},
}))
})

it("streams text, reasoning, tool-call and usage chunks", async () => {
const handler = new OpencodeGoHandler(mockOptions)
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]

const chunks = []
for await (const chunk of handler.createMessage("You are helpful.", messages)) {
chunks.push(chunk)
}

expect(chunks).toContainEqual({ type: "text", text: "Hello" })
expect(chunks).toContainEqual({ type: "reasoning", text: "thinking…" })
expect(chunks).toContainEqual({
type: "tool_call_partial",
index: 0,
id: "call_1",
name: "read_file",
arguments: '{"path":',
})
expect(chunks).toContainEqual({
type: "usage",
inputTokens: 12,
outputTokens: 7,
cacheReadTokens: 4,
})
})

it("requests a streaming completion with usage included", async () => {
const handler = new OpencodeGoHandler(mockOptions)
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
for await (const _chunk of handler.createMessage("sys", messages)) {
void _chunk // drain
}

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "glm-5.1",
stream: true,
stream_options: { include_usage: true },
max_completion_tokens: 32768,
temperature: expect.any(Number),
}),
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

describe("completePrompt", () => {
it("returns the message content for a non-streaming completion", async () => {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "the answer" } }] })
const handler = new OpencodeGoHandler(mockOptions)
expect(await handler.completePrompt("ping")).toBe("the answer")
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "glm-5.1",
stream: false,
max_completion_tokens: 32768,
}),
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("wraps errors with an Opencode Go-specific message", async () => {
mockCreate.mockRejectedValue(new Error("boom"))
const handler = new OpencodeGoHandler(mockOptions)
await expect(handler.completePrompt("ping")).rejects.toThrow("Opencode Go completion error: boom")
})
})
})
Loading
Loading