diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3a5a99eb98..bf5c3f025d 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -279,6 +279,7 @@ export const SECRET_STATE_KEYS = [ "zaiApiKey", "fireworksApiKey", "vercelAiGatewayApiKey", + "opencodeGoApiKey", "basetenApiKey", ] as const diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index ef532a9791..2daadcd5ef 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -43,6 +43,7 @@ export const dynamicProviders = [ "unbound", "poe", "deepseek", + "opencode-go", ] as const export type DynamicProvider = (typeof dynamicProviders)[number] @@ -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(), }) @@ -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, ]) @@ -471,6 +478,7 @@ export const providerSettingsSchema = z.object({ ...fireworksSchema.shape, ...qwenCodeSchema.shape, ...vercelAiGatewaySchema.shape, + ...opencodeGoSchema.shape, ...codebaseIndexProviderSchema.shape, }) @@ -501,6 +509,7 @@ export const modelIdKeys = [ "unboundModelId", "litellmModelId", "vercelAiGatewayModelId", + "opencodeGoModelId", ] as const satisfies readonly (keyof ProviderSettings)[] export type ModelIdKey = (typeof modelIdKeys)[number] @@ -546,6 +555,7 @@ export const modelIdKeysByProvider: Record = { zai: "apiModelId", fireworks: "apiModelId", "vercel-ai-gateway": "vercelAiGatewayModelId", + "opencode-go": "opencodeGoModelId", } /** @@ -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: [] }, diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index e75f5c4240..60e5e142cc 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -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" @@ -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" @@ -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": diff --git a/packages/types/src/providers/opencode-go.ts b/packages/types/src/providers/opencode-go.ts new file mode 100644 index 0000000000..0efabcf155 --- /dev/null +++ b/packages/types/src/providers/opencode-go.ts @@ -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 diff --git a/src/api/index.ts b/src/api/index.ts index c9e5e7b1b9..1e83e2d518 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -32,6 +32,7 @@ import { ZAiHandler, FireworksHandler, VercelAiGatewayHandler, + OpencodeGoHandler, MiniMaxHandler, MimoHandler, BasetenHandler, @@ -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": diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts new file mode 100644 index 0000000000..8d022d473b --- /dev/null +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -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), + }), + ) + }) + }) + + 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, + }), + ) + }) + + 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") + }) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/opencode-go.spec.ts b/src/api/providers/fetchers/__tests__/opencode-go.spec.ts new file mode 100644 index 0000000000..150d1c0f79 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/opencode-go.spec.ts @@ -0,0 +1,104 @@ +// npx vitest run src/api/providers/fetchers/__tests__/opencode-go.spec.ts + +import axios from "axios" + +import { opencodeGoDefaultModelInfo } from "@roo-code/types" + +import { getOpencodeGoModels, parseOpencodeGoModel } from "../opencode-go" + +vitest.mock("axios") +const mockedAxios = axios as any + +describe("Opencode Go Fetchers", () => { + beforeEach(() => { + vitest.clearAllMocks() + }) + + describe("getOpencodeGoModels", () => { + it("maps the /models response and sends the API key as a Bearer header", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + id: "glm-5.1", + name: "GLM-5.1", + description: "Zhipu GLM 5.1", + context_window: 202752, + max_output_tokens: 32768, + }, + { id: "deepseek-v4-pro", context_length: 1048576 }, + ], + }, + }) + + const models = await getOpencodeGoModels("test-key") + + expect(mockedAxios.get).toHaveBeenCalledWith("https://opencode.ai/zen/go/v1/models", { + headers: { Authorization: "Bearer test-key" }, + timeout: 10_000, + }) + + expect(Object.keys(models).sort()).toEqual(["deepseek-v4-pro", "glm-5.1"]) + expect(models["glm-5.1"]).toMatchObject({ + contextWindow: 202752, + maxTokens: 32768, + supportsPromptCache: false, + description: "Zhipu GLM 5.1", + }) + expect(models["deepseek-v4-pro"].contextWindow).toBe(1048576) + }) + + it("falls back to default context/max tokens when metadata is absent", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [{ id: "kimi-k2.6" }] } }) + + const models = await getOpencodeGoModels("k") + + expect(models["kimi-k2.6"]).toMatchObject({ + contextWindow: opencodeGoDefaultModelInfo.contextWindow, + maxTokens: opencodeGoDefaultModelInfo.maxTokens, + supportsPromptCache: false, + }) + }) + + it("returns an empty map on network error", async () => { + mockedAxios.get.mockRejectedValue(new Error("network")) + expect(await getOpencodeGoModels("k")).toEqual({}) + }) + + it("falls back to an empty array when response.data.data is not an array", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: null } }) + expect(await getOpencodeGoModels("k")).toEqual({}) + }) + + it("skips entries that fail safeParse with a console.warn", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { id: "valid-model", context_window: 50000 }, + { not_a_field: true }, // no `id` — will fail safeParse + ], + }, + }) + const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) + + const models = await getOpencodeGoModels("k") + + expect(Object.keys(models)).toEqual(["valid-model"]) + // Two warns: one for the outer schema mismatch, one for the invalid item + expect(warnSpy).toHaveBeenCalledTimes(2) + expect(warnSpy.mock.calls[0][0]).toContain("did not match expected schema") + expect(warnSpy.mock.calls[1][0]).toContain("Skipping invalid Opencode Go model entry") + + warnSpy.mockRestore() + }) + }) + + describe("parseOpencodeGoModel", () => { + it("treats a model with no cache pricing as not cache-capable", () => { + const info = parseOpencodeGoModel({ id: "x", context_window: 100000, max_tokens: 8000 }) + expect(info.supportsPromptCache).toBe(false) + expect(info.contextWindow).toBe(100000) + expect(info.maxTokens).toBe(8000) + }) + }) +}) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index ee4ea5bfe6..1589fcffec 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -18,6 +18,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" +import { getOpencodeGoModels } from "./opencode-go" import { getRequestyModels } from "./requesty" import { getUnboundModels } from "./unbound" import { getLiteLLMModels } from "./litellm" @@ -86,6 +87,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise + +const opencodeGoModelsResponseSchema = z.object({ + data: z.array(opencodeGoModelSchema), +}) + +/** + * Maps a raw Opencode Go model entry to the internal {@link ModelInfo} shape. + * + * Falls back to {@link opencodeGoDefaultModelInfo} when the upstream payload + * omits context-window or max-token fields, ensuring downstream consumers + * always receive a fully-populated object. + * + * @param model - Validated model entry from the `/models` response. + * @returns Normalised model metadata suitable for the model picker. + */ +export const parseOpencodeGoModel = (model: OpencodeGoModel): ModelInfo => ({ + maxTokens: model.max_output_tokens ?? model.max_tokens ?? opencodeGoDefaultModelInfo.maxTokens, + contextWindow: model.context_window ?? model.context_length ?? opencodeGoDefaultModelInfo.contextWindow, + supportsImages: model.supports_images ?? false, + supportsPromptCache: false, + description: model.description ?? model.name, +}) + +/** + * Fetches the list of available models from the Opencode Go `/models` endpoint. + * + * The endpoint shape mirrors the OpenAI `/models` response. A permissive Zod + * schema is used so that unknown fields are silently dropped rather than + * causing a hard failure. Invalid entries (e.g. missing `id`) are skipped + * with a console warning rather than propagated to the UI. + * + * @param apiKey - Optional Bearer token for authenticated requests. + * @returns A record mapping model IDs to their normalised {@link ModelInfo}. + */ +export async function getOpencodeGoModels(apiKey?: string): Promise> { + const models: Record = {} + + try { + const response = await axios.get(`${OPENCODE_GO_BASE_URL}/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + timeout: 10_000, + }) + + const result = opencodeGoModelsResponseSchema.safeParse(response.data) + const rawData = result.success ? result.data.data : response.data?.data + const data = Array.isArray(rawData) ? rawData : [] + + if (!result.success) { + console.warn(`Opencode Go models response did not match expected schema; falling back to per-item parsing: ${JSON.stringify(result.error.format())}`) + } + + for (const rawModel of data) { + const parsed = opencodeGoModelSchema.safeParse(rawModel) + if (!parsed.success) { + console.warn(`Skipping invalid Opencode Go model entry: ${JSON.stringify(rawModel)}`) + continue + } + models[parsed.data.id] = parseOpencodeGoModel(parsed.data) + } + } catch (error) { + console.error(`Error fetching Opencode Go models: ${error instanceof Error ? error.message : String(error)}`) + } + + return models +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 416cef1c47..bbbbc6beb5 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -25,6 +25,7 @@ export { XAIHandler } from "./xai" export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" +export { OpencodeGoHandler } from "./opencode-go" export { MiniMaxHandler } from "./minimax" export { MimoHandler } from "./mimo" export { BasetenHandler } from "./baseten" diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts new file mode 100644 index 0000000000..6b66aa6846 --- /dev/null +++ b/src/api/providers/opencode-go.ts @@ -0,0 +1,143 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { opencodeGoDefaultModelId, opencodeGoDefaultModelInfo, OPENCODE_GO_DEFAULT_TEMPERATURE } from "@roo-code/types" + +import { ApiHandlerOptions } from "../../shared/api" + +import { ApiStream } from "../transform/stream" +import { convertToOpenAiMessages } from "../transform/openai-format" + +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { RouterProvider } from "./router-provider" + +/** + * API handler for the Opencode "Go" subscription plan. + * + * Routes requests through the OpenAI-compatible gateway at + * `https://opencode.ai/zen/go/v1`, delegating model resolution and streaming + * logic to the shared {@link RouterProvider} base class. + * + * Exposes the Go subscription's models as a first-class provider with a dynamic + * model list (fetched from `/v1/models`) so users can switch models on the fly, + * instead of configuring each one manually as a separate OpenAI-Compatible + * provider (#172). + * + * Supports text generation, reasoning content (GLM/DeepSeek), tool calls, + * and non-streaming prompt completion. + */ +export class OpencodeGoHandler extends RouterProvider implements SingleCompletionHandler { + /** Creates a new handler bound to the user's Go API key and selected model. */ + constructor(options: ApiHandlerOptions) { + super({ + options, + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + apiKey: options.opencodeGoApiKey, + modelId: options.opencodeGoModelId, + defaultModelId: opencodeGoDefaultModelId, + defaultModelInfo: opencodeGoDefaultModelInfo, + }) + } + + /** + * Streams a chat completion response, yielding typed chunks for text, + * reasoning, partial tool calls, and token usage. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { id: modelId, info } = await this.fetchModel() + + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + const body: OpenAI.Chat.ChatCompletionCreateParams = { + model: modelId, + messages: openAiMessages, + temperature: this.supportsTemperature(modelId) + ? (this.options.modelTemperature ?? OPENCODE_GO_DEFAULT_TEMPERATURE) + : undefined, + max_completion_tokens: info.maxTokens, + stream: true, + stream_options: { include_usage: true }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + } + + const completion = await this.client.chat.completions.create(body) + + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + + if (delta?.content) { + yield { type: "text", text: delta.content } + } + + // Several Go-plan models (GLM, DeepSeek) stream reasoning via this field. + if (delta && "reasoning_content" in delta && delta.reasoning_content) { + yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" } + } + + // Emit raw tool call chunks - NativeToolCallParser handles state management. + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + + if (chunk.usage) { + yield { + type: "usage", + inputTokens: chunk.usage.prompt_tokens || 0, + outputTokens: chunk.usage.completion_tokens || 0, + cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || undefined, + } + } + } + } + + /** + * Performs a non-streaming chat completion and returns the full response text. + * + * @param prompt - The user prompt to send as a single user message. + * @returns The model's reply text, or an empty string if no content is returned. + * @throws Error with an Opencode Go-specific prefix if the request fails. + */ + async completePrompt(prompt: string): Promise { + const { id: modelId, info } = await this.fetchModel() + + try { + const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { + model: modelId, + messages: [{ role: "user", content: prompt }], + stream: false, + } + + if (this.supportsTemperature(modelId)) { + requestOptions.temperature = this.options.modelTemperature ?? OPENCODE_GO_DEFAULT_TEMPERATURE + } + + requestOptions.max_completion_tokens = info.maxTokens + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`Opencode Go completion error: ${error.message}`) + } + throw error + } + } +} diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index be9d705684..4fe9e131be 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2479,6 +2479,7 @@ describe("ClineProvider - Router Models", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, + "opencode-go": {}, }, values: undefined, }) @@ -2525,6 +2526,7 @@ describe("ClineProvider - Router Models", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, + "opencode-go": {}, }, values: undefined, }) @@ -2620,6 +2622,7 @@ describe("ClineProvider - Router Models", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, + "opencode-go": {}, }, values: undefined, }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 17e0caebb0..ebea3c90b6 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -371,6 +371,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, + "opencode-go": {}, }, values: undefined, }) @@ -457,6 +458,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, + "opencode-go": {}, }, values: undefined, }) @@ -512,6 +514,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, + "opencode-go": {}, }, values: undefined, }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 429de051b8..d0af86423b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -929,6 +929,7 @@ export const webviewMessageHandler = async ( lmstudio: {}, poe: {}, deepseek: {}, + "opencode-go": {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1012,6 +1013,20 @@ export const webviewMessageHandler = async ( }) } + // Opencode Go is conditional on apiKey (its /models endpoint requires auth) + const opencodeGoApiKey = message?.values?.opencodeGoApiKey ?? apiConfiguration.opencodeGoApiKey + + if (opencodeGoApiKey) { + if (message?.values?.opencodeGoApiKey) { + await flushModels({ provider: "opencode-go", apiKey: opencodeGoApiKey }, true) + } + + candidates.push({ + key: "opencode-go", + options: { provider: "opencode-go", apiKey: opencodeGoApiKey }, + }) + } + // Apply single provider filter if specified const modelFetchPromises = providerFilter ? candidates.filter(({ key }) => key === providerFilter) diff --git a/src/shared/api.ts b/src/shared/api.ts index a6f31855ca..842e1781a2 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -178,6 +178,7 @@ const dynamicProviderExtras = { lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type poe: {} as { apiKey?: string; baseUrl?: string }, deepseek: {} as { apiKey?: string; baseUrl?: string }, + "opencode-go": {} as { apiKey?: string }, } as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 7a6cb17ba0..2b724e6dde 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -30,6 +30,7 @@ import { mainlandZAiDefaultModelId, fireworksDefaultModelId, vercelAiGatewayDefaultModelId, + opencodeGoDefaultModelId, minimaxDefaultModelId, mimoDefaultModelId, unboundDefaultModelId, @@ -93,6 +94,7 @@ import { ZAi, Fireworks, VercelAiGateway, + OpenCodeGo, MiniMax, Mimo, } from "./providers" @@ -363,6 +365,7 @@ const ApiOptions = ({ fireworks: { field: "apiModelId", default: fireworksDefaultModelId }, poe: { field: "apiModelId", default: poeDefaultModelId }, "vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId }, + "opencode-go": { field: "opencodeGoModelId", default: opencodeGoDefaultModelId }, openai: { field: "openAiModelId" }, ollama: { field: "ollamaModelId" }, lmstudio: { field: "lmStudioModelId" }, @@ -688,6 +691,17 @@ const ApiOptions = ({ /> )} + {selectedProvider === "opencode-go" && ( + + )} + {selectedProvider === "fireworks" && ( void + routerModels?: RouterModels + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +export const OpenCodeGo = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: OpenCodeGoProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.opencodeGoApiKey && ( + + {t("settings:providers.getOpencodeGoApiKey")} + + )} + + + ) +} diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenCodeGo.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenCodeGo.spec.tsx new file mode 100644 index 0000000000..eee28b2867 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/OpenCodeGo.spec.tsx @@ -0,0 +1,90 @@ +import { render, screen, fireEvent } from "@testing-library/react" + +import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" +import { opencodeGoDefaultModelId } from "@roo-code/types" + +import { OpenCodeGo } from "../OpenCodeGo" + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, type }: any) => ( +
+ {children} + onInput(e)} data-testid="api-key-input" /> +
+ ), +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children, href }: any) => ( + + {children} + + ), +})) + +// Stub ModelPicker so we can assert the props it receives without pulling in its hooks. +vi.mock("../../ModelPicker", () => ({ + ModelPicker: ({ defaultModelId, modelIdKey, serviceName }: any) => ( +
+ ), +})) + +describe("OpenCodeGo", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + const mockSetApiConfigurationField = vi.fn() + + const renderComponent = (apiConfiguration: ProviderSettings) => + render( + , + ) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("updates the API key via setApiConfigurationField on input", () => { + renderComponent({ opencodeGoApiKey: "" }) + + fireEvent.change(screen.getByTestId("api-key-input"), { target: { value: "secret-key" } }) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("opencodeGoApiKey", "secret-key") + }) + + it("shows the get-API-key CTA only when no API key is set", () => { + const { rerender } = renderComponent({ opencodeGoApiKey: "" }) + const link = screen.getByTestId("get-api-key-link") + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute("href", "https://opencode.ai/docs/go/") + + rerender( + , + ) + expect(screen.queryByTestId("get-api-key-link")).not.toBeInTheDocument() + }) + + it("wires the ModelPicker with the Opencode Go defaults", () => { + renderComponent({ opencodeGoApiKey: "key" }) + + const picker = screen.getByTestId("model-picker") + expect(picker).toHaveAttribute("data-default-model-id", opencodeGoDefaultModelId) + expect(picker).toHaveAttribute("data-model-id-key", "opencodeGoModelId") + expect(picker).toHaveAttribute("data-service-name", "Opencode Go") + }) +}) diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 02a928ffb5..1e10979c63 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -22,6 +22,7 @@ export { ZAi } from "./ZAi" export { LiteLLM } from "./LiteLLM" export { Fireworks } from "./Fireworks" export { VercelAiGateway } from "./VercelAiGateway" +export { OpenCodeGo } from "./OpenCodeGo" export { MiniMax } from "./MiniMax" export { Mimo } from "./Mimo" export { Baseten } from "./Baseten" diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index c4f3040084..62d6213722 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -27,6 +27,7 @@ import { qwenCodeModels, litellmDefaultModelInfo, lMStudioDefaultModelInfo, + opencodeGoDefaultModelInfo, BEDROCK_1M_CONTEXT_MODEL_IDS, VERTEX_1M_CONTEXT_MODEL_IDS, isDynamicProvider, @@ -346,6 +347,17 @@ function getSelectedModel({ const info = routerModels["vercel-ai-gateway"]?.[id] return { id, info } } + case "opencode-go": { + const id = getValidatedModelId( + apiConfiguration.opencodeGoModelId, + routerModels["opencode-go"], + defaultModelId, + ) + // Fall back to the provider's default ModelInfo so capability-driven UI + // keeps working when the /models list is empty or unavailable. + const info = routerModels["opencode-go"]?.[id] ?? opencodeGoDefaultModelInfo + return { id, info } + } // case "anthropic": // case "fake-ai": default: { diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index d12335d9aa..6f952dfc97 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Obtenir clau API d'OpenRouter", "vercelAiGatewayApiKey": "Clau API de Vercel AI Gateway", "getVercelAiGatewayApiKey": "Obtenir clau API de Vercel AI Gateway", + "opencodeGoApiKey": "Clau API de Opencode Go", + "getOpencodeGoApiKey": "Obtenir clau API de Opencode Go", "apiKeyStorageNotice": "Les claus API s'emmagatzemen de forma segura a l'Emmagatzematge Secret de VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 8e778de4ad..d9feec46ef 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "OpenRouter API-Schlüssel erhalten", "vercelAiGatewayApiKey": "Vercel AI Gateway API-Schlüssel", "getVercelAiGatewayApiKey": "Vercel AI Gateway API-Schlüssel erhalten", + "opencodeGoApiKey": "Opencode Go API-Schlüssel", + "getOpencodeGoApiKey": "Opencode Go API-Schlüssel erhalten", "apiKeyStorageNotice": "API-Schlüssel werden sicher im VSCode Secret Storage gespeichert", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index bafb74393d..d51f5da8c5 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -377,6 +377,8 @@ "getOpenRouterApiKey": "Get OpenRouter API Key", "vercelAiGatewayApiKey": "Vercel AI Gateway API Key", "getVercelAiGatewayApiKey": "Get Vercel AI Gateway API Key", + "opencodeGoApiKey": "Opencode Go API Key", + "getOpencodeGoApiKey": "Get Opencode Go API Key", "apiKeyStorageNotice": "API keys are stored securely in VSCode's Secret Storage", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index aa5770bfe0..e7ae567f17 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Obtener clave API de OpenRouter", "vercelAiGatewayApiKey": "Clave API de Vercel AI Gateway", "getVercelAiGatewayApiKey": "Obtener clave API de Vercel AI Gateway", + "opencodeGoApiKey": "Clave API de Opencode Go", + "getOpencodeGoApiKey": "Obtener clave API de Opencode Go", "apiKeyStorageNotice": "Las claves API se almacenan de forma segura en el Almacenamiento Secreto de VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 98aa755809..5a7a940c09 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Obtenir la clé API OpenRouter", "vercelAiGatewayApiKey": "Clé API Vercel AI Gateway", "getVercelAiGatewayApiKey": "Obtenir la clé API Vercel AI Gateway", + "opencodeGoApiKey": "Clé API Opencode Go", + "getOpencodeGoApiKey": "Obtenir la clé API Opencode Go", "apiKeyStorageNotice": "Les clés API sont stockées en toute sécurité dans le stockage sécurisé de VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 0636d01a17..d83c775550 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "OpenRouter API कुंजी प्राप्त करें", "vercelAiGatewayApiKey": "Vercel AI Gateway API कुंजी", "getVercelAiGatewayApiKey": "Vercel AI Gateway API कुंजी प्राप्त करें", + "opencodeGoApiKey": "Opencode Go API कुंजी", + "getOpencodeGoApiKey": "Opencode Go API कुंजी प्राप्त करें", "apiKeyStorageNotice": "API कुंजियाँ VSCode के सुरक्षित स्टोरेज में सुरक्षित रूप से संग्रहीत हैं", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b879bf0c36..c217c7c0f2 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Dapatkan OpenRouter API Key", "vercelAiGatewayApiKey": "Vercel AI Gateway API Key", "getVercelAiGatewayApiKey": "Dapatkan Vercel AI Gateway API Key", + "opencodeGoApiKey": "Opencode Go API Key", + "getOpencodeGoApiKey": "Dapatkan Opencode Go API Key", "apiKeyStorageNotice": "API key disimpan dengan aman di Secret Storage VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index c8d8f41a42..7e40c7379b 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Ottieni chiave API OpenRouter", "vercelAiGatewayApiKey": "Chiave API Vercel AI Gateway", "getVercelAiGatewayApiKey": "Ottieni chiave API Vercel AI Gateway", + "opencodeGoApiKey": "Chiave API Opencode Go", + "getOpencodeGoApiKey": "Ottieni chiave API Opencode Go", "apiKeyStorageNotice": "Le chiavi API sono memorizzate in modo sicuro nell'Archivio Segreto di VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 7a149ce6f9..5483e07cdd 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "OpenRouter APIキーを取得", "vercelAiGatewayApiKey": "Vercel AI Gateway APIキー", "getVercelAiGatewayApiKey": "Vercel AI Gateway APIキーを取得", + "opencodeGoApiKey": "Opencode Go APIキー", + "getOpencodeGoApiKey": "Opencode Go APIキーを取得", "apiKeyStorageNotice": "APIキーはVSCodeのシークレットストレージに安全に保存されます", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 0231abf317..80bd3b2ab5 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "OpenRouter API 키 받기", "vercelAiGatewayApiKey": "Vercel AI Gateway API 키", "getVercelAiGatewayApiKey": "Vercel AI Gateway API 키 받기", + "opencodeGoApiKey": "Opencode Go API 키", + "getOpencodeGoApiKey": "Opencode Go API 키 받기", "apiKeyStorageNotice": "API 키는 VSCode의 보안 저장소에 안전하게 저장됩니다", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 7dce57c344..4b7c20d6f7 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "OpenRouter API-sleutel ophalen", "vercelAiGatewayApiKey": "Vercel AI Gateway API-sleutel", "getVercelAiGatewayApiKey": "Vercel AI Gateway API-sleutel ophalen", + "opencodeGoApiKey": "Opencode Go API-sleutel", + "getOpencodeGoApiKey": "Opencode Go API-sleutel ophalen", "apiKeyStorageNotice": "API-sleutels worden veilig opgeslagen in de geheime opslag van VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 5190e94e7b..9ede811b29 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Uzyskaj klucz API OpenRouter", "vercelAiGatewayApiKey": "Klucz API Vercel AI Gateway", "getVercelAiGatewayApiKey": "Uzyskaj klucz API Vercel AI Gateway", + "opencodeGoApiKey": "Klucz API Opencode Go", + "getOpencodeGoApiKey": "Uzyskaj klucz API Opencode Go", "apiKeyStorageNotice": "Klucze API są bezpiecznie przechowywane w Tajnym Magazynie VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 24daa058a1..1ff078d66b 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Obter chave de API OpenRouter", "vercelAiGatewayApiKey": "Chave API do Vercel AI Gateway", "getVercelAiGatewayApiKey": "Obter chave API do Vercel AI Gateway", + "opencodeGoApiKey": "Chave API do Opencode Go", + "getOpencodeGoApiKey": "Obter chave API do Opencode Go", "apiKeyStorageNotice": "As chaves de API são armazenadas com segurança no Armazenamento Secreto do VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 96abdb0d0a..b1ce9c3050 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Получить OpenRouter API-ключ", "vercelAiGatewayApiKey": "Ключ API Vercel AI Gateway", "getVercelAiGatewayApiKey": "Получить ключ API Vercel AI Gateway", + "opencodeGoApiKey": "Ключ API Opencode Go", + "getOpencodeGoApiKey": "Получить ключ API Opencode Go", "apiKeyStorageNotice": "API-ключи хранятся безопасно в Secret Storage VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 8bb76737ac..367d8f6114 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "OpenRouter API Anahtarı Al", "vercelAiGatewayApiKey": "Vercel AI Gateway API Anahtarı", "getVercelAiGatewayApiKey": "Vercel AI Gateway API Anahtarı Al", + "opencodeGoApiKey": "Opencode Go API Anahtarı", + "getOpencodeGoApiKey": "Opencode Go API Anahtarı Al", "apiKeyStorageNotice": "API anahtarları VSCode'un Gizli Depolamasında güvenli bir şekilde saklanır", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e44309cf7e..75c836f027 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "Lấy khóa API OpenRouter", "vercelAiGatewayApiKey": "Khóa API Vercel AI Gateway", "getVercelAiGatewayApiKey": "Lấy khóa API Vercel AI Gateway", + "opencodeGoApiKey": "Khóa API Opencode Go", + "getOpencodeGoApiKey": "Lấy khóa API Opencode Go", "apiKeyStorageNotice": "Khóa API được lưu trữ an toàn trong Bộ lưu trữ bí mật của VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 54bd582dfd..a265e9c387 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -314,6 +314,8 @@ "getOpenRouterApiKey": "获取 OpenRouter API 密钥", "vercelAiGatewayApiKey": "Vercel AI Gateway API 密钥", "getVercelAiGatewayApiKey": "获取 Vercel AI Gateway API 密钥", + "opencodeGoApiKey": "Opencode Go API 密钥", + "getOpencodeGoApiKey": "获取 Opencode Go API 密钥", "apiKeyStorageNotice": "API 密钥安全存储在 VSCode 的密钥存储中", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 8b81b7e9fc..386202b905 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -324,6 +324,8 @@ "getOpenRouterApiKey": "取得 OpenRouter API 金鑰", "vercelAiGatewayApiKey": "Vercel AI Gateway API 金鑰", "getVercelAiGatewayApiKey": "取得 Vercel AI Gateway API 金鑰", + "opencodeGoApiKey": "Opencode Go API 金鑰", + "getOpencodeGoApiKey": "取得 Opencode Go API 金鑰", "apiKeyStorageNotice": "API 金鑰會安全地儲存在 VS Code 的 Secret Storage 中", "openAiCodexRateLimits": { "title": "Codex 用量限制{{planLabel}}", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 7d6152a03d..dab2b4fefa 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -46,6 +46,7 @@ describe("Model Validation Functions", () => { "vercel-ai-gateway": {}, poe: {}, deepseek: {}, + "opencode-go": {}, } const allowAllOrganization: OrganizationAllowList = { @@ -174,6 +175,41 @@ describe("Model Validation Functions", () => { expect(result).toBeUndefined() // Should exclude model-specific org errors }) }) + + describe("Opencode Go validation", () => { + it("returns an apiKey error when the Opencode Go API key is missing", () => { + const config: ProviderSettings = { + apiProvider: "opencode-go", + opencodeGoModelId: "glm-5.1", + // Missing opencodeGoApiKey + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBe("settings:validation.apiKey") + }) + + it("returns undefined for a valid Opencode Go configuration", () => { + const config: ProviderSettings = { + apiProvider: "opencode-go", + opencodeGoApiKey: "valid-key", + opencodeGoModelId: "glm-5.1", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + + it("returns a modelId error when no Opencode Go model id is set", () => { + const config: ProviderSettings = { + apiProvider: "opencode-go", + opencodeGoApiKey: "valid-key", + // Missing opencodeGoModelId + } + + const result = getModelValidationError(config, mockRouterModels, allowAllOrganization) + expect(result).toBe("settings:validation.modelId") + }) + }) }) describe("validateBedrockArn", () => { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index f506171acc..0e6e1c6b9d 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -123,6 +123,11 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri return i18next.t("settings:validation.apiKey") } break + case "opencode-go": + if (!apiConfiguration.opencodeGoApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case "baseten": if (!apiConfiguration.basetenApiKey) { return i18next.t("settings:validation.apiKey")