Track peak vs off-peak usage for first-party DeepSeek and Z.ai routes (#1561)

Consumption-share split in `codeburn models`, reworked per review:

- First-party routes only: the split applies to `dsh` (DeepSeek API) and
  `zcode` (Z.ai plan) calls. The same model behind a gateway never had
  the vendor discount, and reported-cost providers may already carry it;
  the gate set stays disjoint from REPORTED_COST_PROVIDERS.
- The numbers agree by construction: both legs are shares of the stored
  list-rate cost (peakUSD + offPeakUSD == costUSD, pinned in tests).
  The 0.5x off-peak discount stays on the vendors' bills; nothing is
  repriced here. peakCostMultiplier and OFF_PEAK_MULTIPLIER are gone.
- Calendars: unverified 2027 projections dropped; the Z.ai
  2026-09-25..10-07 all-day off-peak promotion is honored as a temporary
  range; deepSeekHolidayCoverageYears() plus a test fail when the
  current year falls out of the holiday list.
- Output shape: JSON carries the five peak keys only on rows with a
  split, so a report without DeepSeek/Z.ai first-party usage keeps its
  exact historical shape; the Z.ai quota note is removed.
- The `codeburn peak` live-status command is left out (scheduling tool,
  not usage tracking); it survives on a separate branch for its own PR.
This commit is contained in:
ozymandiashh 2026-09-28 19:48:38 +03:00 • committed by GitHub
parent 0259392a20
commit c166e69e46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 669 additions and 20 deletions

View file

@ -93,7 +93,9 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi
| `codeburn models --task feature` | Filter to feature-development work |
| `codeburn models --provider claude` | Filter to a single provider |
Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Mouse-wheel scrolling is off so that click and drag still selects text for copying; press `m` to turn the wheel on for the rest of the run. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
DeepSeek and GLM/Z.ai rows carry a Peak / Off-peak column when their calls have usable timestamps. The split is a consumption report, not a repricing: both amounts are shares of the stored list-rate cost, so they always add up to the Cost column. DeepSeek peak hours are Mon–Fri 01:00–04:00 and 06:00–10:00 UTC (excluding Chinese public holidays), and their bill discounts off-peak usage to 0.5x. GLM/Z.ai peak hours are Mon–Fri 14:00–18:00 Singapore time, and the 0.5x off-peak discount applies to plan credits. Only first-party routes are split (`dsh` for DeepSeek, `zcode` for Z.ai): the same model reached through a gateway does not carry the vendor discount, and provider-billed costs are left alone.
Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all columns widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Mouse-wheel scrolling is off so that click and drag still selects text for copying; press `m` to turn the wheel on for the rest of the run. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
## Your month at a glance

View file

@ -2860,6 +2860,9 @@ program
process.stdout.write(renderMarkdown(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
} else if (fmt === 'table') {
process.stdout.write(renderTable(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
if (renderRows.some(r => r.peakUSD != null || r.offPeakUSD != null)) {
process.stdout.write('Peak / Off-peak: consumption shares of the list-rate cost — DeepSeek peak hours are Mon–Fri 01:00–04:00 and 06:00–10:00 UTC (excl. Chinese public holidays), GLM/Z.ai peak hours are Mon–Fri 14:00–18:00 Singapore time. The vendors discount off-peak usage on their own bills (DeepSeek USD at 0.5x, Z.ai plan credits at 0.5x); the split only shows where usage ran. First-party routes only (dsh, zcode).\n')
}
// Never advise aliasing unconditionally: a subscription or flat-rate model
// is correctly $0, and mapping it onto another model's rate invents spend.
if (opts.unpriced) process.stdout.write(unpricedModelHint() + '\n')

View file

@ -5,6 +5,7 @@ import { behavioralCallWeight } from './behavioral-weight.js'
import { codexCredits } from './codex-credits.js'
import { formatCost, formatTokens } from './format.js'
import { billableOutputTokens, fallbackRawModelDisplayName, getModelRoute, getRouteById, getShortModelName, modelRowKey, resolveCanonicalModelId, routeSuffix, sanitizeModelForDisplay } from './models.js'
import { classifyPeak, peakBillingKind } from './peak-hours.js'
import { getProvider } from './providers/index.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
@ -45,6 +46,20 @@ export type ModelReportRow = {
/// True when `credits` is a partial sum because some contributing buckets
/// had no known credit rate.
creditsIncomplete?: boolean
/// Peak-hours split (#peak-hours): consumption shares of the stored
/// `costUSD` (list rate), never a repricing — `peakUSD + offPeakUSD ==
/// costUSD` for every split row, because the vendor's own bill applies the
/// off-peak discount. Null on rows whose route has no first-party peak
/// regime (see peak-hours.ts) or whose calls all landed in `unknown`
/// (missing/invalid timestamps) — never guessed.
peakUSD?: number | null
offPeakUSD?: number | null
peakCalls?: number | null
offPeakCalls?: number | null
/// Which vendor's windows the split ran under: 'deepseek-usd' (0.5x off-peak
/// on the DeepSeek API bill) or 'zai-credits' (0.5x off-peak in Z.ai plan
/// credits). Absent with the split.
peakKind?: 'deepseek-usd' | 'zai-credits'
topCategory?: TaskCategory
topCategoryCost?: number
topCategoryShare?: number
@ -83,6 +98,15 @@ type Bucket = {
savingsUSD: number
savingsBaselineModel: string
calls: number
/// Peak-hours split (#peak-hours): additive per-window cost/calls resolved
/// from each call's own timestamp, at the stored list rate. peakCostUSD +
/// offPeakCostUSD == costUSD for classified calls — a consumption share,
/// never a repricing (the vendor's bill applies the off-peak discount).
peakKind: 'deepseek-usd' | 'zai-credits' | null
peakCostUSD: number
offPeakCostUSD: number
peakCalls: number
offPeakCalls: number
}
type ModelKey = string
@ -121,6 +145,32 @@ function bucketKey(provider: string, model: string, route: string | null, catego
return `${provider} ${model} ${route ?? ''} ${category ?? ''} ${agentType ?? ''}`
}
/// Additive peak-hours merge (#peak-hours): buckets fold into a row only when
/// they share one canonical id, so the split sums cleanly across folded
/// aliases. A row whose calls all landed `unknown` keeps null fields (never
/// guessed); the kind is set only while the split is non-empty.
function peakSplitForRow(bucket: Bucket): Pick<ModelReportRow, 'peakUSD' | 'offPeakUSD' | 'peakCalls' | 'offPeakCalls' | 'peakKind'> {
if (!bucket.peakKind || (bucket.peakCostUSD === 0 && bucket.offPeakCostUSD === 0)) {
return { peakUSD: null, offPeakUSD: null, peakCalls: null, offPeakCalls: null }
}
return {
peakUSD: bucket.peakCostUSD,
offPeakUSD: bucket.offPeakCostUSD,
peakCalls: bucket.peakCalls,
offPeakCalls: bucket.offPeakCalls,
peakKind: bucket.peakKind,
}
}
function mergePeakSplit(row: ModelReportRow, bucket: Bucket): void {
if (!bucket.peakKind || (bucket.peakCostUSD === 0 && bucket.offPeakCostUSD === 0)) return
row.peakUSD = (row.peakUSD ?? 0) + bucket.peakCostUSD
row.offPeakUSD = (row.offPeakUSD ?? 0) + bucket.offPeakCostUSD
row.peakCalls = (row.peakCalls ?? 0) + bucket.peakCalls
row.offPeakCalls = (row.offPeakCalls ?? 0) + bucket.offPeakCalls
row.peakKind = bucket.peakKind
}
/// Walks every parsed turn, attributes each assistant call to a
/// (provider, raw-model, category, agent) bucket, then merges buckets that
/// resolve to the same provider + alias-resolved canonical id. Display names
@ -168,6 +218,11 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
savingsUSD: 0,
savingsBaselineModel: '',
calls: 0,
peakKind: peakBillingKind(provider, model),
peakCostUSD: 0,
offPeakCostUSD: 0,
peakCalls: 0,
offPeakCalls: 0,
}
buckets.set(key, bucket)
}
@ -186,7 +241,24 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
}
// Supplementary accounting calls keep their tokens and cost above but are not
// distinct requests, so they add no call weight (see behavioral-weight.ts).
bucket.calls += behavioralCallWeight(call)
const callWeight = behavioralCallWeight(call)
bucket.calls += callWeight
// Peak-hours split (#peak-hours): classify from the call's own
// timestamp, never guessed — unknown when the route has no
// first-party peak regime or the timestamp is missing/invalid.
// Both legs carry the stored list-rate cost, so the split is a
// consumption share that adds back up to costUSD; the vendor's
// bill, not this report, applies the off-peak discount.
if (bucket.peakKind) {
const peak = classifyPeak(provider, model, call.timestamp)
if (peak === 'peak') {
bucket.peakCostUSD += call.costUSD
bucket.peakCalls += callWeight
} else if (peak === 'off-peak') {
bucket.offPeakCostUSD += call.costUSD
bucket.offPeakCalls += callWeight
}
}
const modelKey = `${provider} ${model} ${route ?? ''}`
let perCat = perModelCategoryCost.get(modelKey)
@ -268,6 +340,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
existing.costUSD += bucket.costUSD
existing.savingsUSD += bucket.savingsUSD
existing.calls += bucket.calls
mergePeakSplit(existing, bucket)
existing.savingsBaselineModel = resolvedBaseline
if (!existing.rawModels.includes(bucket.model)) existing.rawModels.push(bucket.model)
const existingRated = existing.credits !== null
@ -294,6 +367,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
calls: bucket.calls,
rawModels: [bucket.model],
credits: bucketCredits,
...peakSplitForRow(bucket),
})
}
@ -363,6 +437,14 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
return filtered
}
/// One-line "peak X / off-peak Y" cell (#peak-hours): list-rate consumption
/// shares that add back up to the row's cost. '-' when the row carries no
/// split.
export function formatPeakSplit(row: Pick<ModelReportRow, 'peakUSD' | 'offPeakUSD' | 'peakKind'>): string {
if (row.peakUSD == null && row.offPeakUSD == null) return '-'
return `${formatCost(row.peakUSD ?? 0)} / ${formatCost(row.offPeakUSD ?? 0)}`
}
function visibleLength(text: string): number {
return stripAnsi(text).length
}
@ -402,7 +484,7 @@ type Column = {
/// Drop priority. 0 = always shown; higher numbers get dropped first when
/// the terminal is narrow.
priority: number
key: 'provider' | 'model' | 'task' | 'input' | 'output' | 'cacheWrite' | 'cacheRead' | 'total' | 'cost' | 'saved'
key: 'provider' | 'model' | 'task' | 'input' | 'output' | 'cacheWrite' | 'cacheRead' | 'total' | 'cost' | 'saved' | 'peak'
}
type TableRenderOptions = {
@ -427,13 +509,14 @@ const DROP_COLUMN_GROUPS: Array<Array<Column['key']>> = [
['saved'],
]
function defaultColumns(byTask: boolean, byAgent: boolean, showSaved: boolean): Column[] {
function defaultColumns(byTask: boolean, byAgent: boolean, showSaved: boolean, showPeak = false): Column[] {
// Higher priority numbers drop FIRST when the terminal is narrow.
// Cache columns are the cheapest to lose, then input/output, then top-task.
// Provider/Model/Total/Cost stay regardless. The Saved column only appears
// when local-model savings actually exist (a `codeburn model-savings`
// mapping produced nonzero avoided spend); otherwise it would be a column of
// dashes for the majority of users, so it is omitted entirely.
// dashes for the majority of users, so it is omitted entirely. Same rule
// for Peak: only when some row carries a peak/off-peak split.
// Widths are MINIMUMS; sizeColumnsToContent() expands them to fit cell text.
return [
{ key: 'provider', header: 'Provider', align: 'left', width: 8, priority: 0 },
@ -446,6 +529,7 @@ function defaultColumns(byTask: boolean, byAgent: boolean, showSaved: boolean):
{ key: 'total', header: 'Total', align: 'right', width: 6, priority: 0 },
{ key: 'cost', header: 'Cost', align: 'right', width: 6, priority: 0 },
...(showSaved ? [{ key: 'saved' as const, header: 'Saved', align: 'right' as const, width: 6, priority: 0 }] : []),
...(showPeak ? [{ key: 'peak' as const, header: 'Peak / Off-peak', align: 'right' as const, width: 15, priority: 1 }] : []),
]
}
@ -470,8 +554,8 @@ function frameWidth(columns: Column[]): number {
return 2 + columns.reduce((acc, c) => acc + c.width + 2, 0) + (columns.length - 1)
}
function chooseColumns(byTask: boolean, byAgent: boolean, available: number, showSaved: boolean): Column[] {
const all = defaultColumns(byTask, byAgent, showSaved)
function chooseColumns(byTask: boolean, byAgent: boolean, available: number, showSaved: boolean, showPeak = false): Column[] {
const all = defaultColumns(byTask, byAgent, showSaved, showPeak)
if (frameWidth(all) <= available) return all
// Drop in this order so the table degrades sensibly. Cache columns drop as
@ -578,6 +662,8 @@ export function renderTable(
const fullWidth = opts.fullWidth ?? true
// Only render the Saved column when something was actually saved.
const showSaved = rows.some(r => r.savingsUSD > 0)
// Same rule for the Peak column: only when a row carries a split.
const showPeak = rows.some(r => r.peakUSD != null || r.offPeakUSD != null)
const valueOf = (row: ModelReportRow, key: Column['key'], isNewGroup: boolean): string => {
switch (key) {
@ -596,6 +682,8 @@ export function renderTable(
case 'total': return formatTokens(row.totalTokens)
case 'cost': return formatCost(row.costUSD)
case 'saved': return row.savingsUSD > 0 ? formatCost(row.savingsUSD) : chalk.dim('-')
case 'peak':
return (row.peakUSD != null || row.offPeakUSD != null) ? formatPeakSplit(row) : chalk.dim('-')
}
}
@ -607,7 +695,7 @@ export function renderTable(
const groupKey = `${row.provider} ${row.modelDisplayName}`
const isNewGroup = !grouped || groupKey !== prevProviderModel
prevProviderModel = groupKey
const allCells = defaultColumns(byTask, byAgent, showSaved).map(col => {
const allCells = defaultColumns(byTask, byAgent, showSaved, showPeak).map(col => {
const raw = valueOf(row, col.key, isNewGroup)
if (col.key === 'provider' && raw) return chalk.dim(raw)
return raw
@ -626,11 +714,14 @@ export function renderTable(
acc.total += r.totalTokens
acc.cost += r.costUSD
acc.savings += r.savingsUSD
acc.peak += r.peakUSD ?? 0
acc.offPeak += r.offPeakUSD ?? 0
return acc
},
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 },
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0, peak: 0, offPeak: 0 },
)
const cells = defaultColumns(byTask, byAgent, showSaved).map(col => {
const totalsPeak = { peakUSD: totals.peak, offPeakUSD: totals.offPeak, peakKind: undefined } as const
const cells: string[] = defaultColumns(byTask, byAgent, showSaved, showPeak).map(col => {
switch (col.key) {
case 'provider': return ''
case 'model': return chalk.yellow.bold('Total')
@ -642,6 +733,7 @@ export function renderTable(
case 'total': return chalk.yellow.bold(formatTokens(totals.total))
case 'cost': return chalk.yellow.bold(formatCost(totals.cost))
case 'saved': return totals.savings > 0 ? chalk.yellow.bold(formatCost(totals.savings)) : chalk.dim('-')
case 'peak': return showPeak ? chalk.yellow.bold(formatPeakSplit(totalsPeak)) : chalk.dim('-')
}
})
totalsEntry = { kind: 'totals', cells, isNewGroup: true }
@ -650,9 +742,9 @@ export function renderTable(
// Pick which columns to include based on terminal width, then size them.
// We index into `cells` by the column key to avoid object-identity pitfalls
// across defaultColumns() invocations.
const allKeys = defaultColumns(byTask, byAgent, showSaved).map(c => c.key)
const allKeys = defaultColumns(byTask, byAgent, showSaved, showPeak).map(c => c.key)
const indexByKey = new Map(allKeys.map((k, i) => [k, i]))
const columns = chooseColumns(byTask, byAgent, available, showSaved)
const columns = chooseColumns(byTask, byAgent, available, showSaved, showPeak)
const projectColumns = (cols: Column[], entry: RowCells) =>
cols.map(c => entry.cells[indexByKey.get(c.key)!] ?? '')
const cellMatrix = [
@ -729,6 +821,18 @@ export function renderJson(rows: ModelReportRow[]): string {
savingsBaselineModel: r.savingsBaselineModel,
credits: r.credits,
creditsIncomplete: r.creditsIncomplete === true,
// Peak keys exist only on rows that carry a split: a report with no
// DeepSeek/Z.ai first-party usage keeps its exact historical shape,
// instead of five null keys on every row.
...(r.peakUSD != null || r.offPeakUSD != null
? {
peakUSD: r.peakUSD,
offPeakUSD: r.offPeakUSD,
peakCalls: r.peakCalls ?? null,
offPeakCalls: r.offPeakCalls ?? null,
peakKind: r.peakKind ?? null,
}
: {}),
})),
null,
2,
@ -757,8 +861,9 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
const byAgent = opts.byAgent ?? false
const showTotals = opts.showTotals ?? true
const header = ['Provider', 'Model', thirdColumnHeader(byTask, byAgent), 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved']
const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:', '---:']
const showPeak = rows.some(r => r.peakUSD != null || r.offPeakUSD != null)
const header = ['Provider', 'Model', thirdColumnHeader(byTask, byAgent), 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved', ...(showPeak ? ['Peak / Off-peak'] : [])]
const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:', '---:', ...(showPeak ? ['---:'] : [])]
const lines: string[] = []
lines.push(`| ${header.join(' | ')} |`)
@ -783,6 +888,7 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
formatTokens(row.totalTokens),
formatCost(row.costUSD),
row.savingsUSD > 0 ? formatCost(row.savingsUSD) : '-',
...(showPeak ? [formatPeakSplit(row)] : []),
]
lines.push(`| ${cells.join(' | ')} |`)
}
@ -797,9 +903,11 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
acc.total += r.totalTokens
acc.cost += r.costUSD
acc.savings += r.savingsUSD
acc.peak += r.peakUSD ?? 0
acc.offPeak += r.offPeakUSD ?? 0
return acc
},
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 },
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0, peak: 0, offPeak: 0 },
)
const totalCells = [
'',
@ -812,6 +920,7 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean;
`**${formatTokens(totals.total)}**`,
`**${formatCost(totals.cost)}**`,
totals.savings > 0 ? `**${formatCost(totals.savings)}**` : '-',
...(showPeak ? [`**${formatPeakSplit({ peakUSD: totals.peak, offPeakUSD: totals.offPeak })}**`] : []),
]
lines.push(`| ${totalCells.join(' | ')} |`)
}
@ -824,12 +933,27 @@ export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean; byAg
const byAgent = opts.byAgent ?? false
// CSV intentionally repeats provider/model on every row so downstream
// consumers can sort/filter without first reconstructing the grouping.
// Peak columns are appended only when a row carries a split, so existing
// consumers never see new columns on data that has no peak-hours content.
const showPeak = rows.some(r => r.peakUSD != null || r.offPeakUSD != null)
const peakHeader = ['peak_usd', 'off_peak_usd', 'peak_calls', 'off_peak_calls', 'peak_kind']
const header = byAgent
? ['provider', 'model', 'agent', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model']
? ['provider', 'model', 'agent', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model', ...(showPeak ? peakHeader : [])]
: byTask
? ['provider', 'model', 'task', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model']
: ['provider', 'model', 'top_task', 'top_task_share', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model']
? ['provider', 'model', 'task', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model', ...(showPeak ? peakHeader : [])]
: ['provider', 'model', 'top_task', 'top_task_share', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model', ...(showPeak ? peakHeader : [])]
const lines: string[] = [header.join(',')]
// Peak cells appended only with the peak header, so rows without a split
// keep the exact historical shape.
const peakCells = (r: ModelReportRow): string[] => showPeak
? [
r.peakUSD != null ? r.peakUSD.toFixed(6) : '',
r.offPeakUSD != null ? r.offPeakUSD.toFixed(6) : '',
r.peakCalls != null ? String(r.peakCalls) : '',
r.offPeakCalls != null ? String(r.offPeakCalls) : '',
r.peakKind ?? '',
]
: []
for (const r of rows) {
const cells = byAgent
? [
@ -845,6 +969,7 @@ export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean; byAg
r.costUSD.toFixed(6),
(r.savingsUSD ?? 0).toFixed(6),
csvEscape(r.savingsBaselineModel),
...peakCells(r),
]
: byTask
? [
@ -860,6 +985,7 @@ export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean; byAg
r.costUSD.toFixed(6),
(r.savingsUSD ?? 0).toFixed(6),
csvEscape(r.savingsBaselineModel),
...peakCells(r),
]
: [
csvEscape(r.providerDisplayName),
@ -875,6 +1001,7 @@ export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean; byAg
r.costUSD.toFixed(6),
(r.savingsUSD ?? 0).toFixed(6),
csvEscape(r.savingsBaselineModel),
...peakCells(r),
]
lines.push(cells.join(','))
}

189
src/peak-hours.ts Normal file
View file

@ -0,0 +1,189 @@
// Peak-hours billing windows for vendors that price peak vs off-peak usage.
//
// Sources (checked 2026-09-25):
// - DeepSeek API docs (api-docs.deepseek.com/quick_start/pricing): off-peak
// rates are half (0.5x) of the peak (list) rates. Peak hours are 01:00-04:00
// and 06:00-10:00 UTC, Monday-Friday, excluding Chinese public holidays.
// Everything else (nights, weekends, CN holidays) is off-peak.
// - Z.ai GLM Coding Plan (docs.z.ai/devpack/overview): off-peak usage is
// charged at 50% of the standard credit rate. Peak hours are Monday-Friday
// 14:00-18:00 Singapore time (SGT = UTC+8). The discount applies to plan
// CREDITS, not to USD pricing.
//
// Design: this module only classifies a call's timestamp into
// peak/off-peak/unknown. Costs are never repriced here — the report splits
// the stored list-rate cost between the windows, so the legs always add back
// up to the Cost column. Repricing (DeepSeek's 0.5x bill) is the vendor's
// job; showing guessed dollars here would double-discount routes whose cost
// was already reported with the discount applied.
//
// Classification is restricted to first-party routes: the vendor's discount
// only exists where the vendor bills directly. The same model reached through
// a gateway (OpenRouter, vercel-gateway, Bedrock routes, ...) pays the
// gateway's uniform price, and providers in REPORTED_COST_PROVIDERS (or calls
// with `costFromBilling`) may already carry the discount in their reported
// cost. The first-party set below must therefore stay disjoint from
// REPORTED_COST_PROVIDERS; a provider joining that set can never appear here.
import { resolveCanonicalModelId } from './models.js'
/// How a route's vendor treats peak-hour usage.
export type PeakBillingKind =
/// DeepSeek API: 0.5x USD bill during off-peak hours.
| 'deepseek-usd'
/// Z.ai coding plan: 0.5x plan-credit rate off-peak; USD pricing untouched.
| 'zai-credits'
export type PeakClass = 'peak' | 'off-peak' | 'unknown'
// Chinese public holidays (observed dates, YYYY-MM-DD), verified against the
// State Council's announcements. Only the date matters: any DeepSeek call
// landing on one of these days is off-peak for the whole day. Years are NOT
// projected ahead — future holiday dates would be guesses; a year not covered
// here treats weekdays as peak (never underestimates cost), and
// deepSeekHolidayCoverageYears() lets a test fail the moment the current
// year drops out of the list.
const CHINESE_PUBLIC_HOLIDAYS: ReadonlySet<string> = new Set([
// 2025: New Year 01-01; Spring Festival 01-28..02-04; Qingming 04-04;
// Labour Day 05-01..05-05; Dragon Boat 05-31..06-02;
// Mid-Autumn + National Day 10-01..10-08.
'2025-01-01',
'2025-01-28', '2025-01-29', '2025-01-30', '2025-01-31',
'2025-02-01', '2025-02-02', '2025-02-03', '2025-02-04',
'2025-04-04',
'2025-05-01', '2025-05-02', '2025-05-03', '2025-05-04', '2025-05-05',
'2025-05-31', '2025-06-01', '2025-06-02',
'2025-10-01', '2025-10-02', '2025-10-03', '2025-10-04',
'2025-10-05', '2025-10-06', '2025-10-07', '2025-10-08',
// 2026: New Year 01-01; Spring Festival 02-16..02-22 (eve 02-16);
// Qingming 04-06; Labour Day 05-01..05-05; Dragon Boat 06-19;
// Mid-Autumn 09-25; National Day 10-01..10-07.
'2026-01-01',
'2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19',
'2026-02-20', '2026-02-21', '2026-02-22',
'2026-04-06',
'2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04', '2026-05-05',
'2026-06-19',
'2026-09-25',
'2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
'2026-10-05', '2026-10-06', '2026-10-07',
])
/// Years the DeepSeek holiday calendar above covers. A test pins the current
/// year to this set so the calendar cannot silently go stale.
export function deepSeekHolidayCoverageYears(): ReadonlySet<number> {
const years = new Set<number>()
for (const key of CHINESE_PUBLIC_HOLIDAYS) years.add(Number(key.slice(0, 4)))
return years
}
// Z.ai promotional all-day off-peak windows as [start, end] SGT calendar
// dates, both inclusive (docs.z.ai announcements). Unlike the DeepSeek
// holiday calendar these are temporary: when a range passes, it simply stops
// matching — nothing extrapolates it, and no new entry is owed.
// 2026-09-25..2026-10-07: Mid-Autumn + National Day promotion; every hour of
// the plan is off-peak.
const ZAI_OFF_PEAK_PROMOTIONS: ReadonlyArray<readonly [string, string]> = [
['2026-09-25', '2026-10-07'],
]
function utcDateKey(date: Date): string {
const y = date.getUTCFullYear()
const m = String(date.getUTCMonth() + 1).padStart(2, '0')
const d = String(date.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function isWeekdayUtc(date: Date): boolean {
const day = date.getUTCDay()
return day >= 1 && day <= 5
}
/// True when the instant falls in a DeepSeek peak window: Mon-Fri 01:00-04:00
/// or 06:00-10:00 UTC, excluding Chinese public holidays (full days off-peak).
export function isDeepSeekPeak(instant: Date): boolean {
if (!isWeekdayUtc(instant)) return false
if (CHINESE_PUBLIC_HOLIDAYS.has(utcDateKey(instant))) return false
const minutes = instant.getUTCHours() * 60 + instant.getUTCMinutes()
return (minutes >= 60 && minutes < 240) || (minutes >= 360 && minutes < 600)
}
/// True when the instant falls in a Z.ai peak window: Mon-Fri 14:00-18:00
/// Singapore time. SGT is UTC+8 with no DST, so 14:00-18:00 SGT is exactly
/// 06:00-10:00 UTC every day — no timezone database needed. Promotional
/// all-day off-peak windows override the weekly schedule.
export function isZaiPeak(instant: Date): boolean {
// The promotion runs on Z.ai's own calendar days (SGT), so read the date
// key from UTC+8, not UTC.
const sgtKey = utcDateKey(new Date(instant.getTime() + 8 * 3_600_000))
if (ZAI_OFF_PEAK_PROMOTIONS.some(([start, end]) => sgtKey >= start && sgtKey <= end)) return false
if (!isWeekdayUtc(instant)) return false
const minutes = instant.getUTCHours() * 60 + instant.getUTCMinutes()
return minutes >= 360 && minutes < 600
}
/// Providers whose calls bill directly with the vendor and therefore carry
/// its peak/off-peak regime. Everything else — gateways, resellers, tools
/// that merely serve the same models — must stay out: their price is uniform
/// across the day (or already discounted in a reported cost), so splitting
/// their usage would misattribute a discount they never had. Keep disjoint
/// from REPORTED_COST_PROVIDERS in src/parser.ts.
const FIRST_PARTY_PEAK_PROVIDERS: ReadonlyMap<string, PeakBillingKind> = new Map([
// DeepSeek Harness: DeepSeek's own CLI, billed on the DeepSeek API account.
['dsh', 'deepseek-usd'],
// ZCode: the GLM Coding Plan client, billed in Z.ai plan credits.
['zcode', 'zai-credits'],
])
function canonicalLeaf(model: string): string {
return resolveCanonicalModelId(model).toLowerCase()
}
const DEEPSEEK_PEAK_MODEL_PATTERNS = [
/^deepseek-chat/,
/^deepseek-reasoner/,
/^deepseek-v4/,
/^deepseek-flash/,
/^deepseek-v3/,
/^deepseek-r1/,
/^deepseek-coder/,
]
const ZAI_PEAK_MODEL_PATTERNS = [
/^glm-/,
/^z-ai\//,
/^zai\./,
/^zhipu\//,
]
/// Which peak-billing regime a call falls under, if any: the provider must
/// bill first-party with the vendor, and the model must be one of the
/// vendor's. Gateway prefixes (cliproxy/, orcarouter/, ...) resolve through
/// the canonical id first, but a gateway provider still fails the gate.
export function peakBillingKind(provider: string, model: string): PeakBillingKind | null {
const kind = FIRST_PARTY_PEAK_PROVIDERS.get(provider)
if (!kind) return null
if (!model || typeof model !== 'string') return null
const leaf = canonicalLeaf(model)
const patterns = kind === 'deepseek-usd' ? DEEPSEEK_PEAK_MODEL_PATTERNS : ZAI_PEAK_MODEL_PATTERNS
return patterns.some(pattern => pattern.test(leaf)) ? kind : null
}
function parseInstant(timestamp: string | undefined): Date | null {
if (!timestamp || typeof timestamp !== 'string') return null
const time = Date.parse(timestamp)
if (!Number.isFinite(time)) return null
return new Date(time)
}
/// Classify one call's timestamp for its route's peak regime.
/// 'unknown' when the route has no peak pricing or the timestamp is
/// missing/invalid — never guessed.
export function classifyPeak(provider: string, model: string, timestamp: string | undefined): PeakClass {
const kind = peakBillingKind(provider, model)
if (!kind) return 'unknown'
const instant = parseInstant(timestamp)
if (!instant) return 'unknown'
const peak = kind === 'deepseek-usd' ? isDeepSeekPeak(instant) : isZaiPeak(instant)
return peak ? 'peak' : 'off-peak'
}

View file

@ -0,0 +1,99 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
// End-to-end proof for the peak-hours split: two DSH DeepSeek sessions, one
// stamped in a peak window (Mon 2026-09-28 02:00 UTC) and one off-peak
// (same day 12:00 UTC). `codeburn models --format json` must report one
// deepseek-chat row whose peakUSD/offPeakUSD split the stored list-rate cost.
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
DSH_HOME: join(home, '.dsh'),
HOME: home, USERPROFILE: home,
TZ: 'UTC',
CODEBURN_PRICING_SNAPSHOT_ONLY: '1',
},
encoding: 'utf-8',
timeout: 60_000,
})
}
function header(id: string, createdAt: number): string {
return JSON.stringify({
type: 'session',
version: 0,
id,
createdAt,
cwd: '/tmp/peak-proof',
delegationDepth: 0,
})
}
function reqHeader(model: string, time: number): string {
return JSON.stringify({
type: 'request/header',
seq: 10,
time,
data: { header: { config: { provider: 'deepseek-official', model } } },
})
}
function assistantMsg(turn: number, usage: Record<string, number>, time: number): string {
return JSON.stringify({
type: 'assistant/message',
seq: 4,
time,
data: {
turn,
step: 0,
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
usage,
},
})
}
describe('CLI peak-hours split (DeepSeek)', () => {
it('reports peakUSD/offPeakUSD on the deepseek-chat row', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-peak-cli-'))
try {
const usage = { inputTokens: 1_000_000, outputTokens: 100_000 }
// Past Monday 2026-09-21: 02:00 UTC = peak, 12:00 UTC = off-peak.
// Past dates because `models --period all` covers history, not the future.
const peakMs = Date.parse('2026-09-21T02:00:00Z')
const offPeakMs = Date.parse('2026-09-21T12:00:00Z')
for (const [name, at] of [['peak', peakMs], ['offpeak', offPeakMs]] as const) {
const dir = join(home, '.dsh', 'sessions', 'proj', name)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'session.jsonl'), [
header(`session-${name}`, at),
reqHeader('deepseek-chat', at),
assistantMsg(1, usage, at),
].join('\n') + '\n')
}
const result = runCli(['models', '--provider', 'dsh', '--format', 'json', '--period', 'all'], home)
expect(result.status).toBe(0)
const rows = JSON.parse(result.stdout) as Array<Record<string, unknown>>
const chat = rows.find(r => String(r['model']).includes('deepseek-chat'))
expect(chat).toBeDefined()
// Snapshot list rate: input 2.8e-7, output 4.2e-7 per token.
// One session = 1M in + 100k out = $0.28 + $0.042 = $0.322 list.
expect(chat!['costUSD']).toBeCloseTo(0.644, 6)
// Consumption shares of the list-rate cost: they add back up to
// costUSD. DeepSeek's own bill applies the 0.5x off-peak discount.
expect(chat!['peakUSD']).toBeCloseTo(0.322, 6)
expect(chat!['offPeakUSD']).toBeCloseTo(0.322, 6)
expect(chat!['peakUSD'] as number + (chat!['offPeakUSD'] as number)).toBeCloseTo(chat!['costUSD'] as number, 6)
expect(chat!['peakKind']).toBe('deepseek-usd')
} finally {
await rm(home, { recursive: true, force: true })
}
})
})

View file

@ -41,6 +41,7 @@ function makeCall(opts: {
cacheRead?: number
savingsUSD?: number
savingsBaselineModel?: string
timestamp?: string
}): ParsedApiCall {
return {
provider: opts.provider,
@ -60,9 +61,9 @@ function makeCall(opts: {
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-05-09T00:00:00.000Z',
timestamp: opts.timestamp ?? '2026-05-09T00:00:00.000Z',
bashCommands: [],
deduplicationKey: `${opts.provider}-${opts.model}-${opts.costUSD}`,
deduplicationKey: `${opts.provider}-${opts.model}-${opts.costUSD}-${opts.timestamp ?? 'default'}`,
savingsUSD: opts.savingsUSD,
savingsBaselineModel: opts.savingsBaselineModel,
}
@ -200,6 +201,86 @@ describe('aggregateModels', () => {
}
})
it('splits DeepSeek usage into peak and off-peak consumption shares', async () => {
const rows = await aggregateModels([makeProject([
// Monday 2026-09-28: 02:00 UTC = peak, 12:00 UTC = off-peak.
makeTurn('feature', [
makeCall({ provider: 'dsh', model: 'deepseek-chat', input: 1000, output: 100, costUSD: 2, timestamp: '2026-09-28T02:00:00Z' }),
]),
makeTurn('feature', [
makeCall({ provider: 'dsh', model: 'deepseek-chat', input: 1000, output: 100, costUSD: 4, timestamp: '2026-09-28T12:00:00Z' }),
]),
])])
expect(rows).toHaveLength(1)
const row = rows[0]!
// Shares of the stored list-rate cost: they add back up to costUSD. The
// 0.5x off-peak discount lives on DeepSeek's bill, not in this report.
expect(row.costUSD).toBeCloseTo(6, 6)
expect(row.peakUSD).toBeCloseTo(2, 6)
expect(row.offPeakUSD).toBeCloseTo(4, 6)
expect(row.peakKind).toBe('deepseek-usd')
expect(row.peakCalls).toBe(1)
expect(row.offPeakCalls).toBe(1)
const parsed = JSON.parse(renderJson(rows))
expect(parsed[0].peakUSD).toBeCloseTo(2, 6)
expect(parsed[0].offPeakUSD).toBeCloseTo(4, 6)
expect(parsed[0].peakKind).toBe('deepseek-usd')
})
it('splits GLM usage as a consumption share without repricing dollars', async () => {
const rows = await aggregateModels([makeProject([
// Monday 2026-09-21 (outside the Sep 25 - Oct 7 promotion):
// 08:00 UTC = peak, 12:00 UTC = off-peak.
makeTurn('feature', [
makeCall({ provider: 'zcode', model: 'glm-5.3', input: 1000, output: 100, costUSD: 3, timestamp: '2026-09-21T08:00:00Z' }),
]),
makeTurn('feature', [
makeCall({ provider: 'zcode', model: 'glm-5.3', input: 1000, output: 100, costUSD: 5, timestamp: '2026-09-21T12:00:00Z' }),
]),
])])
expect(rows).toHaveLength(1)
const row = rows[0]!
expect(row.costUSD).toBeCloseTo(8, 6)
expect(row.peakUSD).toBeCloseTo(3, 6)
expect(row.offPeakUSD).toBeCloseTo(5, 6)
expect(row.peakKind).toBe('zai-credits')
})
it('does not split the same model reached through a gateway', async () => {
const rows = await aggregateModels([makeProject([
makeTurn('feature', [
makeCall({ provider: 'openrouter', model: 'deepseek/deepseek-chat', input: 1000, output: 100, costUSD: 2, timestamp: '2026-09-28T02:00:00Z' }),
]),
makeTurn('feature', [
makeCall({ provider: 'vercel-gateway', model: 'glm-5.3', input: 1000, output: 100, costUSD: 3, timestamp: '2026-09-28T02:00:00Z' }),
]),
])])
expect(rows).toHaveLength(2)
// Gateways pay a uniform price across the day; no vendor discount to
// attribute, so no split at all.
for (const row of rows) {
expect(row.peakUSD).toBeNull()
expect(row.offPeakUSD).toBeNull()
expect(row.peakKind).toBeUndefined()
}
})
it('leaves rows without a peak regime or timestamp unsplit', async () => {
const rows = await aggregateModels([makeProject([
makeTurn('feature', [
makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 100, output: 10, costUSD: 1, timestamp: '2026-09-28T02:00:00Z' }),
]),
])])
expect(rows).toHaveLength(1)
expect(rows[0]!.peakUSD).toBeNull()
expect(rows[0]!.offPeakUSD).toBeNull()
expect(rows[0]!.peakKind).toBeUndefined()
// JSON omits the peak keys entirely on rows without a split.
const parsed = JSON.parse(renderJson(rows))
expect(parsed[0]).not.toHaveProperty('peakUSD')
expect(parsed[0]).not.toHaveProperty('peakKind')
})
it('includes credits in the JSON output', async () => {
const rows = await aggregateModels([makeProject([
makeTurn('feature', [

148
tests/peak-hours.test.ts Normal file
View file

@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import {
classifyPeak,
deepSeekHolidayCoverageYears,
isDeepSeekPeak,
isZaiPeak,
peakBillingKind,
} from '../src/peak-hours.js'
const at = (iso: string): Date => new Date(iso)
describe('isDeepSeekPeak', () => {
it('is peak on a weekday inside 01:00-04:00 UTC', () => {
// Monday 2026-09-28.
expect(isDeepSeekPeak(at('2026-09-28T01:00:00Z'))).toBe(true)
expect(isDeepSeekPeak(at('2026-09-28T02:30:00Z'))).toBe(true)
expect(isDeepSeekPeak(at('2026-09-28T03:59:59Z'))).toBe(true)
})
it('is peak on a weekday inside 06:00-10:00 UTC', () => {
expect(isDeepSeekPeak(at('2026-09-28T06:00:00Z'))).toBe(true)
expect(isDeepSeekPeak(at('2026-09-28T09:59:59Z'))).toBe(true)
})
it('is off-peak in the gaps between windows', () => {
expect(isDeepSeekPeak(at('2026-09-28T00:59:59Z'))).toBe(false)
expect(isDeepSeekPeak(at('2026-09-28T04:00:00Z'))).toBe(false)
expect(isDeepSeekPeak(at('2026-09-28T05:30:00Z'))).toBe(false)
expect(isDeepSeekPeak(at('2026-09-28T10:00:00Z'))).toBe(false)
expect(isDeepSeekPeak(at('2026-09-28T23:00:00Z'))).toBe(false)
})
it('is off-peak on weekends', () => {
// Saturday 2026-09-26, Sunday 2026-09-27.
expect(isDeepSeekPeak(at('2026-09-26T02:00:00Z'))).toBe(false)
expect(isDeepSeekPeak(at('2026-09-27T08:00:00Z'))).toBe(false)
})
it('is off-peak on Chinese public holidays', () => {
// Mid-Autumn 2026-09-25 is a Friday; National Day 2026-10-01 is a Thursday.
expect(isDeepSeekPeak(at('2026-09-25T02:00:00Z'))).toBe(false)
expect(isDeepSeekPeak(at('2026-10-01T08:00:00Z'))).toBe(false)
})
})
describe('isZaiPeak', () => {
// Monday 2026-09-21 sits outside the 2026-09-25..2026-10-07 promotion, so
// these pin the plain weekly window.
it('is peak Mon-Fri 14:00-18:00 SGT (06:00-10:00 UTC)', () => {
expect(isZaiPeak(at('2026-09-21T06:00:00Z'))).toBe(true)
expect(isZaiPeak(at('2026-09-21T09:59:59Z'))).toBe(true)
})
it('is off-peak outside the SGT window', () => {
expect(isZaiPeak(at('2026-09-21T05:59:59Z'))).toBe(false)
expect(isZaiPeak(at('2026-09-21T10:00:00Z'))).toBe(false)
expect(isZaiPeak(at('2026-09-21T02:00:00Z'))).toBe(false)
})
it('is off-peak on weekends (no holiday exception)', () => {
expect(isZaiPeak(at('2026-09-19T08:00:00Z'))).toBe(false)
})
it('treats the 2026-09-25..10-07 promotion as all-day off-peak', () => {
// Monday 2026-09-28 and Wednesday 2026-10-07 are weekdays inside the
// 06:00-10:00 UTC window, yet the promotion makes them off-peak.
expect(isZaiPeak(at('2026-09-28T08:00:00Z'))).toBe(false)
expect(isZaiPeak(at('2026-10-07T07:00:00Z'))).toBe(false)
// The Thursday after the promotion ends goes back to the weekly window.
expect(isZaiPeak(at('2026-10-08T07:00:00Z'))).toBe(true)
})
})
describe('deepSeekHolidayCoverageYears', () => {
it('covers the current year', () => {
// Fails the moment the calendar goes stale: a new year needs its verified
// holiday dates added (never projected ahead).
expect(deepSeekHolidayCoverageYears().has(new Date().getUTCFullYear())).toBe(true)
})
it('does not project future years', () => {
expect(deepSeekHolidayCoverageYears().has(2027)).toBe(false)
})
})
describe('peakBillingKind', () => {
it('maps first-party DeepSeek routes to USD peak billing', () => {
expect(peakBillingKind('dsh', 'deepseek-chat')).toBe('deepseek-usd')
expect(peakBillingKind('dsh', 'deepseek/deepseek-chat')).toBe('deepseek-usd')
expect(peakBillingKind('dsh', 'deepseek-reasoner')).toBe('deepseek-usd')
expect(peakBillingKind('dsh', 'deepseek-v4-pro')).toBe('deepseek-usd')
expect(peakBillingKind('dsh', 'deepseek-flash')).toBe('deepseek-usd')
})
it('maps first-party Z.ai routes to credit peak billing', () => {
expect(peakBillingKind('zcode', 'glm-5.3')).toBe('zai-credits')
expect(peakBillingKind('zcode', 'GLM-5.2')).toBe('zai-credits')
expect(peakBillingKind('zcode', 'z-ai/glm-5.2')).toBe('zai-credits')
expect(peakBillingKind('zcode', 'cliproxy/zcode/glm-5.3-flash')).toBe('zai-credits')
})
it('returns null through gateways, resellers and other providers', () => {
// The same model behind a gateway pays the gateway's uniform price — no
// vendor discount to attribute.
expect(peakBillingKind('openrouter', 'deepseek/deepseek-chat')).toBe(null)
expect(peakBillingKind('openrouter', 'z-ai/glm-5.2')).toBe(null)
expect(peakBillingKind('vercel-gateway', 'glm-5.3')).toBe(null)
expect(peakBillingKind('zerostack', 'deepseek/deepseek-v4-pro')).toBe(null)
expect(peakBillingKind('claude', 'glm-5.3')).toBe(null)
})
it('returns null when provider and model vendor disagree', () => {
expect(peakBillingKind('dsh', 'glm-5.3')).toBe(null)
expect(peakBillingKind('zcode', 'deepseek-chat')).toBe(null)
})
it('returns null for unrelated models and junk', () => {
expect(peakBillingKind('dsh', 'claude-sonnet-4-6')).toBe(null)
expect(peakBillingKind('zcode', 'gpt-5.5')).toBe(null)
expect(peakBillingKind('dsh', '')).toBe(null)
})
})
describe('classifyPeak', () => {
it('classifies a first-party DeepSeek call by its timestamp', () => {
expect(classifyPeak('dsh', 'deepseek-chat', '2026-09-28T02:00:00Z')).toBe('peak')
expect(classifyPeak('dsh', 'deepseek-chat', '2026-09-28T12:00:00Z')).toBe('off-peak')
})
it('classifies a first-party GLM call by the Z.ai window', () => {
// 2026-09-21 is outside the Sep 25 - Oct 7 promotion.
expect(classifyPeak('zcode', 'glm-5.3', '2026-09-21T08:00:00Z')).toBe('peak')
expect(classifyPeak('zcode', 'glm-5.3', '2026-09-21T12:00:00Z')).toBe('off-peak')
})
it('returns unknown through gateways and provider/model mismatches', () => {
expect(classifyPeak('openrouter', 'deepseek/deepseek-chat', '2026-09-28T02:00:00Z')).toBe('unknown')
expect(classifyPeak('vercel-gateway', 'glm-5.3', '2026-09-21T08:00:00Z')).toBe('unknown')
expect(classifyPeak('dsh', 'glm-5.3', '2026-09-28T02:00:00Z')).toBe('unknown')
})
it('returns unknown without a model match or timestamp', () => {
expect(classifyPeak('dsh', 'gpt-5.5', '2026-09-28T02:00:00Z')).toBe('unknown')
expect(classifyPeak('dsh', 'deepseek-chat', undefined)).toBe('unknown')
expect(classifyPeak('dsh', 'deepseek-chat', 'not-a-date')).toBe('unknown')
})
})