/** * Pure pricing core: the DeepSeek peak/off-peak table, the Beijing-time window * rule, and token-bucket pricing. No React, no DOM, no host services — the * host half, the browser half, and the tests all read this one module. * * @module dsh-peak-pricing/pricing */ /** Per-million-token prices (CNY) for one model in one pricing period. */ export interface PeriodRates { /** 百万tokens输入(缓存命中). */ cacheHit: number /** 百万tokens输入(缓存未命中). */ cacheMiss: number /** 百万tokens输出. */ output: number } /** The two periods one model is priced in. */ export interface ModelRates { peak: PeriodRates offPeak: PeriodRates } /** Which half of the day a moment falls in. */ export type Period = 'peak' | 'offPeak' /** * DeepSeek's announced time-of-day price table (CNY, peak/off-peak split) — * see the official price page, * https://api-docs.deepseek.com/zh-cn/quick_start/pricing/. * Off-peak is exactly half of peak for every bucket, but both halves are * spelled out so a future announcement that breaks that symmetry is a data * edit rather than a code change. Models billed in other currencies or * without a day/night split live in `FLAT_PRICE_TABLE`. */ export const PRICE_TABLE: Readonly> = Object.freeze({ 'deepseek-v4-flash': { offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 }, peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }, }, 'deepseek-v4-pro': { offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 }, peak: { cacheHit: 0.3, cacheMiss: 9.0, output: 27.0 }, }, // Same rates as flash. Images sent to the vision model are converted to // tokens by size and billed with text input, so they arrive in the same // usage buckets and need no special handling here. 'deepseek-v4-flash-vision-exp': { offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 }, peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }, }, }) /** * Peak windows as [startMinute, endMinute) pairs of the Beijing-time day: * 9:00-12:00 and 14:00-18:00, Monday through Friday only. Everything else — * nights, and all day Saturday and Sunday — is off-peak. */ export const PEAK_WINDOWS: readonly (readonly [number, number])[] = Object.freeze([ Object.freeze([9 * 60, 12 * 60] as const), Object.freeze([14 * 60, 18 * 60] as const), ]) /** Weekdays whose daytime windows are peak, on the Monday = 0 … Sunday = 6 scale. */ const PEAK_WEEKDAYS: ReadonlySet = new Set([0, 1, 2, 3, 4]) const MS_PER_DAY = 86_400_000 const MS_PER_MINUTE = 60_000 /** Beijing is UTC+8 year-round — the PRC observes no daylight saving. */ const BEIJING_OFFSET_MS = 8 * 60 * MS_PER_MINUTE /** * Weekday of a Beijing-day index counted from the epoch, Monday = 0 … Sunday = 6. * Day 0 of the epoch (1970-01-01) was a Thursday — weekday 3 on this scale. */ function weekdayOf(dayIndex: number): number { return (((dayIndex + 3) % 7) + 7) % 7 } /** * Milliseconds elapsed in the current Beijing-time day. * Derived from UTC alone, so the answer is identical whatever timezone the * browser is in. * @param now - the moment to place. * @returns milliseconds since Beijing midnight, in `[0, 86400000)`. */ export function beijingMsOfDay(now: Date): number { return (now.getTime() + BEIJING_OFFSET_MS) % MS_PER_DAY } /** * Beijing-time weekday of a moment. * Derived from UTC alone, like `beijingMsOfDay`, so the answer is identical * whatever timezone the browser is in. * @param now - the moment to place. * @returns `0` (Monday) through `6` (Sunday). */ export function beijingWeekday(now: Date): number { return weekdayOf(Math.floor((now.getTime() + BEIJING_OFFSET_MS) / MS_PER_DAY)) } /** * Which pricing period a moment falls in. * * Peak needs BOTH a peak weekday and a peak window: Saturday 10:00 sits inside * a window's hours but outside peak, because the windows run Monday–Friday. * @param now - the moment to classify. * @returns `'peak'` inside a weekday peak window, `'offPeak'` otherwise. */ export function periodAt(now: Date): Period { if (!PEAK_WEEKDAYS.has(beijingWeekday(now))) return 'offPeak' const minute = beijingMsOfDay(now) / MS_PER_MINUTE const inPeak = PEAK_WINDOWS.some(([start, end]) => minute >= start && minute < end) return inPeak ? 'peak' : 'offPeak' } /** * Time left before the price changes. * * Edges are the four window boundaries of each peak weekday. After Friday * 18:00 there is no edge until Monday 09:00 — the whole weekend prices as * off-peak — so the scan walks forward over whole Beijing days until it finds * a peak weekday with a future edge. Fifteen days bounds any wait (two full * weekends plus slack); the trailing fallback is unreachable. * @param now - the moment to measure from. * @returns milliseconds until the next window edge (always positive). */ export function msUntilNextSwitch(now: Date): number { const t = now.getTime() const today = Math.floor((t + BEIJING_OFFSET_MS) / MS_PER_DAY) for (let day = today; day < today + 15; day++) { if (!PEAK_WEEKDAYS.has(weekdayOf(day))) continue const midnightMs = day * MS_PER_DAY - BEIJING_OFFSET_MS for (const [start, end] of PEAK_WINDOWS) { const startMs = midnightMs + start * MS_PER_MINUTE if (startMs > t) return startMs - t const endMs = midnightMs + end * MS_PER_MINUTE if (endMs > t) return endMs - t } } return MS_PER_DAY } /** * Provider-reported usage, in the four disjoint buckets the token-meter * projection publishes. */ export interface UsageBuckets { uncachedInputTokens: number outputTokens: number cacheReadTokens: number cacheWriteTokens: number } /** A usage reading with every bucket at zero. */ export const ZERO_USAGE: Readonly = Object.freeze({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, }) /** * The currency a model bills in. Subtotals are kept PER CURRENCY and never * summed: a session that mixes DeepSeek (CNY) with an USD-priced model reads * `¥1.69 + $0.02`, not one meaningless number. */ export type Currency = 'CNY' | 'USD' /** Fixed display order for mixed-currency readouts (deterministic joins). */ export const CURRENCY_ORDER: readonly Currency[] = Object.freeze(['CNY', 'USD']) const CURRENCY_SYMBOLS: Readonly> = Object.freeze({ CNY: '¥', USD: '$' }) /** * The currency's usual leading symbol, e.g. `¥` for CNY and `$` for USD. * @param currency - which symbol to return. */ export function currencySymbol(currency: Currency): string { return CURRENCY_SYMBOLS[currency] } /** * One flat-rate step: the price trio in force FROM a point in time onward. * `from === null` means "no lower bound" — the catch-all step, normally first * in the list. */ export interface RateStep { /** Earliest millisecond instant (`Date#getTime` scale) this step applies; `null` = since forever. */ from: number | null /** Per-million rates in force from `from` onward. */ rates: PeriodRates } /** * A model priced flat around the clock in its provider's own currency, * optionally stepped in time — how a limited-time promo is expressed: promo * rates anchored `from: null`, list prices anchored at the revert instant. */ export interface FlatModelRates { currency: Currency /** Ascending steps; the LAST one whose anchor has passed is in force. */ steps: readonly RateStep[] } /** * Z.AI's 50%-off promo for glm-5.3-flash ends 2026-09-09 16:00 UTC — Beijing * midnight entering September 10 (+8, no DST). */ export const GLM_FLASH_PROMO_END_MS = Date.parse('2026-09-09T16:00:00Z') /** * Flat-priced models outside DeepSeek's time-of-day scheme. Resolution takes * the latest passed step, so a promo simply stops matching at its deadline: * no timers, no stored-row migration — each increment was already priced at * the rule that governed it, which is exactly what the accumulator keeps. */ export const FLAT_PRICE_TABLE: Readonly> = Object.freeze({ 'z-ai/glm-5.3-flash': { currency: 'USD', steps: [ // Limited-time 50% discount via ZAI through 2026-09-09 16:00 UTC: // $0.075/M input · $0.25/M output. No cache split is published, so hit // and miss bill at the single input price. { from: null, rates: { cacheHit: 0.075, cacheMiss: 0.075, output: 0.25 } }, // Reverts to list price afterwards: twice the promo across the board. { from: GLM_FLASH_PROMO_END_MS, rates: { cacheHit: 0.15, cacheMiss: 0.15, output: 0.5 } }, ], }, }) /** What `ratesFor` resolves: the per-million rates to bill NOW, and their unit. */ export interface ResolvedRates { currency: Currency rates: PeriodRates } /** The last of `steps` whose anchor is at-or-before `t`; undefined when none matched. */ function stepInForce(steps: readonly RateStep[], t: number): RateStep | undefined { let chosen: RateStep | undefined for (const step of steps) { if (step.from === null || step.from <= t) chosen = step } return chosen } /** * Resolve a provider model id to the rates in force at `now`, in the model's * own currency. Time-of-day models resolve through `periodAt(now)`; flat * models pick their time step; unknown ids resolve to `undefined` rather than * to a guessed row: showing no figure beats showing a wrong one. * @param model - provider-owned model id, e.g. `deepseek-v4-flash`. * @param now - the moment to price at; defaults to the real clock. * @returns rates plus currency, or `undefined` when no row covers the id. */ export function ratesFor( model: string | null | undefined, now: Date = new Date(), ): ResolvedRates | undefined { if (!model) return undefined // Tolerate dated or suffixed ids (`deepseek-v4-pro-2026-08-17`) by longest // known prefix across BOTH tables; an unrelated model still misses. const prefix = [...Object.keys(PRICE_TABLE), ...Object.keys(FLAT_PRICE_TABLE)] .filter((id) => model.startsWith(id)) .sort((a, b) => b.length - a.length)[0] if (prefix === undefined) return undefined const peakEntry = PRICE_TABLE[prefix] if (peakEntry) return { currency: 'CNY', rates: peakEntry[periodAt(now)] } const flat = FLAT_PRICE_TABLE[prefix]! const step = stepInForce(flat.steps, now.getTime()) return step && { currency: flat.currency, rates: step.rates } } /** * Price one usage reading with one model's rates. * * Bucket mapping: `cacheReadTokens` bills at the cache-hit input price, and * `uncachedInputTokens + cacheWriteTokens` bills at the cache-miss input * price — providers price input as hit-or-miss only, and a cache write IS the * miss that populated the cache. Rows without a published split set both * input prices equal, which degrades to a single input rate. * * @param usage - the usage to price (a full reading or a delta). * @param rates - per-million rates in force (see `ratesFor`). * @returns cost in the RESOLVED CURRENCY'S main unit (`ResolvedRates.currency`), * never a cross-currency sum. */ export function priceUsage(usage: UsageBuckets, rates: PeriodRates): number { const missTokens = usage.uncachedInputTokens + usage.cacheWriteTokens const perMillion = usage.cacheReadTokens * rates.cacheHit + missTokens * rates.cacheMiss + usage.outputTokens * rates.output return perMillion / 1_000_000 } /** * Bucket-wise difference between two readings. * @param next - the newer cumulative reading. * @param previous - the older cumulative reading. * @returns `next - previous` per bucket (components may be negative). */ export function diffUsage(next: UsageBuckets, previous: UsageBuckets): UsageBuckets { return { uncachedInputTokens: next.uncachedInputTokens - previous.uncachedInputTokens, outputTokens: next.outputTokens - previous.outputTokens, cacheReadTokens: next.cacheReadTokens - previous.cacheReadTokens, cacheWriteTokens: next.cacheWriteTokens - previous.cacheWriteTokens, } } /** * Whether a reading went backwards in any bucket — the session log was * forked, replaced, or replayed from an earlier point, so an accumulated cost * anchored to the old reading no longer describes it. * @param next - the newer cumulative reading. * @param previous - the older cumulative reading. * @returns true when any bucket shrank. */ export function isRegression(next: UsageBuckets, previous: UsageBuckets): boolean { const delta = diffUsage(next, previous) return ( delta.uncachedInputTokens < 0 || delta.outputTokens < 0 || delta.cacheReadTokens < 0 || delta.cacheWriteTokens < 0 ) } /** * Total tokens across the four buckets. * @param usage - the reading to sum. * @returns the bucket sum. */ export function totalTokens(usage: UsageBuckets): number { return ( usage.uncachedInputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens ) } /** * Numeric part of a token-price readout: four decimals below 1 unit (a short * session costs fractions of a fen or a cent), two at or above it. The name * predates multi-currency support; the thresholds are currency-independent. * @param yuan - the amount in the relevant currency's main unit. * @returns the formatted amount WITHOUT a currency symbol. */ export function formatYuan(yuan: number): string { const safe = Number.isFinite(yuan) ? Math.max(0, yuan) : 0 return safe < 1 ? safe.toFixed(4) : safe.toFixed(2) } /** * Numeric part of a per-million-token rate readout: two decimals for every * announced rate, a third only when the rate needs it — Z.AI's promo input * price is $0.075, which two decimals would round to 0.08, a 7% error on the * very figure the tooltip exists to show. * @param rate - the per-million rate to show. * @returns the formatted rate WITHOUT a currency symbol or unit. */ export function formatRate(rate: number): string { const two = rate.toFixed(2) return Number(two) === rate ? two : rate.toFixed(3) } /** * Format an amount WITH its currency symbol, e.g. `¥1.69` / `$0.0750`. * @param amount - the amount in the currency's main unit. * @param currency - which symbol to prefix. */ export function formatMoney(amount: number, currency: Currency): string { return `${CURRENCY_SYMBOLS[currency]}${formatYuan(amount)}` } /** * Format a countdown as `H:MM:SS`, or `MM:SS` under an hour. * @param ms - remaining milliseconds. * @returns the formatted countdown. */ export function formatCountdown(ms: number): string { const total = Math.max(0, Math.ceil(ms / 1000)) const seconds = total % 60 const minutes = Math.floor(total / 60) % 60 const hours = Math.floor(total / 3600) const mm = String(minutes).padStart(2, '0') const ss = String(seconds).padStart(2, '0') return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}` }