diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 706c75cd1e..524f3d6c32 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -318,6 +318,7 @@ export const SECRET_STATE_KEYS = [ "fireworksApiKey", "vercelAiGatewayApiKey", "opencodeGoApiKey", + "kenariApiKey", "basetenApiKey", ] as const diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 26c4dee7e1..566b0230a0 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -46,6 +46,7 @@ export const dynamicProviders = [ "poe", "deepseek", "opencode-go", + "kenari", ] as const export type DynamicProvider = (typeof dynamicProviders)[number] @@ -407,6 +408,11 @@ const opencodeGoSchema = baseProviderSettingsSchema.extend({ opencodeGoModelId: z.string().optional(), }) +const kenariSchema = baseProviderSettingsSchema.extend({ + kenariApiKey: z.string().optional(), + kenariModelId: z.string().optional(), +}) + const zooGatewaySchema = baseProviderSettingsSchema.extend({ zooSessionToken: z.string().optional(), zooGatewayModelId: z.string().optional(), @@ -452,6 +458,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv 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") })), + kenariSchema.merge(z.object({ apiProvider: z.literal("kenari") })), zooGatewaySchema.merge(z.object({ apiProvider: z.literal("zoo-gateway") })), defaultSchema, ]) @@ -488,6 +495,7 @@ export const providerSettingsSchema = z.object({ ...qwenCodeSchema.shape, ...vercelAiGatewaySchema.shape, ...opencodeGoSchema.shape, + ...kenariSchema.shape, ...zooGatewaySchema.shape, ...codebaseIndexProviderSchema.shape, }) @@ -520,6 +528,7 @@ export const modelIdKeys = [ "litellmModelId", "vercelAiGatewayModelId", "opencodeGoModelId", + "kenariModelId", "zooGatewayModelId", ] as const satisfies readonly (keyof ProviderSettings)[] @@ -567,6 +576,7 @@ export const modelIdKeysByProvider: Record = { fireworks: "apiModelId", "vercel-ai-gateway": "vercelAiGatewayModelId", "opencode-go": "opencodeGoModelId", + kenari: "kenariModelId", "zoo-gateway": "zooGatewayModelId", } @@ -701,6 +711,7 @@ export const MODELS_BY_PROVIDER: Record< 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: [] }, + kenari: { id: "kenari", label: "Kenari", models: [] }, "zoo-gateway": { id: "zoo-gateway", label: "Zoo Gateway", models: [] }, // Local providers; models discovered from localhost endpoints. diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index f283cb474c..50437477de 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -23,6 +23,7 @@ export * from "./vscode-llm.js" export * from "./xai.js" export * from "./vercel-ai-gateway.js" export * from "./opencode-go.js" +export * from "./kenari.js" export * from "./zai.js" export * from "./minimax.js" export * from "./mimo.js" @@ -49,6 +50,7 @@ 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 { kenariDefaultModelId } from "./kenari.js" import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js" import { minimaxDefaultModelId } from "./minimax.js" import { mimoDefaultModelId } from "./mimo.js" @@ -121,6 +123,8 @@ export function getProviderDefaultModelId( return vercelAiGatewayDefaultModelId case "opencode-go": return opencodeGoDefaultModelId + case "kenari": + return kenariDefaultModelId case "zoo-gateway": return zooGatewayDefaultModelId case "anthropic": diff --git a/packages/types/src/providers/kenari.ts b/packages/types/src/providers/kenari.ts new file mode 100644 index 0000000000..245611c616 --- /dev/null +++ b/packages/types/src/providers/kenari.ts @@ -0,0 +1,21 @@ +import type { ModelInfo } from "../model.js" + +// Kenari: Indonesian OpenAI-compatible AI gateway billed in Rupiah (IDR). +// https://kenari.id/docs · base URL: https://kenari.id/v1 +// +// The full model list (and metadata) is fetched dynamically from +// `https://kenari.id/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 kenariDefaultModelId = "glm-5-2" + +export const kenariDefaultModelInfo: ModelInfo = { + maxTokens: 32_768, + contextWindow: 1_048_576, + supportsImages: false, + supportsPromptCache: false, + // Pricing is intentionally omitted: Kenari bills in IDR (micro-rupiah per 1M tokens), + // which cannot be rendered in the USD price fields without a misleading conversion. + description: "Kenari model. Available models and metadata are resolved dynamically from /v1/models.", +} + +export const KENARI_DEFAULT_TEMPERATURE = 0 diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts new file mode 100644 index 0000000000..b86e73dac6 --- /dev/null +++ b/src/api/__tests__/index.spec.ts @@ -0,0 +1,29 @@ +// npx vitest run src/api/__tests__/index.spec.ts + +// Mock vscode first to avoid import errors +vitest.mock("vscode", () => ({ + workspace: { + getConfiguration: () => ({ + get: (_key: string, defaultValue?: unknown) => defaultValue, + }), + }, +})) + +import type { ProviderSettings } from "@roo-code/types" + +import { buildApiHandler } from "../index" +import { KenariHandler } from "../providers/kenari" + +describe("buildApiHandler", () => { + it("returns a KenariHandler for the kenari provider", () => { + const configuration: ProviderSettings = { + apiProvider: "kenari", + kenariApiKey: "test-key", + kenariModelId: "glm-5-2", + } + + const handler = buildApiHandler(configuration) + + expect(handler).toBeInstanceOf(KenariHandler) + }) +}) diff --git a/src/api/index.ts b/src/api/index.ts index 9e4ba3bfb5..904dbcb931 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -33,6 +33,7 @@ import { FireworksHandler, VercelAiGatewayHandler, OpencodeGoHandler, + KenariHandler, ZooGatewayHandler, MiniMaxHandler, MimoHandler, @@ -193,6 +194,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new VercelAiGatewayHandler(options) case "opencode-go": return new OpencodeGoHandler(options) + case "kenari": + return new KenariHandler(options) case "zoo-gateway": return new ZooGatewayHandler(options) case "minimax": diff --git a/src/api/providers/__tests__/kenari.spec.ts b/src/api/providers/__tests__/kenari.spec.ts new file mode 100644 index 0000000000..36ddb56665 --- /dev/null +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -0,0 +1,376 @@ +// npx vitest run src/api/providers/__tests__/kenari.spec.ts + +// Mock vscode first to avoid import errors +vitest.mock("vscode", () => ({ + workspace: { + getConfiguration: () => ({ + get: (_key: string, defaultValue?: unknown) => defaultValue, + }), + }, +})) + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { kenariDefaultModelId } from "@roo-code/types" + +import { KenariHandler } from "../kenari" +import { getModels } from "../fetchers/modelCache" +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-2": { + maxTokens: 32768, + contextWindow: 1048576, + supportsImages: false, + supportsPromptCache: false, + description: "GLM 5.2", + }, + }), + ), + getModelsFromCache: vitest.fn().mockReturnValue(undefined), +})) + +const mockCreate = vitest.fn() + +;(OpenAI as any).mockImplementation(function () { + return { + chat: { completions: { create: mockCreate } }, + } +}) + +describe("KenariHandler", () => { + const mockOptions: ApiHandlerOptions = { + kenariApiKey: "test-key", + kenariModelId: "glm-5-2", + } + + beforeEach(() => { + vitest.clearAllMocks() + mockCreate.mockClear() + }) + + it("initializes the OpenAI client with the Kenari base URL and key", () => { + const handler = new KenariHandler(mockOptions) + expect(handler).toBeInstanceOf(KenariHandler) + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://kenari.id/v1", + apiKey: "test-key", + }), + ) + }) + + describe("fetchModel", () => { + it("returns the configured model info", async () => { + const handler = new KenariHandler(mockOptions) + const result = await handler.fetchModel() + expect(result.id).toBe("glm-5-2") + expect(result.info.maxTokens).toBe(32768) + expect(result.info.contextWindow).toBe(1048576) + expect(result.info.supportsPromptCache).toBe(false) + }) + + it("falls back to the default model id when none is configured", async () => { + const handler = new KenariHandler({ kenariApiKey: "test-key" }) + const result = await handler.fetchModel() + expect(result.id).toBe(kenariDefaultModelId) + }) + }) + + 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 KenariHandler(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("yields nothing for a chunk whose delta has no content, reasoning or tool calls", async () => { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: {}, index: 0 }], usage: null } + yield { choices: [], usage: null } + }, + })) + + const handler = new KenariHandler(mockOptions) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + expect(chunks).toEqual([]) + }) + + it("streams tool call chunks even when the function name and arguments are missing", async () => { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { tool_calls: [{ index: 1 }] }, index: 0 }], + usage: null, + } + }, + })) + + const handler = new KenariHandler(mockOptions) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + expect(chunks).toEqual([ + { + type: "tool_call_partial", + index: 1, + id: undefined, + name: undefined, + arguments: undefined, + }, + ]) + }) + + it("reports undefined cache reads when usage has no prompt_tokens_details", async () => { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + } + }, + })) + + const handler = new KenariHandler(mockOptions) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + expect(chunks).toEqual([ + { + type: "usage", + inputTokens: 3, + outputTokens: 2, + cacheReadTokens: undefined, + }, + ]) + }) + + it("skips the reasoning chunk when reasoning_content is an empty string", async () => { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Hi", reasoning_content: "" }, index: 0 }], + usage: null, + } + }, + })) + + const handler = new KenariHandler(mockOptions) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + expect(chunks).toEqual([{ type: "text", text: "Hi" }]) + }) + + it("omits temperature for models that do not support it", async () => { + vitest.mocked(getModels).mockResolvedValueOnce({ + "openai/o3-mini": { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + description: "o3-mini via Kenari", + }, + }) + + const handler = new KenariHandler({ kenariApiKey: "test-key", kenariModelId: "openai/o3-mini" }) + for await (const _chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + void _chunk // drain + } + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "openai/o3-mini", + temperature: undefined, + }), + ) + }) + + it("sends an explicitly configured model temperature", async () => { + const handler = new KenariHandler({ ...mockOptions, modelTemperature: 0.7 }) + for await (const _chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + void _chunk // drain + } + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0.7 })) + }) + + it("honors metadata.parallelToolCalls false", async () => { + const handler = new KenariHandler(mockOptions) + for await (const _chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }], { + taskId: "task-1", + parallelToolCalls: false, + })) { + void _chunk // drain + } + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ parallel_tool_calls: false })) + }) + + it("reports zero usage when the upstream counts are zero", async () => { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "x" }, index: 0 }], + usage: { prompt_tokens: 0, completion_tokens: 0 }, + } + }, + })) + + const handler = new KenariHandler(mockOptions) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ + type: "usage", + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: undefined, + }) + }) + + it("requests a streaming completion with usage included", async () => { + const handler = new KenariHandler(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-2", + 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 KenariHandler(mockOptions) + expect(await handler.completePrompt("ping")).toBe("the answer") + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "glm-5-2", + stream: false, + max_completion_tokens: 32768, + }), + ) + }) + + it("returns an empty string when the completion has no content", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: null } }] }) + const handler = new KenariHandler(mockOptions) + expect(await handler.completePrompt("ping")).toBe("") + }) + + it("wraps errors with a Kenari-specific message", async () => { + mockCreate.mockRejectedValue(new Error("boom")) + const handler = new KenariHandler(mockOptions) + await expect(handler.completePrompt("ping")).rejects.toThrow("Kenari completion error: boom") + }) + + it("re-throws non-Error rejections unchanged", async () => { + mockCreate.mockRejectedValue("string failure") + const handler = new KenariHandler(mockOptions) + await expect(handler.completePrompt("ping")).rejects.toBe("string failure") + }) + + it("omits temperature for models that do not support it", async () => { + vitest.mocked(getModels).mockResolvedValueOnce({ + "openai/o3-mini": { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + description: "o3-mini via Kenari", + }, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] }) + + const handler = new KenariHandler({ kenariApiKey: "test-key", kenariModelId: "openai/o3-mini" }) + expect(await handler.completePrompt("ping")).toBe("ok") + + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.model).toBe("openai/o3-mini") + expect("temperature" in callArgs).toBe(false) + }) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/kenari.spec.ts b/src/api/providers/fetchers/__tests__/kenari.spec.ts new file mode 100644 index 0000000000..3cf39272a1 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/kenari.spec.ts @@ -0,0 +1,131 @@ +// npx vitest run src/api/providers/fetchers/__tests__/kenari.spec.ts + +import axios from "axios" + +import { kenariDefaultModelInfo } from "@roo-code/types" + +import { getKenariModels, parseKenariModel } from "../kenari" + +vitest.mock("axios") +const mockedAxios = axios as any + +describe("Kenari Fetchers", () => { + beforeEach(() => { + vitest.clearAllMocks() + }) + + describe("getKenariModels", () => { + it("maps the /models response and sends the API key as a Bearer header", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + id: "glm-5-2", + description: "Zhipu GLM 5.2", + context_length: 1048576, + modalities: { input: ["text"], output: ["text"] }, + }, + { + id: "claude-sonnet-5", + context_length: 1000000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + ], + }, + }) + + const models = await getKenariModels("test-key") + + expect(mockedAxios.get).toHaveBeenCalledWith("https://kenari.id/v1/models", { + headers: { Authorization: "Bearer test-key" }, + timeout: 10_000, + }) + + expect(Object.keys(models).sort()).toEqual(["claude-sonnet-5", "glm-5-2"]) + expect(models["glm-5-2"]).toMatchObject({ + contextWindow: 1048576, + supportsImages: false, + supportsPromptCache: false, + description: "Zhipu GLM 5.2", + }) + expect(models["claude-sonnet-5"]).toMatchObject({ + contextWindow: 1000000, + supportsImages: true, + }) + }) + + it("falls back to default context/max tokens when metadata is absent", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [{ id: "kimi-k2-7-code" }] } }) + + const models = await getKenariModels("k") + + expect(models["kimi-k2-7-code"]).toMatchObject({ + contextWindow: kenariDefaultModelInfo.contextWindow, + maxTokens: kenariDefaultModelInfo.maxTokens, + supportsPromptCache: false, + }) + }) + + it("returns an empty map on network error", async () => { + mockedAxios.get.mockRejectedValue(new Error("network")) + expect(await getKenariModels("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 getKenariModels("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`, so it will fail safeParse + ], + }, + }) + const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) + + const models = await getKenariModels("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 Kenari model entry") + + warnSpy.mockRestore() + }) + }) + + describe("parseKenariModel", () => { + it("sends no Authorization header when called without an API key", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + + await getKenariModels() + + expect(mockedAxios.get).toHaveBeenCalledWith("https://kenari.id/v1/models", { + headers: undefined, + timeout: 10_000, + }) + }) + + it("returns an empty map on a non-Error rejection", async () => { + mockedAxios.get.mockRejectedValue("boom") + expect(await getKenariModels("k")).toEqual({}) + }) + + it("falls back to the model name when no description is provided", () => { + const info = parseKenariModel({ id: "x", name: "Model X" }) + expect(info.description).toBe("Model X") + }) + + it("treats a model with no cache pricing as not cache-capable", () => { + const info = parseKenariModel({ 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/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 5b771f2bda..4b851d7d1f 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -43,6 +43,7 @@ vi.mock("fs", () => ({ vi.mock("../litellm") vi.mock("../openrouter") vi.mock("../requesty") +vi.mock("../kenari") // Mock ContextProxy with a simple static instance vi.mock("../../../core/config/ContextProxy", () => ({ @@ -63,10 +64,12 @@ import { getModels, getModelsFromCache } from "../modelCache" import { getLiteLLMModels } from "../litellm" import { getOpenRouterModels } from "../openrouter" import { getRequestyModels } from "../requesty" +import { getKenariModels } from "../kenari" const mockGetLiteLLMModels = getLiteLLMModels as Mock const mockGetOpenRouterModels = getOpenRouterModels as Mock const mockGetRequestyModels = getRequestyModels as Mock +const mockGetKenariModels = getKenariModels as Mock const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" @@ -130,6 +133,23 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("calls getKenariModels with optional API key", async () => { + const mockModels = { + "glm-5-2": { + maxTokens: 32768, + contextWindow: 1048576, + supportsPromptCache: false, + description: "GLM 5.2 via Kenari", + }, + } + mockGetKenariModels.mockResolvedValue(mockModels) + + const result = await getModels({ provider: "kenari", apiKey: "kenari-key-for-testing" }) + + expect(mockGetKenariModels).toHaveBeenCalledWith("kenari-key-for-testing") + expect(result).toEqual(mockModels) + }) + it("handles errors and re-throws them", async () => { const expectedError = new Error("LiteLLM connection failed") mockGetLiteLLMModels.mockRejectedValue(expectedError) diff --git a/src/api/providers/fetchers/kenari.ts b/src/api/providers/fetchers/kenari.ts new file mode 100644 index 0000000000..e0042f2584 --- /dev/null +++ b/src/api/providers/fetchers/kenari.ts @@ -0,0 +1,100 @@ +import axios from "axios" +import { z } from "zod" + +import type { ModelInfo } from "@roo-code/types" +import { kenariDefaultModelInfo } from "@roo-code/types" + +const KENARI_BASE_URL = "https://kenari.id/v1" + +// The Kenari `/models` endpoint follows the OpenAI `/models` shape and is +// public (no key required). The `id` is the only guaranteed field; metadata is +// optional and best-effort, so the schema is intentionally permissive. +// Pricing is intentionally NOT parsed: Kenari returns prices in IDR +// (`micro_idr_per_1m_tokens`), and the ModelInfo price fields are USD per 1M +// tokens, so reporting a converted or raw value would be wrong. Cost stays +// undefined instead. +const kenariModelSchema = z.object({ + id: z.string(), + name: z.string().optional(), + description: z.string().optional(), + context_window: z.number().optional(), + context_length: z.number().optional(), + max_tokens: z.number().optional(), + max_output_tokens: z.number().optional(), + modalities: z + .object({ + input: z.array(z.string()).optional(), + output: z.array(z.string()).optional(), + }) + .optional(), +}) + +export type KenariModel = z.infer + +const kenariModelsResponseSchema = z.object({ + data: z.array(kenariModelSchema), +}) + +/** + * Maps a raw Kenari model entry to the internal {@link ModelInfo} shape. + * + * Falls back to {@link kenariDefaultModelInfo} 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 parseKenariModel = (model: KenariModel): ModelInfo => ({ + maxTokens: model.max_output_tokens ?? model.max_tokens ?? kenariDefaultModelInfo.maxTokens, + contextWindow: model.context_window ?? model.context_length ?? kenariDefaultModelInfo.contextWindow, + supportsImages: model.modalities?.input?.includes("image") ?? false, + supportsPromptCache: false, + description: model.description ?? model.name, +}) + +/** + * Fetches the list of available models from the Kenari `/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; the endpoint is public but the key is + * sent when available. + * @returns A record mapping model IDs to their normalised {@link ModelInfo}. + */ +export async function getKenariModels(apiKey?: string): Promise> { + const models: Record = {} + + try { + const response = await axios.get(`${KENARI_BASE_URL}/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + timeout: 10_000, + }) + + const result = kenariModelsResponseSchema.safeParse(response.data) + const rawData = result.success ? result.data.data : response.data?.data + const data = Array.isArray(rawData) ? rawData : [] + + if (!result.success) { + console.warn( + `Kenari 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 = kenariModelSchema.safeParse(rawModel) + if (!parsed.success) { + console.warn(`Skipping invalid Kenari model entry: ${JSON.stringify(rawModel)}`) + continue + } + models[parsed.data.id] = parseKenariModel(parsed.data) + } + } catch (error) { + console.error(`Error fetching Kenari models: ${error instanceof Error ? error.message : String(error)}`) + } + + return models +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 312f4f9382..deca6a7c00 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -20,6 +20,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" import { getOpencodeGoModels } from "./opencode-go" +import { getKenariModels } from "./kenari" import { getRequestyModels } from "./requesty" import { getUnboundModels } from "./unbound" import { getLiteLLMModels } from "./litellm" @@ -211,6 +212,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): 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 ?? KENARI_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(`Kenari 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 1904b46bd4..b1e5df93f9 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2686,6 +2686,8 @@ describe("ClineProvider - Router Models", () => { }) // Opencode Go's /models endpoint is public, so it is fetched like the other no-auth routers. expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" })) + // Kenari's /models endpoint is public, so it is fetched like the other no-auth routers. + expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "kenari" })) // Verify response was sent expect(mockPostMessage).toHaveBeenCalledWith({ @@ -2702,6 +2704,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, "opencode-go": mockModels, + kenari: mockModels, }, values: undefined, }) @@ -2734,6 +2737,7 @@ describe("ClineProvider - Router Models", () => { .mockResolvedValueOnce(mockModels) // zoo-gateway success .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail .mockResolvedValueOnce(mockModels) // opencode-go (public endpoint) + .mockResolvedValueOnce(mockModels) // kenari (public endpoint) await messageHandler({ type: "requestRouterModels" }) @@ -2752,6 +2756,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, "opencode-go": mockModels, + kenari: mockModels, }, values: undefined, }) @@ -2849,6 +2854,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, "opencode-go": mockModels, + kenari: mockModels, }, values: undefined, }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 335911a7c7..12832da249 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -384,6 +384,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { }) // Opencode Go's /models endpoint is public, so it is fetched like the other no-auth routers. expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" })) + // Kenari's /models endpoint is public, so it is fetched like the other no-auth routers. + expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "kenari" })) // Verify response was sent expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ @@ -400,6 +402,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, "opencode-go": mockModels, + kenari: mockModels, }, values: undefined, }) @@ -468,6 +471,40 @@ describe("webviewMessageHandler - requestRouterModels", () => { }) }) + it("flushes and fetches Kenari models when an explicit API key is supplied", async () => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: {}, + }) + mockGetModels.mockResolvedValue({ + "glm-5-2": { + maxTokens: 32768, + contextWindow: 1048576, + supportsPromptCache: false, + description: "Kenari model", + }, + }) + + await webviewMessageHandler(mockClineProvider, { + type: "requestRouterModels", + values: { + provider: "kenari", + kenariApiKey: "fresh-kenari-key", + }, + }) + + expect(mockFlushModels).toHaveBeenCalledWith({ provider: "kenari", apiKey: "fresh-kenari-key" }, true) + expect(mockGetModels).toHaveBeenCalledWith({ provider: "kenari", apiKey: "fresh-kenari-key" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "routerModels", + routerModels: { + kenari: { + "glm-5-2": expect.objectContaining({ description: "Kenari model" }), + }, + }, + values: { provider: "kenari" }, + }) + }) + it("handles LiteLLM models with values from message when config is missing", async () => { mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: { @@ -551,6 +588,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, "opencode-go": mockModels, + kenari: mockModels, }, values: undefined, }) @@ -610,6 +648,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, "opencode-go": mockModels, + kenari: mockModels, }, values: undefined, }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fdcc53ddc1..01abc5db98 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1042,6 +1042,7 @@ export const webviewMessageHandler = async ( poe: {}, deepseek: {}, "opencode-go": {}, + kenari: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1152,6 +1153,23 @@ export const webviewMessageHandler = async ( options: { provider: "opencode-go", apiKey: opencodeGoApiKey }, }) + // Kenari's /models endpoint is public — it returns the full model list with no + // Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway + // above. Gating it behind a key meant the picker stayed empty (and fell back to the default + // model) whenever the key wasn't yet in apiConfiguration at fetch time. The key is still + // forwarded when present. + const kenariApiKey = message?.values?.kenariApiKey ?? apiConfiguration.kenariApiKey + + // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). + if (message?.values?.kenariApiKey) { + await flushModels({ provider: "kenari", apiKey: kenariApiKey }, true) + } + + candidates.push({ + key: "kenari", + options: { provider: "kenari", apiKey: kenariApiKey }, + }) + // 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 fe2042bd5f..d5a579be9d 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -187,6 +187,7 @@ const dynamicProviderExtras = { poe: {} as { apiKey?: string; baseUrl?: string }, deepseek: {} as { apiKey?: string; baseUrl?: string }, "opencode-go": {} as { apiKey?: string }, + kenari: {} 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 d54e0b634e..0ecbb0e067 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -73,6 +73,7 @@ import { Fireworks, VercelAiGateway, OpenCodeGo, + Kenari, ZooGateway, MiniMax, Mimo, @@ -645,6 +646,17 @@ const ApiOptions = ({ /> )} + {selectedProvider === "kenari" && ( + + )} + {selectedProvider === "zoo-gateway" && ( ({ ), })) +// Mock Kenari provider for tests +vi.mock("../providers/Kenari", () => ({ + Kenari: ({ apiConfiguration, setApiConfigurationField }: any) => ( +
+ setApiConfigurationField("kenariApiKey", e.target.value)} + placeholder="API Key" + /> +
+ ), +})) + vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ useSelectedModel: vi.fn((apiConfiguration: ProviderSettings) => { if (apiConfiguration.apiModelId?.includes("thinking")) { @@ -596,6 +611,30 @@ describe("ApiOptions", () => { }) }) + describe("Kenari provider tests", () => { + it("renders Kenari component when provider is selected", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "kenari", + kenariApiKey: "kn-test-key", + }, + }) + + expect(screen.getByTestId("kenari-provider")).toBeInTheDocument() + expect(screen.getByTestId("kenari-api-key")).toHaveValue("kn-test-key") + }) + + it("does not render Kenari component when other provider is selected", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "anthropic", + }, + }) + + expect(screen.queryByTestId("kenari-provider")).not.toBeInTheDocument() + }) + }) + it("renders retired provider message and hides provider-specific forms", () => { renderApiOptions({ apiConfiguration: { diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 566370c837..f0eb6dc150 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -64,6 +64,7 @@ export const PROVIDERS = [ { value: "fireworks", label: "Fireworks AI", proxy: false }, { value: "vercel-ai-gateway", label: "Vercel AI Gateway", proxy: false }, { value: "opencode-go", label: "Opencode Go", proxy: false }, + { value: "kenari", label: "Kenari", proxy: false }, { value: "zoo-gateway", label: "Zoo Gateway", proxy: false }, { value: "minimax", label: "MiniMax", proxy: false }, { value: "mimo", label: "Xiaomi MiMo", proxy: false }, diff --git a/webview-ui/src/components/settings/providers/Kenari.tsx b/webview-ui/src/components/settings/providers/Kenari.tsx new file mode 100644 index 0000000000..577a44ac71 --- /dev/null +++ b/webview-ui/src/components/settings/providers/Kenari.tsx @@ -0,0 +1,82 @@ +import { useCallback } from "react" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { + type ProviderSettings, + type OrganizationAllowList, + type RouterModels, + kenariDefaultModelId, +} from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" + +import { inputEventTransform } from "../transforms" +import { ModelPicker } from "../ModelPicker" + +type KenariProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + routerModels?: RouterModels + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +export const Kenari = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: KenariProps) => { + 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?.kenariApiKey && ( + + {t("settings:providers.getKenariApiKey")} + + )} + + + ) +} diff --git a/webview-ui/src/components/settings/providers/__tests__/Kenari.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Kenari.spec.tsx new file mode 100644 index 0000000000..e9fb7ce34c --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Kenari.spec.tsx @@ -0,0 +1,90 @@ +import { render, screen, fireEvent } from "@testing-library/react" + +import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" +import { kenariDefaultModelId } from "@roo-code/types" + +import { Kenari } from "../Kenari" + +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("Kenari", () => { + 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({ kenariApiKey: "" }) + + fireEvent.change(screen.getByTestId("api-key-input"), { target: { value: "secret-key" } }) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("kenariApiKey", "secret-key") + }) + + it("shows the get-API-key CTA only when no API key is set", () => { + const { rerender } = renderComponent({ kenariApiKey: "" }) + const link = screen.getByTestId("get-api-key-link") + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute("href", "https://kenari.id/login?next=/keys") + + rerender( + , + ) + expect(screen.queryByTestId("get-api-key-link")).not.toBeInTheDocument() + }) + + it("wires the ModelPicker with the Kenari defaults", () => { + renderComponent({ kenariApiKey: "key" }) + + const picker = screen.getByTestId("model-picker") + expect(picker).toHaveAttribute("data-default-model-id", kenariDefaultModelId) + expect(picker).toHaveAttribute("data-model-id-key", "kenariModelId") + expect(picker).toHaveAttribute("data-service-name", "Kenari") + }) +}) diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index d5dd0d0ded..e83f3fdbd9 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -23,6 +23,7 @@ export { LiteLLM } from "./LiteLLM" export { Fireworks } from "./Fireworks" export { VercelAiGateway } from "./VercelAiGateway" export { OpenCodeGo } from "./OpenCodeGo" +export { Kenari } from "./Kenari" export { ZooGateway } from "./ZooGateway" export { MiniMax } from "./MiniMax" export { Mimo } from "./Mimo" diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 9cc9dafa01..09559b63de 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -25,6 +25,7 @@ import { litellmDefaultModelId, vercelAiGatewayDefaultModelId, opencodeGoDefaultModelId, + kenariDefaultModelId, zooGatewayDefaultModelId, } from "@roo-code/types" @@ -126,6 +127,7 @@ const PROVIDER_MODEL_CONFIG: Partial> poe: { field: "apiModelId", default: poeDefaultModelId }, "vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId }, "opencode-go": { field: "opencodeGoModelId", default: opencodeGoDefaultModelId }, + kenari: { field: "kenariModelId", default: kenariDefaultModelId }, "zoo-gateway": { field: "zooGatewayModelId", default: zooGatewayDefaultModelId }, openai: { field: "openAiModelId" }, ollama: { field: "ollamaModelId" }, diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 3e314a8941..2cd721cac6 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -10,6 +10,8 @@ import { ModelInfo, BEDROCK_1M_CONTEXT_MODEL_IDS, litellmDefaultModelInfo, + kenariDefaultModelId, + kenariDefaultModelInfo, openAiModelInfoSaneDefaults, minimaxDefaultModelId, minimaxModels, @@ -720,6 +722,78 @@ describe("useSelectedModel", () => { }) }) + describe("kenari provider", () => { + beforeEach(() => { + mockUseOpenRouterModelProviders.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as any) + }) + + it("should return routerModels info for the configured kenari model", () => { + const customModelInfo: ModelInfo = { + maxTokens: 32768, + contextWindow: 1048576, + supportsImages: false, + supportsPromptCache: false, + description: "GLM 5.2 via Kenari", + } + + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + kenari: { + "glm-5-2": customModelInfo, + }, + }, + isLoading: false, + isError: false, + } as any) + + const apiConfiguration: ProviderSettings = { + apiProvider: "kenari", + kenariModelId: "glm-5-2", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe("kenari") + expect(result.current.id).toBe("glm-5-2") + expect(result.current.info).toEqual(customModelInfo) + }) + + it("should use kenariDefaultModelInfo as fallback when routerModels.kenari is empty", () => { + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + kenari: {}, + }, + isLoading: false, + isError: false, + } as any) + + const apiConfiguration: ProviderSettings = { + apiProvider: "kenari", + kenariModelId: "some-model", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe("kenari") + // Falls back to the kenari default model ID when the router list is empty + expect(result.current.id).toBe(kenariDefaultModelId) + // Should use kenariDefaultModelInfo as fallback + expect(result.current.info).toEqual(kenariDefaultModelInfo) + }) + }) + describe("openai provider", () => { beforeEach(() => { mockUseRouterModels.mockReturnValue({ diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index f86619a1d4..c966153e63 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -28,6 +28,7 @@ import { litellmDefaultModelInfo, lMStudioDefaultModelInfo, opencodeGoDefaultModelInfo, + kenariDefaultModelInfo, BEDROCK_1M_CONTEXT_MODEL_IDS, VERTEX_1M_CONTEXT_MODEL_IDS, isDynamicProvider, @@ -378,6 +379,13 @@ function getSelectedModel({ const info = routerModels["opencode-go"]?.[id] ?? opencodeGoDefaultModelInfo return { id, info } } + case "kenari": { + const id = getValidatedModelId(apiConfiguration.kenariModelId, routerModels["kenari"], 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["kenari"]?.[id] ?? kenariDefaultModelInfo + return { id, info } + } case "zoo-gateway": { const id = getValidatedModelId( apiConfiguration.zooGatewayModelId, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 779d87b5ee..e665d6e14c 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Obtenir clau API de Vercel AI Gateway", "opencodeGoApiKey": "Clau API de Opencode Go", "getOpencodeGoApiKey": "Obtenir clau API de Opencode Go", + "kenariApiKey": "Clau API de Kenari", + "getKenariApiKey": "Obtenir clau API de Kenari", "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 c4a3d4b257..772ab078e5 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Vercel AI Gateway API-Schlüssel erhalten", "opencodeGoApiKey": "Opencode Go API-Schlüssel", "getOpencodeGoApiKey": "Opencode Go API-Schlüssel erhalten", + "kenariApiKey": "Kenari API-Schlüssel", + "getKenariApiKey": "Kenari 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 e5b0bf7cb3..1fe36f81a4 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -462,6 +462,8 @@ "getVercelAiGatewayApiKey": "Get Vercel AI Gateway API Key", "opencodeGoApiKey": "Opencode Go API Key", "getOpencodeGoApiKey": "Get Opencode Go API Key", + "kenariApiKey": "Kenari API Key", + "getKenariApiKey": "Get Kenari 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 bbb9e32f45..7612a58813 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Obtener clave API de Vercel AI Gateway", "opencodeGoApiKey": "Clave API de Opencode Go", "getOpencodeGoApiKey": "Obtener clave API de Opencode Go", + "kenariApiKey": "Clave API de Kenari", + "getKenariApiKey": "Obtener clave API de Kenari", "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 844140300c..5aeca7c1bf 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Obtenir la clé API Vercel AI Gateway", "opencodeGoApiKey": "Clé API Opencode Go", "getOpencodeGoApiKey": "Obtenir la clé API Opencode Go", + "kenariApiKey": "Clé API Kenari", + "getKenariApiKey": "Obtenir la clé API Kenari", "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 9d092f5078..324dd50a2b 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Vercel AI Gateway API कुंजी प्राप्त करें", "opencodeGoApiKey": "Opencode Go API कुंजी", "getOpencodeGoApiKey": "Opencode Go API कुंजी प्राप्त करें", + "kenariApiKey": "Kenari API कुंजी", + "getKenariApiKey": "Kenari 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 03216a2ff2..08e2fbb920 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Dapatkan Vercel AI Gateway API Key", "opencodeGoApiKey": "Opencode Go API Key", "getOpencodeGoApiKey": "Dapatkan Opencode Go API Key", + "kenariApiKey": "Kenari API Key", + "getKenariApiKey": "Dapatkan Kenari 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 cb0bd254a9..d13ac18663 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Ottieni chiave API Vercel AI Gateway", "opencodeGoApiKey": "Chiave API Opencode Go", "getOpencodeGoApiKey": "Ottieni chiave API Opencode Go", + "kenariApiKey": "Chiave API Kenari", + "getKenariApiKey": "Ottieni chiave API Kenari", "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 7aeadb71e3..007713553c 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Vercel AI Gateway APIキーを取得", "opencodeGoApiKey": "Opencode Go APIキー", "getOpencodeGoApiKey": "Opencode Go APIキーを取得", + "kenariApiKey": "Kenari APIキー", + "getKenariApiKey": "Kenari 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 cafcfaaa81..1d5b1a0049 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Vercel AI Gateway API 키 받기", "opencodeGoApiKey": "Opencode Go API 키", "getOpencodeGoApiKey": "Opencode Go API 키 받기", + "kenariApiKey": "Kenari API 키", + "getKenariApiKey": "Kenari 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 fae892592c..a1cc3d27b8 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Vercel AI Gateway API-sleutel ophalen", "opencodeGoApiKey": "Opencode Go API-sleutel", "getOpencodeGoApiKey": "Opencode Go API-sleutel ophalen", + "kenariApiKey": "Kenari API-sleutel", + "getKenariApiKey": "Kenari 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 27b90e98d5..5801ba487b 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Uzyskaj klucz API Vercel AI Gateway", "opencodeGoApiKey": "Klucz API Opencode Go", "getOpencodeGoApiKey": "Uzyskaj klucz API Opencode Go", + "kenariApiKey": "Klucz API Kenari", + "getKenariApiKey": "Uzyskaj klucz API Kenari", "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 7c3165c3de..e3853914d4 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Obter chave API do Vercel AI Gateway", "opencodeGoApiKey": "Chave API do Opencode Go", "getOpencodeGoApiKey": "Obter chave API do Opencode Go", + "kenariApiKey": "Chave API do Kenari", + "getKenariApiKey": "Obter chave API do Kenari", "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 6fb07bd8bf..ae3676a191 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Получить ключ API Vercel AI Gateway", "opencodeGoApiKey": "Ключ API Opencode Go", "getOpencodeGoApiKey": "Получить ключ API Opencode Go", + "kenariApiKey": "Ключ API Kenari", + "getKenariApiKey": "Получить ключ API Kenari", "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 346ee6b568..2d22c4cb25 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Vercel AI Gateway API Anahtarı Al", "opencodeGoApiKey": "Opencode Go API Anahtarı", "getOpencodeGoApiKey": "Opencode Go API Anahtarı Al", + "kenariApiKey": "Kenari API Anahtarı", + "getKenariApiKey": "Kenari 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 4e42c9bc03..75c1b865a9 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "Lấy khóa API Vercel AI Gateway", "opencodeGoApiKey": "Khóa API Opencode Go", "getOpencodeGoApiKey": "Lấy khóa API Opencode Go", + "kenariApiKey": "Khóa API Kenari", + "getKenariApiKey": "Lấy khóa API Kenari", "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 90b683b86b..f0e4286e52 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -382,6 +382,8 @@ "getVercelAiGatewayApiKey": "获取 Vercel AI Gateway API 密钥", "opencodeGoApiKey": "Opencode Go API 密钥", "getOpencodeGoApiKey": "获取 Opencode Go API 密钥", + "kenariApiKey": "Kenari API 密钥", + "getKenariApiKey": "获取 Kenari 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 3f0defb886..4c31e39204 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -409,6 +409,8 @@ "getVercelAiGatewayApiKey": "取得 Vercel AI Gateway API 金鑰", "opencodeGoApiKey": "Opencode Go API 金鑰", "getOpencodeGoApiKey": "取得 Opencode Go API 金鑰", + "kenariApiKey": "Kenari API 金鑰", + "getKenariApiKey": "取得 Kenari 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 5d4f54b927..021d12dfe9 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -52,6 +52,7 @@ describe("Model Validation Functions", () => { poe: {}, deepseek: {}, "opencode-go": {}, + kenari: {}, "zoo-gateway": {}, } @@ -216,6 +217,40 @@ describe("Model Validation Functions", () => { expect(result).toBe("settings:validation.modelId") }) }) + describe("Kenari validation", () => { + it("returns an apiKey error when the Kenari API key is missing", () => { + const config: ProviderSettings = { + apiProvider: "kenari", + kenariModelId: "glm-5.1", + // Missing kenariApiKey + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBe("settings:validation.apiKey") + }) + + it("returns undefined for a valid Kenari configuration", () => { + const config: ProviderSettings = { + apiProvider: "kenari", + kenariApiKey: "valid-key", + kenariModelId: "glm-5.1", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + + it("returns a modelId error when no Kenari model id is set", () => { + const config: ProviderSettings = { + apiProvider: "kenari", + kenariApiKey: "valid-key", + // Missing kenariModelId + } + + const result = getModelValidationError(config, mockRouterModels, allowAllOrganization) + expect(result).toBe("settings:validation.modelId") + }) + }) describe("Zoo Gateway validation", () => { describe("validateApiConfiguration (welcome-view entry point)", () => { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 3de6480802..1aac6701ed 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -132,6 +132,11 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.apiKey") } break + case "kenari": + if (!apiConfiguration.kenariApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case "zoo-gateway": if (!apiConfiguration.zooSessionToken && !zooCodeIsAuthenticated) { return i18next.t("settings:validation.zooGatewaySignIn")