Skip to content

Commit 0203b7c

Browse files
[codex] Route opencode chat models through Zen (#638)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent cba6d1d commit 0203b7c

3 files changed

Lines changed: 113 additions & 28 deletions

File tree

common/src/constants/model-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export type openrouterModel =
5555

5656
export const openCodeZenModels = {
5757
opencode_kimi_k2_6: 'opencode/kimi-k2.6',
58+
opencode_minimax_m2_7: 'opencode/minimax-m2.7',
5859
} as const
5960
export type OpenCodeZenModel =
6061
(typeof openCodeZenModels)[keyof typeof openCodeZenModels]

web/src/app/api/v1/chat/completions/__tests__/completions.test.ts

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -869,13 +869,24 @@ describe('/api/v1/chat/completions POST endpoint', () => {
869869
)
870870

871871
it(
872-
'routes OpenCode Zen models to the direct OpenCode Zen provider',
872+
'routes OpenCode Zen-prefixed and Kimi models to the direct OpenCode Zen provider',
873873
async () => {
874-
const expectedUpstreamModel: Record<string, string> = {
875-
'opencode/kimi-k2.6': 'kimi-k2.6',
876-
}
874+
const testCases = [
875+
{
876+
codebuffModel: openCodeZenModels.opencode_kimi_k2_6,
877+
upstreamModel: 'kimi-k2.6',
878+
},
879+
{
880+
codebuffModel: openCodeZenModels.opencode_minimax_m2_7,
881+
upstreamModel: 'minimax-m2.7',
882+
},
883+
{
884+
codebuffModel: 'moonshotai/kimi-k2.6',
885+
upstreamModel: 'kimi-k2.6',
886+
},
887+
]
877888

878-
for (const codebuffModel of Object.values(openCodeZenModels)) {
889+
for (const { codebuffModel, upstreamModel } of testCases) {
879890
const fetchedBodies: Record<string, unknown>[] = []
880891
const fetchedUrls: string[] = []
881892
const fetchViaOpenCodeZen = mock(
@@ -889,7 +900,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
889900
return new Response(
890901
JSON.stringify({
891902
id: 'test-id',
892-
model: expectedUpstreamModel[codebuffModel],
903+
model: upstreamModel,
893904
choices: [{ message: { content: 'test response' } }],
894905
usage: {
895906
prompt_tokens: 10,
@@ -968,16 +979,67 @@ describe('/api/v1/chat/completions POST endpoint', () => {
968979
expect(fetchedUrls[0]).toBe(
969980
'https://opencode.ai/zen/v1/chat/completions',
970981
)
971-
expect(fetchedBodies[0].model).toBe(
972-
expectedUpstreamModel[codebuffModel],
973-
)
982+
expect(fetchedBodies[0].model).toBe(upstreamModel)
974983
expect(body.model).toBe(codebuffModel)
975984
expect(body.provider).toBe('OpenCode Zen')
976985
}
977986
},
978987
FETCH_PATH_TEST_TIMEOUT_MS,
979988
)
980989

990+
it(
991+
'rejects unsupported OpenCode Zen-prefixed models without calling the provider',
992+
async () => {
993+
const fetchViaOpenCodeZen = mock(
994+
async (url: string | URL | Request) => {
995+
if (String(url).startsWith('https://api.ipinfo.io/lookup/')) {
996+
return Response.json({})
997+
}
998+
999+
throw new Error('OpenCode Zen provider should not be called')
1000+
},
1001+
) as unknown as typeof globalThis.fetch
1002+
1003+
const req = new NextRequest(
1004+
'http://localhost:3000/api/v1/chat/completions',
1005+
{
1006+
method: 'POST',
1007+
headers: {
1008+
Authorization: 'Bearer test-api-key-123',
1009+
},
1010+
body: JSON.stringify({
1011+
model: 'opencode/qwen3-coder',
1012+
messages: [{ role: 'user', content: 'hello' }],
1013+
stream: false,
1014+
codebuff_metadata: {
1015+
run_id: 'run-123',
1016+
client_id: 'test-client-id-123',
1017+
},
1018+
}),
1019+
},
1020+
)
1021+
1022+
const response = await postChatCompletions({
1023+
req,
1024+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
1025+
logger: mockLogger,
1026+
trackEvent: mockTrackEvent,
1027+
getUserUsageData: mockGetUserUsageData,
1028+
getAgentRunFromId: mockGetAgentRunFromId,
1029+
fetch: fetchViaOpenCodeZen,
1030+
insertMessageBigquery: mockInsertMessageBigquery,
1031+
loggerWithContext: mockLoggerWithContext,
1032+
})
1033+
1034+
const body = await response.json()
1035+
expect(response.status).toBe(400)
1036+
expect(body.error.code).toBe('unsupported_model')
1037+
expect(body.error.message).toContain('opencode/qwen3-coder')
1038+
expect(fetchViaOpenCodeZen).toHaveBeenCalledTimes(0)
1039+
},
1040+
FETCH_PATH_TEST_TIMEOUT_MS,
1041+
)
1042+
9811043
it('rejects the DeepSeek V4 free agent when it requests another free model', async () => {
9821044
const req = new NextRequest(
9831045
'http://localhost:3000/api/v1/chat/completions',

web/src/llm-api/opencode-zen.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,34 +34,56 @@ interface OpenCodeZenPricing {
3434
outputCostPerToken: number
3535
}
3636

37-
const OPENCODE_ZEN_MODELS: Record<
38-
string,
39-
{ opencodeId: string; pricing: OpenCodeZenPricing }
40-
> = {
41-
[openCodeZenModels.opencode_kimi_k2_6]: {
42-
opencodeId: 'kimi-k2.6',
43-
pricing: {
44-
inputCostPerToken: 0.95 / 1_000_000,
45-
cachedInputCostPerToken: 0.16 / 1_000_000,
46-
outputCostPerToken: 4.0 / 1_000_000,
47-
},
37+
const OPENCODE_MODEL_PREFIX = 'opencode/'
38+
const MOONSHOT_KIMI_MODEL = 'moonshotai/kimi-k2.6'
39+
const KIMI_ZEN_MODEL = 'kimi-k2.6'
40+
const MINIMAX_M2_7_ZEN_MODEL = 'minimax-m2.7'
41+
42+
const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
43+
[openCodeZenModels.opencode_kimi_k2_6]: KIMI_ZEN_MODEL,
44+
[openCodeZenModels.opencode_minimax_m2_7]: MINIMAX_M2_7_ZEN_MODEL,
45+
[MOONSHOT_KIMI_MODEL]: KIMI_ZEN_MODEL,
46+
}
47+
const SUPPORTED_OPENCODE_ZEN_MODELS = Object.keys(OPENCODE_ZEN_MODEL_ALIASES)
48+
49+
const KIMI_ZEN_PRICING: OpenCodeZenPricing = {
50+
inputCostPerToken: 0.95 / 1_000_000,
51+
cachedInputCostPerToken: 0.16 / 1_000_000,
52+
outputCostPerToken: 4.0 / 1_000_000,
53+
}
54+
55+
const OPENCODE_ZEN_PRICING: Record<string, OpenCodeZenPricing> = {
56+
[KIMI_ZEN_MODEL]: KIMI_ZEN_PRICING,
57+
[MINIMAX_M2_7_ZEN_MODEL]: {
58+
inputCostPerToken: 0.3 / 1_000_000,
59+
cachedInputCostPerToken: 0.06 / 1_000_000,
60+
outputCostPerToken: 1.2 / 1_000_000,
4861
},
4962
}
5063

51-
export function isOpenCodeZenModel(model: string): boolean {
52-
return model in OPENCODE_ZEN_MODELS
64+
export function isOpenCodeZenModel(model: unknown): model is string {
65+
if (typeof model !== 'string') return false
66+
return (
67+
model.startsWith(OPENCODE_MODEL_PREFIX) ||
68+
model in OPENCODE_ZEN_MODEL_ALIASES
69+
)
5370
}
5471

5572
function getOpenCodeZenModelId(model: string): string {
56-
return OPENCODE_ZEN_MODELS[model]?.opencodeId ?? model
73+
const opencodeId = OPENCODE_ZEN_MODEL_ALIASES[model]
74+
if (opencodeId) return opencodeId
75+
76+
throw new OpenCodeZenError(400, 'Bad Request', {
77+
error: {
78+
message: `Unsupported OpenCode Zen model: ${model}. Supported models: ${SUPPORTED_OPENCODE_ZEN_MODELS.join(', ')}`,
79+
code: 'unsupported_model',
80+
type: 'invalid_request_error',
81+
},
82+
})
5783
}
5884

5985
function getOpenCodeZenPricing(model: string): OpenCodeZenPricing {
60-
const entry = OPENCODE_ZEN_MODELS[model]
61-
if (!entry) {
62-
throw new Error(`No OpenCode Zen pricing found for model: ${model}`)
63-
}
64-
return entry.pricing
86+
return OPENCODE_ZEN_PRICING[getOpenCodeZenModelId(model)] ?? KIMI_ZEN_PRICING
6587
}
6688

6789
type StreamState = {

0 commit comments

Comments
 (0)