Fix interleaved DSH accounting and preserve inclusive output

This commit is contained in:
xz 2026-09-11 21:12:15 +08:00
parent a2f1fe2007
commit cd2e0dd43a
8 changed files with 115 additions and 36 deletions

View file

@ -72,18 +72,20 @@ None at the provider level; the log file is the cached source path and the norma
- **DSH is a developer preview.** The parser explicitly supports released formats v0-v3 and checks both the canonical generation filename and header. A future version is skipped with a notice; **a version bump upstream still requires a semantic reader update, not just relaxing the check.**
- **The JSONL backend only.** DSH also ships an opt-in SQLite persistence backend (`@deepseek-ai/dsh-session-persistence-sqlite`); it is not the default and is not read.
- **DSH records tokens, never dollars.** `usage` is `{ inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, reasoningTokens? }` with no cost field, so every call is priced from the shared tables. The buckets are disjoint on input; `reasoningTokens` is informational detail already included in `outputTokens`, so CodeBurn subtracts it from ordinary output and retains it as a separate reasoning bucket for shared pricing and display. pi-ai routes do not persist separate reasoning detail. Complete valid usage keeps `costIsEstimated` false; incomplete or inconsistent usage is reported with a notice and marked estimated. An attempt without usage is omitted with a notice rather than represented as an exact zero.
- **DSH records tokens, never dollars.** `usage` is `{ inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, reasoningTokens? }` with no cost field, so every call is priced from the shared tables. The buckets are disjoint on input; `reasoningTokens` is informational detail already included in `outputTokens`, as documented in the [DSH TokenUsage contract](https://github.com/deepseek-ai/deepseek-harness/blob/c291e7961a515f6d7af9304e7fd1d257929aef26/docs/subsystems/llm-streaming.md#tokenusage). CodeBurn preserves raw output and applies the shared inclusive-output rule to pricing, cached reads, and display. pi-ai routes do not persist separate reasoning detail. Complete valid usage keeps `costIsEstimated` false; incomplete or inconsistent usage is reported with a notice and marked estimated. An attempt without usage is omitted with a notice rather than represented as an exact zero.
- **`assistant/message` usage wins over the `assistant/chunk` sample** for the same `(turn, step)` — the two are adjacent reports of one API call, not two calls. A late chunk never overwrites a final report, so the two are never summed.
- **The model comes from the message, not the request.** `data.message.source.model` is what actually served the step; the current `request/context` model is the fallback, followed by `request/header`. A changed header model clears the previous context fallback. The `provider` field there (`deepseek-official`) is the upstream LLM route, not the tool — the codeburn provider name is always `dsh`.
- **A forked session's log replays its parent's events.** v0/v1 use header `seedLength`; v2/v3 use the last `session/end-seed` marker carrying `{ inherited: true }`. CodeBurn excludes the inherited prefix to avoid billing the parent's calls twice.
- **A forked session's log replays its parent's events.** v0/v1 use header `seedLength` only when `parentSession` is present, preserving the legacy non-fork behavior; v2/v3 use the last `session/end-seed` marker carrying `{ inherited: true }`. CodeBurn excludes the inherited prefix to avoid billing the parent's calls twice.
- **`user/message` also carries agent-injected context** (runtime snapshots, skill bodies, file-change notices) under `source.kind: 'plugin'`. Only `kind: 'user'` messages become the preview.
- **Delta chunks are packed.** Runs of streamed deltas are stored as `text-chunks` / `reasoning-chunks` / `tool-call-chunks` storage rows rather than one event per line. They carry no usage and no tool identity the `tool/call` event lacks, so they are ignored — as is any event type the parser does not know.
- **A torn final zstd frame is ignored.** A crashed writer leaves an incomplete trailing frame; the complete frames before it parse normally. A structurally corrupt file is skipped whole with a notice rather than throwing.
## When fixing a bug here
`v3-retry.jsonl` also covers a failed `assistant/attempt`, scheduled retry, and successful settlement with exact `totalTokens`. The same official strict restore and reducer yield input 110, output 24, cache read 33, and cache write 7 (174 total tokens).
1. Reproduce with a minimal session dir: `sessions/--proj--/<id>/session.jsonl` (uncompressed is easiest to hand-write).
2. `tests/fixtures/dsh/bash-tool-turn.jsonl` is the upstream `examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl` snapshot with its template placeholders filled in — refresh it from the DSH repo when the format moves.
3. Run `tests/providers/dsh.test.ts`.
4. `.zstd` fixtures must compress **each batch separately**; one `zstdCompressSync` over the whole file is a single-frame layout DSH never writes.
5. `tests/fixtures/dsh/v0.jsonl` through `v3.jsonl` are minimal synthetic, sanitized format fixtures. Each was restored with DSH's official `sessionFormatCatalog` (`recovery: 'strict', validation: 'current'`) and folded through `tokenUsageProjectionDefinition` at DSH commit `2ad76263a53497763575b5ad4ca5acf67ae00987`. All four yield uncached input 100, full output 20, cache read 30, and cache write 5; the provider tests assert those buckets, including the output/reasoning split. v0/v1 cite their top-level chunk via `sourceEventSeqs`; v2/v3 carry the embedded stream.
5. `tests/fixtures/dsh/v0.jsonl` through `v3.jsonl` are minimal synthetic, sanitized format fixtures. Each was restored with DSH's official `sessionFormatCatalog` (`recovery: 'strict', validation: 'current'`) and folded through `tokenUsageProjectionDefinition` at DSH commit `c291e7961a515f6d7af9304e7fd1d257929aef26`. All four yield uncached input 100, full output 20, cache read 30, and cache write 5; the provider tests assert those buckets, including inclusive output and informational reasoning detail. v0/v1 cite their top-level chunk via `sourceEventSeqs`; v2/v3 carry the embedded stream.

View file

@ -33,7 +33,9 @@ export type ModelCosts = {
/// and nothing else, and its supplementary store-row/shutdown calls carry
/// reasoningTokens with outputTokens 0 while the per-turn assistant.message call
/// bills the full output, so adding reasoning on top bills it twice.
const REASONING_INCLUDED_IN_OUTPUT = new Set(['claude', 'codex', 'copilot'])
/// DSH TokenUsage includes reasoning in output too; see the pinned contract:
/// https://github.com/deepseek-ai/deepseek-harness/blob/c291e7961a515f6d7af9304e7fd1d257929aef26/docs/subsystems/llm-streaming.md#tokenusage
const REASONING_INCLUDED_IN_OUTPUT = new Set(['claude', 'codex', 'copilot', 'dsh'])
/// Output tokens to bill and display for one call. Single source of truth so
/// the pricing sites and the display sums can never disagree about whether a

View file

@ -4,7 +4,7 @@ import { homedir } from 'os'
import zlib from 'zlib'
import { MAX_SESSION_FILE_BYTES, readSessionFile, readSessionLines } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import { billableOutputTokens, calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -486,7 +486,8 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
// Events a forked session inherited from its parent. They are a verbatim
// copy of the parent's log, which codeburn parses as its own session, so
// counting them here would bill the same calls twice.
let inheritedCut = formatVersion <= 1 && typeof header.seedLength === 'number'
let inheritedCut = formatVersion <= 1 && typeof header.parentSession === 'string' && header.parentSession
&& typeof header.seedLength === 'number'
? header.seedLength - 1
: -1
if (formatVersion >= 2) {
@ -505,7 +506,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
const userMessageByTurn = new Map<number, string>()
const buckets = new Map<string, StepBucket>()
const projectionState: { last: { key: string; index: number } | null } = { last: null }
const activeAttempts = new Map<string, number>()
for (const event of events) {
if (event.type === 'session') {
@ -541,7 +542,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
if (event.type === 'llm/retry-started') {
const turn = event.data?.turn ?? currentTurn
const step = event.data?.step ?? 0
if (projectionState.last?.key === `${turn}:${step}`) projectionState.last = null
activeAttempts.delete(`${turn}:${step}`)
continue
}
@ -607,7 +608,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
const key = `${turn}:${step}`
if (!usage) {
if (projectionState.last?.key !== key) {
if (!activeAttempts.has(key)) {
notice(`codeburn: DSH session contains an attempt without usage; totals may be incomplete: ${source.path}\n`)
}
continue
@ -618,17 +619,16 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
buckets.set(key, bucket)
}
const observation = { usage, time: event.time, model: reportedModel, final: isFinal }
// This is the official tokenUsage projection's replacement slot: a
// settlement for the same (turn, step) replaces the immediately prior
// sample, while llm/retry-started closes the slot so the next attempt
// is additive.
if (projectionState.last?.key === key) {
if (!bucket.observations[projectionState.last.index]?.final || isFinal) {
bucket.observations[projectionState.last.index] = observation
// A different step cannot close this step's replacement slot. Only
// its own retry-started event makes the next observation additive.
const activeIndex = activeAttempts.get(key)
if (activeIndex !== undefined) {
if (!bucket.observations[activeIndex]?.final || isFinal) {
bucket.observations[activeIndex] = observation
}
} else {
bucket.observations.push(observation)
projectionState.last = { key, index: bucket.observations.length - 1 }
activeAttempts.set(key, bucket.observations.length - 1)
}
}
@ -659,16 +659,16 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
seenKeys.add(dedupKey)
// DSH TokenUsage defines reasoning as informational detail already
// included in outputTokens. Split the persisted buckets so shared
// pricing and display can add ordinary output and reasoning once.
const costUSD = calculateCost(observation.model, input, output, cacheWrite, cacheRead, 0)
// included in outputTokens. Preserve raw output and use the same
// shared rule as cache rehydration and display (#1075).
const costUSD = calculateCost(observation.model, input, billableOutputTokens('dsh', output, reasoning), cacheWrite, cacheRead, 0)
const [turn] = key.split(':').map(Number)
yield {
provider: 'dsh',
model: observation.model,
inputTokens: input,
outputTokens: output - reasoning,
outputTokens: output,
cacheCreationInputTokens: cacheWrite,
cacheReadInputTokens: cacheRead,
cachedInputTokens: cacheRead,

View file

@ -371,7 +371,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
grok: 'authoritative-usage-v4',
// v0-v3 generations, embedded attempt streams, retry accounting, and the
// version-specific inherited-prefix rules all change cached DSH calls.
dsh: 'session-formats-v0-v3-attempts-v4',
dsh: 'session-formats-v0-v3-attempts-v5',
// cost-provenance-v3: preserve Hermes included/estimated/actual status and
// rebuild the provider section alongside the v3 lifetime ledger. The parse
// bump is required with the ledger bump: seeding a new ledger from a section

View file

@ -105,6 +105,12 @@ describe('#1115 billableOutputTokens on report/optimize totals', () => {
expect(sessionBillableOutputTokens(makeSession('codex', 10, 3))).toBe(10)
})
it('inclusive dsh: shared session and daily totals include reasoning exactly once', () => {
expect(billableOutputTokens('dsh', 10, 3)).toBe(10)
expect(sessionBillableOutputTokens(makeSession('dsh', 10, 3))).toBe(10)
expect(aggregateProjectsIntoDays([makeProject(makeSession('dsh', 10, 3))])[0]!.outputTokens).toBe(10)
})
it('day-aggregator uses billable output per call', () => {
const grokDays = aggregateProjectsIntoDays([makeProject(makeSession('grok', 10, 3))])
expect(grokDays[0]!.outputTokens).toBe(13)

View file

@ -10,7 +10,7 @@ const testRoot = vi.hoisted(() => {
})
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { calculateCost } from '../src/models.js'
import { billableOutputTokens, calculateCost } from '../src/models.js'
import { aggregateSessions } from '../src/sessions-report.js'
import { aggregateModelStats } from '../src/compare-stats.js'
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
@ -21,7 +21,7 @@ import {
fingerprintFile,
type SessionCache,
} from '../src/session-cache.js'
import { writeCacheOnDisk } from './fixtures/session-cache-io.js'
import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js'
const DSH_HOME = join(testRoot, 'dsh')
const CACHE_DIR = join(testRoot, 'cache')
@ -98,6 +98,25 @@ afterAll(async () => {
})
describe('DSH multi-generation session cache', () => {
it('reparses v4 caches that stored exclusive output before the shared inclusive rule', async () => {
const path = await writeLog(3, [])
await writeFile(path, [header(3), message(0, 1, 100, 3)].join('\n') + '\n')
await calls()
const cache = await readCacheOnDisk()
const section = cache.providers.dsh!
section.envFingerprint = createHash('sha256').update([
`DSH_HOME=${DSH_HOME}`, 'parser=session-formats-v0-v3-attempts-v4',
].join('\0')).digest('hex').slice(0, 16)
for (const turn of section.files[path]!.turns) {
for (const call of turn.calls) call.usage.outputTokens = 2
}
await writeCacheOnDisk(cache)
clearSessionCache()
const corrected = await calls()
expect(corrected[0]!.usage).toMatchObject({ outputTokens: 5, reasoningTokens: 3 })
expect(corrected[0]!.costUSD).toBeCloseTo(calculateCost('gpt-5.4', 100, 5, 0, 0, 0), 12)
})
it('counts separate legacy and current sessions while counting migrated history only once, cold and warm', async () => {
const versions = [0, 1, 2, 3] as const
for (const version of versions) {
@ -125,7 +144,7 @@ describe('DSH multi-generation session cache', () => {
const parsed = sessions.flatMap(session => session.turns).flatMap(turn => turn.assistantCalls)
expect(parsed).toHaveLength(5)
expect(parsed.reduce((sum, call) => sum + call.usage.inputTokens, 0)).toBe(500)
expect(parsed.reduce((sum, call) => sum + call.usage.outputTokens + call.usage.reasoningTokens, 0)).toBe(100)
expect(parsed.reduce((sum, call) => sum + billableOutputTokens('dsh', call.usage.outputTokens, call.usage.reasoningTokens), 0)).toBe(100)
expect(parsed.reduce((sum, call) => sum + call.usage.cacheReadInputTokens, 0)).toBe(150)
expect(parsed.reduce((sum, call) => sum + call.usage.cacheCreationInputTokens, 0)).toBe(25)
}
@ -194,7 +213,7 @@ describe('DSH multi-generation session cache', () => {
expect(await calls()).toEqual([])
})
it('preserves disjoint reasoning buckets and shared totals through cold and hot caches', async () => {
it('preserves inclusive reasoning buckets and shared totals through cold and hot caches', async () => {
const path = await writeLog(3, [])
await writeFile(path, [header(3), message(0, 1, 100, 3)].join('\n') + '\n')
const expectedCost = calculateCost('gpt-5.4', 100, 5, 0, 0, 0)
@ -205,7 +224,7 @@ describe('DSH multi-generation session cache', () => {
const parsedCalls = projects.flatMap(project => project.sessions)
.flatMap(session => session.turns).flatMap(turn => turn.assistantCalls)
expect(parsedCalls).toHaveLength(1)
expect(parsedCalls[0]!.usage).toMatchObject({ inputTokens: 100, outputTokens: 2, reasoningTokens: 3 })
expect(parsedCalls[0]!.usage).toMatchObject({ inputTokens: 100, outputTokens: 5, reasoningTokens: 3 })
expect(parsedCalls[0]!.costUSD).toBeCloseTo(expectedCost, 12)
expect(aggregateSessions(projects)).toEqual([expect.objectContaining({ outputTokens: 5, cost: expectedCost })])
expect(aggregateModelStats(projects)).toEqual([expect.objectContaining({ outputTokens: 5, cost: expectedCost })])

11
tests/fixtures/dsh/v3-retry.jsonl vendored Normal file
View file

@ -0,0 +1,11 @@
{"type":"session","version":3,"id":"fixture-retry","createdAt":1786707340000,"cwd":"/fixture/project","delegationDepth":0,"isSeeded":false}
{"type":"turn/start","data":{"turn":1},"seq":0,"time":1786707340000}
{"type":"step/start","data":{"turn":1,"step":1},"seq":1,"time":1786707340001}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"}},"reason":"initial"},"seq":2,"time":1786707340002}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"seq":3,"time":1786707340003}
{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1786707340000,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4,"cacheReadTokens":3,"cacheWriteTokens":2,"reasoningTokens":2,"totalTokens":19}}}]},"seq":4,"time":1786707340004}
{"type":"llm/retry","time":1786707340005,"data":{"retryId":"fixture-retry-1","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"default","retry":1,"maxRetries":1,"delayMs":0,"failure":{"code":"SERVER","message":"Fixture failure"}},"seq":5}
{"type":"llm/retry-started","data":{"turn":1,"step":1,"retryId":"fixture-retry-1","retry":1},"seq":6,"time":1786707340006}
{"type":"assistant/message","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"id":"fixture-success","role":"assistant","content":[{"type":"text","text":"Done"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"}},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":30,"cacheWriteTokens":5,"reasoningTokens":8,"totalTokens":155},"stream":[{"type":"chunk","time":1786707340000,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":30,"cacheWriteTokens":5,"reasoningTokens":8,"totalTokens":155}}}]},"seq":7,"time":1786707340007}
{"type":"step/end","data":{"turn":1,"step":1},"seq":8,"time":1786707340008}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":9,"time":1786707340009}

View file

@ -142,7 +142,7 @@ async function writeVersionedSession(
}
// Released layouts checked against deepseek-ai/deepseek-harness at
// 2ad76263a53497763575b5ad4ca5acf67ae00987:
// c291e7961a515f6d7af9304e7fd1d257929aef26:
// packages/session/session-format-v0-to-v1/src/codec.ts (v1),
// packages/session/session-format-v1-to-v2/src/codec.ts (v2), and
// packages/session/session-format-v2-to-v3/src/codec.ts (v3).
@ -302,6 +302,45 @@ describe('dsh provider - session discovery', () => {
})
describe('dsh provider - parsing', () => {
it('counts failed and successful attempts from the official-validated retry fixture', async () => {
const content = await readFile(join(import.meta.dirname, '../fixtures/dsh/v3-retry.jsonl'), 'utf8')
const path = await writeVersionedSession(3, '--fixture--', 'fixture-retry', content.trim().split('\n'))
const calls = await parseAll(createDshProvider(tmpDir), path)
expect(calls.map(call => [call.inputTokens, call.outputTokens, call.reasoningTokens])).toEqual([[10, 4, 2], [100, 20, 8]])
expect(calls.every(call => call.model === 'deepseek-v4-flash')).toBe(true)
expect(calls.reduce((sum, call) => sum + call.inputTokens + billableOutputTokens('dsh', call.outputTokens, call.reasoningTokens)
+ call.cacheReadInputTokens + call.cacheCreationInputTokens, 0)).toBe(174)
})
it.each(([0, 1, 2, 3] as const).flatMap(version => [false, true].map(retry => ({ version, retry }))))(
'replaces non-adjacent settlements per step in v$version (retry=$retry)', async ({ version, retry }) => {
const sample = (step: number, input: number) => version <= 1
? chunkUsage(1, step, { inputTokens: input, outputTokens: 10 }, 1786707340000)
: JSON.stringify({ type: 'assistant/attempt', seq: step, time: 1786707340000,
data: { turn: 1, step, stream: embeddedUsage({ inputTokens: input, outputTokens: 10 }, 1786707340000) } })
const rows = [version === 0 ? sessionHeader() : versionedHeader(version),
sample(1, 100), sample(2, 200),
assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340100),
...(retry ? [JSON.stringify({ type: 'llm/retry-started', seq: 8, time: 1786707340150,
data: { turn: 1, step: 1, retryId: 'interleaved-retry', retry: 1 } }),
assistantMessage(1, 1, { inputTokens: 150, outputTokens: 10 }, 1786707340180)] : []),
assistantMessage(1, 2, { inputTokens: 200, outputTokens: 10 }, 1786707340200)]
const path = version === 0 ? await writePlainSession('--fixture--', 'interleaved', rows)
: await writeVersionedSession(version, '--fixture--', 'interleaved', rows)
const calls = await parseAll(createDshProvider(tmpDir), path)
expect(calls.map(call => call.inputTokens)).toEqual(retry ? [100, 150, 200] : [100, 200])
})
it.each([0, 1] as const)('retains non-fork v%s usage when only seedLength is present', async version => {
const rows = [JSON.stringify({ type: 'session', version, id: 'non-fork', seedLength: 4,
createdAt: 1786707340000, cwd: '/fixture/project', delegationDepth: 0 }),
...[1, 2, 3, 4].map((step, seq) => JSON.stringify({ type: 'assistant/message', seq,
time: 1786707340000, data: { turn: 1, step, usage: { inputTokens: 100, outputTokens: 10 } } }))]
const path = version === 0 ? await writePlainSession('--fixture--', 'non-fork', rows)
: await writeVersionedSession(version, '--fixture--', 'non-fork', rows)
expect((await parseAll(createDshProvider(tmpDir), path)).map(call => call.inputTokens)).toEqual([100, 100, 100, 100])
})
it.each([0, 1, 2, 3] as const)('matches official DSH totals for the sanitized v%s fixture', async version => {
const content = await readFile(join(import.meta.dirname, `../fixtures/dsh/v${version}.jsonl`), 'utf8')
const lines = content.trim().split('\n')
@ -310,7 +349,7 @@ describe('dsh provider - parsing', () => {
: await writeVersionedSession(version, '--fixture--', 'fixture-session', lines)
const calls = await parseAll(createDshProvider(tmpDir), path)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ inputTokens: 100, outputTokens: 12, reasoningTokens: 8, cacheReadInputTokens: 30, cacheCreationInputTokens: 5 })
expect(calls[0]).toMatchObject({ inputTokens: 100, outputTokens: 20, reasoningTokens: 8, cacheReadInputTokens: 30, cacheCreationInputTokens: 5 })
expect(billableOutputTokens('dsh', calls[0]!.outputTokens, calls[0]!.reasoningTokens)).toBe(20)
})
it('does not reuse the previous request context after a model change', async () => {
@ -354,7 +393,7 @@ describe('dsh provider - parsing', () => {
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ inputTokens: 10, outputTokens: 0, costIsEstimated: true })
expect(calls[0]).toMatchObject({ inputTokens: 10, outputTokens: 'outputTokens' in usage ? usage.outputTokens : 0, costIsEstimated: true })
})
it.each([1, 2, 3] as const)('uses only the usage layout belonging to format v%s', async (version) => {
@ -584,8 +623,8 @@ describe('dsh provider - parsing', () => {
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(14981)
// The final output includes reasoning; expose only ordinary output here.
expect(calls[0]!.outputTokens).toBe(47)
// Preserve raw output, including reasoning, for the shared billing rule.
expect(calls[0]!.outputTokens).toBe(656)
expect(calls[0]!.reasoningTokens).toBe(609)
expect(calls[0]!.timestamp).toBe(new Date(1786707340050).toISOString())
})
@ -628,7 +667,7 @@ describe('dsh provider - parsing', () => {
expect(calls.map(c => c.model)).toEqual(['deepseek-v4-pro', 'deepseek-v4-pro', 'deepseek-v4-flash'])
})
it('splits inclusive reasoning into disjoint output buckets at the output rate', async () => {
it('preserves inclusive reasoning and bills output once through the shared rule', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-reason', [
[sessionHeader({ id: 'session-reason' })],
[requestHeader('deepseek-v4-pro')],
@ -637,7 +676,7 @@ describe('dsh provider - parsing', () => {
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ outputTokens: 60, reasoningTokens: 40 })
expect(calls[0]).toMatchObject({ outputTokens: 100, reasoningTokens: 40 })
expect(billableOutputTokens('dsh', calls[0]!.outputTokens, calls[0]!.reasoningTokens)).toBe(100)
// DSH's TokenUsage reasoningTokens is informational detail already
// included in outputTokens, so the output bucket is billed exactly once.
@ -791,7 +830,7 @@ describe('dsh provider - real log fidelity', () => {
expect(calls.map(c => c.model)).toEqual(['deepseek-v4-flash', 'deepseek-v4-flash'])
expect(calls[0]).toMatchObject({
inputTokens: 2877,
outputTokens: 72,
outputTokens: 90,
cacheReadInputTokens: 0,
reasoningTokens: 18,
sessionId: 'e128dda9-ed11-4868-8266-0ef90d03c3d6',
@ -799,7 +838,7 @@ describe('dsh provider - real log fidelity', () => {
projectPath: '/home/u/proj',
workingDirectory: '/home/u/proj',
})
expect(calls[1]).toMatchObject({ inputTokens: 168, outputTokens: 3, cacheReadInputTokens: 2816, reasoningTokens: 22 })
expect(calls[1]).toMatchObject({ inputTokens: 168, outputTokens: 25, cacheReadInputTokens: 2816, reasoningTokens: 22 })
// Reasoning is a subset of output, so it must not be added a second time.
expect(calls[0]!.costUSD).toBe(calculateCost('deepseek-v4-flash', 2877, 90, 0, 0, 0))
expect(calls[0]!.costUSD).toBeGreaterThan(0)