Merge remote-tracking branch 'origin/master' into claude/web-llm-pi-ai-config-385e24
# Conflicts: # docs/event-producer-consumer.md
This commit is contained in:
@@ -29,17 +29,99 @@ export interface ToolMessageSource {
|
||||
callId: CallId
|
||||
}
|
||||
|
||||
/**
|
||||
* What SHAPE of information a producer-supplied context carries, declared by
|
||||
* the producer beside its provenance.
|
||||
*
|
||||
* `MessageSource.kind` answers *who produced this*; `form` answers *what kind
|
||||
* of thing it is*, and the two axes are deliberately independent — several
|
||||
* producers share one form (three snapshot producers today), and one producer
|
||||
* may emit more than one form over a session.
|
||||
*
|
||||
* The vocabulary is SEMANTIC, never visual: a value states that the content is
|
||||
* a file's instructions or a catalog of available items, and a consumer decides
|
||||
* what that looks like. Colors, icons, ordering, and collapse defaults are the
|
||||
* consumer's business and must not enter this union. It grows one value at a
|
||||
* time as producers gain the structured fields their form needs; an absent or
|
||||
* unknown value is the documented default, presented as opaque content.
|
||||
*/
|
||||
export type ContextForm =
|
||||
/** Instructions read out of workspace files the model is expected to follow. */
|
||||
| 'instructions'
|
||||
/** A catalog of items available in this session, republished as it changes. */
|
||||
| 'catalog'
|
||||
/** Current state, where a later snapshot from the same producer supersedes an earlier one. */
|
||||
| 'snapshot'
|
||||
/** A one-off account of something that just happened; it supersedes nothing. */
|
||||
| 'notice'
|
||||
/** A message another agent addressed to this one. */
|
||||
| 'relay'
|
||||
/** Material lifted out of another session's log, possibly reduced on the way in. */
|
||||
| 'recall'
|
||||
|
||||
/** One named contribution to a `snapshot`-form context, in assembly order. */
|
||||
export interface ContextSnapshotSection {
|
||||
/** The contributing subsystem's name. */
|
||||
readonly name: string
|
||||
/** That contribution's model-facing text, exactly as assembled. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer-declared {@link ContextForm} and the fields that form requires,
|
||||
* mixed into the source shapes that carry one.
|
||||
*
|
||||
* Discriminated by `form` so a producer cannot declare a shape without the
|
||||
* facts that shape is presented from: a `notice` must record its one-line
|
||||
* account, a `snapshot` its sections. Omitting `form` stays valid — an
|
||||
* undeclared context is the documented default.
|
||||
*/
|
||||
export type ContextFormed =
|
||||
| { readonly form?: never }
|
||||
| { readonly form: 'instructions' }
|
||||
| { readonly form: 'catalog' }
|
||||
| {
|
||||
readonly form: 'snapshot'
|
||||
/** The named contributions this snapshot assembled, in order. */
|
||||
readonly sections: readonly ContextSnapshotSection[]
|
||||
}
|
||||
| {
|
||||
readonly form: 'notice'
|
||||
/** One-line account of what happened, shown without expanding the row. */
|
||||
readonly summary: string
|
||||
}
|
||||
| { readonly form: 'relay' }
|
||||
| { readonly form: 'recall' }
|
||||
|
||||
/**
|
||||
* Where a message (or injected content) came from.
|
||||
* Merge-extensible sum type — plugins add their own `kind`s.
|
||||
*/
|
||||
export interface MessageSourceMap {
|
||||
user: { kind: 'user' }
|
||||
plugin: { kind: 'plugin'; plugin: string }
|
||||
plugin: { kind: 'plugin'; plugin: string } & ContextFormed
|
||||
model: ModelMessageSource
|
||||
tool: ToolMessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound for a `notice` summary. The account rides a collapsed transcript row
|
||||
* and is committed to the durable log, while its inputs — task labels, goal
|
||||
* objectives, tool arguments — are caller text with no length of their own.
|
||||
*/
|
||||
export const CONTEXT_SUMMARY_MAX_CHARS = 120
|
||||
|
||||
/**
|
||||
* Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
|
||||
* @param summary - the producer's one-line account, of any length.
|
||||
* @returns the account, ellipsized when it exceeds the bound.
|
||||
*/
|
||||
export function boundContextSummary(summary: string): string {
|
||||
return summary.length <= CONTEXT_SUMMARY_MAX_CHARS
|
||||
? summary
|
||||
: `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…`
|
||||
}
|
||||
|
||||
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
|
||||
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
|
||||
README.md: 0935a48a5f5773fbb280bc45e07faaa05c0a4f6e
|
||||
README.zh.md: 83282ab47e6d406cfca30bbd8af40e0c94050504
|
||||
README.md: 8f868f25f3c4caf1fdab5b50965aab41efecf5af
|
||||
README.zh.md: 3621105ff35606b62b0587063038116b4772c6cf
|
||||
|
||||
@@ -23,17 +23,21 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket
|
||||
|
||||
## Session projections
|
||||
|
||||
When the composition provides `ctx.sessionProjections`, token-meter registers two units through an optional child fiber.
|
||||
When the composition provides `ctx.sessionProjections`, token-meter registers three units through an optional child fiber.
|
||||
|
||||
`tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again.
|
||||
|
||||
`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and optional `contextWindow` from the newest `request/context` record. Pressure stays absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage.
|
||||
`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — optional `projectedTokens`, and optional `contextWindow` from the newest `request/context` record. Both figures stay absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so `pressureTokens` holds still while a turn streams and steps forward when the next request reports its usage.
|
||||
|
||||
Both units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes both keys. A composition without the projection seam keeps the measurement service's existing behavior.
|
||||
`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero and folded through the same `surface-fold.ts` the measurement service replays. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`.
|
||||
|
||||
`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays `surface-fold.ts` — the same positional fold `measure()` runs — so it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total.
|
||||
|
||||
All three units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes all three keys. A composition without the projection seam keeps the measurement service's existing behavior.
|
||||
|
||||
### Context occupancy is an approximation, by design
|
||||
|
||||
`pressureTokens` and `contextWindow` are independent last-wins fields and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's pressure until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now.
|
||||
The occupancy fields are independent last-wins records and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's sample until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now — `projectedTokens` carries that sample forward over the surface's movement, but its anchor is still the older request.
|
||||
|
||||
This is deliberate. An occupancy percentage is a user-facing reference figure, not a billing record or a gating input — nothing in the harness makes decisions from it, and compaction reads `measure()` instead. A UI computes occupancy by dividing measured pressure by the separately resolved capacity for the selected model.
|
||||
|
||||
|
||||
@@ -23,17 +23,21 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
|
||||
|
||||
## 会话投影
|
||||
|
||||
当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册两个单元。
|
||||
当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册三个单元。
|
||||
|
||||
`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。
|
||||
|
||||
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前压力保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。
|
||||
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和)、可选的 `projectedTokens`,以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前两个数字都保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间 `pressureTokens` 保持不动,等到下一个请求报告用量时才前进。
|
||||
|
||||
两个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这两个键。不带投影 seam 的组合会保留测量服务的既有行为。
|
||||
`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到又跑完一整轮为止。占用率展示读取 `projectedTokens`。
|
||||
|
||||
`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——与 `measure()` 运行的位置折叠是同一份——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点恰好把这些明细行仍然带着的误差排除在外(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。
|
||||
|
||||
三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。
|
||||
|
||||
### 上下文占用率是刻意为之的近似值
|
||||
|
||||
`pressureTokens` 与 `contextWindow` 是两个各自后者胜的独立字段,**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的压力配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层。
|
||||
这些占用率字段各自后者胜、彼此独立,**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的样本配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层——`projectedTokens` 把该样本沿表层的增减推进到当下,但它的锚点仍然是那个较早的请求。
|
||||
|
||||
这是刻意的选择。占用率百分比是面向用户的参考数字,既不是计费记录,也不是门控输入:harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。UI 用测得的压力除以为所选模型单独解析出的容量来计算占用率。
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -41,6 +42,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
69
packages/llm/token-meter/src/breakdown-projection.ts
Normal file
69
packages/llm/token-meter/src/breakdown-projection.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Pure fold for the heuristic context-composition projection: system prompt
|
||||
* and tool schemas from the newest request envelope, conversation from the
|
||||
* live surface. Prices with the same shared estimator as the meter service,
|
||||
* so the three figures match `measure()`'s heuristic vocabulary exactly.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts'
|
||||
import { foldSurfaceProjection } from './surface-projection.ts'
|
||||
import type { ShadowPriceClaim } from './surface-projection.ts'
|
||||
// Import for the `contextBreakdown` SessionProjectionMap key merge.
|
||||
import type {} from './projection.ts'
|
||||
|
||||
interface ContextBreakdownState {
|
||||
systemTokens: number
|
||||
toolsTokens: number
|
||||
messageTokens: number
|
||||
/** Shadow price armed by the immediately preceding metering event. */
|
||||
claim?: ShadowPriceClaim
|
||||
}
|
||||
|
||||
const breakdownSchema = z.object({
|
||||
systemTokens: z.number().int().nonnegative(),
|
||||
toolsTokens: z.number().int().nonnegative(),
|
||||
messageTokens: z.number().int().nonnegative(),
|
||||
}).strict()
|
||||
|
||||
/**
|
||||
* Token-meter's context-composition projection unit.
|
||||
*
|
||||
* Envelope figures are last-wins per `request/header`; the message figure
|
||||
* rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy
|
||||
* projection uses — so it equals `measure().surfaceTokens` at every event
|
||||
* boundary and compaction shrinks it by its logged shadow price, the way it
|
||||
* shrinks the next request. The state is a fixed handful of numbers, so the
|
||||
* persisted checkpoint stays O(1) over the session's life.
|
||||
*/
|
||||
export const contextBreakdownProjectionDefinition:
|
||||
ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = {
|
||||
key: 'contextBreakdown',
|
||||
schema: breakdownSchema,
|
||||
init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }),
|
||||
apply: (state, event) => {
|
||||
const fold = foldSurfaceProjection(state.claim, event)
|
||||
let systemTokens = state.systemTokens
|
||||
let toolsTokens = state.toolsTokens
|
||||
if (event.type === 'request/header') {
|
||||
const header = canonicalHeader(event.data.header)
|
||||
systemTokens = estimateSystemTokens(header)
|
||||
toolsTokens = estimateToolsTokens(header)
|
||||
}
|
||||
if (systemTokens === state.systemTokens
|
||||
&& toolsTokens === state.toolsTokens
|
||||
&& fold.deltaTokens === 0
|
||||
&& fold.claim === undefined
|
||||
&& state.claim === undefined) return state
|
||||
return {
|
||||
systemTokens,
|
||||
toolsTokens,
|
||||
messageTokens: state.messageTokens + fold.deltaTokens,
|
||||
...fold.claim === undefined ? {} : { claim: fold.claim },
|
||||
}
|
||||
},
|
||||
view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }),
|
||||
stateVersion: 2,
|
||||
}
|
||||
87
packages/llm/token-meter/src/estimate.ts
Normal file
87
packages/llm/token-meter/src/estimate.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Fixed-density heuristic token pricing shared by the meter service and the
|
||||
* pure context-breakdown projection, so both surfaces price identical content
|
||||
* to identical numbers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-token-meter/estimate
|
||||
*/
|
||||
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { EpochHeader } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Fixed text-density estimate used until exact tokenization is needed. */
|
||||
const CHARS_PER_TOKEN = 4
|
||||
|
||||
/** Per-block structural overhead for JSON framing and type tags. */
|
||||
const BLOCK_OVERHEAD = 4
|
||||
|
||||
/** Role-field framing overhead added to every priced message. */
|
||||
export const ROLE_OVERHEAD = 4
|
||||
|
||||
/**
|
||||
* Price content blocks recursively under the fixed density heuristic.
|
||||
* @param blocks - content blocks to price without mutation.
|
||||
* @returns heuristic tokens including per-block structural overhead.
|
||||
*/
|
||||
export function 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 += 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristically price one model-visible message.
|
||||
* @param message - message to price without mutation.
|
||||
* @returns content and role-framing tokens under the fixed heuristic.
|
||||
*/
|
||||
export function estimateMessage(message: Message): number {
|
||||
return estimateContent(message.content) + ROLE_OVERHEAD
|
||||
}
|
||||
|
||||
/**
|
||||
* Price the system-prompt part of a canonical request envelope.
|
||||
* @param header - canonical envelope, or undefined before any request.
|
||||
* @returns heuristic system-prompt tokens; 0 when absent.
|
||||
*/
|
||||
export function estimateSystemTokens(header: EpochHeader | undefined): number {
|
||||
if (header?.system === undefined) return 0
|
||||
return Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
|
||||
}
|
||||
|
||||
/**
|
||||
* Price the tool-schema part of a canonical request envelope.
|
||||
* @param header - canonical envelope, or undefined before any request.
|
||||
* @returns heuristic tool-schema tokens; 0 when absent or empty.
|
||||
*/
|
||||
export function estimateToolsTokens(header: EpochHeader | undefined): number {
|
||||
if (header?.tools === undefined || header.tools.length === 0) return 0
|
||||
return Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
|
||||
}
|
||||
|
||||
/**
|
||||
* Price the complete non-surface request envelope.
|
||||
* @param header - canonical envelope, or undefined before any request.
|
||||
* @returns heuristic system plus tool tokens.
|
||||
*/
|
||||
export function estimateHeader(header: EpochHeader | undefined): number {
|
||||
return estimateSystemTokens(header) + estimateToolsTokens(header)
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
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 type { Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: resolves the optional projection registry Context seam.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -18,19 +18,13 @@ import type {
|
||||
TokenMeterConfig,
|
||||
TokenSurfaceNode,
|
||||
} from './types.ts'
|
||||
import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts'
|
||||
import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts'
|
||||
import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from './estimate.ts'
|
||||
import { foldSurfaceTokens } from './surface-fold.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
/** Fixed text-density estimate used until exact tokenization is needed. */
|
||||
const CHARS_PER_TOKEN = 4
|
||||
|
||||
/** 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 MeasurementAnchor {
|
||||
readonly header: EpochHeader | undefined
|
||||
readonly surfaceTokens: number
|
||||
@@ -46,11 +40,6 @@ interface ReplayState {
|
||||
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
|
||||
@@ -98,6 +87,7 @@ export class TokenMeterService extends Service {
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition)
|
||||
projectionCtx.sessionProjections.register(contextPressureProjectionDefinition)
|
||||
projectionCtx.sessionProjections.register(contextBreakdownProjectionDefinition)
|
||||
})
|
||||
|
||||
// Readers catch up independently, while eager observation bounds ordinary
|
||||
@@ -141,7 +131,7 @@ export class TokenMeterService extends Service {
|
||||
} else {
|
||||
baseline = {
|
||||
kind: 'estimated',
|
||||
tokens: this._estimateHeader(header) + state.surfaceTokens,
|
||||
tokens: estimateHeader(header) + state.surfaceTokens,
|
||||
}
|
||||
surfaceDeltaTokens = 0
|
||||
}
|
||||
@@ -157,12 +147,13 @@ export class TokenMeterService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristically price one model-visible message.
|
||||
* Heuristically price one model-visible message (instance face of the pure
|
||||
* `estimateMessage` export from `estimate.ts`).
|
||||
* @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
|
||||
return estimateMessage(message)
|
||||
}
|
||||
|
||||
/** Catch one session's fold up to the current durable tail. */
|
||||
@@ -224,7 +215,7 @@ export class TokenMeterService extends Service {
|
||||
}
|
||||
|
||||
const surface = isSurfaceEvent(event)
|
||||
? this._prepareSurfaceMutation(session, state, event)
|
||||
? foldSurfaceTokens(state.surface, event)
|
||||
: undefined
|
||||
|
||||
if (event.type === 'assistant/message') {
|
||||
@@ -246,7 +237,7 @@ export class TokenMeterService extends Service {
|
||||
)
|
||||
const anchorSurfaceTokens = stepStart.surfaceTokens + providerAssistantTokens
|
||||
const providerTokens = usageTokens(event.data.usage)
|
||||
const estimatedAnchorTokens = this._estimateHeader(nextHeader) + anchorSurfaceTokens
|
||||
const estimatedAnchorTokens = estimateHeader(nextHeader) + anchorSurfaceTokens
|
||||
nextAnchor = {
|
||||
header: nextHeader,
|
||||
surfaceTokens: anchorSurfaceTokens,
|
||||
@@ -263,7 +254,7 @@ export class TokenMeterService extends Service {
|
||||
surfaceTokens: anchorSurfaceTokens,
|
||||
baseline: {
|
||||
kind: 'estimated',
|
||||
tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens,
|
||||
tokens: estimateHeader(nextHeader) + anchorSurfaceTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -271,53 +262,13 @@ export class TokenMeterService extends Service {
|
||||
|
||||
state.header = nextHeader
|
||||
state.stepStart = nextStepStart
|
||||
if (surface !== undefined) surface.commit(state)
|
||||
if (surface !== undefined) {
|
||||
state.surface = surface.nodes
|
||||
state.surfaceTokens += surface.deltaTokens
|
||||
}
|
||||
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
|
||||
@@ -355,46 +306,7 @@ export class TokenMeterService extends Service {
|
||||
assembler.push(sourceEvent.data.chunk)
|
||||
}
|
||||
const providerContent = assembler.blocks()
|
||||
return providerContent.length === 0 ? 0 : this._estimateContent(providerContent) + ROLE_OVERHEAD
|
||||
}
|
||||
|
||||
/** 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
|
||||
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
|
||||
return providerContent.length === 0 ? 0 : estimateContent(providerContent) + ROLE_OVERHEAD
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ export const inject = ['invariants']
|
||||
/**
|
||||
* No runtime invariant: token estimates are per-call outputs and the private
|
||||
* session cache is invalidated at its event mutation boundary. The package's
|
||||
* projection does expose an observation stream, but its schema fixes the JSON
|
||||
* payload and its pure fold replaces same-step samples; totals need not be
|
||||
* monotone when a final usage sample corrects an earlier chunk.
|
||||
* three projections do expose observation streams, but their schemas fix the
|
||||
* JSON payloads; the usage folds replace same-step samples, so totals need not
|
||||
* be monotone when a final sample corrects an earlier chunk, and the
|
||||
* composition fold prices through the same `estimate.ts` heuristic as the
|
||||
* measurement service and subtracts producer-logged shadow prices derived
|
||||
* from that service's own nodes, which makes its message figure equal
|
||||
* `measure().surfaceTokens` by construction rather than by a relation worth
|
||||
* observing at runtime.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -20,13 +20,12 @@ export interface TokenUsageProjection {
|
||||
/**
|
||||
* Approximate context occupancy for a status display.
|
||||
*
|
||||
* The two fields, when present, are deliberately NOT one atomic request
|
||||
* observation: `pressureTokens` is the newest provider-reported prompt size,
|
||||
* `contextWindow` the newest recorded route capacity. Switching models can
|
||||
* therefore pair a fresh capacity with the previous route's pressure until the
|
||||
* next request reports usage. This is an intentional trade — the value is a
|
||||
* user-facing reference, not a billing or gating input. See the token-meter
|
||||
* README for the full rationale.
|
||||
* The fields, when present, are deliberately NOT one atomic request
|
||||
* observation: each is a last-wins record of a different moment. Switching
|
||||
* models can therefore pair a fresh capacity with the previous route's
|
||||
* pressure until the next request reports usage. This is an intentional trade
|
||||
* — the value is a user-facing reference, not a billing or gating input. See
|
||||
* the token-meter README for the full rationale.
|
||||
*/
|
||||
export interface ContextPressureProjection {
|
||||
/**
|
||||
@@ -35,15 +34,44 @@ export interface ContextPressureProjection {
|
||||
* grow as the current turn streams. Absent until a provider reports usage.
|
||||
*/
|
||||
pressureTokens?: number
|
||||
/**
|
||||
* What the NEXT request's prompt would cost: {@link pressureTokens} plus the
|
||||
* heuristic repricing of everything the surface gained or lost since that
|
||||
* sample. Only the delta is estimated, so the figure stays anchored to the
|
||||
* provider while still reacting the moment a compaction shadows a span —
|
||||
* which `pressureTokens` alone cannot do, since compaction reports no usage
|
||||
* of its own. Absent until a provider reports usage.
|
||||
*/
|
||||
projectedTokens?: number
|
||||
/** Newest recorded route capacity; absent when no adapter advertised one. */
|
||||
contextWindow?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic composition of the next request's context: what the prompt is
|
||||
* made of, not what it costs. All three figures use the meter's fixed
|
||||
* density estimate, so they will not sum to the provider-anchored
|
||||
* `projectedTokens`: the estimator systematically underprices CJK text and
|
||||
* JSON schemas, which is exactly the error the anchoring in
|
||||
* {@link ContextPressureProjection.projectedTokens} keeps out of the occupancy
|
||||
* figure. Present these as approximations of composition, never as a total.
|
||||
*/
|
||||
export interface ContextBreakdownProjection {
|
||||
/** Heuristic tokens of the newest request envelope's system prompt; 0 before any request. */
|
||||
systemTokens: number
|
||||
/** Heuristic tokens of the newest request envelope's tool schemas; 0 before any request. */
|
||||
toolsTokens: number
|
||||
/** Heuristic tokens of the current model-visible conversation surface. */
|
||||
messageTokens: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Provider-reported usage accumulated across the complete durable log. */
|
||||
tokenUsage: TokenUsageProjection
|
||||
/** Newest request pressure paired with the newest known route capacity. */
|
||||
contextPressure: ContextPressureProjection
|
||||
/** Heuristic system/tools/message composition of the next request. */
|
||||
contextBreakdown: ContextBreakdownProjection
|
||||
}
|
||||
}
|
||||
|
||||
64
packages/llm/token-meter/src/surface-fold.ts
Normal file
64
packages/llm/token-meter/src/surface-fold.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The measurement service's positional surface fold: the per-node priced
|
||||
* surface `measure()` serves and compaction plans against. The projection
|
||||
* units deliberately do NOT share this fold — their state must stay O(1)
|
||||
* for the persisted checkpoint, so they ride `surface-projection.ts`'s
|
||||
* shadow-price protocol instead. The two stay in agreement by construction:
|
||||
* both price through `estimate.ts`, and every logged shadow price is derived
|
||||
* from THIS fold's nodes by the replace producer.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-token-meter/surface-fold
|
||||
*/
|
||||
|
||||
import { deriveEventMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { TokenSurfaceNode } from './types.ts'
|
||||
import { estimateMessage } from './estimate.ts'
|
||||
|
||||
/** One surface event's placement and cost against the surface preceding it. */
|
||||
export interface SurfaceTokenFold {
|
||||
/** Heuristic price of the event's own message; 0 when it derives none. */
|
||||
readonly tokens: number
|
||||
/** The surface after the event, detached from the input. */
|
||||
readonly nodes: TokenSurfaceNode[]
|
||||
/** Signed change in the surface total: `tokens` minus anything shadowed. */
|
||||
readonly deltaTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one surface event onto a priced surface.
|
||||
*
|
||||
* Total and allocation-fresh: the caller assigns the result rather than
|
||||
* mutating in place, so a throw here leaves the caller's state untouched and
|
||||
* the same malformed event fails identically on every retry.
|
||||
* @param nodes - the priced surface preceding this event, in model-visible order.
|
||||
* @param event - the surface event to place.
|
||||
* @returns the event's price, the next surface, and the signed total delta.
|
||||
* @throws when a replacement names a range absent from `nodes` — committed
|
||||
* logs are surface-validated at append time, so an unresolvable range is log
|
||||
* corruption and must fail loud rather than skip the event.
|
||||
*/
|
||||
export function foldSurfaceTokens(
|
||||
nodes: readonly TokenSurfaceNode[],
|
||||
event: SurfaceEvent,
|
||||
): SurfaceTokenFold {
|
||||
const message = deriveEventMessage(event)
|
||||
const tokens = message === null ? 0 : estimateMessage(message)
|
||||
const op = event.surfaceOp
|
||||
if (op === 'append') {
|
||||
return { tokens, nodes: [...nodes, { seq: event.seq, tokens }], deltaTokens: tokens }
|
||||
}
|
||||
const startIdx = nodes.findIndex(node => node.seq === op.start)
|
||||
const endIdx = nodes.findIndex(node => node.seq === op.end)
|
||||
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
|
||||
throw new Error(
|
||||
`token surface: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`,
|
||||
)
|
||||
}
|
||||
const removed = nodes
|
||||
.slice(startIdx, endIdx + 1)
|
||||
.reduce((total, node) => total + node.tokens, 0)
|
||||
const next = [...nodes]
|
||||
next.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens })
|
||||
return { tokens, nodes: next, deltaTokens: tokens - removed }
|
||||
}
|
||||
84
packages/llm/token-meter/src/surface-projection.ts
Normal file
84
packages/llm/token-meter/src/surface-projection.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The O(1) surface-token fold shared by the token-meter projection units.
|
||||
*
|
||||
* A projection state must stay bounded — the persisted projection cache
|
||||
* checkpoints every unit's whole state, so carrying the priced surface
|
||||
* (one node per model-visible message) would grow a checkpoint without
|
||||
* bound over the session's life. Instead, replacements ride the compact
|
||||
* seam's shadow-price protocol: the metering event immediately before a
|
||||
* surface `replace` (`compact/summary` or `compact/prune`) states the
|
||||
* heuristic price of the exact replaced range, so the fold keeps a running
|
||||
* total plus at most one pending claim and never retains per-node prices.
|
||||
* The counts are exact by construction: producers derive them from the same
|
||||
* fixed estimator this module prices appends with.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-token-meter/surface-projection
|
||||
*/
|
||||
|
||||
import { deriveEventMessage, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: the `compact/*` SessionEventMap merges (shadow-price events).
|
||||
import type {} from '@deepseek-ai/dsh-compact'
|
||||
import { estimateMessage } from './estimate.ts'
|
||||
|
||||
/**
|
||||
* One armed shadow price: the heuristic tokens of the surface range the
|
||||
* IMMEDIATELY following event replaces. Plain JSON — it is part of the
|
||||
* persisted unit state while armed.
|
||||
*/
|
||||
export interface ShadowPriceClaim {
|
||||
/** Declared inclusive first surface-node seq of the priced range. */
|
||||
start: number
|
||||
/** Declared inclusive last surface-node seq of the priced range. */
|
||||
end: number
|
||||
/** Heuristic tokens of the priced range under the fixed estimator. */
|
||||
tokens: number
|
||||
}
|
||||
|
||||
/** One event's effect on a running surface-token total. */
|
||||
export interface SurfaceTokensFold {
|
||||
/** Signed change in the surface total; 0 for events off the surface. */
|
||||
readonly deltaTokens: number
|
||||
/** Claim to carry into the next event; undefined when none survives. */
|
||||
readonly claim: ShadowPriceClaim | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one committed event onto a running surface-token total.
|
||||
*
|
||||
* A shadow-price event arms a claim; any other event expires it, and a
|
||||
* surface `replace` must consume a claim naming its exact range — the
|
||||
* producers append the metering event and the replacement synchronously
|
||||
* adjacent, so a surviving claim always prices the very next event.
|
||||
* @param claim - the claim armed by the immediately preceding event, if any.
|
||||
* @param event - the next committed session event.
|
||||
* @returns the signed token delta and the claim state after this event.
|
||||
* @throws when a replacement arrives without a claim for its exact range —
|
||||
* every in-repo replace producer meters its replacement, so an unpriced
|
||||
* replacement is a shadow-price contract violation and must fail loud
|
||||
* rather than let the total drift.
|
||||
*/
|
||||
export function foldSurfaceProjection(
|
||||
claim: ShadowPriceClaim | undefined,
|
||||
event: SessionEvent,
|
||||
): SurfaceTokensFold {
|
||||
if (event.type === 'compact/summary' || event.type === 'compact/prune') {
|
||||
const { shadowedRange, shadowedTokenCount } = event.data
|
||||
return {
|
||||
deltaTokens: 0,
|
||||
claim: { start: shadowedRange.start, end: shadowedRange.end, tokens: shadowedTokenCount },
|
||||
}
|
||||
}
|
||||
if (!isSurfaceEvent(event)) return { deltaTokens: 0, claim: undefined }
|
||||
const message = deriveEventMessage(event)
|
||||
const tokens = message === null ? 0 : estimateMessage(message)
|
||||
const op = event.surfaceOp
|
||||
if (op === 'append') return { deltaTokens: tokens, claim: undefined }
|
||||
if (claim === undefined || claim.start !== op.start || claim.end !== op.end) {
|
||||
throw new Error(
|
||||
`token surface: replace at seq ${event.seq} over range ${op.start}-${op.end} has no adjacent shadow price`
|
||||
+ (claim === undefined ? '' : ` (armed claim covers ${claim.start}-${claim.end})`),
|
||||
)
|
||||
}
|
||||
return { deltaTokens: tokens - claim.tokens, claim: undefined }
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
|
||||
export type { ContextBreakdownProjection, ContextPressureProjection, TokenUsageProjection } from './projection.ts'
|
||||
|
||||
/** Token-meter plugin configuration; the fixed estimator has no settings. */
|
||||
export type TokenMeterConfig = Record<string, never>
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
|
||||
import { foldSurfaceProjection } from './surface-projection.ts'
|
||||
import type { ShadowPriceClaim } from './surface-projection.ts'
|
||||
|
||||
interface UsageSample {
|
||||
turn: number
|
||||
@@ -60,6 +63,7 @@ const projectionSchema = z.object({
|
||||
// `number | undefined` where the interface declares absent-or-number fields.
|
||||
const pressureSchema = z.object({
|
||||
pressureTokens: z.number().int().nonnegative().optional(),
|
||||
projectedTokens: z.number().int().nonnegative().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
}).strict() as unknown as z.ZodType<ContextPressureProjection>
|
||||
|
||||
@@ -67,6 +71,29 @@ const pressureSchema = z.object({
|
||||
const pressureFrom = (usage: TokenUsage): number =>
|
||||
usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
||||
|
||||
/** The usage a chunk or finalized message reports for its step, if any. */
|
||||
const usageOf = (event: SessionEvent): TokenUsage | undefined =>
|
||||
event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
|
||||
? event.data.chunk.usage
|
||||
: event.type === 'assistant/message'
|
||||
? event.data.usage
|
||||
: undefined
|
||||
|
||||
/**
|
||||
* Context-occupancy state: the two independent last-wins records plus the
|
||||
* O(1) running surface total needed to carry the newest sample forward.
|
||||
*/
|
||||
interface ContextPressureState {
|
||||
contextWindow?: number
|
||||
pressureTokens?: number
|
||||
/** Running heuristic total over the current surface ({@link foldSurfaceProjection}). */
|
||||
surfaceTokens: number
|
||||
/** {@link surfaceTokens} at the newest usage sample; absent until one lands. */
|
||||
sampledSurfaceTokens?: number
|
||||
/** Shadow price armed by the immediately preceding metering event. */
|
||||
claim?: ShadowPriceClaim
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-meter's session projection unit.
|
||||
*
|
||||
@@ -115,39 +142,64 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = {
|
||||
/**
|
||||
* Token-meter's context-occupancy projection unit.
|
||||
*
|
||||
* Two independent last-wins slots: the newest usage sample supplies the
|
||||
* Independent last-wins slots: the newest usage sample supplies the provider
|
||||
* numerator, the newest `request/context` record the denominator. Both are
|
||||
* whole values, so replay order alone decides the result and no cross-field
|
||||
* consistency is claimed — the pair is explicitly not one atomic request
|
||||
* observation (see {@link ContextPressureProjection}).
|
||||
*
|
||||
* The numerator is prompt-side only, so it holds still while a turn streams
|
||||
* and steps forward once the next request reports its usage.
|
||||
* `pressureTokens` is prompt-side only, so it holds still while a turn streams
|
||||
* and steps forward once the next request reports its usage. Because nothing
|
||||
* but a request reports usage, it also cannot see a compaction: the fold
|
||||
* therefore carries a running surface total alongside it and publishes
|
||||
* `projectedTokens` — the sample plus the surface's signed movement since it
|
||||
* was taken — so occupancy answers for the next request rather than the last
|
||||
* one. The total rides {@link foldSurfaceProjection}, so the state stays O(1)
|
||||
* and a replacement shrinks it by its logged shadow price. A usage sample is
|
||||
* stamped BEFORE the same event joins the surface, so an `assistant/message`
|
||||
* anchors against the surface its own request saw.
|
||||
*/
|
||||
export const contextPressureProjectionDefinition:
|
||||
ProjectionDefinition<'contextPressure', ContextPressureProjection> = {
|
||||
ProjectionDefinition<'contextPressure', ContextPressureState> = {
|
||||
key: 'contextPressure',
|
||||
schema: pressureSchema,
|
||||
init: () => ({}),
|
||||
init: () => ({ surfaceTokens: 0 }),
|
||||
apply: (state, event) => {
|
||||
const fold = foldSurfaceProjection(state.claim, event)
|
||||
let next = state
|
||||
if (event.type === 'request/context') {
|
||||
const contextWindow = event.data.contextWindow
|
||||
if (contextWindow === state.contextWindow) return state
|
||||
if (contextWindow !== undefined) return { ...state, contextWindow }
|
||||
const { contextWindow: _removed, ...withoutContextWindow } = state
|
||||
return withoutContextWindow
|
||||
if (contextWindow !== state.contextWindow) {
|
||||
if (contextWindow !== undefined) {
|
||||
next = { ...next, contextWindow }
|
||||
} else {
|
||||
const { contextWindow: _removed, ...withoutContextWindow } = next
|
||||
next = withoutContextWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
const usage = event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
|
||||
? event.data.chunk.usage
|
||||
: event.type === 'assistant/message'
|
||||
? event.data.usage
|
||||
: undefined
|
||||
if (usage === undefined) return state
|
||||
const pressureTokens = pressureFrom(usage)
|
||||
return pressureTokens === state.pressureTokens
|
||||
? state
|
||||
: { ...state, pressureTokens }
|
||||
const usage = usageOf(event)
|
||||
if (usage !== undefined) {
|
||||
const pressureTokens = pressureFrom(usage)
|
||||
if (pressureTokens !== next.pressureTokens || next.sampledSurfaceTokens !== next.surfaceTokens) {
|
||||
next = { ...next, pressureTokens, sampledSurfaceTokens: next.surfaceTokens }
|
||||
}
|
||||
}
|
||||
if (fold.deltaTokens !== 0) {
|
||||
next = { ...next, surfaceTokens: next.surfaceTokens + fold.deltaTokens }
|
||||
}
|
||||
// A defined fold.claim is always freshly built, so presence decides claim
|
||||
// bookkeeping: no claim before or after this event leaves `next` as is.
|
||||
if (state.claim === undefined && fold.claim === undefined) return next
|
||||
const { claim: _expired, ...withoutClaim } = next
|
||||
return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim }
|
||||
},
|
||||
view: state => state,
|
||||
stateVersion: 2,
|
||||
view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({
|
||||
...contextWindow === undefined ? {} : { contextWindow },
|
||||
...pressureTokens === undefined ? {} : { pressureTokens },
|
||||
...pressureTokens === undefined || sampledSurfaceTokens === undefined
|
||||
? {}
|
||||
: { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) },
|
||||
}),
|
||||
stateVersion: 4,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// contextBreakdown projection: heuristic system/tools/message composition,
|
||||
// plus the shared estimator's pricing branches.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts'
|
||||
import {
|
||||
estimateContent,
|
||||
estimateHeader,
|
||||
estimateMessage,
|
||||
estimateSystemTokens,
|
||||
estimateToolsTokens,
|
||||
} from '../src/estimate.ts'
|
||||
|
||||
const CONFIG = { provider: 'test', model: 'test-model' }
|
||||
|
||||
const TOOLS: ToolSchema[] = [{
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
}]
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(TokenMeterService)
|
||||
return { ctx, session: ctx.sessions.create() }
|
||||
}
|
||||
|
||||
const projected = (ctx: Context, session: Session): ContextBreakdownProjection => {
|
||||
const value = ctx.sessionProjections.snapshot(session).values.contextBreakdown
|
||||
if (value === undefined) throw new Error('contextBreakdown projection is not registered')
|
||||
return value
|
||||
}
|
||||
|
||||
function appendUser(session: Session, text: string): number {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }).seq
|
||||
}
|
||||
|
||||
/**
|
||||
* Meter one upcoming replacement the way compact-basic does: price the
|
||||
* replaced span from the measurement service's own nodes and log the
|
||||
* shadow-price event directly before the replace.
|
||||
*/
|
||||
function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void {
|
||||
const nodes = ctx.tokenMeter.measure(session).nodes
|
||||
const startIdx = nodes.findIndex(node => node.seq === start)
|
||||
const endIdx = nodes.findIndex(node => node.seq === end)
|
||||
const shadowed = nodes.slice(startIdx, endIdx + 1)
|
||||
session.append('compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: shadowed.map(node => node.seq),
|
||||
shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0),
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
}
|
||||
|
||||
describe('contextBreakdown session projection', () => {
|
||||
it('serves zeros for an empty log', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 })
|
||||
})
|
||||
|
||||
it('prices the newest envelope last-wins and pushes no change for a restated one', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
session.append('request/header', {
|
||||
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
|
||||
reason: 'initial',
|
||||
})
|
||||
expect(projected(ctx, session)).toEqual({
|
||||
systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }),
|
||||
toolsTokens: estimateToolsTokens({ config: CONFIG, tools: TOOLS }),
|
||||
messageTokens: 0,
|
||||
})
|
||||
|
||||
const changed: string[] = []
|
||||
ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
|
||||
session.append('request/header', {
|
||||
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
|
||||
reason: 'change',
|
||||
})
|
||||
session.append('todo/write', { todos: [] })
|
||||
expect(changed).not.toContain('contextBreakdown')
|
||||
|
||||
// A system-less, tool-less envelope prices back to zero.
|
||||
session.append('request/header', { header: { config: CONFIG }, reason: 'change' })
|
||||
expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 })
|
||||
})
|
||||
|
||||
it('sums surface appends and skips an empty-content assistant message', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
appendUser(session, 'abcd')
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
}),
|
||||
usage: { inputTokens: 9, outputTokens: 0 },
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
// 'abcd' prices to 9 (1 text + 4 block + 4 role); the usage-only assistant
|
||||
// message derives to no transcript entry and adds nothing.
|
||||
expect(projected(ctx, session).messageTokens).toBe(9)
|
||||
})
|
||||
|
||||
it('shrinks the message figure when a metered replacement compacts the surface', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const first = appendUser(session, 'before compaction, a longer message')
|
||||
const second = appendUser(session, 'and a second entry')
|
||||
const summary = createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
appendSummaryMeter(ctx, session, first, second)
|
||||
session.append('user/message', summary, {
|
||||
surfaceOp: { op: 'replace', start: first, end: second },
|
||||
sourceEventSeqs: [first, second],
|
||||
})
|
||||
expect(projected(ctx, session).messageTokens).toBe(estimateMessage(summary))
|
||||
})
|
||||
|
||||
it('keeps the message figure equal to the service surface across appends and a compaction', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
// The panel's composition rows and `measure()` answer the same question in
|
||||
// the same vocabulary; one shared fold is what makes that true.
|
||||
const agree = (): number => {
|
||||
const messageTokens = projected(ctx, session).messageTokens
|
||||
expect(messageTokens).toBe(ctx.tokenMeter.measure(session).surfaceTokens)
|
||||
return messageTokens
|
||||
}
|
||||
session.append('request/header', {
|
||||
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
|
||||
reason: 'initial',
|
||||
})
|
||||
expect(agree()).toBe(0)
|
||||
|
||||
const question = appendUser(session, 'a first question, long enough to price above zero')
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const answer = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'a considered answer' }],
|
||||
source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
}),
|
||||
usage: { inputTokens: 40, outputTokens: 7 },
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
const grown = agree()
|
||||
expect(grown).toBeGreaterThan(0)
|
||||
|
||||
appendSummaryMeter(ctx, session, question, answer)
|
||||
// The armed shadow price must not move the published figure by itself.
|
||||
expect(agree()).toBe(grown)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: question, end: answer },
|
||||
sourceEventSeqs: [question, answer],
|
||||
})
|
||||
expect(agree()).toBeLessThan(grown)
|
||||
})
|
||||
|
||||
it('fails loud on a replacement without an adjacent matching shadow price', () => {
|
||||
const definition = contextBreakdownProjectionDefinition
|
||||
const replace = (start: number, end: number): SessionEvent => ({
|
||||
type: 'user/message',
|
||||
seq: 9,
|
||||
time: 0,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [start, end],
|
||||
} as unknown as SessionEvent)
|
||||
const append = (seq: number): SessionEvent => ({
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: 0,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
} as unknown as SessionEvent)
|
||||
const meter = (start: number, end: number, seq: number): SessionEvent => ({
|
||||
type: 'compact/prune',
|
||||
seq,
|
||||
time: 0,
|
||||
data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 },
|
||||
} as unknown as SessionEvent)
|
||||
let state = definition.init()
|
||||
state = definition.apply(state, append(1))
|
||||
state = definition.apply(state, append(3))
|
||||
// No metering event at all.
|
||||
expect(() => definition.apply(state, replace(1, 3))).toThrow('no adjacent shadow price')
|
||||
// A claim for a different range does not price this replacement.
|
||||
const mismatched = definition.apply(state, meter(1, 1, 8))
|
||||
expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
|
||||
// A claim expires after one intervening event instead of lingering.
|
||||
let expired = definition.apply(state, meter(1, 3, 8))
|
||||
expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
|
||||
expect(() => definition.apply(expired, replace(1, 3))).toThrow('no adjacent shadow price')
|
||||
// The armed claim prices exactly the next event's matching replacement.
|
||||
const armed = definition.apply(state, meter(1, 3, 8))
|
||||
expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens)
|
||||
.toBe(definition.view(state).messageTokens - 5 + estimateMessage(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
|
||||
))
|
||||
})
|
||||
|
||||
it('keeps the persisted checkpoint O(1) as the surface grows and compacts', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const first = appendUser(session, 'the first of many messages')
|
||||
for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`)
|
||||
const last = appendUser(session, 'the last message before compaction')
|
||||
const stateKeys = (): string[] => {
|
||||
const row = ctx.sessionProjections.checkpoint(session)['contextBreakdown']
|
||||
if (row === undefined) throw new Error('contextBreakdown checkpoint row is missing')
|
||||
return Object.keys(row.val as Record<string, unknown>).sort()
|
||||
}
|
||||
// Growth adds no per-node bookkeeping to the durable state.
|
||||
expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens'])
|
||||
const shadowed = session.surface.nodes.slice(
|
||||
session.surface.nodes.indexOf(first),
|
||||
session.surface.nodes.indexOf(last) + 1,
|
||||
)
|
||||
appendSummaryMeter(ctx, session, first, last)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: first, end: last },
|
||||
sourceEventSeqs: [...shadowed],
|
||||
})
|
||||
expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens'])
|
||||
expect(projected(ctx, session).messageTokens)
|
||||
.toBe(ctx.tokenMeter.measure(session).surfaceTokens)
|
||||
})
|
||||
|
||||
it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const meterFiber = await ctx.plugin(TokenMeterService)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('request/header', {
|
||||
header: { config: CONFIG, system: 'You are terse.' },
|
||||
reason: 'initial',
|
||||
})
|
||||
appendUser(session, 'abcd')
|
||||
const checkpoint = JSON.parse(JSON.stringify(
|
||||
ctx.sessionProjections.checkpoint(session),
|
||||
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
|
||||
|
||||
await meterFiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextBreakdown')
|
||||
|
||||
await ctx.plugin(TokenMeterService)
|
||||
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual({
|
||||
systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }),
|
||||
toolsTokens: 0,
|
||||
messageTokens: 9,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared estimator', () => {
|
||||
it('prices every content-block shape under the fixed heuristic', () => {
|
||||
expect(estimateContent([{ type: 'text', text: 'abcd' }])).toBe(5)
|
||||
expect(estimateContent([{ type: 'reasoning', text: 'abcdefgh' }] as ContentBlock[])).toBe(6)
|
||||
expect(estimateContent([{ type: 'tool-call', id: 'c' as never, name: 'bash', arguments: '{"a":1}' }])).toBe(7)
|
||||
expect(estimateContent([{
|
||||
type: 'tool-result', toolCallId: 'c' as never,
|
||||
content: [{ type: 'text', text: 'abcd' }],
|
||||
}])).toBe(9)
|
||||
const unknown = { type: 'mystery', payload: 'abc' } as unknown as ContentBlock
|
||||
expect(estimateContent([unknown])).toBe(4 + Math.ceil(JSON.stringify(unknown).length / 4))
|
||||
})
|
||||
|
||||
it('prices envelope parts independently and absent parts to zero', () => {
|
||||
expect(estimateSystemTokens(undefined)).toBe(0)
|
||||
expect(estimateSystemTokens({ config: CONFIG })).toBe(0)
|
||||
expect(estimateSystemTokens({ config: CONFIG, system: 'abcdefgh' })).toBe(6)
|
||||
expect(estimateToolsTokens(undefined)).toBe(0)
|
||||
expect(estimateToolsTokens({ config: CONFIG, tools: [] })).toBe(0)
|
||||
expect(estimateToolsTokens({ config: CONFIG, tools: TOOLS }))
|
||||
.toBe(Math.ceil(JSON.stringify(TOOLS).length / 4) + 4)
|
||||
expect(estimateHeader(undefined)).toBe(0)
|
||||
expect(estimateHeader({ config: CONFIG, system: 'abcdefgh', tools: TOOLS }))
|
||||
.toBe(6 + Math.ceil(JSON.stringify(TOOLS).length / 4) + 4)
|
||||
})
|
||||
})
|
||||
@@ -70,6 +70,26 @@ const projected = (ctx: Context, session: Session): TokenUsageProjection => {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Meter one upcoming replacement the way compact-basic does: price the
|
||||
* replaced span from the measurement service's own nodes and log the
|
||||
* shadow-price event directly before the replace.
|
||||
*/
|
||||
function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void {
|
||||
const nodes = ctx.tokenMeter.measure(session).nodes
|
||||
const startIdx = nodes.findIndex(node => node.seq === start)
|
||||
const endIdx = nodes.findIndex(node => node.seq === end)
|
||||
const shadowed = nodes.slice(startIdx, endIdx + 1)
|
||||
session.append('compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: shadowed.map(node => node.seq),
|
||||
shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0),
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
}
|
||||
|
||||
describe('tokenUsage session projection', () => {
|
||||
it('serves zero buckets for an empty log', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
@@ -184,6 +204,7 @@ describe('tokenUsage session projection', () => {
|
||||
content: [{ type: 'text', text: 'before compaction' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
appendSummaryMeter(ctx, session, before.seq, before.seq)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
@@ -235,6 +256,34 @@ function recordContext(session: Session, model: string, contextWindow?: number):
|
||||
})
|
||||
}
|
||||
|
||||
/** Append one model-visible user turn and return its surface seq. */
|
||||
function appendUser(session: Session, text: string): number {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }).seq
|
||||
}
|
||||
|
||||
/** Append one finalized assistant turn carrying its provider usage. */
|
||||
function appendAssistant(
|
||||
session: Session,
|
||||
text: string,
|
||||
usage: TokenUsage,
|
||||
turn: number,
|
||||
step: number,
|
||||
): number {
|
||||
return session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
}),
|
||||
usage,
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
|
||||
}
|
||||
|
||||
describe('contextPressure session projection', () => {
|
||||
it('serves no pressure or capacity for an empty log', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
@@ -277,9 +326,13 @@ describe('contextPressure session projection', () => {
|
||||
startStep(session, 1, 1)
|
||||
recordContext(session, 'small', 64_000)
|
||||
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
|
||||
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 64_000 })
|
||||
expect(pressure(ctx, session)).toEqual({
|
||||
pressureTokens: 100, projectedTokens: 100, contextWindow: 64_000,
|
||||
})
|
||||
recordContext(session, 'large', 256_000)
|
||||
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 256_000 })
|
||||
expect(pressure(ctx, session)).toEqual({
|
||||
pressureTokens: 100, projectedTokens: 100, contextWindow: 256_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('removes an older capacity when the newest route advertises none', async () => {
|
||||
@@ -288,7 +341,7 @@ describe('contextPressure session projection', () => {
|
||||
recordContext(session, 'small', 64_000)
|
||||
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
|
||||
recordContext(session, 'unknown')
|
||||
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100 })
|
||||
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, projectedTokens: 100 })
|
||||
})
|
||||
|
||||
it('pushes no change for unrelated events or a restated capacity', async () => {
|
||||
@@ -319,7 +372,7 @@ describe('contextPressure session projection', () => {
|
||||
const checkpoint = JSON.parse(JSON.stringify(
|
||||
ctx.sessionProjections.checkpoint(session),
|
||||
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
|
||||
expect(checkpoint.contextPressure?.ver).toBe(2)
|
||||
expect(checkpoint.contextPressure?.ver).toBe(4)
|
||||
|
||||
await meterFiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure')
|
||||
@@ -327,7 +380,62 @@ describe('contextPressure session projection', () => {
|
||||
await ctx.plugin(TokenMeterService)
|
||||
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextPressure).toEqual({
|
||||
pressureTokens: 42,
|
||||
projectedTokens: 42,
|
||||
contextWindow: 64_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the sample forward over surface growth and a compaction', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
recordContext(session, 'large', 128_000)
|
||||
const question = appendUser(session, 'a first question worth a few tokens')
|
||||
startStep(session, 1, 1)
|
||||
// The provider prices the prompt its request actually carried; the sample
|
||||
// must anchor against the surface as of that request, not after the
|
||||
// assistant message joins it.
|
||||
const answer = appendAssistant(session, 'an answer of some length', { inputTokens: 900, outputTokens: 20 }, 1, 1)
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
const afterTurn = pressure(ctx, session)
|
||||
expect(afterTurn.pressureTokens).toBe(900)
|
||||
// The assistant message landed after the sample, so it already shows.
|
||||
expect(afterTurn.projectedTokens).toBeGreaterThan(900)
|
||||
|
||||
const grown = appendUser(session, 'a follow-up question that grows the surface further')
|
||||
const beforeCompaction = pressure(ctx, session).projectedTokens
|
||||
expect(beforeCompaction).toBeGreaterThan(afterTurn.projectedTokens!)
|
||||
|
||||
// Compaction reports no usage of its own, so `pressureTokens` cannot move;
|
||||
// the projected figure must shrink anyway — the defect this field fixes.
|
||||
appendSummaryMeter(ctx, session, question, grown)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: question, end: grown },
|
||||
sourceEventSeqs: [question, answer, grown],
|
||||
})
|
||||
const compacted = pressure(ctx, session)
|
||||
expect(compacted.pressureTokens).toBe(900)
|
||||
expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!)
|
||||
})
|
||||
|
||||
it('clamps a projection that heuristic error drove below zero', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
recordContext(session, 'large', 128_000)
|
||||
const question = appendUser(session, 'a question long enough to outprice the sample'.repeat(4))
|
||||
startStep(session, 1, 1)
|
||||
// A provider sample far below the heuristic price of what it replaced:
|
||||
// shadowing that span subtracts more than the sample holds.
|
||||
appendAssistant(session, 'ok', { inputTokens: 3, outputTokens: 1 }, 1, 1)
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
appendSummaryMeter(ctx, session, question, question)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '.' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: question, end: question },
|
||||
sourceEventSeqs: [question],
|
||||
})
|
||||
expect(pressure(ctx, session).projectedTokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user