Skip to content

Commit eb9af0e

Browse files
committed
feat(kenari): add Kenari as a first-class provider
1 parent 1c728e7 commit eb9af0e

43 files changed

Lines changed: 887 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ export const SECRET_STATE_KEYS = [
318318
"fireworksApiKey",
319319
"vercelAiGatewayApiKey",
320320
"opencodeGoApiKey",
321+
"kenariApiKey",
321322
"basetenApiKey",
322323
] as const
323324

packages/types/src/provider-settings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const dynamicProviders = [
4646
"poe",
4747
"deepseek",
4848
"opencode-go",
49+
"kenari",
4950
] as const
5051

5152
export type DynamicProvider = (typeof dynamicProviders)[number]
@@ -407,6 +408,11 @@ const opencodeGoSchema = baseProviderSettingsSchema.extend({
407408
opencodeGoModelId: z.string().optional(),
408409
})
409410

411+
const kenariSchema = baseProviderSettingsSchema.extend({
412+
kenariApiKey: z.string().optional(),
413+
kenariModelId: z.string().optional(),
414+
})
415+
410416
const zooGatewaySchema = baseProviderSettingsSchema.extend({
411417
zooSessionToken: z.string().optional(),
412418
zooGatewayModelId: z.string().optional(),
@@ -452,6 +458,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
452458
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
453459
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
454460
opencodeGoSchema.merge(z.object({ apiProvider: z.literal("opencode-go") })),
461+
kenariSchema.merge(z.object({ apiProvider: z.literal("kenari") })),
455462
zooGatewaySchema.merge(z.object({ apiProvider: z.literal("zoo-gateway") })),
456463
defaultSchema,
457464
])
@@ -488,6 +495,7 @@ export const providerSettingsSchema = z.object({
488495
...qwenCodeSchema.shape,
489496
...vercelAiGatewaySchema.shape,
490497
...opencodeGoSchema.shape,
498+
...kenariSchema.shape,
491499
...zooGatewaySchema.shape,
492500
...codebaseIndexProviderSchema.shape,
493501
})
@@ -520,6 +528,7 @@ export const modelIdKeys = [
520528
"litellmModelId",
521529
"vercelAiGatewayModelId",
522530
"opencodeGoModelId",
531+
"kenariModelId",
523532
"zooGatewayModelId",
524533
] as const satisfies readonly (keyof ProviderSettings)[]
525534

@@ -567,6 +576,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
567576
fireworks: "apiModelId",
568577
"vercel-ai-gateway": "vercelAiGatewayModelId",
569578
"opencode-go": "opencodeGoModelId",
579+
kenari: "kenariModelId",
570580
"zoo-gateway": "zooGatewayModelId",
571581
}
572582

@@ -701,6 +711,7 @@ export const MODELS_BY_PROVIDER: Record<
701711
unbound: { id: "unbound", label: "Unbound", models: [] },
702712
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },
703713
"opencode-go": { id: "opencode-go", label: "Opencode Go", models: [] },
714+
kenari: { id: "kenari", label: "Kenari", models: [] },
704715
"zoo-gateway": { id: "zoo-gateway", label: "Zoo Gateway", models: [] },
705716

706717
// Local providers; models discovered from localhost endpoints.

packages/types/src/providers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export * from "./vscode-llm.js"
2323
export * from "./xai.js"
2424
export * from "./vercel-ai-gateway.js"
2525
export * from "./opencode-go.js"
26+
export * from "./kenari.js"
2627
export * from "./zai.js"
2728
export * from "./minimax.js"
2829
export * from "./mimo.js"
@@ -49,6 +50,7 @@ import { vscodeLlmDefaultModelId } from "./vscode-llm.js"
4950
import { xaiDefaultModelId } from "./xai.js"
5051
import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js"
5152
import { opencodeGoDefaultModelId } from "./opencode-go.js"
53+
import { kenariDefaultModelId } from "./kenari.js"
5254
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
5355
import { minimaxDefaultModelId } from "./minimax.js"
5456
import { mimoDefaultModelId } from "./mimo.js"
@@ -121,6 +123,8 @@ export function getProviderDefaultModelId(
121123
return vercelAiGatewayDefaultModelId
122124
case "opencode-go":
123125
return opencodeGoDefaultModelId
126+
case "kenari":
127+
return kenariDefaultModelId
124128
case "zoo-gateway":
125129
return zooGatewayDefaultModelId
126130
case "anthropic":
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ModelInfo } from "../model.js"
2+
3+
// Kenari: Indonesian OpenAI-compatible AI gateway billed in Rupiah (IDR).
4+
// https://kenari.id/docs · base URL: https://kenari.id/v1
5+
//
6+
// The full model list (and metadata) is fetched dynamically from
7+
// `https://kenari.id/v1/models`, so models can be switched on the fly.
8+
// The values below are only a fallback used before the live list resolves.
9+
export const kenariDefaultModelId = "glm-5-2"
10+
11+
export const kenariDefaultModelInfo: ModelInfo = {
12+
maxTokens: 32_768,
13+
contextWindow: 1_048_576,
14+
supportsImages: false,
15+
supportsPromptCache: false,
16+
// Pricing is intentionally omitted: Kenari bills in IDR (micro-rupiah per 1M tokens),
17+
// which cannot be rendered in the USD price fields without a misleading conversion.
18+
description: "Kenari model. Available models and metadata are resolved dynamically from /v1/models.",
19+
}
20+
21+
export const KENARI_DEFAULT_TEMPERATURE = 0

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
FireworksHandler,
3434
VercelAiGatewayHandler,
3535
OpencodeGoHandler,
36+
KenariHandler,
3637
ZooGatewayHandler,
3738
MiniMaxHandler,
3839
MimoHandler,
@@ -193,6 +194,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
193194
return new VercelAiGatewayHandler(options)
194195
case "opencode-go":
195196
return new OpencodeGoHandler(options)
197+
case "kenari":
198+
return new KenariHandler(options)
196199
case "zoo-gateway":
197200
return new ZooGatewayHandler(options)
198201
case "minimax":
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// npx vitest run src/api/providers/__tests__/kenari.spec.ts
2+
3+
// Mock vscode first to avoid import errors
4+
vitest.mock("vscode", () => ({
5+
workspace: {
6+
getConfiguration: () => ({
7+
get: (_key: string, defaultValue?: unknown) => defaultValue,
8+
}),
9+
},
10+
}))
11+
12+
import { Anthropic } from "@anthropic-ai/sdk"
13+
import OpenAI from "openai"
14+
15+
import { kenariDefaultModelId } from "@roo-code/types"
16+
17+
import { KenariHandler } from "../kenari"
18+
import { ApiHandlerOptions } from "../../../shared/api"
19+
20+
vitest.mock("openai")
21+
vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
22+
vitest.mock("../fetchers/modelCache", () => ({
23+
getModels: vitest.fn().mockImplementation(() =>
24+
Promise.resolve({
25+
"glm-5-2": {
26+
maxTokens: 32768,
27+
contextWindow: 1048576,
28+
supportsImages: false,
29+
supportsPromptCache: false,
30+
description: "GLM 5.2",
31+
},
32+
}),
33+
),
34+
getModelsFromCache: vitest.fn().mockReturnValue(undefined),
35+
}))
36+
37+
const mockCreate = vitest.fn()
38+
39+
;(OpenAI as any).mockImplementation(function () {
40+
return {
41+
chat: { completions: { create: mockCreate } },
42+
}
43+
})
44+
45+
describe("KenariHandler", () => {
46+
const mockOptions: ApiHandlerOptions = {
47+
kenariApiKey: "test-key",
48+
kenariModelId: "glm-5-2",
49+
}
50+
51+
beforeEach(() => {
52+
vitest.clearAllMocks()
53+
mockCreate.mockClear()
54+
})
55+
56+
it("initializes the OpenAI client with the Kenari base URL and key", () => {
57+
const handler = new KenariHandler(mockOptions)
58+
expect(handler).toBeInstanceOf(KenariHandler)
59+
expect(OpenAI).toHaveBeenCalledWith(
60+
expect.objectContaining({
61+
baseURL: "https://kenari.id/v1",
62+
apiKey: "test-key",
63+
}),
64+
)
65+
})
66+
67+
describe("fetchModel", () => {
68+
it("returns the configured model info", async () => {
69+
const handler = new KenariHandler(mockOptions)
70+
const result = await handler.fetchModel()
71+
expect(result.id).toBe("glm-5-2")
72+
expect(result.info.maxTokens).toBe(32768)
73+
expect(result.info.contextWindow).toBe(1048576)
74+
expect(result.info.supportsPromptCache).toBe(false)
75+
})
76+
77+
it("falls back to the default model id when none is configured", async () => {
78+
const handler = new KenariHandler({ kenariApiKey: "test-key" })
79+
const result = await handler.fetchModel()
80+
expect(result.id).toBe(kenariDefaultModelId)
81+
})
82+
})
83+
84+
describe("createMessage", () => {
85+
beforeEach(() => {
86+
mockCreate.mockImplementation(async () => ({
87+
[Symbol.asyncIterator]: async function* () {
88+
yield {
89+
choices: [
90+
{
91+
delta: {
92+
content: "Hello",
93+
reasoning_content: "thinking…",
94+
tool_calls: [
95+
{
96+
index: 0,
97+
id: "call_1",
98+
function: { name: "read_file", arguments: '{"path":' },
99+
},
100+
],
101+
},
102+
index: 0,
103+
},
104+
],
105+
usage: null,
106+
}
107+
yield {
108+
choices: [{ delta: {}, index: 0 }],
109+
usage: {
110+
prompt_tokens: 12,
111+
completion_tokens: 7,
112+
total_tokens: 19,
113+
prompt_tokens_details: { cached_tokens: 4 },
114+
},
115+
}
116+
},
117+
}))
118+
})
119+
120+
it("streams text, reasoning, tool-call and usage chunks", async () => {
121+
const handler = new KenariHandler(mockOptions)
122+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
123+
124+
const chunks = []
125+
for await (const chunk of handler.createMessage("You are helpful.", messages)) {
126+
chunks.push(chunk)
127+
}
128+
129+
expect(chunks).toContainEqual({ type: "text", text: "Hello" })
130+
expect(chunks).toContainEqual({ type: "reasoning", text: "thinking…" })
131+
expect(chunks).toContainEqual({
132+
type: "tool_call_partial",
133+
index: 0,
134+
id: "call_1",
135+
name: "read_file",
136+
arguments: '{"path":',
137+
})
138+
expect(chunks).toContainEqual({
139+
type: "usage",
140+
inputTokens: 12,
141+
outputTokens: 7,
142+
cacheReadTokens: 4,
143+
})
144+
})
145+
146+
it("requests a streaming completion with usage included", async () => {
147+
const handler = new KenariHandler(mockOptions)
148+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
149+
for await (const _chunk of handler.createMessage("sys", messages)) {
150+
void _chunk // drain
151+
}
152+
153+
expect(mockCreate).toHaveBeenCalledWith(
154+
expect.objectContaining({
155+
model: "glm-5-2",
156+
stream: true,
157+
stream_options: { include_usage: true },
158+
max_completion_tokens: 32768,
159+
temperature: expect.any(Number),
160+
}),
161+
)
162+
})
163+
})
164+
165+
describe("completePrompt", () => {
166+
it("returns the message content for a non-streaming completion", async () => {
167+
mockCreate.mockResolvedValue({ choices: [{ message: { content: "the answer" } }] })
168+
const handler = new KenariHandler(mockOptions)
169+
expect(await handler.completePrompt("ping")).toBe("the answer")
170+
expect(mockCreate).toHaveBeenCalledWith(
171+
expect.objectContaining({
172+
model: "glm-5-2",
173+
stream: false,
174+
max_completion_tokens: 32768,
175+
}),
176+
)
177+
})
178+
179+
it("wraps errors with a Kenari-specific message", async () => {
180+
mockCreate.mockRejectedValue(new Error("boom"))
181+
const handler = new KenariHandler(mockOptions)
182+
await expect(handler.completePrompt("ping")).rejects.toThrow("Kenari completion error: boom")
183+
})
184+
})
185+
})

0 commit comments

Comments
 (0)