Merge remote-tracking branch 'origin/token-meter-service' into compact-post-step-overflow-recovery

# Conflicts:
#	docs/core-data-structures/compaction.md
#	docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml
#	docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md
#	docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md
#	docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md
#	examples/coding-agent/cordis.yml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/automatic.ts
#	packages/compact/compact-basic/src/config.ts
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
This commit is contained in:
Hypatia May
2026-07-16 16:18:16 +08:00
36 changed files with 939 additions and 1292 deletions

View File

@@ -8,25 +8,25 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
- **Measurement** — the latest durable routed request model's `ModelTokenMeter` prices the canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue, while an actually routed model without a meter profile fails the otherwise-successful turn with the typed meter error.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error.
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
## Config (`BasicCompactConfig`)
Every common setting is optional. Every model known to `ctx.tokenMeter` receives the default compact policy lazily; named overrides merge only the fields supplied and must name a configured meter profile.
Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected.
| Key | Required | Meaning |
|---|---|---|
| `models.<model>.thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
| `models.<model>.retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. |
| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. |
| `summarizationModel` | no (default `''`) | Empty resolves the latest logged routed model, then `AgentOptions.model`. |
| `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. |
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
@@ -117,7 +117,7 @@ Rules:
## Known Limitations and Deferred Work
- **Meter accuracy follows the selected profile** — missing provider usage falls back to the token meter's configured character density and structural overhead.
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix.
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.

View File

@@ -1,65 +1,75 @@
/**
* Runtime defaulting and per-model policy validation for compact-basic.
* Runtime defaulting and policy validation for compact-basic.
*
* @module @deepseek-ai/dsh-compact-basic/config
*/
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ModelTokenMeter, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type {
BasicCompactConfig,
ModelCompactConfig,
ResolvedConfig,
ResolvedModelCompactConfig,
} from './types.ts'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
/** Default request-pressure fraction for every metered model. */
/** Default request-pressure fraction of the token meter's context window. */
const DEFAULT_THRESHOLD_RATIO = 0.8
/** Default verbatim-tail fraction of a model's context window. */
/** Default verbatim-tail fraction of the token meter's context window. */
const DEFAULT_RETAIN_RATIO = 0.16
/** Complete public configuration key set. */
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
'thresholdRatio',
'retainTokens',
'summarizationModel',
'maxTokens',
'compactionRetries',
'maxOverflowRetries',
'auto',
])
/** Reject stale or misspelled keys before defaults can hide them. */
function validateConfigKeys(config: BasicCompactConfig): void {
for (const key of Object.keys(config)) {
if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) {
throw new Error(
`BasicCompactConfig: unknown key "${key}" `
+ '(allowed: thresholdRatio, retainTokens, summarizationModel, maxTokens, '
+ 'compactionRetries, maxOverflowRetries, auto)',
)
}
}
}
/**
* Resolve common defaults and validate every named model override.
* Resolve defaults and validate the service-wide compaction policy.
* @param config - raw compact-basic configuration.
* @param tokenMeter - owning meter service used to reject unknown override names.
* @returns a detached deeply immutable top-level configuration.
* @param tokenMeter - token meter supplying the context capacity.
* @returns a detached deeply immutable configuration.
*/
export function resolveConfig(
config: BasicCompactConfig = {},
tokenMeter: TokenMeterService,
): ResolvedConfig {
const configuredModels: unknown = config.models
const models = configuredModels === undefined ? {} : configuredModels
if (typeof models !== 'object' || models === null || Array.isArray(models)) {
throw new Error('BasicCompactConfig: models must be an object')
}
const detachedModels: Record<string, ModelCompactConfig> = {}
for (const [model, override] of Object.entries(models as Record<string, unknown>)) {
if (typeof override !== 'object' || override === null || Array.isArray(override)) {
throw new Error(`BasicCompactConfig: models.${model} must be an object`)
}
const meter = tokenMeter.resolve(model)
detachedModels[model] = { ...override as ModelCompactConfig }
resolveModelConfig({
models: detachedModels,
summarizationModel: '',
maxTokens: 8192,
compactionRetries: 1,
maxOverflowRetries: 1,
auto: true,
}, meter)
}
validateConfigKeys(config)
const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
const retainTokens = config.retainTokens
?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO)
const resolved: ResolvedConfig = {
models: detachedModels,
thresholdRatio,
retainTokens,
summarizationModel: config.summarizationModel ?? '',
maxTokens: config.maxTokens ?? 8192,
compactionRetries: config.compactionRetries ?? 1,
maxOverflowRetries: config.maxOverflowRetries ?? 1,
auto: config.auto ?? true,
}
assertRatio('thresholdRatio', resolved.thresholdRatio)
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio)
if (resolved.retainTokens >= thresholdTokens) {
throw new Error(
`BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
)
}
assertPositiveInteger('maxTokens', resolved.maxTokens)
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
@@ -69,36 +79,7 @@ export function resolveConfig(
if (typeof resolved.auto !== 'boolean') {
throw new Error('BasicCompactConfig: auto must be a boolean')
}
return deepFreeze(structuredClone(resolved))
}
/**
* Resolve one effective model's default policy plus optional field overrides.
* @param config - validated compact-basic configuration.
* @param meter - effective model's token-meter handle and context capacity.
* @returns a detached immutable model policy.
*/
export function resolveModelConfig(
config: ResolvedConfig,
meter: ModelTokenMeter,
): ResolvedModelCompactConfig {
const override = config.models[meter.model]
const thresholdRatio = override?.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
const retainTokens = override?.retainTokens ?? Math.floor(meter.contextWindow * DEFAULT_RETAIN_RATIO)
assertRatio(`models.${meter.model}.thresholdRatio`, thresholdRatio)
assertNonNegativeInteger(`models.${meter.model}.retainTokens`, retainTokens)
const thresholdTokens = Math.floor(meter.contextWindow * thresholdRatio)
if (retainTokens >= thresholdTokens) {
throw new Error(
`BasicCompactConfig: models.${meter.model}.retainTokens (${retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
)
}
return deepFreeze({
model: meter.model,
contextWindow: meter.contextWindow,
thresholdRatio,
retainTokens,
})
return deepFreeze(resolved)
}
function assertPositiveInteger(name: string, value: number): void {

View File

@@ -11,34 +11,21 @@ import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compa
import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import {
TOKEN_METER_MODEL_UNCONFIGURED,
TokenMeterError,
} from '@deepseek-ai/dsh-token-meter'
import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { resolveConfig, resolveModelConfig } from './config.ts'
import { resolveConfig } from './config.ts'
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
import { summarizeWithLlm } from './summarizer.ts'
import type {
BasicCompactConfig,
ResolvedConfig,
ResolvedModelCompactConfig,
} from './types.ts'
export { resolveConfig, resolveModelConfig } from './config.ts'
export { resolveConfig } from './config.ts'
export type {
BasicCompactConfig,
ModelCompactConfig,
ResolvedConfig,
ResolvedModelCompactConfig,
} from './types.ts'
/** Resolve the latest actual routed model, then the agent's configured fallback. */
function effectiveModel(agent: Agent): string | undefined {
return agent.session.requestHeader()?.config.model ?? agent.options.model
}
/** Resolve the exact model durably routed for the latest provider request. */
function routedModel(session: Session): string | undefined {
const model = session.requestHeader()?.config.model
@@ -50,17 +37,15 @@ function routedModel(session: Session): string | undefined {
* retention, provenance, and summary-convergence pricing.
*
* `summarize()` is the sole subclass customization hook; the replay and durable
* mutation strategy stays fixed so every pricing decision uses one effective
* conversation-model meter.
* mutation strategy stays fixed so every pricing decision uses the singleton
* token meter.
*/
export class BasicCompactService extends CompactService {
static inject = ['llm', 'tokenMeter']
static Config: z<BasicCompactConfig> = z.object({
models: z.dict(z.object({
thresholdRatio: z.number(),
retainTokens: z.number().step(1),
})),
thresholdRatio: z.number().default(0.8),
retainTokens: z.number().step(1),
summarizationModel: z.string().default(''),
maxTokens: z.number().step(1).min(1).default(8192),
compactionRetries: z.number().step(1).min(0).default(1),
@@ -68,11 +53,9 @@ export class BasicCompactService extends CompactService {
auto: z.boolean().default(true),
})
/** Resolved and validated common configuration plus named partial overrides. */
/** Resolved and validated compaction configuration. */
readonly config: ResolvedConfig
private readonly modelConfigs = new Map<string, ResolvedModelCompactConfig>()
constructor(ctx: Context, config: BasicCompactConfig = {}) {
super(ctx)
this.config = resolveConfig(config, ctx.tokenMeter)
@@ -105,10 +88,6 @@ export class BasicCompactService extends CompactService {
const result = await this.compactIfNeeded(agent, 'pressure', signal)
if (result !== null) logResult(result, 'post-step pressure')
} catch (error: unknown) {
// A named routed model without a meter profile is configuration failure,
// not an optional operational compaction miss.
if (error instanceof TokenMeterError
&& error.code === TOKEN_METER_MODEL_UNCONFIGURED) throw error
const message = error instanceof Error ? error.message : String(error)
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
}
@@ -157,7 +136,7 @@ export class BasicCompactService extends CompactService {
/**
* Compact for replayed post-step pressure or one provider-confirmed context
* overflow. Both triggers price the latest durable routed request model;
* overflow. Both triggers price the latest durable routed request envelope;
* overflow bypasses the normal threshold and retained-tail policy so it can
* force one useful balanced reduction.
* @param agent - agent whose latest durable routed request is measured.
@@ -172,28 +151,21 @@ export class BasicCompactService extends CompactService {
): Promise<CompactionResult | null> {
const model = routedModel(agent.session)
if (model === undefined) return null
const meter = this.ctx.tokenMeter.resolve(model)
const policy = this._modelConfig(meter)
const meter = this.ctx.tokenMeter
if (trigger === 'context-overflow') {
const surface = meter.measureSurface(agent.session)
const range = selectCompactableRange(agent.session, surface, 0)
const measurement = meter.measure(agent.session)
const range = selectCompactableRange(agent.session, measurement, 0)
if (range === null) return null
return this.compactRegion(agent.session, range.start, range.end, agent, signal)
}
const threshold = Math.floor(policy.contextWindow * policy.thresholdRatio)
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
let measurement = meter.measure(agent.session)
if (measurement.totalTokens < threshold) return null
let result: CompactionResult | null = null
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
const surface = meter.measureSurface(agent.session)
if (surface.logRevision !== measurement.logRevision) {
throw new Error(
`compaction: pressure revision ${measurement.logRevision} does not match surface revision ${surface.logRevision}`,
)
}
const range = selectCompactableRange(agent.session, surface, policy.retainTokens)
const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens)
if (range === null) {
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
if (result === null) return null
@@ -213,12 +185,12 @@ export class BasicCompactService extends CompactService {
/**
* Compact one inclusive positional surface range using the effective
* conversation model for all retention and shrink pricing. Reject an agent
* that does not own the exact target before any resolution or mutation.
* token meter for all retention and shrink pricing. Reject an agent that does
* not own the exact target before any mutation.
* @param session - session whose surface is mutated; must equal `agent.session`.
* @param start - inclusive first surface-node seq.
* @param end - inclusive last surface-node seq.
* @param agent - owner of the target session, used by the summarizer and model resolver.
* @param agent - owner of the target session, used by the summarizer.
* @param signal - optional summarization cancellation signal.
* @returns the successful durable compaction result.
*/
@@ -232,27 +204,11 @@ export class BasicCompactService extends CompactService {
if (session !== agent.session) {
throw new Error('compactRegion: agent.session must be the exact target session')
}
const model = effectiveModel(agent)
if (model === undefined || model.length === 0) {
throw new Error('compactRegion: no routed or configured conversation model is available for token pricing')
}
const meter = this.ctx.tokenMeter.resolve(model)
this._modelConfig(meter)
return compactSurfaceRegion({
meter,
meter: this.ctx.tokenMeter,
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
}, session, start, end, agent, signal)
}
/** Resolve and memoize one lazy default/override model policy. */
private _modelConfig(meter: ModelTokenMeter): ResolvedModelCompactConfig {
let modelConfig = this.modelConfigs.get(meter.model)
if (modelConfig === undefined) {
modelConfig = resolveModelConfig(this.config, meter)
this.modelConfigs.set(meter.model, modelConfig)
}
return modelConfig
}
}
export default BasicCompactService

View File

@@ -10,14 +10,14 @@ import {
toolPairingBalancedBefore,
} from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import type { ModelTokenMeter, TokenSurfaceMeasurement } from '@deepseek-ai/dsh-token-meter'
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { frameSummary } from './summarizer.ts'
import type { SummaryResult } from './summarizer.ts'
interface RegionDependencies {
readonly meter: ModelTokenMeter
readonly meter: TokenMeterService
summarize(text: string, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
}
@@ -25,16 +25,16 @@ interface RegionDependencies {
* Resolve the next head-anchored range while retaining a priced recent tail
* and never splitting an assistant tool-call/result pair.
* @param session - session supplying authoritative current surface positions.
* @param pricedSurface - same-revision surface measurement from the conversation meter.
* @param measurement - unified pressure and surface measurement from the conversation meter.
* @param retainTokens - minimum recent tail budget retained verbatim.
* @returns the inclusive positional seq range to compact, or `null`.
*/
export function selectCompactableRange(
session: Session,
pricedSurface: TokenSurfaceMeasurement,
measurement: TokenMeasurement,
retainTokens: number,
): { start: number; end: number } | null {
const pricedNodes = pricedSurface.nodes
const pricedNodes = measurement.nodes
if (pricedNodes.length === 0) return null
const surfaceNodes = session.surface.nodes
@@ -115,8 +115,8 @@ export async function compactSurfaceRegion(
try {
// Capture after the lock event so any later durable append, including a
// log-only one, invalidates the async selection before replacement.
const lockedSurface = dependencies.meter.measureSurface(session)
const selected = lockedSurface.nodes.slice(startIdx, endIdx + 1)
const lockedMeasurement = dependencies.meter.measure(session)
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
if (selected.length !== shadowedSeqs.length
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
throw new Error('compaction: selected surface changed before summarization began')
@@ -125,8 +125,8 @@ export async function compactSurfaceRegion(
const text = renderTranscript(session.events, shadowedSeqs)
const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal)
const currentSurface = dependencies.meter.measureSurface(session)
if (currentSurface.logRevision !== lockedSurface.logRevision) {
const currentMeasurement = dependencies.meter.measure(session)
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
throw new Error('compaction: session log changed during summarization')
}
const framedSummary = frameSummary(summary)

View File

@@ -4,18 +4,12 @@
* @module @deepseek-ai/dsh-compact-basic/types
*/
/** Optional pressure and retention policy for one metered model. */
export interface ModelCompactConfig {
/** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */
/** Basic compaction configuration; every common field has a deployment default. */
export interface BasicCompactConfig {
/** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */
thresholdRatio?: number
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
retainTokens?: number
}
/** Basic compaction configuration; every common field has a deployment default. */
export interface BasicCompactConfig {
/** Field-wise pressure/retention overrides keyed by configured token-meter model name. */
models?: Record<string, ModelCompactConfig>
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
summarizationModel?: string
/** Provider generation cap for summarization. Defaults to `8192`. */
@@ -28,20 +22,13 @@ export interface BasicCompactConfig {
auto?: boolean
}
/** Validated top-level defaults plus detached per-model partial overrides. */
/** Validated and detached compaction configuration. */
export interface ResolvedConfig {
readonly models: Readonly<Record<string, Readonly<ModelCompactConfig>>>
readonly thresholdRatio: number
readonly retainTokens: number
readonly summarizationModel: string
readonly maxTokens: number
readonly compactionRetries: number
readonly maxOverflowRetries: number
readonly auto: boolean
}
/** Fully resolved pressure/retention policy for one effective model. */
export interface ResolvedModelCompactConfig {
readonly model: string
readonly contextWindow: number
readonly thresholdRatio: number
readonly retainTokens: number
}

View File

@@ -1,9 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import BasicCompactService, {
resolveConfig,
resolveModelConfig,
} from '@deepseek-ai/dsh-compact-basic'
import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic'
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
@@ -11,22 +8,15 @@ import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService, {
TOKEN_METER_MODEL_UNCONFIGURED,
TokenMeterError,
} from '@deepseek-ai/dsh-token-meter'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { Agent } from '@deepseek-ai/dsh-agent'
const SIGNAL = new AbortController().signal
const MODEL = 'test-model'
function createContext(
models: Record<string, { contextWindow?: number; charsPerToken?: number }> = {
[MODEL]: { contextWindow: 100, charsPerToken: 1_000 },
},
): Context {
function createContext(contextWindow = 1_000): Context {
const ctx = new Context()
void new TokenMeterService(ctx, { models })
void new TokenMeterService(ctx, { contextWindow })
return ctx
}
@@ -35,7 +25,7 @@ function agent(session: Session, model?: string): Agent {
}
/** Closed two-message turns followed by one open turn for durable compaction events. */
function conversation(turns = 4, text = 'fixture'): Session {
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
const session = new Session(SessionId(`conversation-${turns}`))
for (let turn = 1; turn <= turns; turn += 1) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -140,51 +130,42 @@ async function compactIfNeeded(
}
describe('compact configuration and defaults', () => {
it('uses low-friction common and per-profile defaults', () => {
const ctx = createContext({
[MODEL]: { contextWindow: 100, charsPerToken: 1_000 },
large: { contextWindow: 1_000, charsPerToken: 4 },
})
it('uses low-friction service-wide defaults', () => {
const ctx = createContext()
const resolved = resolveConfig({}, ctx.tokenMeter)
expect(resolved).toEqual({
models: {},
thresholdRatio: 0.8,
retainTokens: 160,
summarizationModel: '',
maxTokens: 8192,
compactionRetries: 1,
maxOverflowRetries: 1,
auto: true,
})
expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve(MODEL))).toEqual({
model: MODEL,
contextWindow: 100,
thresholdRatio: 0.8,
retainTokens: 16,
})
expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve('large')).retainTokens).toBe(160)
expect(Object.isFrozen(resolved)).toBe(true)
})
it('merges threshold and retention overrides field-wise', () => {
it('resolves threshold and retention overrides independently', () => {
const ctx = createContext()
const thresholdOnly = resolveConfig({
models: { [MODEL]: { thresholdRatio: 0.5 } },
}, ctx.tokenMeter)
expect(resolveModelConfig(thresholdOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({
thresholdRatio: 0.5,
retainTokens: 16,
}, ctx.tokenMeter)
expect(thresholdOnly).toMatchObject({
thresholdRatio: 0.5,
retainTokens: 160,
})
const retentionOnly = resolveConfig({
models: { [MODEL]: { retainTokens: 7 } },
retainTokens: 70,
}, ctx.tokenMeter)
expect(resolveModelConfig(retentionOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({
expect(retentionOnly).toMatchObject({
thresholdRatio: 0.8,
retainTokens: 7,
retainTokens: 70,
})
})
it('validates common values and model policy invariants', () => {
it('validates common values and pressure-policy invariants', () => {
const ctx = createContext()
const bad = [
[{ maxTokens: 0 }, /maxTokens/],
@@ -192,33 +173,25 @@ describe('compact configuration and defaults', () => {
[{ maxOverflowRetries: -1 }, /maxOverflowRetries/],
[{ auto: 'yes' }, /auto must be a boolean/],
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
[{ models: null }, /models must be an object/],
[{ models: { [MODEL]: null } }, /must be an object/],
[{ models: { [MODEL]: { thresholdRatio: 0 } } }, /number in \(0, 1\]/],
[{ models: { [MODEL]: { thresholdRatio: 1.1 } } }, /number in \(0, 1\]/],
[{ models: { [MODEL]: { retainTokens: -1 } } }, /non-negative integer/],
[{ models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 50 } } }, /less than threshold/],
[{ thresholdRatio: 0 }, /number in \(0, 1\]/],
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
[{ retainTokens: -1 }, /non-negative integer/],
[{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/],
[{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/],
[{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/],
] as Array<[unknown, RegExp]>
for (const [config, pattern] of bad) {
expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern)
}
})
it('rejects an override for an unknown meter profile with the exact typed error', () => {
const ctx = createContext()
expect(() => resolveConfig({ models: { missing: { retainTokens: 1 } } }, ctx.tokenMeter))
.toThrow(expect.objectContaining({
code: TOKEN_METER_MODEL_UNCONFIGURED,
model: 'missing',
}))
})
})
describe('pressure measurement and retention', () => {
const compactConfig: BasicCompactConfig = {
auto: false,
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.5,
retainTokens: 180,
}
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
@@ -230,15 +203,15 @@ describe('pressure measurement and retention', () => {
expect(compact.calls).toHaveLength(0)
})
it('throws for a named unconfigured model instead of swallowing it', async () => {
it('meters any routed model without profile resolution', async () => {
const compact = service(compactConfig)
const session = conversation()
session.append('request/header', {
header: { config: { model: 'missing' } },
header: { config: { model: 'unlisted-model' } },
reason: 'resume',
})
await expect(compactIfNeeded(compact, session))
.rejects.toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED, model: 'missing' })
.resolves.not.toBeNull()
})
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
@@ -286,16 +259,17 @@ describe('pressure measurement and retention', () => {
it('counts the durable routed request envelope without putting its prefix on the surface', async () => {
const compact = service({
auto: false,
models: { [MODEL]: { thresholdRatio: 0.7, retainTokens: 9 } },
thresholdRatio: 0.9,
retainTokens: 50,
})
const session = conversation(2, 'x'.repeat(2_000))
const session = conversation(2, 'x'.repeat(600))
expect(await compactIfNeeded(compact, session)).toBeNull()
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(10_000) }] }]
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }]
session.append('request/header', {
header: {
config: { model: MODEL },
system: 's'.repeat(5_000),
system: 's'.repeat(600),
messagePrefix: prefix,
},
reason: 'resume',
@@ -306,23 +280,24 @@ describe('pressure measurement and retention', () => {
expect(session.events.some(event => event.type === 'context/message')).toBe(false)
})
it('uses the latest logged routed model instead of AgentOptions.model', async () => {
const ctx = createContext({
actual: { contextWindow: 100, charsPerToken: 1_000 },
fallback: { contextWindow: 10_000, charsPerToken: 1_000 },
})
it('uses the latest logged request envelope without an AgentOptions override', async () => {
const ctx = createContext()
const compact = service({
auto: false,
models: { actual: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.5,
retainTokens: 180,
}, ctx)
const session = conversation(4)
session.append('request/header', {
header: { config: { model: 'actual' } },
reason: 'initial',
})
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
const result = await compactIfNeeded(compact, session, 'pressure', 'fallback')
expect(result).not.toBeNull()
expect(session.requestHeader()?.config.model).toBe('actual')
expect(measure.mock.calls[0]).toEqual([session])
})
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
@@ -343,24 +318,23 @@ describe('pressure measurement and retention', () => {
expect(await compactIfNeeded(compact, retained)).toBeNull()
})
it('detects scalar/surface revision disagreement', async () => {
it('uses one unified measurement for each pressure-and-retention decision', async () => {
const ctx = createContext()
const meter = ctx.tokenMeter.resolve(MODEL)
const original = meter.measureSurface.bind(meter)
vi.spyOn(meter, 'measureSurface').mockImplementation((session) => {
const measurement = original(session)
return { ...measurement, logRevision: measurement.logRevision - 1 }
})
const compact = service(compactConfig, ctx)
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
const stop = new Error('stop after first decision')
vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop)
await expect(compactIfNeeded(compact, conversation(4))).rejects.toThrow(/revision/)
await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop)
expect(measure).toHaveBeenCalledTimes(1)
})
it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
const compact = service({
auto: false,
compactionRetries: 0,
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.3,
retainTokens: 180,
})
compact.summary = Array.from({ length: 7 }, (_, index) => ({
type: 'text',
@@ -374,8 +348,9 @@ describe('pressure measurement and retention', () => {
it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => {
const compact = service({
auto: false,
models: { [MODEL]: { thresholdRatio: 0.8, retainTokens: 8 } },
})
thresholdRatio: 0.8,
retainTokens: 80,
}, createContext(4_000))
const session = toolConversation()
const result = await compactIfNeeded(compact, session)
expect(result).not.toBeNull()
@@ -393,7 +368,7 @@ describe('pressure measurement and retention', () => {
it('rejects a priced surface that is not the current positional surface', () => {
const ctx = createContext()
const session = conversation(2)
const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session)
const priced = ctx.tokenMeter.measure(session)
expect(() => selectCompactableRange(session, {
...priced,
nodes: priced.nodes.slice(1),
@@ -421,7 +396,7 @@ describe('pressure measurement and retention', () => {
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session)
const priced = ctx.tokenMeter.measure(session)
expect(selectCompactableRange(session, priced, 1)).toBeNull()
})
})
@@ -563,9 +538,9 @@ describe('compaction region transaction', () => {
it('rejects a meter snapshot that changed before summarization began', async () => {
const ctx = createContext()
const meter = ctx.tokenMeter.resolve(MODEL)
const original = meter.measureSurface.bind(meter)
vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => {
const meter = ctx.tokenMeter
const original = meter.measure.bind(meter)
vi.spyOn(meter, 'measure').mockImplementationOnce((session) => {
const measurement = original(session)
return { ...measurement, nodes: measurement.nodes.slice(1) }
})
@@ -635,7 +610,7 @@ describe('compaction region transaction', () => {
it('rejects a non-shrinking framed summary under the conversation meter', async () => {
const compact = service()
compact.summary = Array.from({ length: 20 }, (_, index) => ({
compact.summary = Array.from({ length: 100 }, (_, index) => ({
type: 'text',
text: `verbose ${index}`,
}))
@@ -651,19 +626,19 @@ describe('compaction region transaction', () => {
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
})
it('requires a conversation model for pricing', async () => {
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
const compact = service()
const session = new Session(SessionId('model-less-region'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
content: [{ type: 'text', text: 'history' }],
content: [{ type: 'text', text: 'history '.repeat(100) }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'answer' }],
content: [{ type: 'text', text: 'answer '.repeat(100) }],
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
const nodes = session.surface.nodes
@@ -672,7 +647,7 @@ describe('compaction region transaction', () => {
nodes[0]!.seq,
nodes[1]!.seq,
agent(session),
)).rejects.toThrow(/no routed or configured conversation model/)
)).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
})
})
@@ -710,7 +685,7 @@ async function summarizerHarness(
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
const ctx = new Context()
await ctx.plugin(LlmService)
void new TokenMeterService(ctx, { models: { [model]: { contextWindow: 100 } } })
void new TokenMeterService(ctx, { contextWindow: 1_000 })
const adapter = new ScriptedAdapter(blocks, finish)
ctx.llm.registerAdapter([model], adapter)
const compact = new BasicCompactService(ctx, config)
@@ -836,7 +811,8 @@ describe('automatic listener and loader composition', () => {
it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => {
const ctx = createContext()
const compact = new TestCompactService(ctx, {
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.5,
retainTokens: 180,
})
const pressured = conversation(4)
await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
@@ -851,7 +827,8 @@ describe('automatic listener and loader composition', () => {
it('skips post-step pressure when the step signal is already aborted', async () => {
const ctx = createContext()
const compact = new TestCompactService(ctx, {
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.5,
retainTokens: 180,
})
const pressured = conversation(4)
const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
@@ -868,7 +845,8 @@ describe('automatic listener and loader composition', () => {
const warnings: string[] = []
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
const compact = new TestCompactService(ctx, {
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.5,
retainTokens: 180,
})
compact.error = 'temporary failure'
const session = conversation(4)
@@ -878,30 +856,17 @@ describe('automatic listener and loader composition', () => {
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
})
it('propagates a named unknown-model configuration failure', async () => {
const ctx = createContext()
void new TestCompactService(ctx)
const session = conversation(4)
session.append('request/header', {
header: { config: { model: 'missing' } },
reason: 'resume',
})
await expect(postStep(ctx, agent(session, MODEL))).rejects.toMatchObject({
code: TOKEN_METER_MODEL_UNCONFIGURED,
model: 'missing',
})
})
it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => {
const ctx = createContext()
const ctx = createContext(10_000)
void new TestCompactService(ctx, {
models: { [MODEL]: { thresholdRatio: 1, retainTokens: 90 } },
thresholdRatio: 1,
retainTokens: 900,
})
const session = conversation(3)
const beforeGeneration = session.surface.replaceGeneration
const retainedSeq = session.surface.nodes.at(-1)!.seq
const threshold = 100
expect(ctx.tokenMeter.resolve(MODEL).measure(session).totalTokens).toBeLessThan(threshold)
const threshold = 10_000
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
expect(decision).toEqual({ action: 'retry' })
@@ -913,7 +878,8 @@ describe('automatic listener and loader composition', () => {
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
const ctx = createContext()
void new TestCompactService(ctx, {
models: { [MODEL]: { thresholdRatio: 1, retainTokens: 90 } },
thresholdRatio: 1,
retainTokens: 90,
})
const session = toolConversation()
const newestAssistant = session.surface.nodes.at(-2)!
@@ -1010,7 +976,7 @@ describe('automatic listener and loader composition', () => {
expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure'))
})
it('delegates once and preserves the original overflow for an unknown routed meter model', async () => {
it('recovers an overflow for an unlisted routed model', async () => {
const ctx = createContext()
void new TestCompactService(ctx)
const session = conversation(2)
@@ -1018,19 +984,8 @@ describe('automatic listener and loader composition', () => {
header: { config: { model: 'unknown-routed-model' } },
reason: 'resume',
})
const original = overflow('original unknown-model overflow')
let delegations = 0
const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
delegations += 1
return Promise.resolve({ action: 'fail' })
})
expect(decision).toEqual({ action: 'fail' })
expect(delegations).toBe(1)
expect(original).toMatchObject({
message: 'original unknown-model overflow',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
})
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
.toEqual({ action: 'retry' })
})
it('honors retry caps, non-context failures, and cancellation', async () => {
@@ -1065,7 +1020,8 @@ describe('automatic listener and loader composition', () => {
const ctx = createContext()
void new TestCompactService(ctx, {
maxOverflowRetries: 0,
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.5,
retainTokens: 180,
})
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
@@ -1079,7 +1035,8 @@ describe('automatic listener and loader composition', () => {
const ctx = createContext()
void new TestCompactService(ctx, {
auto: false,
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.5,
retainTokens: 180,
})
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
@@ -1093,7 +1050,7 @@ describe('automatic listener and loader composition', () => {
const meterFiber = await ctx.plugin(TokenMeterService)
const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
expect(ctx.tokenMeter.resolve('deepseek-v4-flash').contextWindow).toBe(128_000)
expect(ctx.tokenMeter.contextWindow).toBe(128_000)
expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
await compactFiber.dispose()
expect(ctx.get('compact')).toBeUndefined()
@@ -1104,11 +1061,10 @@ describe('automatic listener and loader composition', () => {
it('removes its automatic listener with the plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(TokenMeterService, {
models: { [MODEL]: { contextWindow: 100, charsPerToken: 1_000 } },
})
await ctx.plugin(TokenMeterService, { contextWindow: 1_000 })
const fiber = await ctx.plugin(TestCompactService, {
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
thresholdRatio: 0.5,
retainTokens: 180,
})
await fiber.dispose()
@@ -1118,17 +1074,3 @@ describe('automatic listener and loader composition', () => {
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
})
})
describe('typed unknown-model boundary', () => {
it('uses TokenMeterError identity rather than message matching', () => {
const ctx = createContext()
let thrown: unknown
try {
ctx.tokenMeter.resolve('missing')
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBeInstanceOf(TokenMeterError)
expect(thrown).toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED })
})
})

View File

@@ -101,9 +101,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, {
models: { mock: { contextWindow: 64, charsPerToken: 1_000 } },
})
await ctx.plugin(TokenMeterService, { contextWindow: 400 })
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
ctx.tools.register(defineTool({
name: 'work',
@@ -113,11 +111,12 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
return [{ type: 'text', text: 'work result' }]
},
}))
// Tiny window so a couple of tool steps cross the threshold and compaction
// fires within the runaway turn.
// Small window so several tool steps cross the threshold and compaction
// fires within the runaway turn after enough history can shrink.
const compact = new ReproCompactService(ctx, {
auto: true,
models: { mock: { thresholdRatio: 0.5, retainTokens: 20 } },
thresholdRatio: 0.5,
retainTokens: 50,
summarizationModel: '',
maxTokens: 8192,
compactionRetries: 1,
@@ -159,7 +158,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
})
it('runs automatic pressure after the current tool result and before step/end', async () => {
const { ctx } = await harness(4)
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' })
agent.send([{ type: 'text', text: 'do tool work' }])
@@ -230,13 +229,12 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, {
models: { mock: { contextWindow: 128, charsPerToken: 4 } },
})
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' }))
await ctx.plugin(BasicCompactService, {
models: { mock: { thresholdRatio: 1, retainTokens: 100 } },
thresholdRatio: 1,
retainTokens: 100,
maxTokens: 64,
compactionRetries: 0,
maxOverflowRetries: 1,

View File

@@ -20,47 +20,75 @@ afterEach(async () => {
root = undefined
})
async function loadYaml(lines: readonly string[]): Promise<Context> {
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [...lines, ''].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
return context
}
describe('real Loader composition', () => {
it('loads the zero-config token-meter then compact-basic YAML pair', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
it('loads the flat token-meter and compact-basic YAML shape', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-token-meter'",
' config:',
' contextWindow: 4096',
"- name: '@deepseek-ai/dsh-compact-basic'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
' config:',
' thresholdRatio: 0.5',
' retainTokens: 512',
' auto: false',
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
const unloaded = [...context.loader.entries()]
const unloaded = [...loaded.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(context.tokenMeter.resolve('deepseek-v4-flash')).toMatchObject({
contextWindow: 128_000,
charsPerToken: 4,
expect(loaded.tokenMeter.contextWindow).toBe(4096)
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
expect((loaded.compact as BasicCompactService).config).toMatchObject({
thresholdRatio: 0.5,
retainTokens: 512,
auto: false,
})
expect(context.get('compact')).toBeInstanceOf(BasicCompactService)
})
it('rejects stale token-meter config after Schemastery normalization', async () => {
context = new Context()
await expect(context.plugin(TokenMeterService, {
models: { legacy: { contextWindow: 4096 } },
} as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/)
})
it('rejects stale compact-basic config after Schemastery normalization', async () => {
context = new Context()
await context.plugin(LlmService)
await context.plugin(TokenMeterService)
await expect(context.plugin(BasicCompactService, {
models: { legacy: { thresholdRatio: 0.5 } },
} as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/)
})
})

View File

@@ -25,9 +25,9 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
## Tool-pairing boundaries
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates the node's seq against current surface membership and resolves the trailing edge from its cached positional successor, so a stale caller-held `node.next` cannot choose the cut.
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper identifies the node by seq alone and answers from balances cached per cut in current surface order, so a stale caller-held `node.next` cannot choose the cut.
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership, successors, and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
## Surface contract

View File

@@ -12,19 +12,21 @@ import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-sessio
interface BalanceCache {
/** Surface rewrite generation this state describes. */
generation: number
/** Number of surface nodes already folded into the state. */
processedNodes: number
/** Balance of the cut immediately before each current surface node. */
beforeSeq: Map<number, boolean>
/** Current positional successor of each surface node. */
successorBySeq: Map<number, number | null>
/** Unanswered tool-call count after the processed surface tail. */
depth: number
/**
* Balance of every surface cut in current order: a surface of N nodes has
* N + 1 cuts, entry `i` being the cut before node `i` and the final entry
* the cut after the surface tail.
*/
cutBalanced: readonly boolean[]
/** Current surface position of each node seq, indexing {@link cutBalanced}. */
indexBySeq: Map<number, number>
/** In-progress tool-call count after the processed surface tail. */
inProgressToolCalls: number
}
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
/** Return how one surface event changes the unanswered tool-call count. */
/** Return how one surface event changes the in-progress tool-call count. */
function nodeDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
@@ -45,61 +47,30 @@ function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): Sessi
return event
}
/** Build balance state for a complete current surface. */
function rebuildCache(
session: Session,
nodes: readonly SurfaceNode[],
generation: number,
): BalanceCache {
const beforeSeq = new Map<number, boolean>()
const successorBySeq = new Map<number, number | null>()
const events = session.events
let depth = 0
let previousSeq: number | undefined
for (const node of nodes) {
beforeSeq.set(node.seq, depth === 0)
successorBySeq.set(node.seq, null)
if (previousSeq !== undefined) successorBySeq.set(previousSeq, node.seq)
depth += nodeDelta(eventForNode(events, node))
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
previousSeq = node.seq
}
return { generation, processedNodes: nodes.length, beforeSeq, successorBySeq, depth }
}
/** Fold a pure surface tail append into existing balance state. */
/** Fold surface nodes not yet in the cache into its balance state. */
function extendCache(
session: Session,
cache: BalanceCache,
nodes: readonly SurfaceNode[],
): BalanceCache {
const tail = nodes.slice(cache.processedNodes)
const processed = cache.cutBalanced.length - 1
const tail = nodes.slice(processed)
// Validate the unseen tail before mutating the live cache, so a corrupt
// append cannot leave a partially advanced state behind.
const events = session.events
const pending: Array<{ seq: number; before: boolean }> = []
let depth = cache.depth
const pendingCuts: boolean[] = []
let inProgressToolCalls = cache.inProgressToolCalls
for (const node of tail) {
pending.push({ seq: node.seq, before: depth === 0 })
depth += nodeDelta(eventForNode(events, node))
if (depth < 0) {
inProgressToolCalls += nodeDelta(eventForNode(events, node))
if (inProgressToolCalls < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
pendingCuts.push(inProgressToolCalls === 0)
}
let previousSeq = nodes[cache.processedNodes - 1]?.seq
for (const entry of pending) {
if (previousSeq !== undefined) cache.successorBySeq.set(previousSeq, entry.seq)
cache.beforeSeq.set(entry.seq, entry.before)
cache.successorBySeq.set(entry.seq, null)
previousSeq = entry.seq
}
cache.processedNodes = nodes.length
cache.depth = depth
tail.forEach((node, offset) => cache.indexBySeq.set(node.seq, processed + offset))
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
cache.inProgressToolCalls = inProgressToolCalls
return cache
}
@@ -110,15 +81,32 @@ function balanceCache(session: Session): BalanceCache {
const generation = surface.replaceGeneration
const cached = balanceCacheBySession.get(session)
if (cached === undefined || cached.generation !== generation || cached.processedNodes > nodes.length) {
const rebuilt = rebuildCache(session, nodes, generation)
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > nodes.length) {
// A rebuild is the same fold started from the empty-surface state, whose
// single leading cut is trivially balanced.
const rebuilt = extendCache(session, {
generation,
cutBalanced: [true],
indexBySeq: new Map(),
inProgressToolCalls: 0,
}, nodes)
balanceCacheBySession.set(session, rebuilt)
return rebuilt
}
if (cached.processedNodes < nodes.length) return extendCache(session, cached, nodes)
if (cached.cutBalanced.length - 1 < nodes.length) return extendCache(session, cached, nodes)
return cached
}
/** Balance of the cut at a node's position plus offset, rejecting seqs outside current membership. */
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
const index = cache.indexBySeq.get(seq)
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
if (balanced === undefined) {
throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
}
return balanced
}
/**
* Whether the cut immediately before a current surface node is tool-pairing balanced.
* @param session - session whose surface is checked.
@@ -128,12 +116,7 @@ function balanceCache(session: Session): BalanceCache {
* matching log event, or a tool result has no preceding open call.
*/
export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean {
const cache = balanceCache(session)
const balanced = cache.beforeSeq.get(node.seq)
if (balanced === undefined) {
throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`)
}
return balanced
return cutBalance(balanceCache(session), node.seq, 0)
}
/**
@@ -145,16 +128,5 @@ export function toolPairingBalancedBefore(session: Session, node: SurfaceNode):
* matching log event, or a tool result has no preceding open call.
*/
export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean {
const cache = balanceCache(session)
const successor = cache.successorBySeq.get(node.seq)
if (successor === undefined) {
throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`)
}
if (successor === null) return cache.depth === 0
// Current membership and positional successors are cache-owned. A caller may
// retain a node across surface changes, so its mutable-looking `next` field is
// never authoritative for this query.
// The successor map and balance map are committed together.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return cache.beforeSeq.get(successor)!
return cutBalance(balanceCache(session), node.seq, 1)
}

View File

@@ -131,7 +131,7 @@ describe('tool-pairing surface identity', () => {
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
})
it('uses the cached positional successor instead of a caller node next field', () => {
it('ignores a caller-held node next field and answers from cached balances', () => {
const session = closedToolStep()
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)

View File

@@ -224,9 +224,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'tokenMeter',
summary: 'Concrete registry and replay owner for all configured model meters.',
summary: 'Replay owner for one service-wide estimator and isolated per-session folds.',
methods: [
'resolve(model: string): ModelTokenMeter',
'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement',
'estimateMessage(message: Message): number',
],
},
{
@@ -776,10 +777,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
},
{
name: 'ModelTokenMeter',
declaration: 'export interface ModelTokenMeter {\n readonly model: string;\n readonly contextWindow: number;\n readonly charsPerToken: number;\n measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement;\n measureSurface(session: Session): TokenSurfaceMeasurement;\n estimateMessage(message: Message): number;\n}',
},
{
name: 'PresetOption',
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
@@ -1010,16 +1007,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'TokenMeasurement',
declaration: 'export interface TokenMeasurement {\n readonly model: string;\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n}',
declaration: 'export interface TokenMeasurement {\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n readonly surfaceTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}',
},
{
name: 'TokenMeasurementBaseline',
declaration: 'export type TokenMeasurementBaseline = {\n readonly kind: \'none\';\n readonly tokens: 0;\n} | {\n readonly kind: \'estimated\';\n readonly tokens: number;\n} | {\n readonly kind: \'usage\';\n readonly tokens: number;\n readonly usage: Readonly<TokenUsage>;\n};',
},
{
name: 'TokenSurfaceMeasurement',
declaration: 'export interface TokenSurfaceMeasurement {\n readonly model: string;\n readonly logRevision: number;\n readonly totalTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}',
},
{
name: 'TokenSurfaceNode',
declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n}',

View File

@@ -5,7 +5,7 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a
| Package | Role | ctx key |
|---|---|---|
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `token-meter/` | Replay-aware, per-model request and surface token measurement | `ctx.tokenMeter` |
| `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` |
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |

View File

@@ -1,29 +1,25 @@
# @deepseek-ai/dsh-token-meter
Replay-aware token measurement through `ctx.tokenMeter`. The service binds one stable meter to each configured model and advances isolated per-model/per-session folds from the durable session log. Compaction consumes it today; other pressure-sensitive plugins can reuse the same accounting without depending on `CompactService`.
Replay-aware token measurement through the singleton `ctx.tokenMeter` service. It advances one isolated fold per session from the durable log, so compaction and other pressure-sensitive plugins can share accounting without depending on `CompactService`.
## Profiles and configuration
The built-in `deepseek-v4-flash` and `deepseek-v4-pro` profiles each use a 128,000-token context window and four characters per estimated token. `models` merges overrides field-by-field, so changing only density keeps the built-in window. A custom model requires `contextWindow`; its `charsPerToken` defaults to `4`.
## Configuration
| Key | Default | Contract |
|---|---:|---|
| `models.<built-in>.contextWindow` | `128000` | Positive integer provider capacity. |
| `models.<model>.charsPerToken` | `4` | Positive finite heuristic density. |
| `contextWindow` | `128000` | Positive integer service-wide context capacity. |
Resolving an unknown model throws `TokenMeterError` with code `TOKEN_METER_MODEL_UNCONFIGURED` and preserves the exact model name. Direct-construction profile validation uses `TOKEN_METER_INVALID_CONFIG`; Loader mounts first apply the package's Schemastery shape validation. There is no universal fallback window.
The estimator intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. `contextWindow` is the only deployment setting. Direct construction validates it; Loader mounts first apply the package's Schemastery shape validation. Unrecognized top-level keys are rejected.
## Measurement contract
`ctx.tokenMeter.resolve(model)` returns a `ModelTokenMeter` with three operations:
`ctx.tokenMeter` directly exposes two operations:
- `measure(session, requestHeader?)` returns scalar request pressure at one consumed-log revision.
- `measureSurface(session)` returns current surface nodes and their per-node prices at the same kind of revision.
- `estimateMessage(message)` prices one detached message under that profile.
- `measure(session, requestHeader?)` returns request pressure and the current priced surface at one consumed-log revision.
- `estimateMessage(message)` prices one message with the fixed heuristic.
Measurements are detached and deeply immutable. A caller that needs a consistent scalar/surface decision compares their `logRevision` values instead of copying the full history on every read.
`measure()` synchronizes once and returns one detached, deeply immutable snapshot. `totalTokens` is request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override affects pressure fields only; the surface fields still describe the current session. Every call clones the positional nodes, so measurement is O(surface).
The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the handle's model and the canonical request envelope match the successful-call anchor. Otherwise the complete current envelope and surface are repriced under the requested model. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.
The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full heuristic anchor; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output.
@@ -34,16 +30,12 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket
- name: '@deepseek-ai/dsh-compact-basic'
```
Both plugins have usable defaults for the bundled DeepSeek profiles. Custom deployments can override only the fields that differ:
Both plugins have usable defaults. A deployment with a different capacity configures the meter once:
```yaml
- name: '@deepseek-ai/dsh-token-meter'
config:
models:
deepseek-v4-flash:
charsPerToken: 2
local-model:
contextWindow: 32768
contextWindow: 32768
```
## Model Experience
@@ -52,6 +44,7 @@ Indirectly, through consumers such as `dsh-compact-basic`; the service itself ad
## Known Limitations and Deferred Work
- **Heuristic density still needs maintenance** — message content without provider usage is priced by configured character density plus structural overhead, not an exact provider tokenizer. CJK-heavy or provider-specific formats may need profile overrides.
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, or call-config changes deliberately fall back to full heuristic repricing.
- **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer.
- **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks.
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, model, or call-config changes deliberately fall back to full heuristic estimation.
- **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-token-meter",
"description": "Replay-aware per-model token measurement service (ctx.tokenMeter) for the DeepSeek Harness",
"description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,192 +1,424 @@
/**
* Replay token-meter service with model-specific context capacity and pricing.
* Single replay-aware token-meter service for request and surface pressure.
*
* @module @deepseek-ai/dsh-token-meter
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { ReplayModelTokenMeter } from './replay.ts'
import type { ModelTokenProfile } from './replay.ts'
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session'
import { applyHeaderDelta, canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import type {
ModelTokenMeter,
ModelTokenMeterConfig,
TokenMeasurement,
TokenMeasurementBaseline,
TokenMeterConfig,
TokenSurfaceNode,
} from './types.ts'
export type * from './types.ts'
/** Exact error code for resolving a model without a configured profile. */
export const TOKEN_METER_MODEL_UNCONFIGURED = 'TOKEN_METER_MODEL_UNCONFIGURED'
/** Default service-wide provider context capacity. */
const DEFAULT_CONTEXT_WINDOW = 128_000
/** Exact error code for invalid token-meter configuration. */
export const TOKEN_METER_INVALID_CONFIG = 'TOKEN_METER_INVALID_CONFIG'
/** Complete public configuration key set. */
const TOKEN_METER_CONFIG_KEYS: ReadonlySet<string> = new Set(['contextWindow'])
/** Closed machine-routable token-meter failure taxonomy. */
export type TokenMeterErrorCode =
| typeof TOKEN_METER_MODEL_UNCONFIGURED
| typeof TOKEN_METER_INVALID_CONFIG
/** Fixed text-density estimate used until exact tokenization is needed. */
const CHARS_PER_TOKEN = 4
/** Built-in DeepSeek model profiles available with zero configuration. */
const BUILTIN_TOKEN_PROFILES: Readonly<Record<string, Readonly<ModelTokenProfile>>> = deepFreeze({
'deepseek-v4-flash': {
model: 'deepseek-v4-flash',
contextWindow: 128_000,
charsPerToken: 4,
},
'deepseek-v4-pro': {
model: 'deepseek-v4-pro',
contextWindow: 128_000,
charsPerToken: 4,
},
})
/** Per-block structural overhead for JSON framing and type tags. */
const BLOCK_OVERHEAD = 4
/** Typed token-meter failure with the affected model preserved for callers. */
export class TokenMeterError extends HarnessError {
declare readonly code: TokenMeterErrorCode
/** Exact model name involved in this error, when applicable. */
readonly model: string | undefined
/** Role-field framing overhead added to every priced message. */
const ROLE_OVERHEAD = 4
constructor(message: string, code: TokenMeterErrorCode, model?: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'TokenMeterError'
this.model = model
interface MeasurementAnchor {
readonly header: EpochHeader | undefined
readonly surfaceTokens: number
readonly baseline: Exclude<TokenMeasurementBaseline, { kind: 'none' }>
}
interface ReplayState {
consumedEvents: number
header: EpochHeader | undefined
surface: TokenSurfaceNode[]
surfaceTokens: number
stepStart: { turn: number; step: number; surfaceTokens: number } | undefined
anchor: MeasurementAnchor | undefined
}
interface PreparedSurfaceMutation {
readonly tokens: number
commit(state: ReplayState): void
}
/** Sum disjoint provider usage buckets without double-counting reasoning output. */
function usageTokens(usage: TokenUsage): number {
return usage.inputTokens
+ (usage.cacheReadTokens ?? 0)
+ (usage.cacheWriteTokens ?? 0)
+ usage.outputTokens
}
/** Compare optional envelopes so a headerless estimate can track later surface deltas. */
function optionalHeaderEquals(
left: EpochHeader | undefined,
right: EpochHeader | undefined,
): boolean {
if (left === undefined || right === undefined) return left === right
return headerEquals(left, right)
}
/** Reject stale or misspelled keys before defaults can hide them. */
function validateConfigKeys(config: TokenMeterConfig): void {
for (const key of Object.keys(config)) {
if (!TOKEN_METER_CONFIG_KEYS.has(key)) {
throw new Error(
`TokenMeterConfig: unknown key "${key}" (allowed: contextWindow)`,
)
}
}
}
/** Resolve and validate the one service-wide context capacity. */
function resolveContextWindow(config: TokenMeterConfig): number {
validateConfigKeys(config)
const contextWindow = config.contextWindow === undefined
? DEFAULT_CONTEXT_WINDOW
: config.contextWindow
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
throw new Error(
`TokenMeterConfig: contextWindow (${contextWindow}) must be a positive integer`,
)
}
return contextWindow
}
declare module 'cordis' {
interface Context {
tokenMeter: TokenMeterService
}
}
/** Validate and detach all configured model profiles. */
function resolveProfiles(config: TokenMeterConfig): readonly ModelTokenProfile[] {
const profiles = new Map<string, ModelTokenProfile>()
for (const profile of Object.values(BUILTIN_TOKEN_PROFILES)) {
profiles.set(profile.model, { ...profile })
}
const configuredValue: unknown = config.models
const configuredModels = configuredValue === undefined ? {} : configuredValue
if (typeof configuredModels !== 'object'
|| configuredModels === null
|| Array.isArray(configuredModels)) {
throw new TokenMeterError(
'TokenMeterConfig: models must be an object',
TOKEN_METER_INVALID_CONFIG,
)
}
for (const [model, override] of Object.entries(configuredModels as Record<string, unknown>)) {
if (model.length === 0) {
throw new TokenMeterError(
'TokenMeterConfig: model names must not be empty',
TOKEN_METER_INVALID_CONFIG,
model,
)
}
assertProfileObject(model, override)
const builtIn = profiles.get(model)
const contextWindow = override.contextWindow ?? builtIn?.contextWindow
const charsPerToken = override.charsPerToken ?? builtIn?.charsPerToken ?? 4
if (contextWindow === undefined) {
throw new TokenMeterError(
`TokenMeterConfig: custom model "${model}" requires contextWindow`,
TOKEN_METER_INVALID_CONFIG,
model,
)
}
assertPositiveInteger(model, 'contextWindow', contextWindow)
assertPositiveFinite(model, 'charsPerToken', charsPerToken)
profiles.set(model, { model, contextWindow, charsPerToken })
}
for (const profile of profiles.values()) {
assertPositiveInteger(profile.model, 'contextWindow', profile.contextWindow)
assertPositiveFinite(profile.model, 'charsPerToken', profile.charsPerToken)
}
return deepFreeze([...profiles.values()].map(profile => ({ ...profile })))
}
function assertProfileObject(model: string, value: unknown): asserts value is ModelTokenMeterConfig {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TokenMeterError(
`TokenMeterConfig: profile "${model}" must be an object`,
TOKEN_METER_INVALID_CONFIG,
model,
)
}
}
function assertPositiveInteger(model: string, name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new TokenMeterError(
`TokenMeterConfig: ${model}.${name} (${value}) must be a positive integer`,
TOKEN_METER_INVALID_CONFIG,
model,
)
}
}
function assertPositiveFinite(model: string, name: string, value: number): void {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new TokenMeterError(
`TokenMeterConfig: ${model}.${name} (${value}) must be a positive finite number`,
TOKEN_METER_INVALID_CONFIG,
model,
)
}
}
/** Concrete registry and replay owner for all configured model meters. */
/** Replay owner for one service-wide estimator and isolated per-session folds. */
export class TokenMeterService extends Service {
static Config: z<TokenMeterConfig> = z.object({
models: z.dict(z.object({
contextWindow: z.number(),
charsPerToken: z.number(),
})),
contextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
})
private readonly meters = new Map<string, ReplayModelTokenMeter>()
/** Provider context-window capacity used by pressure consumers. */
readonly contextWindow: number
private readonly states = new WeakMap<Session, ReplayState>()
constructor(ctx: Context, config: TokenMeterConfig = {}) {
super(ctx, 'tokenMeter')
for (const profile of resolveProfiles(config)) {
this.meters.set(profile.model, new ReplayModelTokenMeter(profile))
}
this.contextWindow = resolveContextWindow(config)
// Readers catch up independently, while eager observation bounds ordinary
// read latency. A reader in an earlier listener consumes the new event;
// this listener then sees the same revision and performs no duplicate fold.
// read latency without creating state for sessions no consumer has read.
ctx.on('session/event', (session) => {
this._observe(session)
if (this.states.has(session)) this._sync(session)
})
}
/**
* Resolve one stable model-bound replay handle.
* @param model - exact routed model name.
* @throws {@link TokenMeterError} with `TOKEN_METER_MODEL_UNCONFIGURED` when no profile exists.
* @returns the configured handle for this model.
* Measure current request pressure and surface through the durable tail.
*
* Provider usage is reused only when the latest successful call's canonical
* request envelope matches `requestHeader` and its total is no lower than
* that call's full heuristic anchor; otherwise the complete envelope and
* surface are heuristically repriced.
*
* `requestHeader` affects request pressure only; surface fields always
* describe the current session surface. Every call clones those positional
* nodes, so measurement is O(surface).
*
* @param session - session to replay through its current durable tail.
* @param requestHeader - optional effective request envelope replacing the latest logged header.
* @returns a detached deeply immutable pressure and surface measurement.
*/
resolve(model: string): ModelTokenMeter {
const meter = this.meters.get(model)
if (meter === undefined) {
throw new TokenMeterError(
`token meter has no profile for model "${model}"`,
TOKEN_METER_MODEL_UNCONFIGURED,
model,
)
measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement {
const state = this._sync(session)
const header = requestHeader === undefined
? state.header
: canonicalHeader(requestHeader)
const anchor = state.anchor
let baseline: TokenMeasurementBaseline
let surfaceDeltaTokens: number
if (anchor !== undefined && optionalHeaderEquals(anchor.header, header)) {
baseline = anchor.baseline
surfaceDeltaTokens = state.surfaceTokens - anchor.surfaceTokens
} else if (header === undefined && state.surfaceTokens === 0) {
baseline = { kind: 'none', tokens: 0 }
surfaceDeltaTokens = 0
} else {
baseline = {
kind: 'estimated',
tokens: this._estimateHeader(header) + state.surfaceTokens,
}
surfaceDeltaTokens = 0
}
return meter
return deepFreeze(structuredClone({
logRevision: state.consumedEvents,
baseline,
surfaceDeltaTokens,
totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens),
surfaceTokens: state.surfaceTokens,
nodes: state.surface,
}))
}
/** Advance every configured model's isolated replay fold. */
private _observe(session: Session): void {
for (const meter of this.meters.values()) meter.observeIfActive(session)
/**
* Heuristically price one model-visible message.
* @param message - message to price without mutation.
* @returns content and role-framing tokens under the fixed service heuristic.
*/
estimateMessage(message: Message): number {
return this._estimateContent(message.content) + ROLE_OVERHEAD
}
/** Catch one session's fold up to the current durable tail. */
private _sync(session: Session): ReplayState {
let state = this.states.get(session)
if (state === undefined) {
state = {
consumedEvents: 0,
header: undefined,
surface: [],
surfaceTokens: 0,
stepStart: undefined,
anchor: undefined,
}
this.states.set(session, state)
}
while (state.consumedEvents < session.events.length) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- contiguous session seqs index the durable log
const event = session.events[state.consumedEvents]!
this._foldEvent(session, state, event)
state.consumedEvents += 1
}
return state
}
/**
* Validate and prepare every fallible part before mutating replay state.
* A malformed event remains unread on every retry instead of partially
* applying the same mutation more than once.
*/
private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void {
let nextHeader = state.header
let nextStepStart = state.stepStart
let nextAnchor = state.anchor
switch (event.type) {
case 'request/header':
nextHeader = canonicalHeader(event.data.header)
break
case 'request/header-delta':
if (state.header === undefined) {
throw new Error(`token meter: request/header-delta at seq ${event.seq} has no preceding header`)
}
nextHeader = applyHeaderDelta(state.header, event.data)
break
case 'step/start':
if (state.stepStart !== undefined) {
throw new Error(
`token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`,
)
}
nextStepStart = { ...event.data, surfaceTokens: state.surfaceTokens }
break
case 'step/end':
if (state.stepStart === undefined
|| state.stepStart.turn !== event.data.turn
|| state.stepStart.step !== event.data.step) {
throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`)
}
nextStepStart = undefined
break
default:
break
}
const surface = isSurfaceEvent(event)
? this._prepareSurfaceMutation(session, state, event)
: undefined
if (event.type === 'assistant/message') {
const stepStart = state.stepStart
if (stepStart === undefined
|| stepStart.turn !== event.data.turn
|| stepStart.step !== event.data.step) {
throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`)
}
// assistant/message is surface-mandatory at every append/seed boundary.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventTokens = surface!.tokens
if (event.data.usage !== undefined && nextHeader !== undefined) {
const providerAssistantTokens = this._estimateProviderAssistant(
session,
event,
eventTokens,
)
const anchorSurfaceTokens = stepStart.surfaceTokens + providerAssistantTokens
const providerTokens = usageTokens(event.data.usage)
const estimatedAnchorTokens = this._estimateHeader(nextHeader) + anchorSurfaceTokens
nextAnchor = {
header: nextHeader,
surfaceTokens: anchorSurfaceTokens,
// Signed heuristic deltas remain conservative only from an anchor
// that is at least as large as the matching full heuristic price.
baseline: providerTokens >= estimatedAnchorTokens
? { kind: 'usage', tokens: providerTokens, usage: event.data.usage }
: { kind: 'estimated', tokens: estimatedAnchorTokens },
}
} else {
const anchorSurfaceTokens = stepStart.surfaceTokens + eventTokens
nextAnchor = {
header: nextHeader,
surfaceTokens: anchorSurfaceTokens,
baseline: {
kind: 'estimated',
tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens,
},
}
}
}
state.header = nextHeader
state.stepStart = nextStepStart
if (surface !== undefined) surface.commit(state)
state.anchor = nextAnchor
}
/** Validate one surface operation and return its allocation-light commit. */
private _prepareSurfaceMutation(
session: Session,
state: ReplayState,
event: SurfaceEvent,
): PreparedSurfaceMutation {
const tokens = this._estimateSurfaceEvent(session, event)
const op = event.surfaceOp
if (op === 'append') {
return {
tokens,
commit(target) {
target.surface.push({ seq: event.seq, tokens })
target.surfaceTokens += tokens
},
}
}
const startIdx = state.surface.findIndex(node => node.seq === op.start)
const endIdx = state.surface.findIndex(node => node.seq === op.end)
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
throw new Error(
`token meter: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`,
)
}
const removedTokens = state.surface
.slice(startIdx, endIdx + 1)
.reduce((total, node) => total + node.tokens, 0)
return {
tokens,
commit(target) {
target.surface.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens })
target.surfaceTokens += tokens - removedTokens
},
}
}
/** Price one current surface event exactly as it projects to a request. */
private _estimateSurfaceEvent(session: Session, event: SurfaceEvent): number {
const message = session.deriveEventMessage(event)
return message === null ? 0 : this.estimateMessage(message)
}
/**
* Reassemble provider output from exact chunk provenance for a usage anchor.
* Missing legacy provenance conservatively treats the durable output as the
* provider output; explicit empty provenance prices a known empty stream.
*/
private _estimateProviderAssistant(
session: Session,
event: SessionEvent<'assistant/message'>,
durableEventTokens: number,
): number {
const sourceSeqs = event.sourceEventSeqs
if (sourceSeqs === undefined) return durableEventTokens
const assembler = new BlockAssembler()
const seen = new Set<number>()
for (const seq of sourceSeqs) {
if (seq >= event.seq) {
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not earlier`)
}
if (seen.has(seq)) {
throw new Error(`token meter: assistant/message at seq ${event.seq} repeats source seq ${seq}`)
}
seen.add(seq)
// Session construction validates contiguous seqs, and the explicit
// earlier-than-assistant check above therefore guarantees existence.
const source = session.events[seq]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sourceEvent = source!
if (sourceEvent.type !== 'assistant/chunk') {
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`)
}
if (sourceEvent.data.turn !== event.data.turn || sourceEvent.data.step !== event.data.step) {
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} belongs to another step`)
}
assembler.push(sourceEvent.data.chunk)
}
const providerMessage = assembler.message()
return providerMessage.content.length === 0 ? 0 : this.estimateMessage(providerMessage)
}
/** Price content blocks recursively under the fixed density heuristic. */
private _estimateContent(blocks: readonly ContentBlock[]): number {
let tokens = 0
for (const block of blocks) {
switch (block.type) {
case 'text':
case 'reasoning':
tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
break
case 'tool-call':
tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN)
+ Math.ceil(block.arguments.length / CHARS_PER_TOKEN)
+ BLOCK_OVERHEAD
break
case 'tool-result':
tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD
break
default:
// ContentBlockMap is merge-extensible; unknown blocks retain a
// conservative structural JSON price under the fixed heuristic.
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
}
}
return tokens
}
/** Price the canonical non-surface request envelope. */
private _estimateHeader(header: EpochHeader | undefined): number {
if (header === undefined) return 0
let tokens = 0
for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message)
if (header.system !== undefined) {
tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
}
if (header.tools !== undefined && header.tools.length > 0) {
tokens += Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
}
return tokens
}
}

View File

@@ -1,367 +0,0 @@
/**
* Model-bound transactional replay of request headers, surface mutations, and
* successful-call token anchors.
*
* @module @deepseek-ai/dsh-token-meter/replay
*/
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session'
import { applyHeaderDelta, canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import type {
ModelTokenMeter,
TokenMeasurement,
TokenMeasurementBaseline,
TokenSurfaceMeasurement,
TokenSurfaceNode,
} from './types.ts'
/** Internal validated pricing profile. */
export interface ModelTokenProfile {
readonly model: string
readonly contextWindow: number
readonly charsPerToken: number
}
/** Per-block structural overhead for JSON framing and type tags. */
const BLOCK_OVERHEAD = 4
/** Role-field framing overhead added to every priced message. */
const ROLE_OVERHEAD = 4
interface UsageAnchor {
readonly header: EpochHeader
readonly surfaceTokens: number
readonly baseline: Exclude<TokenMeasurementBaseline, { kind: 'none' }>
}
interface ReplayState {
consumedEvents: number
header: EpochHeader | undefined
surface: TokenSurfaceNode[]
surfaceTokens: number
stepStart: { turn: number; step: number; surfaceTokens: number } | undefined
anchor: UsageAnchor | undefined
}
interface PreparedSurfaceMutation {
readonly tokens: number
commit(state: ReplayState): void
}
/** Sum disjoint provider usage buckets without double-counting reasoning output. */
function usageTokens(usage: TokenUsage): number {
return usage.inputTokens
+ (usage.cacheReadTokens ?? 0)
+ (usage.cacheWriteTokens ?? 0)
+ usage.outputTokens
}
/** One configured model's replay fold, weakly isolated by session identity. */
export class ReplayModelTokenMeter implements ModelTokenMeter {
readonly model: string
readonly contextWindow: number
readonly charsPerToken: number
private readonly states = new WeakMap<Session, ReplayState>()
constructor(profile: ModelTokenProfile) {
this.model = profile.model
this.contextWindow = profile.contextWindow
this.charsPerToken = profile.charsPerToken
}
/**
* Advance an already-read model/session fold without creating unused state.
* @param session - session whose durable tail advanced.
*/
observeIfActive(session: Session): void {
if (this.states.has(session)) this._sync(session)
}
/** @inheritdoc */
estimateMessage(message: Message): number {
return this._estimateContent(message.content) + ROLE_OVERHEAD
}
/** @inheritdoc */
measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement {
const state = this._sync(session)
const header = requestHeader === undefined
? state.header
: canonicalHeader(requestHeader)
const anchor = state.anchor
let baseline: TokenMeasurementBaseline
let surfaceDeltaTokens: number
if (anchor !== undefined && header !== undefined && headerEquals(anchor.header, header)) {
baseline = anchor.baseline
surfaceDeltaTokens = state.surfaceTokens - anchor.surfaceTokens
} else if (header === undefined && state.surfaceTokens === 0) {
baseline = { kind: 'none', tokens: 0 }
surfaceDeltaTokens = 0
} else {
baseline = {
kind: 'estimated',
tokens: this._estimateHeader(header) + state.surfaceTokens,
}
surfaceDeltaTokens = 0
}
return deepFreeze(structuredClone({
model: this.model,
logRevision: state.consumedEvents,
baseline,
surfaceDeltaTokens,
totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens),
}))
}
/** @inheritdoc */
measureSurface(session: Session): TokenSurfaceMeasurement {
const state = this._sync(session)
return deepFreeze(structuredClone({
model: this.model,
logRevision: state.consumedEvents,
totalTokens: state.surfaceTokens,
nodes: state.surface,
}))
}
/** Catch one session's fold up to the current durable tail. */
private _sync(session: Session): ReplayState {
let state = this.states.get(session)
if (state === undefined) {
state = {
consumedEvents: 0,
header: undefined,
surface: [],
surfaceTokens: 0,
stepStart: undefined,
anchor: undefined,
}
this.states.set(session, state)
}
while (state.consumedEvents < session.events.length) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- contiguous session seqs index the durable log
const event = session.events[state.consumedEvents]!
this._foldEvent(session, state, event)
state.consumedEvents += 1
}
return state
}
/**
* Validate and prepare every fallible part before mutating replay state.
* A malformed event therefore remains the next unread event on every retry
* instead of applying a partial surface mutation twice.
*/
private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void {
let nextHeader = state.header
let nextStepStart = state.stepStart
let nextAnchor = state.anchor
switch (event.type) {
case 'request/header':
nextHeader = canonicalHeader(event.data.header)
break
case 'request/header-delta':
if (state.header === undefined) {
throw new Error(`token meter: request/header-delta at seq ${event.seq} has no preceding header`)
}
nextHeader = applyHeaderDelta(state.header, event.data)
break
case 'step/start':
if (state.stepStart !== undefined) {
throw new Error(
`token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`,
)
}
nextStepStart = { ...event.data, surfaceTokens: state.surfaceTokens }
break
case 'step/end':
if (state.stepStart === undefined
|| state.stepStart.turn !== event.data.turn
|| state.stepStart.step !== event.data.step) {
throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`)
}
nextStepStart = undefined
break
default:
break
}
const surface = isSurfaceEvent(event)
? this._prepareSurfaceMutation(session, state, event)
: undefined
if (event.type === 'assistant/message' && nextHeader?.config.model === this.model) {
const stepStart = state.stepStart
if (stepStart === undefined
|| stepStart.turn !== event.data.turn
|| stepStart.step !== event.data.step) {
throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`)
}
// assistant/message is surface-mandatory at every append/seed boundary.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventTokens = surface!.tokens
if (event.data.usage !== undefined) {
const providerAssistantTokens = this._estimateProviderAssistant(
session,
event,
eventTokens,
)
nextAnchor = {
header: nextHeader,
surfaceTokens: stepStart.surfaceTokens + providerAssistantTokens,
baseline: {
kind: 'usage',
tokens: usageTokens(event.data.usage),
usage: event.data.usage,
},
}
} else {
const anchorSurfaceTokens = stepStart.surfaceTokens + eventTokens
nextAnchor = {
header: nextHeader,
surfaceTokens: anchorSurfaceTokens,
baseline: {
kind: 'estimated',
tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens,
},
}
}
}
state.header = nextHeader
state.stepStart = nextStepStart
if (surface !== undefined) surface.commit(state)
state.anchor = nextAnchor
}
/** Validate one surface operation and return its allocation-light commit. */
private _prepareSurfaceMutation(
session: Session,
state: ReplayState,
event: SurfaceEvent,
): PreparedSurfaceMutation {
const tokens = this._estimateSurfaceEvent(session, event)
const op = event.surfaceOp
if (op === 'append') {
return {
tokens,
commit(target) {
target.surface.push({ seq: event.seq, tokens })
target.surfaceTokens += tokens
},
}
}
const startIdx = state.surface.findIndex(node => node.seq === op.start)
const endIdx = state.surface.findIndex(node => node.seq === op.end)
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
throw new Error(
`token meter: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`,
)
}
const removedTokens = state.surface
.slice(startIdx, endIdx + 1)
.reduce((total, node) => total + node.tokens, 0)
return {
tokens,
commit(target) {
target.surface.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens })
target.surfaceTokens += tokens - removedTokens
},
}
}
/** Price one current surface event exactly as it projects to a request. */
private _estimateSurfaceEvent(session: Session, event: SurfaceEvent): number {
const message = session.deriveEventMessage(event)
return message === null ? 0 : this.estimateMessage(message)
}
/**
* Reassemble provider output from exact chunk provenance for a usage anchor.
* Missing legacy provenance conservatively treats the durable output as the
* provider output; explicit empty provenance prices a known empty stream.
*/
private _estimateProviderAssistant(
session: Session,
event: SessionEvent<'assistant/message'>,
durableEventTokens: number,
): number {
const sourceSeqs = event.sourceEventSeqs
if (sourceSeqs === undefined) return durableEventTokens
const assembler = new BlockAssembler()
const seen = new Set<number>()
for (const seq of sourceSeqs) {
if (seq >= event.seq) {
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not earlier`)
}
if (seen.has(seq)) {
throw new Error(`token meter: assistant/message at seq ${event.seq} repeats source seq ${seq}`)
}
seen.add(seq)
// Session construction validates contiguous seqs, and the explicit
// earlier-than-assistant check above therefore guarantees existence.
const source = session.events[seq]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sourceEvent = source!
if (sourceEvent.type !== 'assistant/chunk') {
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`)
}
if (sourceEvent.data.turn !== event.data.turn || sourceEvent.data.step !== event.data.step) {
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} belongs to another step`)
}
assembler.push(sourceEvent.data.chunk)
}
const providerMessage = assembler.message()
return providerMessage.content.length === 0 ? 0 : this.estimateMessage(providerMessage)
}
/** Price content blocks recursively under this model's density profile. */
private _estimateContent(blocks: readonly ContentBlock[]): number {
let tokens = 0
for (const block of blocks) {
switch (block.type) {
case 'text':
case 'reasoning':
tokens += Math.ceil(block.text.length / this.charsPerToken) + BLOCK_OVERHEAD
break
case 'tool-call':
tokens += Math.ceil(block.name.length / this.charsPerToken)
+ Math.ceil(block.arguments.length / this.charsPerToken)
+ BLOCK_OVERHEAD
break
case 'tool-result':
tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD
break
default:
// ContentBlockMap is merge-extensible; unknown blocks retain a
// conservative structural JSON price under the selected profile.
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / this.charsPerToken)
}
}
return tokens
}
/** Price the canonical non-surface request envelope. */
private _estimateHeader(header: EpochHeader | undefined): number {
if (header === undefined) return 0
let tokens = 0
for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message)
if (header.system !== undefined) {
tokens += Math.ceil(header.system.length / this.charsPerToken) + ROLE_OVERHEAD
}
if (header.tools !== undefined && header.tools.length > 0) {
tokens += Math.ceil(JSON.stringify(header.tools).length / this.charsPerToken) + BLOCK_OVERHEAD
}
return tokens
}
}

View File

@@ -4,21 +4,12 @@
* @module @deepseek-ai/dsh-token-meter/types
*/
import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
/** Optional pricing fields for one configured model. */
export interface ModelTokenMeterConfig {
/** Provider context-window capacity in tokens. Required for a custom model. */
contextWindow?: number
/** Heuristic text density in characters per token. Defaults to `4`. */
charsPerToken?: number
}
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
/** Token-meter plugin configuration. */
export interface TokenMeterConfig {
/** Built-in field overrides and custom model profiles, keyed by routed model name. */
models?: Record<string, ModelTokenMeterConfig>
/** Service-wide context-window capacity in tokens. Defaults to `128000`. */
contextWindow?: number
}
/** The baseline from which a signed surface delta produces current pressure. */
@@ -27,10 +18,8 @@ export type TokenMeasurementBaseline =
| { readonly kind: 'estimated'; readonly tokens: number }
| { readonly kind: 'usage'; readonly tokens: number; readonly usage: Readonly<TokenUsage> }
/** Detached immutable scalar pressure at one consumed session-log revision. */
/** Detached immutable request-pressure and surface snapshot at one consumed log revision. */
export interface TokenMeasurement {
/** Model profile used for every heuristic component. */
readonly model: string
/** Number of durable events consumed; equal to the next unread event seq. */
readonly logRevision: number
/** Provider or heuristic anchor used for this measurement. */
@@ -39,6 +28,10 @@ export interface TokenMeasurement {
readonly surfaceDeltaTokens: number
/** Non-negative current request-and-response pressure. */
readonly totalTokens: number
/** Total heuristic tokens across the current surface. */
readonly surfaceTokens: number
/** Current surface nodes in positional head-to-tail order. */
readonly nodes: readonly TokenSurfaceNode[]
}
/** One token-priced node in the current ordered session surface. */
@@ -48,54 +41,3 @@ export interface TokenSurfaceNode {
/** Heuristic tokens for the exact message projected by this node. */
readonly tokens: number
}
/** Detached immutable priced surface at one consumed session-log revision. */
export interface TokenSurfaceMeasurement {
/** Model profile used to price every node. */
readonly model: string
/** Number of durable events consumed; equal to the next unread event seq. */
readonly logRevision: number
/** Total heuristic tokens across the current surface. */
readonly totalTokens: number
/** Current surface nodes in positional head-to-tail order. */
readonly nodes: readonly TokenSurfaceNode[]
}
/** A model-bound replay meter returned by {@link TokenMeterService.resolve}. */
export interface ModelTokenMeter {
/** Routed model name bound to this handle. */
readonly model: string
/** Provider context-window capacity in tokens. */
readonly contextWindow: number
/** Heuristic text density in characters per token. */
readonly charsPerToken: number
/**
* Measure current request pressure through the session's durable tail.
*
* Provider usage is reused only when its routed model and canonical request
* envelope match `requestHeader`; otherwise the complete envelope and
* surface are heuristically repriced for this handle's model.
*
* @param session - session to replay through its current durable tail.
* @param requestHeader - optional effective request envelope replacing the latest logged header.
* @returns a detached deeply immutable pressure measurement.
*/
measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement
/**
* Price the current surface for retention and replacement decisions.
*
* @param session - session to replay through its current durable tail.
* @returns a detached deeply immutable positional surface measurement.
*/
measureSurface(session: Session): TokenSurfaceMeasurement
/**
* Heuristically price one model-visible message.
*
* @param message - message to price without mutation.
* @returns content and role-framing tokens under this model profile.
*/
estimateMessage(message: Message): number
}

View File

@@ -4,12 +4,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
import type { EpochHeader } from '@deepseek-ai/dsh-session'
import TokenMeterService, {
TOKEN_METER_INVALID_CONFIG,
TOKEN_METER_MODEL_UNCONFIGURED,
TokenMeterError,
} from '@deepseek-ai/dsh-token-meter'
import type { ModelTokenMeter, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { TokenMeasurement, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
function header(model: string, extras: Omit<EpochHeader, 'config'> = {}): EpochHeader {
return canonicalHeader({ config: { model }, ...extras })
@@ -75,70 +71,34 @@ function meter(config: TokenMeterConfig = {}): TokenMeterService {
return new TokenMeterService(new Context(), config)
}
function expectSurfaceTotal(measurement: TokenMeasurement): void {
expect(measurement.nodes.reduce((total, node) => total + node.tokens, 0))
.toBe(measurement.surfaceTokens)
}
describe('TokenMeterService configuration and registration', () => {
it('provides immutable zero-config DeepSeek profiles', () => {
it('provides one zero-config context window', () => {
const service = meter()
expect(service.resolve('deepseek-v4-flash')).toMatchObject({
model: 'deepseek-v4-flash',
contextWindow: 128_000,
charsPerToken: 4,
})
expect(service.resolve('deepseek-v4-pro')).toMatchObject({
model: 'deepseek-v4-pro',
contextWindow: 128_000,
charsPerToken: 4,
})
expect(service.contextWindow).toBe(128_000)
})
it('merges built-in overrides field-wise and defaults custom density', () => {
const service = meter({
models: {
'deepseek-v4-flash': { charsPerToken: 2 },
custom: { contextWindow: 32_000 },
},
})
expect(service.resolve('deepseek-v4-flash')).toMatchObject({ contextWindow: 128_000, charsPerToken: 2 })
expect(service.resolve('deepseek-v4-pro')).toMatchObject({ contextWindow: 128_000, charsPerToken: 4 })
expect(service.resolve('custom')).toMatchObject({ contextWindow: 32_000, charsPerToken: 4 })
it('accepts one service-wide context-window override', () => {
expect(meter({ contextWindow: 32_000 }).contextWindow).toBe(32_000)
})
it('throws a typed exact-code error for unknown models', () => {
const service = meter()
let thrown: unknown
try {
service.resolve('unconfigured-model')
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBeInstanceOf(TokenMeterError)
expect(thrown).toMatchObject({
code: TOKEN_METER_MODEL_UNCONFIGURED,
model: 'unconfigured-model',
})
expect((thrown as Error).message).toContain('unconfigured-model')
it.each(['models', 'contextWidow'])('rejects unknown top-level config key %s', (key) => {
expect(() => meter({ [key]: {} }))
.toThrow(`TokenMeterConfig: unknown key "${key}"`)
})
it.each([
[{ models: null }, /models must be an object/],
[{ models: [] }, /models must be an object/],
[{ models: { custom: {} } }, /requires contextWindow/],
[{ models: { '': { contextWindow: 1 } } }, /must not be empty/],
[{ models: { custom: { contextWindow: 0 } } }, /positive integer/],
[{ models: { custom: { contextWindow: 1.5 } } }, /positive integer/],
[{ models: { custom: { contextWindow: 1, charsPerToken: 0 } } }, /positive finite/],
[{ models: { custom: { contextWindow: 1, charsPerToken: Number.NaN } } }, /positive finite/],
[{ models: { custom: null } }, /must be an object/],
[{ models: { custom: [] } }, /must be an object/],
] as unknown as Array<[TokenMeterConfig, RegExp]>)('rejects invalid profile config %#', (config, pattern) => {
let thrown: unknown
try {
meter(config)
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBeInstanceOf(TokenMeterError)
expect(thrown).toMatchObject({ code: TOKEN_METER_INVALID_CONFIG })
expect((thrown as Error).message).toMatch(pattern)
{ contextWindow: 0 },
{ contextWindow: -1 },
{ contextWindow: 1.5 },
{ contextWindow: Number.NaN },
{ contextWindow: null },
] as unknown as TokenMeterConfig[])('rejects invalid context capacity %#', (config) => {
expect(() => meter(config)).toThrow(/contextWindow .* positive integer/)
})
it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => {
@@ -151,9 +111,9 @@ describe('TokenMeterService configuration and registration', () => {
})
})
describe('ModelTokenMeter pricing', () => {
it('prices every built-in content shape and merge-extended blocks', () => {
const handle = meter({ models: { custom: { contextWindow: 100, charsPerToken: 2 } } }).resolve('custom')
describe('TokenMeterService pricing', () => {
it('prices every built-in content shape and merge-extended blocks with one fixed heuristic', () => {
const service = meter({ contextWindow: 100 })
const blocks: ContentBlock[] = [
{ type: 'text', text: 'abcd' },
{ type: 'reasoning', text: 'ab' },
@@ -166,55 +126,66 @@ describe('ModelTokenMeter pricing', () => {
},
{ type: 'future-block', payload: 'abcd' } as unknown as ContentBlock,
]
const estimated = handle.estimateMessage({ role: 'assistant', content: blocks })
const estimated = service.estimateMessage({ role: 'assistant', content: blocks })
expect(estimated).toBeGreaterThan(30)
expect(handle.estimateMessage(textMessage('abcd'))).toBe(10)
expect(service.estimateMessage(textMessage('abcd'))).toBe(9)
})
it('returns a detached deeply immutable empty measurement', () => {
const handle = meter().resolve('deepseek-v4-flash')
const service = meter()
const session = new Session(SessionId('empty'))
const result = handle.measure(session)
const result = service.measure(session)
expect(result).toEqual({
model: 'deepseek-v4-flash',
logRevision: 0,
baseline: { kind: 'none', tokens: 0 },
surfaceDeltaTokens: 0,
totalTokens: 0,
surfaceTokens: 0,
nodes: [],
})
expect(Object.isFrozen(result)).toBe(true)
expect(Object.isFrozen(result.baseline)).toBe(true)
expect(Object.isFrozen(result.nodes)).toBe(true)
expectSurfaceTotal(result)
expect(() => {
;(result as { totalTokens: number }).totalTokens = 1
}).toThrow(TypeError)
})
it('keeps earlier scalar and surface snapshots detached from later replay', () => {
const handle = meter().resolve('deepseek-v4-flash')
it('keeps an earlier unified snapshot detached from later replay', () => {
const service = meter()
const session = new Session(SessionId('detached'))
session.append('user/message', {
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const scalar = handle.measure(session)
const surface = handle.measureSurface(session)
const scalarCopy = structuredClone(scalar)
const surfaceCopy = structuredClone(surface)
const snapshot = service.measure(session)
const snapshotCopy = structuredClone(snapshot)
expect(Object.isFrozen(snapshot.nodes)).toBe(true)
expect(Object.isFrozen(snapshot.nodes[0])).toBe(true)
expectSurfaceTotal(snapshot)
expect(() => {
;(snapshot.nodes as Array<{ seq: number; tokens: number }>).push({ seq: 99, tokens: 1 })
}).toThrow(TypeError)
expect(() => {
;(snapshot.nodes[0] as { seq: number; tokens: number }).tokens = 1
}).toThrow(TypeError)
session.append('user/message', {
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(handle.measure(session).logRevision).toBe(2)
expect(handle.measureSurface(session).nodes).toHaveLength(2)
expect(scalar).toEqual(scalarCopy)
expect(surface).toEqual(surfaceCopy)
expect(scalar.logRevision).toBe(1)
expect(surface.nodes).toHaveLength(1)
const advanced = service.measure(session)
expect(advanced.logRevision).toBe(2)
expect(advanced.nodes).toHaveLength(2)
expectSurfaceTotal(advanced)
expect(snapshot).toEqual(snapshotCopy)
expect(snapshot.logRevision).toBe(1)
expect(snapshot.nodes).toHaveLength(1)
})
it('prices header, prefix, tools, and surface when no reusable usage exists', () => {
const handle = meter().resolve('deepseek-v4-flash')
const service = meter()
const session = new Session(SessionId('heuristic'))
session.append('user/message', {
content: [{ type: 'text', text: 'question' }],
@@ -225,10 +196,29 @@ describe('ModelTokenMeter pricing', () => {
messagePrefix: [textMessage('prefix')],
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
}))
const result = handle.measure(session)
const result = service.measure(session)
expect(result.baseline.kind).toBe('estimated')
expect(result.totalTokens).toBeGreaterThan(handle.measureSurface(session).totalTokens)
expect(result.totalTokens).toBeGreaterThan(result.surfaceTokens)
expect(result.logRevision).toBe(session.events.length)
expectSurfaceTotal(result)
})
it('keeps request-header overrides out of the returned surface', () => {
const service = meter()
const session = new Session(SessionId('override-surface'))
session.append('user/message', {
content: [{ type: 'text', text: 'question' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const logged = service.measure(session)
const overridden = service.measure(session, header('another-model', {
system: 'large override '.repeat(100),
}))
expect(overridden.totalTokens).toBeGreaterThan(logged.totalTokens)
expect(overridden.surfaceTokens).toBe(logged.surfaceTokens)
expect(overridden.nodes).toEqual(logged.nodes)
expectSurfaceTotal(overridden)
})
})
@@ -242,7 +232,7 @@ describe('replay anchors and surface folds', () => {
}
it('uses disjoint provider usage and signed durable-output rewrites', () => {
const handle = meter().resolve('deepseek-v4-flash')
const service = meter()
const session = new Session(SessionId('usage'))
session.append('user/message', {
content: [{ type: 'text', text: 'before' }],
@@ -253,7 +243,7 @@ describe('replay anchors and surface folds', () => {
durableText: 'a much longer rewritten durable assistant answer',
usage: USAGE,
})
const result = handle.measure(session)
const result = service.measure(session)
expect(result.baseline).toMatchObject({ kind: 'usage', tokens: 34, usage: USAGE })
expect(result.surfaceDeltaTokens).toBeGreaterThan(0)
expect(result.totalTokens).toBe(34 + result.surfaceDeltaTokens)
@@ -262,21 +252,51 @@ describe('replay anchors and surface folds', () => {
}).toThrow(TypeError)
})
it('selects a heuristic anchor when provider usage would undercut its scale', () => {
const service = meter()
const session = new Session(SessionId('low-usage-anchor'))
const system = 'system context'
const requestHeader = header('deepseek-v4-flash', { system })
appendSuccessfulCall(session, requestHeader, {
providerText: 'abcd'.repeat(512),
usage: { inputTokens: 20, outputTokens: 7 },
})
const anchored = service.measure(session)
expect(anchored.baseline.kind).toBe('estimated')
const assistant = anchored.nodes[0]!.seq
session.append('user/message', {
content: [{ type: 'text', text: 'short' }],
source: { kind: 'plugin', plugin: 'test' },
}, {
surfaceOp: { op: 'replace', start: assistant, end: assistant },
sourceEventSeqs: [assistant],
})
const shrunken = service.measure(session)
expect(27 + shrunken.surfaceDeltaTokens).toBeLessThan(0)
expect(shrunken.totalTokens).toBeGreaterThan(0)
expect(shrunken.totalTokens).toBe(service.measure(
session,
header('different-model', { system }),
).totalTokens)
})
it('uses an estimated anchor when provider usage is absent', () => {
const handle = meter().resolve('deepseek-v4-flash')
const service = meter()
const session = new Session(SessionId('missing-usage'))
appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), {
providerText: 'provider',
durableText: 'rewritten',
})
const anchored = handle.measure(session)
const anchored = service.measure(session)
expect(anchored.baseline.kind).toBe('estimated')
expect(anchored.surfaceDeltaTokens).toBe(0)
session.append('user/message', {
content: [{ type: 'text', text: 'later' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const advanced = handle.measure(session)
const advanced = service.measure(session)
expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0)
})
@@ -295,24 +315,17 @@ describe('replay anchors and surface folds', () => {
usage: USAGE,
provenance: 'absent',
})
const handle = meter().resolve('deepseek-v4-flash')
expect(handle.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0)
expect(handle.measure(legacy).surfaceDeltaTokens).toBe(0)
const service = meter()
expect(service.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0)
expect(service.measure(legacy).surfaceDeltaTokens).toBe(0)
})
it('preserves one model anchor across another model success and reuses it after switching back', () => {
const service = meter({
models: {
alpha: { contextWindow: 1000 },
beta: { contextWindow: 1000, charsPerToken: 2 },
},
})
const alpha = service.resolve('alpha')
const beta = service.resolve('beta')
it('keeps only the latest successful request anchor across model switches', () => {
const service = meter({ contextWindow: 1_000 })
const session = new Session(SessionId('switch'))
const alphaHeader = header('alpha', { system: 'same envelope' })
appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' })
expect(alpha.measure(session).baseline.kind).toBe('usage')
expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 34 })
appendSuccessfulCall(session, header('beta'), {
turn: 1,
@@ -320,34 +333,33 @@ describe('replay anchors and surface folds', () => {
usage: { inputTokens: 100, outputTokens: 50 },
providerText: 'beta response',
})
expect(alpha.measure(session).baseline.kind).toBe('estimated')
expect(beta.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 150 })
expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 150 })
appendHeader(session, alphaHeader)
const switchedBack = alpha.measure(session)
expect(switchedBack.baseline).toMatchObject({ kind: 'usage', tokens: 34 })
expect(switchedBack.surfaceDeltaTokens).toBeGreaterThan(0)
const switchedBack = service.measure(session)
expect(switchedBack.baseline.kind).toBe('estimated')
expect(switchedBack.surfaceDeltaTokens).toBe(0)
})
it('invalidates usage for any canonical envelope change or explicit override', () => {
const handle = meter().resolve('deepseek-v4-flash')
const service = meter()
const session = new Session(SessionId('envelope'))
const anchoredHeader = header('deepseek-v4-flash', { system: 'one' })
appendSuccessfulCall(session, anchoredHeader, { usage: USAGE })
expect(handle.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage')
expect(handle.measure(session, header('deepseek-v4-flash', { system: 'two' })).baseline.kind)
expect(service.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage')
expect(service.measure(session, header('deepseek-v4-flash', { system: 'two' })).baseline.kind)
.toBe('estimated')
expect(handle.measure(session, header('deepseek-v4-pro', { system: 'one' })).baseline.kind)
expect(service.measure(session, header('deepseek-v4-pro', { system: 'one' })).baseline.kind)
.toBe('estimated')
expect(handle.measure(session, {
expect(service.measure(session, {
...anchoredHeader,
config: { ...anchoredHeader.config, temperature: 0.2 },
}).baseline.kind).toBe('estimated')
expect(handle.measure(session, {
expect(service.measure(session, {
...anchoredHeader,
messagePrefix: [textMessage('prefix')],
}).baseline.kind).toBe('estimated')
expect(handle.measure(session, {
expect(service.measure(session, {
...anchoredHeader,
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
}).baseline.kind).toBe('estimated')
@@ -357,7 +369,7 @@ describe('replay anchors and surface folds', () => {
const session = new Session(SessionId('header-delta'))
appendHeader(session, header('deepseek-v4-flash'))
session.append('request/header-delta', { config: { model: 'deepseek-v4-pro' } })
const result = meter().resolve('deepseek-v4-flash').measure(session)
const result = meter().measure(session)
expect(result.baseline.kind).toBe('estimated')
expect(result.logRevision).toBe(2)
})
@@ -374,28 +386,27 @@ describe('replay anchors and surface folds', () => {
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const seeded = new Session(SessionId('surface-seeded'), original.events)
const handle = service.resolve('deepseek-v4-flash')
const before = handle.measureSurface(seeded)
const beforeScalar = handle.measure(seeded)
const before = service.measure(seeded)
expect(before.nodes).toHaveLength(2)
expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0)
expect(before.surfaceDeltaTokens).toBeGreaterThan(0)
expectSurfaceTotal(before)
const first = seeded.surface.nodes[0]!.seq
seeded.append('user/message', {
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] })
const after = handle.measureSurface(seeded)
const afterScalar = handle.measure(seeded)
const after = service.measure(seeded)
expect(after.nodes).toHaveLength(2)
expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1)
expect(after.logRevision).toBe(seeded.events.length)
expect(Object.isFrozen(after.nodes)).toBe(true)
expect(Object.isFrozen(after.nodes[0])).toBe(true)
expect(afterScalar.surfaceDeltaTokens).toBeLessThan(0)
expect(after.surfaceDeltaTokens).toBeLessThan(0)
expectSurfaceTotal(after)
expect(before.nodes).toHaveLength(2)
expect(before.logRevision).toBe(original.events.length)
expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0)
expect(before.surfaceDeltaTokens).toBeGreaterThan(0)
})
it('prices an empty assistant surface anchor as zero', () => {
@@ -405,26 +416,27 @@ describe('replay anchors and surface folds', () => {
durableText: '',
provenance: 'empty',
})
const surface = meter().resolve('deepseek-v4-flash').measureSurface(session)
const measurement = meter().measure(session)
const assistant = session.events.find(event => event.type === 'assistant/message')!
expect(surface.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }])
expect(surface.totalTokens).toBe(0)
expect(measurement.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }])
expect(measurement.surfaceTokens).toBe(0)
expectSurfaceTotal(measurement)
})
})
describe('malformed replay and listener lifecycle', () => {
function expectRepeatedFailure(handle: ModelTokenMeter, session: Session, pattern: RegExp): void {
expect(() => handle.measure(session)).toThrow(pattern)
expect(() => handle.measure(session)).toThrow(pattern)
function expectRepeatedFailure(service: TokenMeterService, session: Session, pattern: RegExp): void {
expect(() => service.measure(session)).toThrow(pattern)
expect(() => service.measure(session)).toThrow(pattern)
}
it('rejects a header delta before any snapshot transactionally', () => {
const session = new Session(SessionId('bad-delta'))
session.append('request/header-delta', { config: { model: 'deepseek-v4-flash' } })
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no preceding header/)
expectRepeatedFailure(meter(), session, /no preceding header/)
})
it('rejects a matching-model assistant without its step boundary transactionally', () => {
it('rejects an assistant without its step boundary transactionally', () => {
const session = new Session(SessionId('bad-step'))
appendHeader(session, header('deepseek-v4-flash'))
session.append('assistant/message', {
@@ -432,7 +444,7 @@ describe('malformed replay and listener lifecycle', () => {
step: 1,
content: [{ type: 'text', text: 'bad' }],
}, { surfaceOp: 'append', sourceEventSeqs: [] })
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no matching step\/start/)
expectRepeatedFailure(meter(), session, /no matching step\/start/)
})
it('clears completed step boundaries and rejects overlapping or late step events', () => {
@@ -440,7 +452,7 @@ describe('malformed replay and listener lifecycle', () => {
overlapping.append('step/start', { turn: 1, step: 1 })
overlapping.append('step/start', { turn: 1, step: 2 })
expectRepeatedFailure(
meter().resolve('deepseek-v4-flash'),
meter(),
overlapping,
/arrived before turn 1\/step 1 ended/,
)
@@ -455,7 +467,7 @@ describe('malformed replay and listener lifecycle', () => {
content: [],
}, { surfaceOp: 'append', sourceEventSeqs: [] })
expectRepeatedFailure(
meter().resolve('deepseek-v4-flash'),
meter(),
late,
/no matching step\/start/,
)
@@ -464,7 +476,7 @@ describe('malformed replay and listener lifecycle', () => {
mismatchedEnd.append('step/start', { turn: 1, step: 1 })
mismatchedEnd.append('step/end', { turn: 1, step: 2 })
expectRepeatedFailure(
meter().resolve('deepseek-v4-flash'),
meter(),
mismatchedEnd,
/step\/end .* no matching step\/start/,
)
@@ -509,7 +521,7 @@ describe('malformed replay and listener lifecycle', () => {
content: [{ type: 'text', text: 'bad' }],
usage: { inputTokens: 1, outputTokens: 1 },
}, { surfaceOp: 'append', sourceEventSeqs })
expect(() => meter().resolve('deepseek-v4-flash').measure(session)).toThrow(testCase.pattern)
expect(() => meter().measure(session)).toThrow(testCase.pattern)
}
})
@@ -528,7 +540,7 @@ describe('malformed replay and listener lifecycle', () => {
content: [],
usage: { inputTokens: 1, outputTokens: 0 },
}, { surfaceOp: 'append', sourceEventSeqs: [source, source] })
expect(() => meter().resolve('deepseek-v4-flash').measure(duplicate)).toThrow(/repeats source seq/)
expect(() => meter().measure(duplicate)).toThrow(/repeats source seq/)
const future = new Session(SessionId('future-source'))
future.append('step/start', { turn: 1, step: 1 })
@@ -539,7 +551,7 @@ describe('malformed replay and listener lifecycle', () => {
content: [],
usage: { inputTokens: 1, outputTokens: 0 },
}, { surfaceOp: 'append', sourceEventSeqs: [99] })
expect(() => meter().resolve('deepseek-v4-flash').measure(future)).toThrow(/is not earlier/)
expect(() => meter().measure(future)).toThrow(/is not earlier/)
})
it('does not partially apply a malformed assistant replacement', () => {
@@ -556,7 +568,7 @@ describe('malformed replay and listener lifecycle', () => {
content: [{ type: 'text', text: 'replacement' }],
}, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] })
expectRepeatedFailure(
meter().resolve('deepseek-v4-flash'),
meter(),
session,
/no matching step\/start/,
)
@@ -572,32 +584,32 @@ describe('malformed replay and listener lifecycle', () => {
content: [{ type: 'text', text: 'bad' }],
source: { kind: 'user' },
}, { surfaceOp: { op: 'replace', start: 99, end: 99 }, sourceEventSeqs: [0] })
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /invalid current range/)
expectRepeatedFailure(meter(), session, /invalid current range/)
})
it('handles earlier-reader catch-up, eager observation, and service reload', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let handle: ModelTokenMeter | undefined
let activeMeter: TokenMeterService | undefined
const revisions: number[] = []
ctx.on('session/event', (session) => {
if (handle !== undefined) revisions.push(handle.measure(session).logRevision)
if (activeMeter !== undefined) revisions.push(activeMeter.measure(session).logRevision)
})
const firstFiber = await ctx.plugin(TokenMeterService)
handle = ctx.tokenMeter.resolve('deepseek-v4-flash')
activeMeter = ctx.tokenMeter
const session = ctx.sessions.create(SessionId('listener-order'))
handle.measure(session)
activeMeter.measure(session)
session.append('user/message', {
content: [{ type: 'text', text: 'one' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(revisions).toEqual([1])
expect(handle.measure(session).logRevision).toBe(1)
expect(activeMeter.measure(session).logRevision).toBe(1)
await firstFiber.dispose()
const secondFiber = await ctx.plugin(TokenMeterService)
handle = ctx.tokenMeter.resolve('deepseek-v4-flash')
expect(handle.measure(session).logRevision).toBe(1)
activeMeter = ctx.tokenMeter
expect(activeMeter.measure(session).logRevision).toBe(1)
await secondFiber.dispose()
})
})