diff --git a/.specs/coding-plans.md b/.specs/coding-plans.md index 7568742eb6..dc46de10f9 100644 --- a/.specs/coding-plans.md +++ b/.specs/coding-plans.md @@ -46,7 +46,9 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S 1.6. The catalog **MUST** contain BytePlus Coding Plan Lite with Plan ID `byteplus-coding-plan-team-lite`, provider ID `byteplus-coding`, a price of $20 in Kilo Credits, and a 30-day billing period. Its displayed limits **MUST** be described as approximately 1,900 requests every 5 hours, 12,000 requests per week, and 24,000 requests per subscription period. The plan's supported model IDs are `dola-seed-2.0-pro`, `dola-seed-2.0-lite`, `dola-seed-2.0-code`, `bytedance-seed-code`, `kimi-k2.5`, `glm-5.1`, `glm-5.2`, `deepseek-v4-flash`, `deepseek-v4-pro`, and `gpt-oss-120b`. Upstream Auto, Kimi-K2-Thinking, and GLM-4.7 **MUST NOT** be exposed. -1.7. When no assignable Managed Plan Credential exists for an offering, customer-facing catalog responses **MUST** identify the offering as sold out without exposing credential counts or credential metadata. +1.7. The catalog **MUST** contain BytePlus Coding Plan Pro with Plan ID `byteplus-coding-plan-team-pro`, provider ID `byteplus-coding`, a price of $100 in Kilo Credits, and a 30-day billing period. Its displayed limits **MUST** be described as approximately 9,500 requests every 5 hours, 60,000 requests per week, and 120,000 requests per subscription period. Pro **MUST** support exactly the same model IDs as Lite and **MUST NOT** expose Upstream Auto, Kimi-K2-Thinking, or GLM-4.7. + +1.8. When no assignable Managed Plan Credential exists for an offering, customer-facing catalog responses **MUST** identify the offering as sold out without exposing credential counts or credential metadata. ## 2. Subscription and billing diff --git a/.specs/subscription-center.md b/.specs/subscription-center.md index 578e1b8761..3eea07bce5 100644 --- a/.specs/subscription-center.md +++ b/.specs/subscription-center.md @@ -28,6 +28,7 @@ Updated 2026-06-05 -- KiloClaw final Commit term continuation behavior. Updated 2026-06-19 -- Current Coding Plan quota presentation and routing independence. Updated 2026-07-01 -- Coding Plans installed-key deletion blocked in BYOK. Updated 2026-06-26 -- MiniMax token plan tiers and provider-level Coding Plan exclusivity. +Updated 2026-08-05 -- BytePlus Coding Plan Pro catalog requirements. ## Conventions @@ -332,8 +333,13 @@ historical Commit names, prices, invoices, and credit deductions. offering with provider name, plan name, recurring USD price, billing period, payment source, and catalog feature copy. MiniMax token offerings MUST include Token Plan Plus, Token Plan Max, and Token Plan Ultra. - BytePlus offerings MUST include Coding Plan Lite at $20 per 30 days. An - offering with assignable credential capacity MUST show a subscribe action. + BytePlus offerings MUST include Coding Plan Lite at $20 per 30 days and + Coding Plan Pro at $100 per 30 days. Lite MUST display approximately 1,900 + requests every 5 hours, 12,000 per week, and 24,000 per subscription + period. Pro MUST display approximately 9,500 requests every 5 hours, + 60,000 per week, and 120,000 per subscription period. Both offerings MUST + use the model IDs and exclusions defined in `.specs/coding-plans.md`. + An offering with assignable credential capacity MUST show a subscribe action. Purchase messaging MUST explain automatic BYOK setup for the selected provider, and purchase MUST be blocked when any personal BYOK key for that provider exists, including a disabled key. In that state, the system MUST direct diff --git a/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts b/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts index a5565c8954..b85073253d 100644 --- a/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts +++ b/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts @@ -22,8 +22,10 @@ jest.mock('@/lib/autoTopUp', () => ({ const PLAN_ID = 'minimax-token-plan-plus'; const MAX_PLAN_ID = 'minimax-token-plan-max'; const BYTEPLUS_PLAN_ID = 'byteplus-coding-plan-team-lite'; +const BYTEPLUS_PRO_PLAN_ID = 'byteplus-coding-plan-team-pro'; const COST_MICRODOLLARS = 20_000_000; const MAX_COST_MICRODOLLARS = 50_000_000; +const BYTEPLUS_PRO_COST_MICRODOLLARS = 100_000_000; const dueAt = new Date(Date.now() - 60_000).toISOString(); async function createSubscription( @@ -37,7 +39,7 @@ async function createSubscription( auto_top_up_enabled: autoTopUpEnabled, }); await uploadKeysToInventory( - planId === BYTEPLUS_PLAN_ID ? 'byteplus-coding' : 'minimax', + planId === BYTEPLUS_PLAN_ID || planId === BYTEPLUS_PRO_PLAN_ID ? 'byteplus-coding' : 'minimax', planId, [`cron-key-${crypto.randomUUID()}::minimax-plan-${crypto.randomUUID()}`], { @@ -162,6 +164,38 @@ describe('Coding Plan billing lifecycle cron', () => { expect(renewal).toEqual([{ description: 'Coding plan renewal: BytePlus Coding Plan Lite' }]); }); + it('renews BytePlus Pro for 100 credits while retaining its assigned credential', async () => { + const { subscriptionId } = await createSubscription( + BYTEPLUS_PRO_COST_MICRODOLLARS * 2, + false, + BYTEPLUS_PRO_PLAN_ID + ); + + const summary = await runCodingPlanBillingLifecycleCron(db); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + const [credential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + const renewal = await db + .select({ description: credit_transactions.description }) + .from(credit_transactions) + .where(eq(credit_transactions.description, 'Coding plan renewal: BytePlus Coding Plan Pro')); + + expect(summary.renewals).toBe(1); + expect(subscription).toMatchObject({ + plan_id: BYTEPLUS_PRO_PLAN_ID, + provider_id: 'byteplus-coding', + cost_microdollars: BYTEPLUS_PRO_COST_MICRODOLLARS, + billing_period_days: 30, + }); + expect(credential.status).toBe('assigned'); + expect(renewal).toEqual([{ description: 'Coding plan renewal: BytePlus Coding Plan Pro' }]); + }); + it('renews after the subscriber deletes the installed MiniMax BYOK key', async () => { const { subscriptionId } = await createSubscription(COST_MICRODOLLARS * 2); const [before] = await db diff --git a/apps/web/src/lib/coding-plans/index.test.ts b/apps/web/src/lib/coding-plans/index.test.ts index 3bef258192..7f17d0ab65 100644 --- a/apps/web/src/lib/coding-plans/index.test.ts +++ b/apps/web/src/lib/coding-plans/index.test.ts @@ -35,10 +35,12 @@ const MAX_PLAN_ID = 'minimax-token-plan-max'; const ULTRA_PLAN_ID = 'minimax-token-plan-ultra'; const PROVIDER_ID = 'minimax'; const BYTEPLUS_PLAN_ID = 'byteplus-coding-plan-team-lite'; +const BYTEPLUS_PRO_PLAN_ID = 'byteplus-coding-plan-team-pro'; const BYTEPLUS_PROVIDER_ID = 'byteplus-coding'; const COST_MICRODOLLARS = 20_000_000; const MAX_COST_MICRODOLLARS = 50_000_000; const ULTRA_COST_MICRODOLLARS = 120_000_000; +const BYTEPLUS_PRO_COST_MICRODOLLARS = 100_000_000; const validatedInventoryUpload = { validateCredential: async () => true }; @@ -120,6 +122,19 @@ describe('coding plans', () => { 'Kilo automatically configures BytePlus in your BYOK settings.', ]), }); + expect(CODING_PLAN_CATALOG[BYTEPLUS_PRO_PLAN_ID]).toEqual({ + planId: BYTEPLUS_PRO_PLAN_ID, + providerName: 'BytePlus', + name: 'Coding Plan Pro', + providerId: BYTEPLUS_PROVIDER_ID, + coveredModelIds: BYTEPLUS_CODING_MODEL_IDS, + costMicrodollars: BYTEPLUS_PRO_COST_MICRODOLLARS, + billingPeriodDays: 30, + features: expect.arrayContaining([ + 'For complex, high-intensity development use.', + 'Approximately 9,500 requests every 5 hours, 60,000 requests per week, and 120,000 requests per 30-day subscription period.', + ]), + }); }); it('activates BytePlus alongside MiniMax using its managed BYOK provider slot', async () => { diff --git a/apps/web/src/lib/coding-plans/inventory-slack-summary.test.ts b/apps/web/src/lib/coding-plans/inventory-slack-summary.test.ts index 99941c3583..30c6c04b7d 100644 --- a/apps/web/src/lib/coding-plans/inventory-slack-summary.test.ts +++ b/apps/web/src/lib/coding-plans/inventory-slack-summary.test.ts @@ -43,14 +43,17 @@ describe('buildCodingPlanInventorySlackNotification', () => { revoked: 19, }); expect(result.notification.text).toBe( - 'Coding Plans inventory: 83 available, 156 assigned, 263 loaded. 5 pending revocation. Token Plan Plus: 79 available. Token Plan Max: 3 available. Token Plan Ultra: 1 available.' + 'Coding Plans inventory: `83` available, `156` assigned, `263` loaded. `5` pending revocation. MiniMax Token Plan Plus: `79` available, `148` assigned, `251` loaded. MiniMax Token Plan Max: `3` available, `7` assigned, `10` loaded. MiniMax Token Plan Ultra: `1` available, `1` assigned, `2` loaded.' ); const rendered = blockText(result); expect(rendered).toContain('MiniMax · Current snapshot'); - expect(rendered).toContain('Token Plan Plus'); - expect(rendered).toContain('79 available · 148 assigned · 251 loaded'); - expect(rendered).toContain('5 pending revocation · 19 revoked'); + expect(rendered).toContain('*Total* · Available `83` · Assigned `156` · Loaded `263`'); + expect(rendered).toContain('*MiniMax*'); + expect(rendered).toContain('Token Plan Plus · Available `79` · Assigned `148` · Loaded `251`'); + expect(rendered).not.toContain('*Provider* | *Plan*'); + expect(rendered).toContain('`5` credentials are pending revocation'); + expect(rendered.match(/pending revocation/g)).toHaveLength(1); expect(rendered).toContain('Snapshot: 2026-07-30 12:00 UTC'); expect(rendered).toContain('/admin/coding-plans|Open Coding Plans'); expect(rendered).not.toContain('open_coding_plans_inventory'); @@ -71,11 +74,42 @@ describe('buildCodingPlanInventorySlackNotification', () => { expect(result.totals.revocationFailed).toBe(2); expect(result.totals.loaded).toBe(268); const rendered = blockText(result); - expect(rendered).toContain('2 failed revocation'); - expect(rendered).toContain('3 quarantined'); + expect(rendered).toContain('`2` credentials failed revocation'); + expect(rendered).toContain('`3` quarantined'); expect(rendered).toContain('Action required'); }); + it('groups plans into sections by provider', () => { + const result = buildCodingPlanInventorySlackNotification([ + { providerId: 'minimax', planId: 'minimax-token-plan-plus', status: 'available', count: 2 }, + { + providerId: 'byteplus-coding', + planId: 'byteplus-coding-plan-team-lite', + status: 'assigned', + count: 4, + }, + { + providerId: 'byteplus-coding', + planId: 'byteplus-coding-plan-team-lite', + status: 'available', + count: 1, + }, + { + providerId: 'byteplus-coding', + planId: 'byteplus-coding-plan-team-pro', + status: 'available', + count: 3, + }, + ]); + + const rendered = blockText(result); + expect(rendered).toContain('*MiniMax*'); + expect(rendered).toContain('Token Plan Plus · Available `2` · Assigned `0` · Loaded `2`'); + expect(rendered).toContain('*BytePlus*'); + expect(rendered).toContain('Coding Plan Lite · Available `1` · Assigned `4` · Loaded `5`'); + expect(rendered).toContain('Coding Plan Pro · Available `3` · Assigned `0` · Loaded `3`'); + }); + it('renders an explicit empty-inventory state', () => { const result = buildCodingPlanInventorySlackNotification([]); @@ -99,7 +133,7 @@ describe('sendCodingPlanInventorySlackSummary', () => { expect(getCounts).toHaveBeenCalledWith(); expect(sendNotification).toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringContaining('83 available') }) + expect.objectContaining({ text: expect.stringContaining('`83` available') }) ); }); }); diff --git a/apps/web/src/lib/coding-plans/inventory-slack-summary.ts b/apps/web/src/lib/coding-plans/inventory-slack-summary.ts index 37baf56799..16b0f4b4bc 100644 --- a/apps/web/src/lib/coding-plans/inventory-slack-summary.ts +++ b/apps/web/src/lib/coding-plans/inventory-slack-summary.ts @@ -49,6 +49,14 @@ function escapeSlackText(value: string): string { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); } +function escapeSlackLabel(value: string): string { + return escapeSlackText(value).replaceAll('`', "'").replaceAll('\n', ' '); +} + +function formatCount(count: number): string { + return `\`${count}\``; +} + function humanizeStatus(status: string): string { return status.replaceAll('_', ' '); } @@ -111,42 +119,58 @@ function inventoryTotals(summaries: InventoryPlanSummary[]): CodingPlanInventory ); } -function formatPlanSummary(summary: InventoryPlanSummary): string { - const available = statusCount(summary, 'available'); - const assigned = statusCount(summary, 'assigned'); - const pending = statusCount(summary, 'revocation_pending'); - const failed = statusCount(summary, 'revocation_failed'); - const revoked = statusCount(summary, 'revoked'); - const isCatalogPlan = getCodingPlanCatalog().some(plan => plan.planId === summary.planId); - const displayName = isCatalogPlan - ? escapeSlackText(summary.displayName) - : `\`${escapeSlackText(summary.displayName.replaceAll('`', "'"))}\``; - const lines = [ - `*${displayName}*`, - `${available} available · ${assigned} assigned · ${summary.loaded} loaded`, - ]; +function formatInventoryTotals(totals: CodingPlanInventoryTotals): string { + return `*Total* · Available ${formatCount(totals.available)} · Assigned ${formatCount(totals.assigned)} · Loaded ${formatCount(totals.loaded)}`; +} - const lifecycleParts = [ - failed > 0 ? `${failed} failed revocation` : null, - pending > 0 ? `${pending} pending revocation` : null, - revoked > 0 ? `${revoked} revoked` : null, - ].filter((value): value is string => value !== null); - if (lifecycleParts.length > 0) { - lines.push(`${failed > 0 || pending > 0 ? ':warning: ' : ''}${lifecycleParts.join(' · ')}`); - } +function groupSummariesByProvider( + summaries: InventoryPlanSummary[] +): Map { + const providerGroups = new Map(); - const unknownStatuses = Object.entries(summary.statusCounts) - .filter(([status]) => !KNOWN_STATUSES.has(status)) - .sort(([left], [right]) => left.localeCompare(right)); - if (unknownStatuses.length > 0) { - lines.push( - unknownStatuses - .map(([status, count]) => `${count} ${escapeSlackText(humanizeStatus(status))}`) - .join(' · ') - ); + for (const summary of summaries) { + const providerSummaries = providerGroups.get(summary.providerName) ?? []; + providerSummaries.push(summary); + providerGroups.set(summary.providerName, providerSummaries); } - return lines.join('\n'); + return providerGroups; +} + +function formatProviderSummary(providerName: string, summaries: InventoryPlanSummary[]): string { + const planLines = summaries.map( + summary => + `${escapeSlackLabel(summary.displayName)} · Available ${formatCount(statusCount(summary, 'available'))} · Assigned ${formatCount(statusCount(summary, 'assigned'))} · Loaded ${formatCount(summary.loaded)}` + ); + + return [`*${escapeSlackLabel(providerName)}*`, ...planLines].join('\n'); +} + +function formatUnknownStatuses(summaries: InventoryPlanSummary[]): string | null { + const unknownStatuses = summaries.flatMap(summary => + Object.entries(summary.statusCounts) + .filter(([status]) => !KNOWN_STATUSES.has(status)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([status, count]) => { + const plan = `${summary.providerName} ${summary.displayName}`; + return `${escapeSlackLabel(plan)}: ${formatCount(count)} ${escapeSlackText(humanizeStatus(status))}`; + }) + ); + + return unknownStatuses.length > 0 ? `*Other statuses:* ${unknownStatuses.join(' · ')}` : null; +} + +function formatRevocationCallout(totals: CodingPlanInventoryTotals): string { + return [ + totals.revocationFailed > 0 + ? `:rotating_light: *Action required:* ${formatCount(totals.revocationFailed)} credential${totals.revocationFailed === 1 ? '' : 's'} failed revocation.` + : null, + totals.revocationPending > 0 + ? `:warning: *${formatCount(totals.revocationPending)} credential${totals.revocationPending === 1 ? ' is' : 's are'} pending revocation.*` + : null, + ] + .filter((value): value is string => value !== null) + .join('\n'); } function formatSnapshotTime(timestamp: Date): string { @@ -166,16 +190,23 @@ export function buildCodingPlanInventorySlackNotification( const providerNames = Array.from(new Set(summaries.map(summary => summary.providerName))); const providerLabel = providerNames.length > 0 ? providerNames.join(', ') : 'All providers'; const planAvailability = summaries - .map(summary => `${summary.displayName}: ${statusCount(summary, 'available')} available`) + .map( + summary => + `${summary.providerName} ${summary.displayName}: ${formatCount(statusCount(summary, 'available'))} available, ${formatCount(statusCount(summary, 'assigned'))} assigned, ${formatCount(summary.loaded)} loaded` + ) .join('. '); const attentionFallback = [ - totals.revocationFailed > 0 ? `${totals.revocationFailed} failed revocation` : null, - totals.revocationPending > 0 ? `${totals.revocationPending} pending revocation` : null, + totals.revocationFailed > 0 + ? `${formatCount(totals.revocationFailed)} failed revocation` + : null, + totals.revocationPending > 0 + ? `${formatCount(totals.revocationPending)} pending revocation` + : null, ] .filter((value): value is string => value !== null) .join(', '); const text = [ - `Coding Plans inventory: ${totals.available} available, ${totals.assigned} assigned, ${totals.loaded} loaded`, + `Coding Plans inventory: ${formatCount(totals.available)} available, ${formatCount(totals.assigned)} assigned, ${formatCount(totals.loaded)} loaded`, attentionFallback || null, planAvailability || 'No inventory recorded', ] @@ -192,15 +223,6 @@ export function buildCodingPlanInventorySlackNotification( type: 'context', elements: [{ type: 'mrkdwn', text: `${escapeSlackText(providerLabel)} · Current snapshot` }], }, - { - type: 'section', - fields: [ - { type: 'mrkdwn', text: `*${totals.available}*\nAvailable` }, - { type: 'mrkdwn', text: `*${totals.assigned}*\nAssigned` }, - { type: 'mrkdwn', text: `*${totals.loaded}*\nLoaded` }, - { type: 'mrkdwn', text: `*${needsAttention}*\nNeeds attention` }, - ], - }, { type: 'divider' }, ]; @@ -210,24 +232,32 @@ export function buildCodingPlanInventorySlackNotification( text: { type: 'mrkdwn', text: 'No Coding Plans inventory is currently recorded.' }, }); } else { - for (const summary of summaries) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: formatInventoryTotals(totals) }, + }); + + for (const [providerName, providerSummaries] of groupSummariesByProvider(summaries)) { blocks.push({ type: 'section', - text: { type: 'mrkdwn', text: formatPlanSummary(summary) }, + text: { + type: 'mrkdwn', + text: formatProviderSummary(providerName, providerSummaries), + }, }); } + + const unknownStatuses = formatUnknownStatuses(summaries); + if (unknownStatuses) { + blocks.push({ type: 'section', text: { type: 'mrkdwn', text: unknownStatuses } }); + } } if (needsAttention > 0) { - const attentionLines = [ - totals.revocationFailed > 0 - ? `:rotating_light: *Action required:* ${totals.revocationFailed} credential${totals.revocationFailed === 1 ? '' : 's'} failed revocation.` - : null, - totals.revocationPending > 0 - ? `:warning: *${totals.revocationPending} credential${totals.revocationPending === 1 ? ' is' : 's are'} pending revocation.*` - : null, - ].filter((value): value is string => value !== null); - blocks.push({ type: 'section', text: { type: 'mrkdwn', text: attentionLines.join('\n') } }); + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: formatRevocationCallout(totals) }, + }); } blocks.push({ diff --git a/apps/web/src/lib/coding-plans/inventory-validation.test.ts b/apps/web/src/lib/coding-plans/inventory-validation.test.ts index aabe47a7c9..de903d5b91 100644 --- a/apps/web/src/lib/coding-plans/inventory-validation.test.ts +++ b/apps/web/src/lib/coding-plans/inventory-validation.test.ts @@ -123,4 +123,19 @@ describe('validateCodingPlanCredential', () => { maxOutputTokens: 1, }); }); + + it('accepts BytePlus Pro credentials through the same direct provider validation', async () => { + mockedGenerateText.mockResolvedValueOnce({ finishReason: 'stop' } as never); + + await expect( + validateCodingPlanCredential({ + apiKey: 'byteplus-pro-inventory-key', + planId: 'byteplus-coding-plan-team-pro', + providerId: 'byteplus-coding', + upstreamPlanId: 'byteplus-pro-plan-123', + }) + ).resolves.toBe(true); + + expect(mockDirectModel).toHaveBeenCalledWith('bytedance-seed-code'); + }); }); diff --git a/apps/web/src/lib/coding-plans/pricing.ts b/apps/web/src/lib/coding-plans/pricing.ts index b91a0867fd..56f7933288 100644 --- a/apps/web/src/lib/coding-plans/pricing.ts +++ b/apps/web/src/lib/coding-plans/pricing.ts @@ -9,6 +9,7 @@ export const CODING_PLAN_IDS = [ 'minimax-token-plan-max', 'minimax-token-plan-ultra', 'byteplus-coding-plan-team-lite', + 'byteplus-coding-plan-team-pro', ] as const; export const CODING_PLAN_PROVIDER_IDS = ['minimax', 'byteplus-coding'] as const; @@ -87,6 +88,22 @@ export const CODING_PLAN_CATALOG = { 'Supported models share one plan quota; one prompt can use multiple model requests.', ], }, + 'byteplus-coding-plan-team-pro': { + planId: 'byteplus-coding-plan-team-pro', + providerName: 'BytePlus', + name: 'Coding Plan Pro', + providerId: BYTEPLUS_CODING_PROVIDER_ID, + coveredModelIds: BYTEPLUS_CODING_MODEL_IDS, + costMicrodollars: 100_000_000, + billingPeriodDays: 30, + features: [ + 'Kilo automatically configures BytePlus in your BYOK settings.', + 'For complex, high-intensity development use.', + 'Approximately 9,500 requests every 5 hours, 60,000 requests per week, and 120,000 requests per 30-day subscription period.', + 'Access to the approved BytePlus coding model set.', + 'Supported models share one plan quota; one prompt can use multiple model requests.', + ], + }, } satisfies Record; export function getCodingPlanCatalog(): CodingPlanCatalogEntry[] { diff --git a/apps/web/src/routers/coding-plans-router.test.ts b/apps/web/src/routers/coding-plans-router.test.ts index f9188cd2cc..bfe3a4650e 100644 --- a/apps/web/src/routers/coding-plans-router.test.ts +++ b/apps/web/src/routers/coding-plans-router.test.ts @@ -26,6 +26,7 @@ const PLAN_ID = 'minimax-token-plan-plus'; const MAX_PLAN_ID = 'minimax-token-plan-max'; const ULTRA_PLAN_ID = 'minimax-token-plan-ultra'; const BYTEPLUS_PLAN_ID = 'byteplus-coding-plan-team-lite'; +const BYTEPLUS_PRO_PLAN_ID = 'byteplus-coding-plan-team-pro'; const COST_MICRODOLLARS = 20_000_000; const MAX_COST_MICRODOLLARS = 50_000_000; const mockedGenerateText = jest.mocked(generateText); @@ -126,6 +127,20 @@ describe('coding plans router', () => { availabilityStatus: 'sold_out', notificationRequested: false, }, + { + planId: BYTEPLUS_PRO_PLAN_ID, + providerName: 'BytePlus', + name: 'Coding Plan Pro', + providerId: 'byteplus-coding', + costKiloCredits: 100, + billingPeriodDays: 30, + features: expect.arrayContaining([ + 'For complex, high-intensity development use.', + 'Approximately 9,500 requests every 5 hours, 60,000 requests per week, and 120,000 requests per 30-day subscription period.', + ]), + availabilityStatus: 'sold_out', + notificationRequested: false, + }, ]); }); @@ -162,6 +177,11 @@ describe('coding plans router', () => { availabilityStatus: 'sold_out', notificationRequested: false, }), + expect.objectContaining({ + planId: BYTEPLUS_PRO_PLAN_ID, + availabilityStatus: 'sold_out', + notificationRequested: false, + }), ]); await expect( caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }) @@ -203,6 +223,11 @@ describe('coding plans router', () => { availabilityStatus: 'sold_out', notificationRequested: false, }), + expect.objectContaining({ + planId: BYTEPLUS_PRO_PLAN_ID, + availabilityStatus: 'sold_out', + notificationRequested: false, + }), ]); }); diff --git a/dev/seed/coding-plans/available-credentials.ts b/dev/seed/coding-plans/available-credentials.ts index 220f390f88..7d1c7bb595 100644 --- a/dev/seed/coding-plans/available-credentials.ts +++ b/dev/seed/coding-plans/available-credentials.ts @@ -13,11 +13,15 @@ const PROVIDERS = { planId: 'byteplus-coding-plan-team-lite', providerId: 'byteplus-coding', }, + 'byteplus-pro': { + planId: 'byteplus-coding-plan-team-pro', + providerId: 'byteplus-coding', + }, } as const; const KEY_PREFIX = 'dev-seed:coding-plans'; const MAX_CREDENTIAL_COUNT = 20; -export const usage = ' [count] [minimax|byteplus]'; +export const usage = ' [count] [minimax|byteplus|byteplus-pro]'; function printUsage(): void { console.log(`Usage: pnpm dev:seed coding-plans:available-credentials ${usage}`); @@ -32,6 +36,9 @@ function printUsage(): void { console.log('Examples:'); console.log(' pnpm dev:seed coding-plans:available-credentials subscription-smoke 1'); console.log(' pnpm dev:seed coding-plans:available-credentials byteplus-smoke 1 byteplus'); + console.log( + ' pnpm dev:seed coding-plans:available-credentials byteplus-pro-smoke 1 byteplus-pro' + ); } function parseCount(rawCount: string | undefined): number { @@ -102,8 +109,8 @@ export async function run(...args: string[]): Promise { const scenario = requireScenario(rawScenario); const count = parseCount(rawCount); - if (rawProvider !== 'minimax' && rawProvider !== 'byteplus') { - throw new Error('provider must be minimax or byteplus'); + if (rawProvider !== 'minimax' && rawProvider !== 'byteplus' && rawProvider !== 'byteplus-pro') { + throw new Error('provider must be minimax, byteplus, or byteplus-pro'); } const provider = PROVIDERS[rawProvider]; const key = requireEncryptionKey(); diff --git a/services/auto-routing/src/coding-plan-preference.ts b/services/auto-routing/src/coding-plan-preference.ts index fdce70f79e..86a8ddc281 100644 --- a/services/auto-routing/src/coding-plan-preference.ts +++ b/services/auto-routing/src/coding-plan-preference.ts @@ -9,12 +9,22 @@ const CODING_PLAN_PREFERENCE_KEY_PREFIX = 'coding_plan_preference:v2:'; const CODING_PLAN_PREFERENCE_TTL_SECONDS = 60; const BYTEPLUS_CODING_PLAN_DEFAULT_MODEL_ID = 'byteplus-coding/bytedance-seed-code'; const MINIMAX_CODING_PLAN_DEFAULT_MODEL_ID = 'minimax/minimax-m3'; +const BYTEPLUS_CODING_PLAN_IDS = [ + 'byteplus-coding-plan-team-lite', + 'byteplus-coding-plan-team-pro', +] as const; +const BYTEPLUS_CODING_PLAN_ID_SET = new Set(BYTEPLUS_CODING_PLAN_IDS); +type ByteplusCodingPlanId = (typeof BYTEPLUS_CODING_PLAN_IDS)[number]; + +function isByteplusCodingPlanId(value: string): value is ByteplusCodingPlanId { + return BYTEPLUS_CODING_PLAN_ID_SET.has(value); +} const CodingPlanPreferenceSchema = z.union([ z.object({ active: z.literal(false) }), z.object({ active: z.literal(true), - planId: z.literal('byteplus-coding-plan-team-lite'), + planId: z.enum(BYTEPLUS_CODING_PLAN_IDS), providerId: z.literal('byteplus-coding'), modelId: z.literal(BYTEPLUS_CODING_PLAN_DEFAULT_MODEL_ID), }), @@ -75,7 +85,7 @@ async function queryCodingPlanPreference( eq(byok_api_keys.is_enabled, true), or( and( - eq(coding_plan_subscriptions.plan_id, 'byteplus-coding-plan-team-lite'), + inArray(coding_plan_subscriptions.plan_id, BYTEPLUS_CODING_PLAN_IDS), eq(coding_plan_subscriptions.provider_id, 'byteplus-coding') ), and( @@ -87,18 +97,17 @@ async function queryCodingPlanPreference( ) .orderBy( sql`case - when ${coding_plan_subscriptions.plan_id} = 'byteplus-coding-plan-team-lite' - and ${coding_plan_subscriptions.provider_id} = 'byteplus-coding' then 0 + when ${coding_plan_subscriptions.provider_id} = 'byteplus-coding' then 0 when ${coding_plan_subscriptions.plan_id} = 'minimax-token-plan-plus' and ${coding_plan_subscriptions.provider_id} = 'minimax' then 1 else 2 end` ) .limit(1); - if (row?.planId === 'byteplus-coding-plan-team-lite' && row.providerId === 'byteplus-coding') { + if (row?.providerId === 'byteplus-coding' && isByteplusCodingPlanId(row.planId)) { return { active: true, - planId: 'byteplus-coding-plan-team-lite', + planId: row.planId, providerId: 'byteplus-coding', modelId: BYTEPLUS_CODING_PLAN_DEFAULT_MODEL_ID, }; diff --git a/services/auto-routing/src/index.test.ts b/services/auto-routing/src/index.test.ts index dd1536b614..0691260b0c 100644 --- a/services/auto-routing/src/index.test.ts +++ b/services/auto-routing/src/index.test.ts @@ -1626,6 +1626,51 @@ describe('auto routing worker', () => { expect(classifyNormalizedInput).not.toHaveBeenCalled(); }); + it('loads and caches the BytePlus Pro coding-plan preference on cache miss', async () => { + dbLimit.mockResolvedValueOnce([ + { planId: 'byteplus-coding-plan-team-pro', providerId: 'byteplus-coding' }, + ]); + + const response = await decideRequest(mirrorPayload({ userId: 'pro-user-1' })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + decision: { + model: 'byteplus-coding/bytedance-seed-code', + source: 'coding_plan_default', + }, + }); + expect(configPut).toHaveBeenCalledWith( + expect.stringMatching(/^coding_plan_preference:v2:[0-9a-f]{16}$/), + JSON.stringify({ + active: true, + planId: 'byteplus-coding-plan-team-pro', + providerId: 'byteplus-coding', + modelId: 'byteplus-coding/bytedance-seed-code', + }), + { expirationTtl: 60 } + ); + expect(classifyNormalizedInput).not.toHaveBeenCalled(); + }); + + it('prioritizes BytePlus Pro over MiniMax when both subscriptions are active', async () => { + dbLimit.mockResolvedValueOnce([ + { planId: 'byteplus-coding-plan-team-pro', providerId: 'byteplus-coding' }, + { planId: 'minimax-token-plan-plus', providerId: 'minimax' }, + ]); + + const response = await decideRequest(mirrorPayload()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + decision: { + model: 'byteplus-coding/bytedance-seed-code', + source: 'coding_plan_default', + }, + }); + expect(dbOrderBy).toHaveBeenCalledTimes(1); + }); + it('uses the database-selected BytePlus row when both recognized plans are active', async () => { dbLimit.mockResolvedValueOnce([ { planId: 'byteplus-coding-plan-team-lite', providerId: 'byteplus-coding' },