Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ ANTHROPIC_API_KEY=dummy_anthropic_key
FIREWORKS_API_KEY=dummy_fireworks_key
CANOPYWAVE_API_KEY=dummy_canopywave_key
SILICONFLOW_API_KEY=dummy_siliconflow_key
OPENCODE_API_KEY=dummy_opencode_key

# Database & Server
DATABASE_URL=postgresql://manicode_user_local:secretpassword_local@localhost:5432/manicode_db_local
Expand Down Expand Up @@ -43,4 +44,4 @@ NEXT_PUBLIC_POSTHOG_API_KEY=phc_dummy_posthog_key
NEXT_PUBLIC_POSTHOG_HOST_URL=https://us.i.posthog.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_dummy_publishable
NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL=https://billing.stripe.com/p/login/test_dummy
NEXT_PUBLIC_WEB_PORT=3000
NEXT_PUBLIC_WEB_PORT=3000
7 changes: 7 additions & 0 deletions common/src/constants/model-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export const openrouterModels = {
export type openrouterModel =
(typeof openrouterModels)[keyof typeof openrouterModels]

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

export const deepseekModels = {
deepseekChat: 'deepseek-chat',
deepseekReasoner: 'deepseek-reasoner',
Expand Down
2 changes: 2 additions & 0 deletions packages/internal/src/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const serverEnvSchema = clientEnvSchema.extend({
CANOPYWAVE_API_KEY: z.string().min(1).optional(),
DEEPSEEK_API_KEY: z.string().min(1).optional(),
SILICONFLOW_API_KEY: z.string().min(1).optional(),
OPENCODE_API_KEY: z.string().min(1).optional(),
LINKUP_API_KEY: z.string().min(1),
CONTEXT7_API_KEY: z.string().optional(),
GRAVITY_API_KEY: z.string().min(1),
Expand Down Expand Up @@ -90,6 +91,7 @@ export const serverProcessEnv: ServerInput = {
CANOPYWAVE_API_KEY: process.env.CANOPYWAVE_API_KEY,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
OPENCODE_API_KEY: process.env.OPENCODE_API_KEY,
LINKUP_API_KEY: process.env.LINKUP_API_KEY,
CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY,
GRAVITY_API_KEY: process.env.GRAVITY_API_KEY,
Expand Down
1 change: 1 addition & 0 deletions packages/internal/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ if (isCI) {
ensureEnvDefault('FIREWORKS_API_KEY', 'test')
ensureEnvDefault('CANOPYWAVE_API_KEY', 'test')
ensureEnvDefault('DEEPSEEK_API_KEY', 'test')
ensureEnvDefault('OPENCODE_API_KEY', 'test')
ensureEnvDefault('LINKUP_API_KEY', 'test')
ensureEnvDefault('GRAVITY_API_KEY', 'test')
ensureEnvDefault('IPINFO_TOKEN', 'test')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
FREEBUFF_GLM_MODEL_ID,
isFreebuffDeploymentHours,
} from '@codebuff/common/constants/freebuff-models'
import { openCodeZenModels } from '@codebuff/common/constants/model-config'
import { postChatCompletions } from '../_post'
import {
checkFreeModeRateLimit,
Expand Down Expand Up @@ -852,6 +853,85 @@ describe('/api/v1/chat/completions POST endpoint', () => {
FETCH_PATH_TEST_TIMEOUT_MS,
)

it(
'rejects OpenCode Zen models while the Zen integration is disabled',
async () => {
const fetchViaOpenCodeZen = mock(
async (_url: string | URL | Request, _init?: RequestInit) => {
throw new Error('OpenCode Zen should not be called')
},
) as unknown as typeof globalThis.fetch

for (const codebuffModel of Object.values(openCodeZenModels)) {
const req = new NextRequest(
'http://localhost:3000/api/v1/chat/completions',
{
method: 'POST',
headers: {
Authorization: 'Bearer test-api-key-123',
},
body: JSON.stringify({
model: codebuffModel,
messages: [
{
role: 'system',
content: 'system prompt',
cache_control: { type: 'ephemeral' },
},
{
role: 'user',
content: [
{
type: 'text',
text: 'hello',
cache_control: { type: 'ephemeral' },
},
],
},
],
tools: [
{
id: 'tool_1',
type: 'function',
function: {
name: 'read_files',
parameters: { type: 'object' },
},
},
],
stream: false,
codebuff_metadata: {
run_id: 'run-123',
client_id: 'test-client-id-123',
},
}),
},
)

const response = await postChatCompletions({
req,
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
logger: mockLogger,
trackEvent: mockTrackEvent,
getUserUsageData: mockGetUserUsageData,
getAgentRunFromId: mockGetAgentRunFromId,
fetch: fetchViaOpenCodeZen,
insertMessageBigquery: mockInsertMessageBigquery,
loggerWithContext: mockLoggerWithContext,
})

const body = await response.json()
expect(response.status).toBe(400)
expect(body).toEqual({
error: 'opencode_zen_disabled',
message: 'OpenCode Zen models are currently disabled.',
})
}
expect(fetchViaOpenCodeZen).not.toHaveBeenCalled()
},
FETCH_PATH_TEST_TIMEOUT_MS,
)

it('rejects the DeepSeek V4 free agent when it requests another free model', async () => {
const req = new NextRequest(
'http://localhost:3000/api/v1/chat/completions',
Expand Down
20 changes: 20 additions & 0 deletions web/src/app/api/v1/chat/completions/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
handleDeepSeekStream,
isDeepSeekModel,
} from '@/llm-api/deepseek'
import { isOpenCodeZenModel } from '@/llm-api/opencode-zen'
import {
SiliconFlowError,
handleSiliconFlowNonStream,
Expand Down Expand Up @@ -377,6 +378,25 @@ export async function postChatCompletions(params: {
)
}

if (isOpenCodeZenModel(typedBody.model)) {
trackEvent({
event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR,
userId,
properties: {
error: 'opencode_zen_disabled',
model: typedBody.model,
},
logger,
})
return NextResponse.json(
{
error: 'opencode_zen_disabled',
message: 'OpenCode Zen models are currently disabled.',
},
{ status: 400 },
)
}

// Free-mode requests must use an allowlisted agent+model combination.
// Without this gate, an attacker on a brand-new unpaid account can set
// cost_mode='free' to bypass both the paid-account check and the balance
Expand Down
Loading
Loading