mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
223 lines
8.7 KiB
TypeScript
223 lines
8.7 KiB
TypeScript
import {
|
|
COST_FABLE_51,
|
|
COST_HAIKU_35,
|
|
COST_HAIKU_45,
|
|
COST_TIER_3_15,
|
|
COST_TIER_5_25,
|
|
COST_TIER_10_50,
|
|
COST_TIER_15_75,
|
|
COST_TIER_30_150,
|
|
type ModelCosts,
|
|
} from './modelCost.js'
|
|
|
|
/**
|
|
* Shared token-accounting rules for activity stats.
|
|
*
|
|
* Two independent code paths compute these stats — the local index reducer and the direct
|
|
* transcript scan in `stats.ts` — and a parity test pins them to identical output. Rules that
|
|
* decide what counts (deduplication, validity, working time, dollars) therefore live here rather
|
|
* than being implemented twice and drifting apart.
|
|
*
|
|
* ## Cost estimation
|
|
*
|
|
* Deliberately does NOT reuse `calculateUSDCost()` from the CLI: that helper falls back to the
|
|
* default model's rates for anything it doesn't recognize and fires an analytics event per call.
|
|
* cc-haha is a multi-provider client, so a third-party model (glm, k3, deepseek, grok, gpt, ...)
|
|
* priced at Claude rates would report wildly wrong dollars, and indexing runs this tens of
|
|
* thousands of times per rebuild. Here an unknown model returns `null` instead — callers keep its
|
|
* tokens in the activity totals but leave it out of the cost total, matching how ccusage and
|
|
* openusage refuse to mix measured tokens with unpriceable ones.
|
|
*
|
|
* Rates come from `src/utils/modelCost.ts` so the dollar figures live in exactly one place; only
|
|
* the model -> tier mapping is maintained here, because the CLI's canonical-name resolver pulls in
|
|
* runtime state (settings, bootstrap) that has no business running inside the indexer.
|
|
*/
|
|
|
|
export type PricedTokens = {
|
|
inputTokens: number
|
|
outputTokens: number
|
|
cacheReadInputTokens: number
|
|
cacheCreationInputTokens: number
|
|
webSearchRequests?: number
|
|
}
|
|
|
|
// @[MODEL LAUNCH]: add the new model's canonical prefix here alongside its entry in MODEL_COSTS.
|
|
// Longest match wins, so a bare family name may sit next to its versioned variants.
|
|
const MODEL_TIERS: ReadonlyArray<readonly [prefix: string, costs: ModelCosts]> = [
|
|
['claude-opus-5', COST_TIER_5_25],
|
|
['claude-opus-4-8', COST_TIER_5_25],
|
|
['claude-opus-4-7', COST_TIER_5_25],
|
|
['claude-opus-4-6', COST_TIER_5_25],
|
|
['claude-opus-4-5', COST_TIER_5_25],
|
|
['claude-opus-4-1', COST_TIER_15_75],
|
|
['claude-opus-4', COST_TIER_15_75],
|
|
['claude-fable-5', COST_TIER_10_50],
|
|
['claude-fable-5-1', COST_FABLE_51],
|
|
['claude-mythos-5', COST_TIER_10_50],
|
|
['claude-sonnet-5', COST_TIER_3_15],
|
|
['claude-sonnet-4-6', COST_TIER_3_15],
|
|
['claude-sonnet-4-5', COST_TIER_3_15],
|
|
['claude-sonnet-4', COST_TIER_3_15],
|
|
['claude-3-7-sonnet', COST_TIER_3_15],
|
|
['claude-3-5-sonnet', COST_TIER_3_15],
|
|
['claude-haiku-4-5', COST_HAIKU_45],
|
|
['claude-3-5-haiku', COST_HAIKU_35],
|
|
['claude-3-haiku', COST_HAIKU_35],
|
|
['claude-3-opus', COST_TIER_15_75],
|
|
]
|
|
|
|
// Fast mode bills at its own rate on the models that offer it; everything else ignores `speed`.
|
|
const FAST_MODE_TIERS: ReadonlyArray<readonly [prefix: string, costs: ModelCosts]> = [
|
|
['claude-opus-5', COST_TIER_10_50],
|
|
['claude-opus-4-7', COST_TIER_30_150],
|
|
]
|
|
|
|
/**
|
|
* Strip the decorations third-party gateways and dated snapshots add to an otherwise standard
|
|
* model id: `claude-opus-4-8-20260101`, `claude-opus-4-8-r`, `anthropic/claude-sonnet-5`.
|
|
*/
|
|
function normalizeModelId(model: string): string {
|
|
const trimmed = model.trim().toLowerCase()
|
|
if (!trimmed) return ''
|
|
const withoutVendor = trimmed.slice(trimmed.lastIndexOf('/') + 1)
|
|
return withoutVendor
|
|
.replace(/-\d{8}$/, '')
|
|
.replace(/-(r|thinking|latest)$/, '')
|
|
}
|
|
|
|
function matchTier(
|
|
normalized: string,
|
|
tiers: ReadonlyArray<readonly [string, ModelCosts]>,
|
|
): ModelCosts | null {
|
|
let best: ModelCosts | null = null
|
|
let bestLength = 0
|
|
for (const [prefix, costs] of tiers) {
|
|
if (prefix.length <= bestLength) continue
|
|
if (normalized === prefix || normalized.startsWith(`${prefix}-`)) {
|
|
best = costs
|
|
bestLength = prefix.length
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
/**
|
|
* Rates for a model, or `null` when we have no published pricing for it (every third-party
|
|
* provider, and any Claude model released after this table was last updated).
|
|
*/
|
|
export function resolveModelCosts(model: string, speed?: string): ModelCosts | null {
|
|
const normalized = normalizeModelId(model)
|
|
if (!normalized) return null
|
|
if (speed === 'fast') {
|
|
const fast = matchTier(normalized, FAST_MODE_TIERS)
|
|
if (fast) return fast
|
|
}
|
|
return matchTier(normalized, MODEL_TIERS)
|
|
}
|
|
|
|
/**
|
|
* Longest silence between two messages still counted as one continuous stretch of work. Beyond
|
|
* this the user has stepped away and come back — a resumed session, not a long-running task.
|
|
* Without this bound "task length" is really a calendar span: a session picked up the next
|
|
* morning reports the whole night as time on task.
|
|
*/
|
|
export const ACTIVE_SESSION_GAP_MS = 30 * 60 * 1000
|
|
|
|
/** Identity fields a transcript line carries for validity checks and deduplication. */
|
|
export type UsageRecordIdentity = {
|
|
version?: unknown
|
|
sessionId?: unknown
|
|
requestId?: unknown
|
|
messageId?: unknown
|
|
forkedFrom?: unknown
|
|
}
|
|
|
|
/** `1.0.24`, `2.3.4-beta` — anything else marks a log written by something that isn't Claude Code. */
|
|
function isSemverPrefix(value: string): boolean {
|
|
return /^\d+\.\d+\.\d/.test(value)
|
|
}
|
|
|
|
/**
|
|
* Whether a line's `usage` should be counted at all. Mirrors ccusage's validity rules: a `version`
|
|
* that isn't semver-ish means a foreign log format, and an id that is present but empty means a
|
|
* malformed line. Deliberately does not reject an empty `model` the way ccusage does — cc-haha
|
|
* files those under `unknown` and still shows their tokens.
|
|
*
|
|
* Only gates token accounting; the entry still counts toward messages and tool calls, which are
|
|
* activity signals rather than billing ones.
|
|
*/
|
|
export function isBillableUsageRecord(identity: UsageRecordIdentity): boolean {
|
|
if (isForkInheritedUsageRecord(identity)) return false
|
|
if (typeof identity.version === 'string' && !isSemverPrefix(identity.version)) return false
|
|
for (const value of [identity.sessionId, identity.requestId, identity.messageId]) {
|
|
if (typeof value === 'string' && value.length === 0) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Conversation branches copy the selected prefix into a new transcript so the fork keeps its
|
|
* context. Those lines retain their original usage and carry this provenance marker; the API calls
|
|
* belong to the source session and must not be attributed to the fork a second time.
|
|
*/
|
|
export function isForkInheritedUsageRecord(record: { forkedFrom?: unknown }): boolean {
|
|
const forkedFrom = record.forkedFrom
|
|
if (!forkedFrom || typeof forkedFrom !== 'object') return false
|
|
const source = forkedFrom as { sessionId?: unknown; messageUuid?: unknown }
|
|
return (
|
|
typeof source.sessionId === 'string' &&
|
|
source.sessionId.length > 0 &&
|
|
typeof source.messageUuid === 'string' &&
|
|
source.messageUuid.length > 0
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Deduplication key for one usage record, or `null` when the line carries no message id to key on
|
|
* (those are always counted, matching ccusage).
|
|
*
|
|
* Claude Code writes one JSONL line per content block of an assistant message — a reply with
|
|
* thinking, text and 12 tool_use blocks is 14 lines — and every one repeats the same complete
|
|
* `usage` object. Counting each line once per block inflated real transcripts by 2.2x.
|
|
*/
|
|
export function usageRecordKey(
|
|
identity: UsageRecordIdentity,
|
|
suffix = '',
|
|
): string | null {
|
|
const messageId = identity.messageId
|
|
if (typeof messageId !== 'string' || !messageId) return null
|
|
const requestId = typeof identity.requestId === 'string' ? identity.requestId : ''
|
|
return `${messageId}\0${requestId}${suffix}`
|
|
}
|
|
|
|
/**
|
|
* Time actually worked between two consecutive messages, or 0 when the gap is a break rather than
|
|
* a stretch of work. Non-positive gaps (out-of-order timestamps) contribute nothing.
|
|
*/
|
|
export function activeGapMs(previousMs: number | null, currentMs: number): number {
|
|
if (previousMs === null) return 0
|
|
const gap = currentMs - previousMs
|
|
return gap > 0 && gap <= ACTIVE_SESSION_GAP_MS ? gap : 0
|
|
}
|
|
|
|
/**
|
|
* Estimated dollars for one usage record, or `null` when the model can't be priced. Callers must
|
|
* treat `null` as "exclude from the cost total", never as zero — a zero would quietly understate
|
|
* spend for anyone running mostly third-party models.
|
|
*/
|
|
export function estimateCostUSD(
|
|
model: string,
|
|
tokens: PricedTokens,
|
|
speed?: string,
|
|
): number | null {
|
|
const costs = resolveModelCosts(model, speed)
|
|
if (!costs) return null
|
|
return (
|
|
(tokens.inputTokens / 1_000_000) * costs.inputTokens +
|
|
(tokens.outputTokens / 1_000_000) * costs.outputTokens +
|
|
(tokens.cacheReadInputTokens / 1_000_000) * costs.promptCacheReadTokens +
|
|
(tokens.cacheCreationInputTokens / 1_000_000) * costs.promptCacheWriteTokens +
|
|
(tokens.webSearchRequests ?? 0) * costs.webSearchRequests
|
|
)
|
|
}
|