feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const lookupMock = vi.hoisted(() => ({
resolveBuiltinPricing: vi.fn(),
}))
vi.mock('@/lib/model-pricing/lookup', () => ({
resolveBuiltinPricing: lookupMock.resolveBuiltinPricing,
}))
import { calcImage, calcText, calcVideo, calcVoice } from '@/lib/billing/cost'
describe('billing/cost error branches', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws ambiguous pricing error when catalog has multiple candidates', () => {
lookupMock.resolveBuiltinPricing.mockReturnValue({
status: 'ambiguous_model',
apiType: 'image',
modelId: 'shared-model',
candidates: [
{
apiType: 'image',
provider: 'p1',
modelId: 'shared-model',
pricing: { mode: 'flat', flatAmount: 1 },
},
{
apiType: 'image',
provider: 'p2',
modelId: 'shared-model',
pricing: { mode: 'flat', flatAmount: 1 },
},
],
})
expect(() => calcImage('shared-model', 1)).toThrow('Ambiguous image pricing modelId')
})
it('throws unknown model when catalog returns not_configured', () => {
lookupMock.resolveBuiltinPricing.mockReturnValue({
status: 'not_configured',
})
expect(() => calcImage('provider::missing-image-model', 1)).toThrow('Unknown image model pricing')
})
it('normalizes invalid numeric inputs to zero before pricing', () => {
lookupMock.resolveBuiltinPricing.mockImplementation(
(input: { selections?: { tokenType?: 'input' | 'output' } }) => {
if (input.selections?.tokenType === 'input') return { status: 'resolved', amount: 2 }
if (input.selections?.tokenType === 'output') return { status: 'resolved', amount: 4 }
return { status: 'resolved', amount: 3 }
},
)
expect(calcText('text-model', Number.NaN, 1_000_000)).toBeCloseTo(4, 8)
expect(calcText('text-model', 1_000_000, Number.NaN)).toBeCloseTo(2, 8)
expect(calcImage('image-model', Number.NaN)).toBe(0)
expect(calcVideo('video-model', '720p', Number.NaN)).toBe(0)
expect(calcVoice(Number.NaN)).toBe(0)
})
})

View File

@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import {
USD_TO_CNY,
calcImage,
calcLipSync,
calcText,
calcVideo,
calcVoice,
calcVoiceDesign,
} from '@/lib/billing/cost'
describe('billing/cost', () => {
it('calculates text cost by known model price table', () => {
const cost = calcText('anthropic/claude-sonnet-4', 1_000_000, 1_000_000)
expect(cost).toBeCloseTo((3 + 15) * USD_TO_CNY, 8)
})
it('throws when text model pricing is unknown', () => {
expect(() => calcText('unknown-model', 500_000, 250_000)).toThrow('Unknown text model pricing')
})
it('throws when image model pricing is unknown', () => {
expect(() => calcImage('missing-image-model', 3)).toThrow('Unknown image model pricing')
})
it('supports resolution-aware video pricing', () => {
const cost720 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '720p', 2)
const cost1080 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '1080p', 2)
expect(cost720).toBeCloseTo(0.86, 8)
expect(cost1080).toBeCloseTo(2.06, 8)
expect(() => calcVideo('doubao-seedance-1-0-pro-fast-251015', '2k', 1)).toThrow('Unsupported video resolution pricing')
expect(() => calcVideo('unknown-video-model', '720p', 1)).toThrow('Unknown video model pricing')
})
it('scales ark video pricing by selected duration when tiers omit duration', () => {
const shortDuration = calcVideo('doubao-seedance-1-0-pro-250528', '480p', 1, {
generationMode: 'normal',
resolution: '480p',
duration: 2,
})
const longDuration = calcVideo('doubao-seedance-1-0-pro-250528', '1080p', 1, {
generationMode: 'normal',
resolution: '1080p',
duration: 12,
})
expect(shortDuration).toBeCloseTo(0.292, 8)
expect(longDuration).toBeCloseTo(8.808, 8)
})
it('uses Ark 1.5 official default generateAudio=true when audio is omitted', () => {
const defaultAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {
generationMode: 'normal',
resolution: '720p',
})
const muteAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {
generationMode: 'normal',
resolution: '720p',
generateAudio: false,
})
expect(defaultAudio).toBeCloseTo(1.73, 8)
expect(muteAudio).toBeCloseTo(0.86, 8)
})
it('supports Ark Seedance 1.0 Lite i2v pricing and duration scaling', () => {
const shortDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '480p', 1, {
generationMode: 'normal',
resolution: '480p',
duration: 2,
})
const longDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '1080p', 1, {
generationMode: 'firstlastframe',
resolution: '1080p',
duration: 12,
})
expect(shortDuration).toBeCloseTo(0.196, 8)
expect(longDuration).toBeCloseTo(5.88, 8)
})
it('rejects unsupported Ark capability values before pricing', () => {
expect(() => calcVideo('doubao-seedance-1-0-lite-i2v-250428', '720p', 1, {
generationMode: 'normal',
resolution: '720p',
duration: 1,
})).toThrow('Unsupported video capability pricing')
})
it('supports minimax capability-aware video pricing', () => {
const hailuoNormal = calcVideo('minimax-hailuo-2.3', '768p', 1, {
generationMode: 'normal',
resolution: '768p',
duration: 6,
})
const hailuoFirstLast = calcVideo('minimax-hailuo-02', '768p', 1, {
generationMode: 'firstlastframe',
resolution: '768p',
duration: 10,
})
const t2v = calcVideo('t2v-01', '720p', 1, {
generationMode: 'normal',
resolution: '720p',
duration: 6,
})
expect(hailuoNormal).toBeCloseTo(2.0, 8)
expect(hailuoFirstLast).toBeCloseTo(4.0, 8)
expect(t2v).toBeCloseTo(3.0, 8)
expect(() => calcVideo('minimax-hailuo-02', '512p', 1, {
generationMode: 'firstlastframe',
resolution: '512p',
duration: 6,
})).toThrow('Unsupported video capability pricing')
})
it('prefers builtin image pricing over custom pricing when builtin exists', () => {
const builtin = calcImage('banana', 1)
const withCustom = calcImage('banana', 1, undefined, {
image: {
basePrice: 99,
},
})
expect(withCustom).toBeCloseTo(builtin, 8)
})
it('uses custom image option pricing for unknown models', () => {
const cost = calcImage(
'openai-compatible:oa-1::gpt-image-1',
2,
{
resolution: '1024x1024',
quality: 'high',
},
{
image: {
basePrice: 0.2,
optionPrices: {
resolution: {
'1024x1024': 0.05,
},
quality: {
high: 0.1,
},
},
},
},
)
expect(cost).toBeCloseTo((0.2 + 0.05 + 0.1) * 2, 8)
})
it('uses custom video option pricing for unknown models', () => {
const cost = calcVideo(
'openai-compatible:oa-1::sora-2',
'720p',
1,
{
resolution: '720x1280',
duration: 8,
},
{
video: {
basePrice: 0.8,
optionPrices: {
resolution: {
'720x1280': 0.2,
},
duration: {
'8': 0.4,
},
},
},
},
)
expect(cost).toBeCloseTo(1.4, 8)
})
it('fails explicitly when selected custom option price is missing', () => {
expect(() => calcVideo(
'openai-compatible:oa-1::sora-2',
'720p',
1,
{
resolution: '1792x1024',
},
{
video: {
optionPrices: {
resolution: {
'720x1280': 0.2,
},
},
},
},
)).toThrow('No custom video price matched')
})
it('returns deterministic fixed costs for call-based APIs', () => {
expect(calcVoiceDesign()).toBeGreaterThan(0)
expect(calcLipSync()).toBeGreaterThan(0)
expect(calcLipSync('vidu::vidu-lipsync')).toBeGreaterThan(0)
expect(calcLipSync('bailian::videoretalk')).toBeGreaterThan(0)
})
it('calculates voice costs from quantities', () => {
expect(calcVoice(30)).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
$transaction: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/logging/core', () => ({
logInfo: vi.fn(),
logError: vi.fn(),
}))
import { addBalance, recordShadowUsage } from '@/lib/billing/ledger'
function buildTxStub() {
return {
userBalance: {
upsert: vi.fn(),
},
balanceTransaction: {
findFirst: vi.fn(),
create: vi.fn(),
},
}
}
describe('billing/ledger extra', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns false when addBalance amount is invalid', async () => {
const result = await addBalance('u1', 0)
expect(result).toBe(false)
expect(prismaMock.$transaction).not.toHaveBeenCalled()
})
it('adds recharge balance with string reason', async () => {
const tx = buildTxStub()
tx.userBalance.upsert.mockResolvedValue({ balance: 8.5 })
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
await callback(tx)
})
const result = await addBalance('u1', 5, 'manual recharge')
expect(result).toBe(true)
expect(tx.balanceTransaction.findFirst).not.toHaveBeenCalled()
expect(tx.userBalance.upsert).toHaveBeenCalledTimes(1)
expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
userId: 'u1',
type: 'recharge',
amount: 5,
}),
}))
})
it('supports idempotent addBalance and short-circuits duplicate key', async () => {
const tx = buildTxStub()
tx.balanceTransaction.findFirst.mockResolvedValue({ id: 'existing_tx' })
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
await callback(tx)
})
const result = await addBalance('u1', 3, {
type: 'adjust',
reason: 'admin adjust',
idempotencyKey: 'idem_1',
operatorId: 'op_1',
externalOrderId: 'order_1',
})
expect(result).toBe(true)
expect(tx.balanceTransaction.findFirst).toHaveBeenCalledTimes(1)
expect(tx.userBalance.upsert).not.toHaveBeenCalled()
expect(tx.balanceTransaction.create).not.toHaveBeenCalled()
})
it('returns false when transaction throws in addBalance', async () => {
prismaMock.$transaction.mockRejectedValue(new Error('db error'))
const result = await addBalance('u1', 2, 'x')
expect(result).toBe(false)
})
it('records shadow usage consume log on success', async () => {
const tx = buildTxStub()
tx.userBalance.upsert.mockResolvedValue({ balance: 11.2 })
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
await callback(tx)
})
const result = await recordShadowUsage('u1', {
projectId: 'p1',
action: 'analyze',
apiType: 'text',
model: 'anthropic/claude-sonnet-4',
quantity: 1000,
unit: 'token',
cost: 0.25,
metadata: { trace: 'abc' },
})
expect(result).toBe(true)
expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
userId: 'u1',
type: 'shadow_consume',
amount: 0,
}),
}))
})
it('returns false when recordShadowUsage transaction fails', async () => {
prismaMock.$transaction.mockRejectedValue(new Error('shadow failed'))
const result = await recordShadowUsage('u1', {
projectId: 'p1',
action: 'analyze',
apiType: 'text',
model: 'anthropic/claude-sonnet-4',
quantity: 1000,
unit: 'token',
cost: 0.25,
})
expect(result).toBe(false)
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { getBillingMode, getBootBillingEnabled } from '@/lib/billing/mode'
describe('billing/mode', () => {
it('falls back to OFF when env is missing', async () => {
delete process.env.BILLING_MODE
await expect(getBillingMode()).resolves.toBe('OFF')
expect(getBootBillingEnabled()).toBe(false)
})
it('normalizes lower-case env mode', async () => {
process.env.BILLING_MODE = 'enforce'
await expect(getBillingMode()).resolves.toBe('ENFORCE')
expect(getBootBillingEnabled()).toBe(true)
})
it('falls back to OFF when env mode is invalid', async () => {
process.env.BILLING_MODE = 'invalid'
await expect(getBillingMode()).resolves.toBe('OFF')
expect(getBootBillingEnabled()).toBe(false)
})
})

View File

@@ -0,0 +1,79 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { describe, expect, it, vi } from 'vitest'
import { recordTextUsage, withTextUsageCollection } from '@/lib/billing/runtime-usage'
describe('billing/runtime-usage', () => {
it('ignores records outside of collection scope', () => {
expect(() => {
recordTextUsage({
model: 'm',
inputTokens: 10,
outputTokens: 20,
})
}).not.toThrow()
})
it('collects and normalizes token usage', async () => {
const { textUsage } = await withTextUsageCollection(async () => {
recordTextUsage({
model: 'test-model',
inputTokens: 10.9,
outputTokens: -2,
})
return { ok: true }
})
expect(textUsage).toEqual([
{
model: 'test-model',
inputTokens: 10,
outputTokens: 0,
},
])
})
it('falls back to empty usage when store is unavailable at read time', async () => {
const getStoreSpy = vi.spyOn(AsyncLocalStorage.prototype, 'getStore')
getStoreSpy.mockReturnValueOnce(undefined as never)
const payload = await withTextUsageCollection(async () => ({ ok: true }))
expect(payload).toEqual({ result: { ok: true }, textUsage: [] })
getStoreSpy.mockRestore()
})
it('normalizes NaN and zero token values to zero', async () => {
const { textUsage } = await withTextUsageCollection(async () => {
recordTextUsage({
model: 'nan-model',
inputTokens: Number.NaN,
outputTokens: 0,
})
return { ok: true }
})
expect(textUsage).toEqual([
{
model: 'nan-model',
inputTokens: 0,
outputTokens: 0,
},
])
})
it('isolates concurrent async local storage contexts', async () => {
const [left, right] = await Promise.all([
withTextUsageCollection(async () => {
recordTextUsage({ model: 'left', inputTokens: 1, outputTokens: 2 })
return 'left'
}),
withTextUsageCollection(async () => {
recordTextUsage({ model: 'right', inputTokens: 3, outputTokens: 4 })
return 'right'
}),
])
expect(left.textUsage).toEqual([{ model: 'left', inputTokens: 1, outputTokens: 2 }])
expect(right.textUsage).toEqual([{ model: 'right', inputTokens: 3, outputTokens: 4 }])
})
})

View File

@@ -0,0 +1,518 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { calcText, calcVoice } from '@/lib/billing/cost'
import type { TaskBillingInfo } from '@/lib/task/types'
const ledgerMock = vi.hoisted(() => ({
confirmChargeWithRecord: vi.fn(),
freezeBalance: vi.fn(),
getBalance: vi.fn(),
getFreezeByIdempotencyKey: vi.fn(),
increasePendingFreezeAmount: vi.fn(),
recordShadowUsage: vi.fn(),
rollbackFreeze: vi.fn(),
}))
const modeMock = vi.hoisted(() => ({
getBillingMode: vi.fn(),
}))
vi.mock('@/lib/billing/ledger', () => ledgerMock)
vi.mock('@/lib/billing/mode', () => modeMock)
import { BillingOperationError, InsufficientBalanceError } from '@/lib/billing/errors'
import {
handleBillingError,
prepareTaskBilling,
rollbackTaskBilling,
settleTaskBilling,
withTextBilling,
withVoiceBilling,
} from '@/lib/billing/service'
describe('billing/service', () => {
beforeEach(() => {
vi.clearAllMocks()
ledgerMock.confirmChargeWithRecord.mockResolvedValue(true)
ledgerMock.freezeBalance.mockResolvedValue('freeze_1')
ledgerMock.getBalance.mockResolvedValue({ balance: 0 })
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue(null)
ledgerMock.increasePendingFreezeAmount.mockResolvedValue(true)
ledgerMock.recordShadowUsage.mockResolvedValue(true)
ledgerMock.rollbackFreeze.mockResolvedValue(true)
})
it('returns raw execution result in OFF mode', async () => {
modeMock.getBillingMode.mockResolvedValue('OFF')
const result = await withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1' },
async () => ({ ok: true }),
)
expect(result).toEqual({ ok: true })
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
expect(ledgerMock.confirmChargeWithRecord).not.toHaveBeenCalled()
})
it('records shadow usage in SHADOW mode without freezing', async () => {
modeMock.getBillingMode.mockResolvedValue('SHADOW')
const result = await withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1' },
async () => ({ ok: true }),
)
expect(result).toEqual({ ok: true })
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
expect(ledgerMock.recordShadowUsage).toHaveBeenCalledTimes(1)
})
it('throws InsufficientBalanceError when ENFORCE freeze fails', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue(null)
ledgerMock.getBalance.mockResolvedValue({ balance: 0.01 })
await expect(
withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1' },
async () => ({ ok: true }),
),
).rejects.toBeInstanceOf(InsufficientBalanceError)
})
it('rolls back freeze when execution throws', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue('freeze_rollback')
await expect(
withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1' },
async () => {
throw new Error('boom')
},
),
).rejects.toThrow('boom')
expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_rollback')
})
it('expands freeze and charges actual voice usage when actual exceeds quoted', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue('freeze_voice')
await withVoiceBilling(
'u1',
5,
{ projectId: 'p1', action: 'voice_gen' },
async () => ({ actualDurationSeconds: 50 }),
)
const confirmCall = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)
expect(confirmCall).toBeTruthy()
const chargedAmount = confirmCall?.[2]?.chargedAmount as number
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
expect(chargedAmount).toBeCloseTo(calcVoice(50), 8)
})
it('fails and rolls back when overage freeze expansion cannot be covered', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue('freeze_voice_low_balance')
ledgerMock.increasePendingFreezeAmount.mockResolvedValue(false)
ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })
await expect(
withVoiceBilling(
'u1',
5,
{ projectId: 'p1', action: 'voice_gen' },
async () => ({ actualDurationSeconds: 50 }),
),
).rejects.toBeInstanceOf(InsufficientBalanceError)
expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_voice_low_balance')
})
it('rejects duplicate sync billing key when freeze is already confirmed', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({
id: 'freeze_confirmed',
userId: 'u1',
amount: 0.5,
status: 'confirmed',
})
const execute = vi.fn(async () => ({ ok: true }))
await expect(
withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1', billingKey: 'billing-key-1' },
execute,
),
).rejects.toThrow('duplicate billing request already confirmed')
expect(execute).not.toHaveBeenCalled()
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
})
it('rejects duplicate sync billing key when freeze is pending', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({
id: 'freeze_pending',
userId: 'u1',
amount: 0.5,
status: 'pending',
})
const execute = vi.fn(async () => ({ ok: true }))
await expect(
withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1', billingKey: 'billing-key-2' },
execute,
),
).rejects.toThrow('duplicate billing request is already in progress')
expect(execute).not.toHaveBeenCalled()
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
})
it('maps insufficient balance error to 402 response payload', async () => {
const response = handleBillingError(new InsufficientBalanceError(1.2, 0.3))
expect(response).toBeTruthy()
expect(response?.status).toBe(402)
const body = await response?.json()
expect(body?.code).toBe('INSUFFICIENT_BALANCE')
expect(body?.required).toBeCloseTo(1.2, 8)
expect(body?.available).toBeCloseTo(0.3, 8)
})
it('returns null for non-billing errors', () => {
expect(handleBillingError(new Error('x'))).toBeNull()
expect(handleBillingError('x')).toBeNull()
})
describe('task billing lifecycle helpers', () => {
function buildTaskInfo(overrides: Partial<Extract<TaskBillingInfo, { billable: true }>> = {}): Extract<TaskBillingInfo, { billable: true }> {
return {
billable: true,
source: 'task',
taskType: 'voice_line',
apiType: 'voice',
model: 'index-tts2',
quantity: 5,
unit: 'second',
maxFrozenCost: calcVoice(5),
action: 'voice_line_generate',
metadata: { foo: 'bar' },
...overrides,
}
}
it('prepareTaskBilling handles OFF/SHADOW/ENFORCE paths', async () => {
modeMock.getBillingMode.mockResolvedValueOnce('OFF')
const off = await prepareTaskBilling({
id: 'task_off',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo(),
})
expect((off as Extract<TaskBillingInfo, { billable: true }>).status).toBe('skipped')
modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')
const shadow = await prepareTaskBilling({
id: 'task_shadow',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo(),
})
expect((shadow as Extract<TaskBillingInfo, { billable: true }>).status).toBe('quoted')
modeMock.getBillingMode.mockResolvedValueOnce('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValueOnce('freeze_task_1')
const enforce = await prepareTaskBilling({
id: 'task_enforce',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo(),
})
const enforceInfo = enforce as Extract<TaskBillingInfo, { billable: true }>
expect(enforceInfo.status).toBe('frozen')
expect(enforceInfo.freezeId).toBe('freeze_task_1')
})
it('prepareTaskBilling tolerates unknown text model pricing in SHADOW mode', async () => {
modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')
const unknownTextInfo = buildTaskInfo({
taskType: 'story_to_script_run',
apiType: 'text',
model: 'gpt-5.2',
quantity: 2400,
unit: 'token',
maxFrozenCost: 0,
action: 'story_to_script_run',
})
const shadow = await prepareTaskBilling({
id: 'task_shadow_unknown_text_model',
userId: 'u1',
projectId: 'p1',
billingInfo: unknownTextInfo,
})
const shadowInfo = shadow as Extract<TaskBillingInfo, { billable: true }>
expect(shadowInfo.status).toBe('skipped')
expect(shadowInfo.maxFrozenCost).toBe(0)
})
it('prepareTaskBilling throws InsufficientBalanceError when ENFORCE freeze fails', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue(null)
ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })
await expect(
prepareTaskBilling({
id: 'task_no_balance',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo(),
}),
).rejects.toBeInstanceOf(InsufficientBalanceError)
})
it('settleTaskBilling handles SHADOW and non-ENFORCE snapshots', async () => {
const shadowSettled = await settleTaskBilling({
id: 'task_shadow_settle',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'SHADOW', status: 'quoted' }),
})
const shadowInfo = shadowSettled as Extract<TaskBillingInfo, { billable: true }>
expect(shadowInfo.status).toBe('settled')
expect(shadowInfo.chargedCost).toBe(0)
expect(ledgerMock.recordShadowUsage).toHaveBeenCalled()
const offSettled = await settleTaskBilling({
id: 'task_off_settle',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'OFF', status: 'quoted' }),
})
const offInfo = offSettled as Extract<TaskBillingInfo, { billable: true }>
expect(offInfo.status).toBe('settled')
expect(offInfo.chargedCost).toBe(0)
})
it('settleTaskBilling does not fail OFF snapshot when text usage model pricing is unknown', async () => {
const settled = await settleTaskBilling({
id: 'task_off_unknown_usage_model',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({
taskType: 'story_to_script_run',
apiType: 'text',
model: 'gpt-5.2',
quantity: 2400,
unit: 'token',
maxFrozenCost: 0,
action: 'story_to_script_run',
modeSnapshot: 'OFF',
status: 'quoted',
}),
}, {
textUsage: [{ model: 'gpt-5.2', inputTokens: 1200, outputTokens: 800 }],
})
const settledInfo = settled as Extract<TaskBillingInfo, { billable: true }>
expect(settledInfo.status).toBe('settled')
expect(settledInfo.chargedCost).toBe(0)
expect(ledgerMock.recordShadowUsage).not.toHaveBeenCalled()
})
it('settleTaskBilling skips SHADOW settlement when text model pricing is unknown', async () => {
const settled = await settleTaskBilling({
id: 'task_shadow_unknown_usage_model',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({
taskType: 'story_to_script_run',
apiType: 'text',
model: 'gpt-5.2',
quantity: 2400,
unit: 'token',
maxFrozenCost: 0,
action: 'story_to_script_run',
modeSnapshot: 'SHADOW',
status: 'quoted',
}),
}, {
textUsage: [{ model: 'gpt-5.2', inputTokens: 1200, outputTokens: 800 }],
})
const settledInfo = settled as Extract<TaskBillingInfo, { billable: true }>
expect(settledInfo.status).toBe('settled')
expect(settledInfo.chargedCost).toBe(0)
expect(ledgerMock.recordShadowUsage).not.toHaveBeenCalled()
})
it('settleTaskBilling handles ENFORCE success/failure branches', async () => {
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_enforce_settle',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_ok' }),
})
expect((settled as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')
const missingFreeze = await settleTaskBilling({
id: 'task_enforce_no_freeze',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: null }),
})
expect((missingFreeze as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
await expect(
settleTaskBilling({
id: 'task_enforce_confirm_fail',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_fail' }),
}),
).rejects.toThrow('confirm failed')
})
it('settleTaskBilling throws BILLING_CONFIRM_FAILED when confirm and rollback both fail', async () => {
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
await expect(
settleTaskBilling({
id: 'task_confirm_and_rollback_fail',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail_confirm' }),
}),
).rejects.toMatchObject({
name: 'BillingOperationError',
code: 'BILLING_CONFIRM_FAILED',
})
})
it('settleTaskBilling rethrows BillingOperationError with task context when rollback succeeds', async () => {
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(
new BillingOperationError(
'BILLING_INVALID_FREEZE',
'invalid freeze',
{ reason: 'status_mismatch' },
),
)
let thrown: unknown = null
try {
await settleTaskBilling({
id: 'task_confirm_billing_error',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_billing_error' }),
})
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(BillingOperationError)
const billingError = thrown as BillingOperationError
expect(billingError.code).toBe('BILLING_INVALID_FREEZE')
expect(billingError.details).toMatchObject({
reason: 'status_mismatch',
taskId: 'task_confirm_billing_error',
freezeId: 'freeze_billing_error',
})
})
it('settleTaskBilling expands freeze when actual exceeds quoted', async () => {
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_enforce_overage',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_overage', quantity: 5 }),
}, {
result: { actualDurationSeconds: 50 },
})
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
expect(ledgerMock.confirmChargeWithRecord).toHaveBeenCalled()
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(calcVoice(50), 8)
})
it('settleTaskBilling keeps quoted charge when text usage has no token counts', async () => {
const quoted = calcText('anthropic/claude-sonnet-4', 500, 500)
const textBillingInfo: Extract<TaskBillingInfo, { billable: true }> = {
billable: true,
source: 'task',
taskType: 'analyze_novel',
apiType: 'text',
model: 'anthropic/claude-sonnet-4',
quantity: 1000,
unit: 'token',
maxFrozenCost: quoted,
action: 'analyze_novel',
modeSnapshot: 'ENFORCE',
status: 'frozen',
freezeId: 'freeze_text_zero',
}
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_text_zero_usage',
userId: 'u1',
projectId: 'p1',
billingInfo: textBillingInfo,
}, {
textUsage: [{ model: 'openai/gpt-5', inputTokens: 0, outputTokens: 0 }],
})
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(quoted, 8)
const recordParams = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)?.[1] as { model: string }
expect(recordParams.model).toBe('openai/gpt-5')
})
it('rollbackTaskBilling handles success and fallback branches', async () => {
const rolledBack = await rollbackTaskBilling({
id: 'task_rb_ok',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_ok' }),
})
expect((rolledBack as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
const rollbackFailed = await rollbackTaskBilling({
id: 'task_rb_fail',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail' }),
})
expect((rollbackFailed as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
})
})
})

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { TASK_TYPE } from '@/lib/task/types'
import { buildDefaultTaskBillingInfo, isBillableTaskType } from '@/lib/billing/task-policy'
import type { TaskBillingInfo } from '@/lib/task/types'
function expectBillableInfo(info: TaskBillingInfo | null): Extract<TaskBillingInfo, { billable: true }> {
expect(info).toBeTruthy()
expect(info?.billable).toBe(true)
if (!info || !info.billable) {
throw new Error('Expected billable task billing info')
}
return info
}
describe('billing/task-policy', () => {
const billingPayload = {
analysisModel: 'anthropic/claude-sonnet-4',
imageModel: 'seedream',
videoModel: 'doubao-seedance-1-5-pro-251215',
} as const
it('builds TaskBillingInfo for every billable task type', () => {
for (const taskType of Object.values(TASK_TYPE)) {
if (!isBillableTaskType(taskType)) continue
const info = expectBillableInfo(buildDefaultTaskBillingInfo(taskType, billingPayload))
expect(info.taskType).toBe(taskType)
expect(info.maxFrozenCost).toBeGreaterThanOrEqual(0)
}
})
it('returns null for a non-billable task type', () => {
const fake = 'not_billable' as unknown as (typeof TASK_TYPE)[keyof typeof TASK_TYPE]
expect(isBillableTaskType(fake)).toBe(false)
expect(buildDefaultTaskBillingInfo(fake, {})).toBeNull()
})
it('builds text billing info from explicit model payload', () => {
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {
analysisModel: 'anthropic/claude-sonnet-4',
}))
expect(info.apiType).toBe('text')
expect(info.model).toBe('anthropic/claude-sonnet-4')
expect(info.quantity).toBe(4200)
})
it('returns null for missing required models in text/image/video tasks', () => {
expect(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {})).toBeNull()
expect(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {})).toBeNull()
expect(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {})).toBeNull()
})
it('honors candidateCount/count for image tasks', () => {
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {
candidateCount: 4,
imageModel: 'seedream4',
}))
expect(info.apiType).toBe('image')
expect(info.quantity).toBe(4)
expect(info.model).toBe('seedream4')
})
it('builds video billing info from firstLastFrame.flModel', () => {
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {
firstLastFrame: {
flModel: 'doubao-seedance-1-0-pro-250528',
},
duration: 8,
}))
expect(info.apiType).toBe('video')
expect(info.model).toBe('doubao-seedance-1-0-pro-250528')
expect(info.quantity).toBe(1)
})
it('uses explicit lip sync model from payload', () => {
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.LIP_SYNC, {
lipSyncModel: 'vidu::vidu-lipsync',
}))
expect(info.apiType).toBe('lip-sync')
expect(info.model).toBe('vidu::vidu-lipsync')
expect(info.quantity).toBe(1)
})
})