Observability contracts: usage accounting, plan blocks, price table (Phase 0)

The 2026-09 observability wave's type-level foundation, landed before the
streams so nothing renders against guessed shapes:

- contracts/chat.ts: CallUsage + TurnUsage (per-model-call token accounting,
  one entry per teacher/wake turn keyed by messageId) and ChatRecord.turns —
  a DELIBERATELY parallel append-only usage log, not per-message metadata:
  the client re-POSTs its message list every turn and its copies never
  carry server-stamped fields, so anything inside messages gets clobbered
  by the next persist (the wave-3 merge-rule lesson).
- contracts/chat.ts: PlanBlockData — the persisted data-plan-block part
  (full DAG on plan_init, one-level neighborhood on plan updates, snapshot
  semantics so history renders what was true then).
- contracts/jobs.ts: JobUsage + JobRecord.usage — a sub-agent run's
  accumulated accounting, written by the runner.
- core/ports.ts + store/chats.ts: ChatStore.appendTurnUsage — idempotent
  per messageId (abort replays / wake retries append once); listChats no
  longer leaks turns into the list payload.
- core/prices.ts: PRICES + CONTEXT_LIMITS (generated from OpenRouter's
  published pricing via scripts/sync-pricing.ts) + pure folds: callCost,
  turnCost, sessionCost, contextWindow, jobCost. Unknown model or absent
  fields render as absent, never zero.
- server/observability.ts: barrel-approved surface for the transport layer
  (fixes the import-law violation in the graph status route that slipped
  through the wave-3 merge verification).

Capture wiring (agent onStepFinish -> persist seam, plan-block emission)
lands next as master seam work; the streams start against these shapes.
This commit is contained in:
amosblomqvist
2026-09-11 02:43:35 +03:00
parent afccb83fdb
commit 83a00800e9
12 changed files with 492 additions and 7 deletions
+69 -1
View File
@@ -8,7 +8,7 @@
*/
import type { UIMessage } from "ai";
import type { Phase3 } from "../phases.ts";
import type { PlanTree } from "./plan.ts";
import type { PlanNodeStatus, PlanTree } from "./plan.ts";
// ─── Chat records (the session store's on-disk format) ───────────────────────
@@ -24,6 +24,74 @@ export interface ChatRecord {
messages: UIMessage[];
/** The session's plan tree (vision §2) — written by plan tools via saveTree. */
tree?: PlanTree;
/** Append-only usage log (observability, 2026-09): one entry per model
* turn. DELIBERATELY a parallel log, not per-message metadata — the client
* re-POSTs its message list every turn and its copies never carry
* server-stamped fields, so anything inside message objects gets clobbered
* by the next persist. Keyed by messageId so the transcript view can fold
* each entry onto its message; aggregates are folds over this log
* (core/prices.ts). */
turns?: TurnUsage[];
}
// ─── Usage accounting (the money channel) ─────────────────────────────────────
/** One model call's token accounting — the SDK's LanguageModelUsage
* flattened to the fields we persist (undefined = provider didn't report;
* renderers show absent, never zero). */
export interface CallUsage {
/** Total input (prompt) tokens, cached portion included. */
inputTokens: number | undefined;
outputTokens: number | undefined;
/** Cached input tokens READ (billed at the cache-read rate, if any). */
cacheReadTokens: number | undefined;
/** Input tokens written to the cache this call (OpenRouter bills writes
* for some providers; undefined = not reported/not billed). */
cacheWriteTokens: number | undefined;
/** The reasoning portion of the output tokens. */
reasoningTokens: number | undefined;
}
/** One billing turn: one full invocation of the teacher (a client turn or a
* wake turn — same handler, same log). A turn with tool calls spans several
* agent steps, and each step bills separately. Capture lands at the persist
* seam (server); cost is INFERRED at read time from core/prices.ts. */
export interface TurnUsage {
/** The assistant message the turn produced — keys this entry to the
* transcript (the block this turn "belongs" to). */
messageId: string;
at: string;
/** The OpenRouter model id that ran the turn. */
model: string;
/** One entry per agent step (model call) in this turn. */
steps: CallUsage[];
}
// ─── Plan blocks (the plan lives in the chat) ────────────────────────────────
/** Payload of the persisted `data-plan-block` UI part (2026-09 design): a
* SNAPSHOT of the plan at the moment it changed, rendered as a block in the
* chat flow. Attached to the assistant message that carries the plan_init
* tool call (kind "full") or the answered check quiz part (kind
* "neighborhood"). Data parts never reach the model (convertToModelMessages
* drops them) — the block is pure UI history, reappearing identically on
* reload because it lives in the record.
*
* Rendering contract: a "full" block renders the whole DAG borderless,
* horizontally filling the chat column, natural height; a "neighborhood"
* block renders current node + direct parents + direct children in its own
* small container, the current node glowing — clearly a fragment, not the
* whole plan. Clicking either opens the full plan in a popup. */
export interface PlanBlockData {
kind: "full" | "neighborhood";
at: string;
/** The current node at snapshot time (the glow target; null = none). */
current: string | null;
/** Included nodes: all of them (full) or the one-level-up/one-level-down
* neighborhood of `current` (neighborhood). Statuses are the snapshot's,
* derived at emission time — the block never re-derives at render. */
nodes: Array<{ id: string; title: string; status: PlanNodeStatus }>;
edges: Array<{ from: string; to: string }>;
}
/** The list view's shape (messageCount instead of full messages) + the