Skip to content

Commit 731faa6

Browse files
committed
feat(coding-plans): implement managed MiniMax lifecycle
1 parent 4444ee8 commit 731faa6

24 files changed

Lines changed: 3415 additions & 107 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { db } from '@/lib/drizzle';
4+
import { CRON_SECRET } from '@/lib/config.server';
5+
import { sentryLogger } from '@/lib/utils.server';
6+
import { runCodingPlanBillingLifecycleCron } from '@/lib/coding-plans/billing-lifecycle-cron';
7+
8+
if (!CRON_SECRET) {
9+
throw new Error('CRON_SECRET is not configured in environment variables');
10+
}
11+
12+
export async function GET(request: Request) {
13+
const authHeader = request.headers.get('authorization');
14+
const expectedAuth = `Bearer ${CRON_SECRET}`;
15+
if (authHeader !== expectedAuth) {
16+
sentryLogger(
17+
'cron',
18+
'warning'
19+
)(
20+
'SECURITY: Invalid coding-plans-billing CRON authorization attempt: ' +
21+
(authHeader ? 'Invalid authorization header' : 'Missing authorization header')
22+
);
23+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24+
}
25+
26+
const summary = await runCodingPlanBillingLifecycleCron(db);
27+
28+
return NextResponse.json(
29+
{
30+
success: true,
31+
summary,
32+
timestamp: new Date().toISOString(),
33+
},
34+
{ status: 200 }
35+
);
36+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/* eslint-disable drizzle/enforce-delete-with-where */
2+
import { encryptApiKey } from '@/lib/ai-gateway/byok/encryption';
3+
import { getBYOKforUser } from '@/lib/ai-gateway/byok';
4+
import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server';
5+
import { db } from '@/lib/drizzle';
6+
import { insertTestUser } from '@/tests/helpers/user.helper';
7+
import { byok_api_keys, kilocode_users } from '@kilocode/db/schema';
8+
import { eq } from 'drizzle-orm';
9+
10+
async function seedMiniMaxKey(managementSource: 'user' | 'coding_plan', isEnabled = true) {
11+
const user = await insertTestUser();
12+
await db.insert(byok_api_keys).values({
13+
kilo_user_id: user.id,
14+
provider_id: 'minimax',
15+
encrypted_api_key: encryptApiKey(`minimax-${crypto.randomUUID()}`, BYOK_ENCRYPTION_KEY),
16+
management_source: managementSource,
17+
is_enabled: isEnabled,
18+
created_by: user.id,
19+
});
20+
return user;
21+
}
22+
23+
afterEach(async () => {
24+
await db.delete(byok_api_keys);
25+
await db.delete(kilocode_users);
26+
});
27+
28+
describe('Coding Plan MiniMax BYOK routing', () => {
29+
it('loads a Token Plan Plus-installed key through ordinary MiniMax routing', async () => {
30+
const user = await seedMiniMaxKey('coding_plan');
31+
32+
const byok = await getBYOKforUser(db, user.id, ['minimax']);
33+
34+
expect(byok).toHaveLength(1);
35+
expect(byok?.[0].providerId).toBe('minimax');
36+
});
37+
38+
it('uses a subscriber replacement as ordinary MiniMax BYOK', async () => {
39+
const user = await seedMiniMaxKey('user');
40+
41+
const byok = await getBYOKforUser(db, user.id, ['minimax']);
42+
43+
expect(byok).toHaveLength(1);
44+
expect(byok?.[0].providerId).toBe('minimax');
45+
});
46+
47+
it('does not route a disabled MiniMax BYOK key', async () => {
48+
const user = await seedMiniMaxKey('coding_plan', false);
49+
50+
expect(await getBYOKforUser(db, user.id, ['minimax'])).toBeNull();
51+
});
52+
53+
it('does not route after the configured MiniMax key is deleted', async () => {
54+
const user = await seedMiniMaxKey('coding_plan');
55+
await db.delete(byok_api_keys).where(eq(byok_api_keys.kilo_user_id, user.id));
56+
57+
expect(await getBYOKforUser(db, user.id, ['minimax'])).toBeNull();
58+
});
59+
});

apps/web/src/lib/ai-gateway/byok/index.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type db } from '@/lib/drizzle';
22
import { byok_api_keys } from '@kilocode/db/schema';
3-
import { eq, and, inArray } from 'drizzle-orm';
3+
import { and, eq, inArray } from 'drizzle-orm';
44
import type { EncryptedData } from '@/lib/ai-gateway/byok/encryption';
55
import { decryptApiKey } from '@/lib/ai-gateway/byok/encryption';
66
import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server';
@@ -19,7 +19,7 @@ export async function getModelUserByokProviders(modelId: string): Promise<UserBy
1919
return ['codestral'];
2020
}
2121
const vercelModelMetadata = await getVercelModelsMetadata();
22-
if (Object.keys(vercelModelMetadata).length == 0) {
22+
if (Object.keys(vercelModelMetadata).length === 0) {
2323
console.error('[getModelUserByokProviders] no Vercel model metadata in the database');
2424
return [];
2525
}
@@ -53,6 +53,9 @@ export async function getBYOKforUser(
5353
userId: string,
5454
providerIds: UserByokProviderId[]
5555
): Promise<BYOKResult[] | null> {
56+
if (providerIds.length === 0) {
57+
return null;
58+
}
5659
const rows = await fromDb
5760
.select({
5861
encrypted_api_key: byok_api_keys.encrypted_api_key,
@@ -68,18 +71,17 @@ export async function getBYOKforUser(
6871
)
6972
.orderBy(byok_api_keys.created_at);
7073

71-
if (rows.length === 0) {
72-
return null;
73-
}
74-
75-
return rows.map(row => decryptByokRow(row));
74+
return rows.length === 0 ? null : rows.map(row => decryptByokRow(row));
7675
}
7776

7877
export async function getBYOKforOrganization(
7978
fromDb: typeof db,
8079
organizationId: string,
8180
providerIds: UserByokProviderId[]
8281
): Promise<BYOKResult[] | null> {
82+
if (providerIds.length === 0) {
83+
return null;
84+
}
8385
const rows = await fromDb
8486
.select({
8587
encrypted_api_key: byok_api_keys.encrypted_api_key,
@@ -95,9 +97,5 @@ export async function getBYOKforOrganization(
9597
)
9698
.orderBy(byok_api_keys.created_at);
9799

98-
if (rows.length === 0) {
99-
return null;
100-
}
101-
102-
return rows.map(row => decryptByokRow(row));
100+
return rows.length === 0 ? null : rows.map(row => decryptByokRow(row));
103101
}

apps/web/src/lib/ai-gateway/byok/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { UserByokProviderIdSchema } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
12
import * as z from 'zod';
23

34
// API response type (never includes decrypted key)
45
export type BYOKApiKeyResponse = {
56
id: string;
67
provider_id: string;
78
provider_name: string;
9+
management_source: 'user' | 'coding_plan';
810
is_enabled: boolean;
911
created_at: string;
1012
updated_at: string;
@@ -20,7 +22,7 @@ const OptionalOrganizationIdSchema = z.object({
2022
// Note: organizationId is optional - if provided, enforces org owner/billing access
2123
// If not provided, uses the authenticated user's kilo_user_id
2224
export const CreateBYOKKeyInputSchema = OptionalOrganizationIdSchema.extend({
23-
provider_id: z.string().min(1),
25+
provider_id: UserByokProviderIdSchema,
2426
api_key: z.string().min(1),
2527
});
2628

@@ -49,6 +51,7 @@ export const BYOKApiKeyResponseSchema = z.object({
4951
id: z.string().uuid(),
5052
provider_id: z.string(),
5153
provider_name: z.string(),
54+
management_source: z.enum(['user', 'coding_plan']),
5255
is_enabled: z.boolean(),
5356
created_at: z.string(),
5457
updated_at: z.string(),

0 commit comments

Comments
 (0)