fix: align compaction cuts and native usage accounting
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* GET /api/activity — the dock's one poll: every running background
|
||||
* process, every view (chat, home, everywhere). Composition only — the
|
||||
* feed lives in server/observability.ts; cost folds client-side via
|
||||
* core/prices.ts. See that module's header for why /api/jobs stays
|
||||
* feed lives in server/observability.ts; costs are projected server-side
|
||||
* through Pi's catalog and public calculator. See that module's header for why /api/jobs stays
|
||||
* chat-scoped and untouched.
|
||||
*/
|
||||
import { activityFeed } from "@/src/server";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
|
||||
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
@@ -17,6 +17,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
return Response.json({
|
||||
...chat,
|
||||
messages: chat.messages,
|
||||
pricing: sessionCost(chat),
|
||||
turnInFlight: isTurnRunning(id),
|
||||
compacting: compactionInFlight(id),
|
||||
coverage: sessionCoverage(id),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* three usage channels (chat extraction runs, notes-source runs,
|
||||
* cluster-naming runs). Thin transport over src/server's graphCosts();
|
||||
* the fold itself is pure core (core/cost-summary.ts). Dollars are
|
||||
* inferred from PRICES at read time — never stored, so a price sync
|
||||
* inferred from Pi's catalog/calculator at read time — never stored, so a catalog update
|
||||
* re-prices the whole history for free.
|
||||
*
|
||||
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
|
||||
import { isModelsRefusal, isProviderKeyRefusal } from "@/src/core";
|
||||
import { formatTokens, planCostLabel } from "@/components/usage-format";
|
||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
|
||||
* oldest-first + extraction runs), fetched when truth moves (events
|
||||
* channel, S5) and folded client-side with core/prices.ts.
|
||||
* Cost is INFERRED (jobCost); the context line is the LAST model call's
|
||||
* Cost is INFERRED server-side through Pi; the context line is the LAST model call's
|
||||
* input+output against the model's limit (JobUsage.lastStep — pending
|
||||
* contract) — never a cumulative sum, which would be the wrong number.
|
||||
* Absent data renders "—", never zero (the doctrine).
|
||||
@@ -48,6 +48,8 @@ interface FeedUsage extends NonNullable<JobRecord["usage"]> {
|
||||
}
|
||||
|
||||
export interface FeedJob extends JobRecord {
|
||||
/** Read-time server estimate; absent on older transport payloads. */
|
||||
costUsd?: number | null;
|
||||
title: string;
|
||||
usage?: FeedUsage;
|
||||
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
|
||||
@@ -200,7 +202,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
||||
// blob shape's call — here the line simply hides).
|
||||
const limit = job.contextLimit;
|
||||
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
|
||||
// Same metric as the session meter (prices.ts stepFootprint — the
|
||||
// Same metric as the session meter (four normalized buckets — the
|
||||
// context-meter audit, owner 2026-09-23): all four directions.
|
||||
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
|
||||
// alone reads wildly low on cache-heavy jobs.
|
||||
@@ -220,7 +222,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
||||
title: job.title,
|
||||
chatId: job.chatId,
|
||||
startedAt: Date.parse(job.createdAt) || 0,
|
||||
cost: jobCost(job),
|
||||
cost: job.costUsd ?? null,
|
||||
context,
|
||||
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
|
||||
// the honest line the runner left ("failed") reads as setup
|
||||
|
||||
+17
-6
@@ -57,7 +57,7 @@ import { PracticeDock } from "@/components/practice-dock";
|
||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
||||
import { TopBar } from "@/components/top-bar";
|
||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
|
||||
import type { LhViewMessage } from "@/src/core";
|
||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||
import {
|
||||
@@ -236,7 +236,7 @@ export function Chat({
|
||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||
|
||||
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -717,8 +717,9 @@ export function Chat({
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
if (!res.ok) return;
|
||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] };
|
||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
|
||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||
setPricing(record.pricing);
|
||||
if (!Array.isArray(record.messages)) return;
|
||||
setMessages(record.messages);
|
||||
// The continuation seam (contracts.ts protocol §4): a job result
|
||||
@@ -845,12 +846,13 @@ export function Chat({
|
||||
const planTree = useMemo(() => latestTree(messages), [messages]);
|
||||
|
||||
// ── Session usage (wave-4 stats bar) ──
|
||||
// The money channel: the record's turns log, folded client-side by
|
||||
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
|
||||
// The money channel: the record's turns log and read-time server Pi
|
||||
// pricing projection. Seeded once on mount (the page doesn't carry turns),
|
||||
// refreshed when the local stream settles (each turn appends to the log
|
||||
// server-side), and kept fresh by syncFromServer whenever it already
|
||||
// fetches the record. No turns yet → the bar renders "—" everywhere.
|
||||
const [turns, setTurns] = useState<TurnUsage[]>([]);
|
||||
const [pricing, setPricing] = useState<SessionPricing | undefined>();
|
||||
// The per-chat model control state (model-picker writes via PUT; the
|
||||
// record is the source of truth, lifted here so the stats bar re-renders).
|
||||
// The dock's reserved tail height (DockOverlay reports it; the spacer at
|
||||
@@ -864,8 +866,9 @@ export function Chat({
|
||||
try {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
if (!res.ok) return;
|
||||
const record = (await res.json()) as { turns?: TurnUsage[] };
|
||||
const record = (await res.json()) as { turns?: TurnUsage[]; pricing?: SessionPricing };
|
||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||
setPricing(record.pricing);
|
||||
} catch {
|
||||
// transient blip: stats stay on the last known figures
|
||||
}
|
||||
@@ -1604,6 +1607,8 @@ export function Chat({
|
||||
onReadingThinking={applyReadingThinking}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
contextTokens={contextTokens}
|
||||
pricing={pricing}
|
||||
onReserve={setDockReserve}
|
||||
dictationSlot={<DictationBar dictation={dictation} />}
|
||||
dictationCancel={dictation.cancel}
|
||||
@@ -1824,6 +1829,8 @@ function DockOverlay({
|
||||
onReadingThinking,
|
||||
phase,
|
||||
turns,
|
||||
contextTokens,
|
||||
pricing,
|
||||
composer,
|
||||
onSubmitAnswer,
|
||||
chatId,
|
||||
@@ -1855,6 +1862,8 @@ function DockOverlay({
|
||||
phase: Phase3;
|
||||
/** The session's usage log — the stats bar beneath the composer folds it. */
|
||||
turns: TurnUsage[];
|
||||
contextTokens: number | null;
|
||||
pricing?: SessionPricing;
|
||||
composer: React.ReactNode;
|
||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||
/** The model control's pass-through (the stats bar hosts it). */
|
||||
@@ -2327,6 +2336,8 @@ function DockOverlay({
|
||||
morphing shell so it never joins a height animation. */}
|
||||
<StatsBar
|
||||
turns={turns}
|
||||
contextTokens={contextTokens}
|
||||
pricing={pricing}
|
||||
dictationSlot={dictationSlot}
|
||||
chatId={chatId}
|
||||
/>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
|
||||
* changing the new-session default. Unpicked reads "—" (the
|
||||
* no-factory-fallback language: the UI never presents a default model).
|
||||
*/
|
||||
export function ModelControl({ chatId }: { chatId?: string }) {
|
||||
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
|
||||
const { catalog, refresh } = useModelCatalog();
|
||||
const [models, setModels] = useState<ModelsSettings | undefined>();
|
||||
const [session, setSession] = useState<ChatSettings | undefined>();
|
||||
@@ -136,6 +136,9 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
||||
: strong;
|
||||
const effectiveId = override?.model?.id;
|
||||
const effectiveProvider = override?.model?.provider;
|
||||
useEffect(() => {
|
||||
onModelChange?.(effectiveProvider && effectiveId ? `${effectiveProvider}:${effectiveId}` : undefined);
|
||||
}, [effectiveProvider, effectiveId, onModelChange]);
|
||||
const storedLevel = override?.thinkingLevel;
|
||||
const entry = catalog?.models.find(
|
||||
(m) => m.id === effectiveId && m.provider === effectiveProvider,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
needsContinuation,
|
||||
measuredContextTokens,
|
||||
newAnswerResult,
|
||||
newNextMessage,
|
||||
newProblemDismissedMarker,
|
||||
@@ -163,6 +164,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
|
||||
export interface SessionState {
|
||||
messages: LhViewMessage[];
|
||||
nativeMessages: LhMessage[];
|
||||
/** Completed conversation usage; null after compaction or before first usage. */
|
||||
contextTokens: number | null;
|
||||
status: SessionStatus;
|
||||
error: { message: string } | null;
|
||||
/** Steers sent but not yet drained (composer steering, 2026-10-03):
|
||||
@@ -220,6 +223,7 @@ export class SessionModel {
|
||||
// The live turn's execution layer (view model mid-turn states):
|
||||
private executions = new Map<string, LiveExecution>();
|
||||
private streamingIds = new Set<string>();
|
||||
private incompleteUsageIds = new Set<string>();
|
||||
/** The in-flight assistant message's client id (see applyWireEvent). */
|
||||
private streamingMessageId: string | null = null;
|
||||
private statusValue: SessionStatus = "ready";
|
||||
@@ -257,6 +261,7 @@ export class SessionModel {
|
||||
return {
|
||||
messages: projectView([...this.base, ...this.tail], live),
|
||||
nativeMessages: [...this.base, ...this.tail],
|
||||
contextTokens: measuredContextTokens([...this.base, ...this.tail], this.incompleteUsageIds),
|
||||
status: this.statusValue,
|
||||
error: this.errorValue,
|
||||
steerQueue: this.steerQueueValue,
|
||||
@@ -364,6 +369,8 @@ export class SessionModel {
|
||||
// the wire) still wins.
|
||||
const id = message.id ?? this.streamingMessageId ?? localId();
|
||||
const withId = { ...message, id } as LhMessage;
|
||||
if (agent.type === "message_end") this.incompleteUsageIds.delete(id);
|
||||
else this.incompleteUsageIds.add(id);
|
||||
if (agent.type === "message_start") {
|
||||
this.streamingMessageId = id;
|
||||
this.streamingIds.add(id);
|
||||
@@ -487,6 +494,7 @@ export class SessionModel {
|
||||
// every path that actually replays onto a stale tail).
|
||||
if (!refetchRecordFirst) {
|
||||
this.mutate(() => {
|
||||
this.incompleteUsageIds.clear();
|
||||
this.tail = [];
|
||||
this.streamingMessageId = null;
|
||||
});
|
||||
@@ -502,6 +510,7 @@ export class SessionModel {
|
||||
if (Array.isArray(record.messages)) {
|
||||
this.mutate(() => {
|
||||
this.base = record.messages!;
|
||||
this.incompleteUsageIds.clear();
|
||||
this.tail = [];
|
||||
});
|
||||
}
|
||||
@@ -509,6 +518,7 @@ export class SessionModel {
|
||||
// The record did not land — restore the clean-slate guard
|
||||
// before the replay (no atomic swap is coming).
|
||||
this.mutate(() => {
|
||||
this.incompleteUsageIds.clear();
|
||||
this.tail = [];
|
||||
this.streamingMessageId = null;
|
||||
});
|
||||
@@ -714,6 +724,7 @@ export class SessionModel {
|
||||
setMessages(record: LhMessage[]): void {
|
||||
this.mutate(() => {
|
||||
this.base = record;
|
||||
this.incompleteUsageIds.clear();
|
||||
this.tail = [];
|
||||
this.streamingMessageId = null; // the tail (and its slot) is gone
|
||||
});
|
||||
|
||||
@@ -124,6 +124,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
return {
|
||||
messages: state.messages,
|
||||
nativeMessages: state.nativeMessages,
|
||||
contextTokens: state.contextTokens,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
steerQueue: state.steerQueue,
|
||||
|
||||
+26
-25
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
|
||||
import type { SessionPricing, TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||
|
||||
export function formatContextMeasurement(tokens: number | null, limit: number | null): string {
|
||||
const numerator = tokens === null ? "?" : formatTokens(tokens);
|
||||
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The below-composer stats bar (wave 4 observability): the session's
|
||||
* money/context footprint as one quiet row beneath the composer —
|
||||
@@ -16,23 +21,23 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model
|
||||
* there died with the injection; a "— · —" summary over an empty
|
||||
* popover is pure noise) — the dictation strip's middle simply widens.
|
||||
*
|
||||
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
|
||||
* — no cost math lives here. Absent data renders "—", never zero (the
|
||||
* doctrine): no turns yet → every figure is "—"; turns the price table
|
||||
* can't price count as unpriced, shown in the popover, never folded into
|
||||
* a fake $0. The RIGHT side is the model + thinking control (components/
|
||||
* model-picker.tsx): one controller for the conversation agents, backed by
|
||||
* pi-ai's catalog; the buttons show the EFFECTIVE model (the record's
|
||||
* settings, else config's pin), while the left-side figures still fold the
|
||||
* turn log's actual billed models.
|
||||
* Spending arrives already priced by the server through Pi's catalog and
|
||||
* calculator. Missing cost stays "—", never fake zero. Context is a separate
|
||||
* completed-conversation measurement, never a money-log/summarizer fold or
|
||||
* transcript estimate. Its catalog denominator follows the selected model;
|
||||
* model changes preserve the numerator. Successful compaction clears it to ?.
|
||||
*/
|
||||
export function StatsBar({
|
||||
turns,
|
||||
contextTokens = null,
|
||||
pricing,
|
||||
dictationSlot,
|
||||
chatId,
|
||||
leadingSlot,
|
||||
}: {
|
||||
turns: TurnUsage[];
|
||||
contextTokens?: number | null;
|
||||
pricing?: SessionPricing;
|
||||
/** The bound session (per-session models, owner 2026-09-28): present
|
||||
* on the in-session bar — the model control binds THE SESSION'S own
|
||||
* record; absent on the draft surfaces, where it binds the strong
|
||||
@@ -49,6 +54,7 @@ export function StatsBar({
|
||||
leadingSlot?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
||||
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
@@ -73,22 +79,17 @@ export function StatsBar({
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
// The live-limits join (context-meter amendment, owner 2026-09-23):
|
||||
// contextWindow's denominator resolves ONLY from pi's live catalog —
|
||||
// built client-side over the picker's cached /api/models fetch (the
|
||||
// same fetch ModelControl below rides; module-level dedupe). Catalog
|
||||
// pending or failed → undefined index → limit null → USAGE-ONLY (the
|
||||
// honest-render precedent, never a fake window); the usage-only render
|
||||
// IS the null path.
|
||||
// Catalog-only denominator; the picker reports its effective selection.
|
||||
// Missing/pending catalog means usage-only, never a hardcoded maximum.
|
||||
const { catalog } = useModelCatalog();
|
||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
||||
const ctx = contextWindow({ turns }, limits);
|
||||
const { cost, unpriced, subscription = 0 } = sessionCost({ turns });
|
||||
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
|
||||
const contextLabel = formatContextMeasurement(contextTokens, limit);
|
||||
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
// otherwise "—" (never a fake $0.0000).
|
||||
const pricedCount = turns.length - unpriced - subscription;
|
||||
const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—";
|
||||
const lastModel = turns.at(-1)?.model;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -110,7 +111,7 @@ export function StatsBar({
|
||||
{leadingSlot}
|
||||
{/* The left side: the session-data summary — only once a session
|
||||
exists (turns). Pre-chat renders nothing (see the header). */}
|
||||
{turns.length > 0 && (
|
||||
{(chatId || turns.length > 0) && (
|
||||
<div ref={barRef} className="relative">
|
||||
{/* No tooltip — the numbers describe themselves and the popover
|
||||
speaks on click (owner, subtlety round 2026-09-25). */}
|
||||
@@ -122,7 +123,7 @@ export function StatsBar({
|
||||
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
|
||||
>
|
||||
<span data-testid="stats-context">
|
||||
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
|
||||
{contextLabel}
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span data-testid="stats-cost">{costLabel}</span>
|
||||
@@ -139,7 +140,7 @@ export function StatsBar({
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Context (last call)</span>
|
||||
<span className="tabular-nums text-card-foreground">
|
||||
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
|
||||
{contextLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
@@ -161,7 +162,7 @@ export function StatsBar({
|
||||
</div>
|
||||
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
||||
{turns.map((turn, i) => {
|
||||
const c = turnCost(turn);
|
||||
const c = pricing?.turnCosts[turn.id] ?? null;
|
||||
return (
|
||||
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-muted-foreground">
|
||||
@@ -190,7 +191,7 @@ export function StatsBar({
|
||||
{/* The model + thinking control — one controller for the conversation
|
||||
agents (prober/teacher); the record's `turns` above still folds
|
||||
the ACTUAL models billed, regardless of what the picker says. */}
|
||||
<ModelControl chatId={chatId} />
|
||||
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Compaction and usage accounting
|
||||
|
||||
## Retention
|
||||
|
||||
`src/core/compaction.ts` locally ports the cutoff-selection behavior and relevant
|
||||
message-size heuristic from **@earendil-works/pi-coding-agent 1.4.0**. There is no
|
||||
coding-agent dependency. Upgrade this port deliberately; the native golden
|
||||
fixtures are in `tests/compaction-native-parity.test.ts`.
|
||||
|
||||
Walk backward to the approximately 20,000-token retained-tail budget, then select
|
||||
the nearest valid user/assistant boundary at or after the crossing. Never start
|
||||
at a tool result. If trailing results alone exceed the budget, keep the preceding
|
||||
assistant call. Preserve adjacent context-invisible metadata. LearnHarness adapts
|
||||
its visible custom markers to user-like entries, excludes UI-only markers, and
|
||||
uses the active compaction's retained history rather than discarded messages.
|
||||
|
||||
Text, visible thinking, tool-call names/arguments use characters divided by four;
|
||||
images receive the native fixed 1,200-token allowance. Encrypted signatures are
|
||||
not measured or stripped. Whole-message boundaries can overshoot. This does
|
||||
**not** guarantee a measured 20k tail or a fixed post-compaction request size.
|
||||
|
||||
Only cutoff selection is ported. LearnHarness retains its educational summary
|
||||
prompt, plan continuity, persistence, threshold, scheduling, and authorization
|
||||
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
|
||||
calls. A cut that drops no active history does not launch a summarizer.
|
||||
|
||||
## Conversation meter
|
||||
|
||||
The numerator is the latest qualifying completed conversation response's native
|
||||
Pi `input + output + cacheRead + cacheWrite`. Reasoning is already inside output;
|
||||
`totalTokens` is not an override. Partial stream updates, errors, aborts, invalid
|
||||
counts, and all-zero usage do not replace the last measurement. A successful
|
||||
compaction marker invalidates earlier measurements: show `? / maximum` until a
|
||||
new qualifying response completes. Summarizer usage never supplies this meter.
|
||||
|
||||
The denominator follows the selected model's catalog limit, independently of the
|
||||
last numeric measurement. Changing models preserves that numerator. No catalog
|
||||
limit means no invented denominator; no percentage display is added.
|
||||
|
||||
## Spending
|
||||
|
||||
`src/server/pricing.ts` uses Pi AI's full, key-independent catalog and public
|
||||
`calculateCost` on a new usage object. The browser receives derived costs, not a
|
||||
second calculator/rate table. Totals are estimates using the currently shipped
|
||||
catalog, not provider invoices; catalog updates can reprice history.
|
||||
|
||||
Saved input is Pi-normalized **fresh input**, disjoint from cache buckets. Never
|
||||
subtract cached reads from it again or migrate old logs as raw inclusive prompt
|
||||
tokens. New captures preserve the one-hour subset of cache writes and individual
|
||||
background-job calls. Older write-bearing records with a lost retention split,
|
||||
and tier-priced multi-call jobs with only aggregate usage, remain unpriced when
|
||||
the lost detail can change the answer. They are not reconstructed from totals.
|
||||
Flat-rate historical jobs with sufficient counts remain priceable. Missing models
|
||||
or required usage stay unpriced; ChatGPT subscriptions remain plan usage, not
|
||||
API dollars. Summarizer costs continue to count toward session spending.
|
||||
|
||||
## Upstream attribution
|
||||
|
||||
The cutoff/estimator port adapts MIT-licensed code from
|
||||
`@earendil-works/pi-coding-agent` 1.4.0, author Mario Zechner (published package
|
||||
metadata). The inspected `dist/core/compaction/compaction.js` SHA-256 is
|
||||
`f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6`.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Mario Zechner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+5
-2
@@ -30,7 +30,6 @@ npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY key
|
||||
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
|
||||
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
|
||||
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
|
||||
npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables
|
||||
```
|
||||
|
||||
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
|
||||
@@ -144,7 +143,7 @@ src/
|
||||
electron/ desktop launcher + security guard (spawns the standalone
|
||||
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
|
||||
scripts/ build-server.mjs (packaging guards), import-notes,
|
||||
backfill-graph, replay-layout, sync-pricing
|
||||
backfill-graph, replay-layout
|
||||
data/ chats/ · graph/ (nodes/chains/sessions/coverage
|
||||
ledgers + positions/sources/render-settings maps) ·
|
||||
jobs/ · settings.json — runtime state, git-ignored
|
||||
@@ -152,6 +151,10 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage
|
||||
|
||||
## Key mechanisms
|
||||
|
||||
See [compaction and accounting](compaction-accounting.md) for the local Pi cutoff port,
|
||||
measurement-only context meter, and server-side current-catalog pricing. There is no
|
||||
pricing-generation step: Pi AI's catalog and public calculator are the authority.
|
||||
|
||||
**Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**.
|
||||
|
||||
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
|
||||
|
||||
@@ -84,6 +84,9 @@ files:
|
||||
# Main-process ID-token signature verification (no bundled OAuth implementation).
|
||||
- node_modules/jose/**/*
|
||||
extraResources:
|
||||
# Attribution/license for the bounded local Pi cutoff-selector port.
|
||||
- from: docs/compaction-accounting.md
|
||||
to: licenses/pi-compaction.md
|
||||
# Copy from the PARENT dir (.next), not .next/standalone itself:
|
||||
# electron-builder hard-excludes a copy's root node_modules and the
|
||||
# default no-filter pattern is a literal path match (empirically both
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Regenerate src/core/prices.ts tables from pi-ai's shipped per-provider
|
||||
* catalogs (providers/data/*.json).
|
||||
*
|
||||
* Usage: npx tsx scripts/sync-pricing.ts
|
||||
*
|
||||
* The catalogs are pi-ai's curated, versioned snapshots — the SAME source
|
||||
* piEngineFor resolves engines from and /api/models serves the picker
|
||||
* from — so the money channel's lookup table can never disagree with the
|
||||
* engine. Fully offline (the JSON ships in node_modules); refreshes happen
|
||||
* by bumping @earendil-works/pi-ai and re-running this script. The tables
|
||||
* are generated; hand edits are overwritten. A model missing from the
|
||||
* catalogs gets no entry — cost renders as absent ("—"), never zero.
|
||||
*
|
||||
* Key format (model slots wave, 2026-09-20): uniformly provider-qualified
|
||||
* ("provider:id", incl. "openrouter:<slug>"). Owner decision: no legacy
|
||||
* compat — records stamped with pre-wave bare slugs simply render unpriced.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const DATA_DIR = path.join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-ai",
|
||||
"dist",
|
||||
"providers",
|
||||
"data",
|
||||
);
|
||||
const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts");
|
||||
|
||||
/** One raw catalog entry (fields we consume; the files carry more). */
|
||||
interface CatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
/** pi-ai 1.0 (catalog schema v6): every entry is type-tagged. */
|
||||
type?: "chat" | "image" | "classifier";
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
}
|
||||
|
||||
/** The pure walk: every catalog entry that carries pricing, keyed
|
||||
* "provider:id". Exported for the drift-guard test (tests/prices-sync)
|
||||
* so the guard asserts EXACTLY what this script would generate — the
|
||||
* test and the generator can never disagree (owner, 2026-09-29). */
|
||||
export function buildPrices(): Map<string, CatalogModel["cost"]> {
|
||||
const priced = new Map<string, CatalogModel["cost"]>();
|
||||
for (const file of fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json"))) {
|
||||
const provider = file.slice(0, -".json".length);
|
||||
const catalog = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), "utf-8")) as Record<
|
||||
string,
|
||||
Record<string, CatalogModel>
|
||||
>;
|
||||
for (const models of Object.values(catalog)) {
|
||||
for (const [id, m] of Object.entries(models)) {
|
||||
// pi-ai 1.0 (catalog schema v6): keys are type-qualified
|
||||
// ("chat:<id>", "image:<id>", "classifier:<id>") and entries
|
||||
// carry their own `type`. We price CHAT models only — our
|
||||
// usage records are chat — and keys stay "provider:id"
|
||||
// (owner format, 2026-09-20): strip the chat: prefix, skip the
|
||||
// rest.
|
||||
if (m.type && m.type !== "chat") continue;
|
||||
const plainId = id.startsWith("chat:") ? id.slice("chat:".length) : id;
|
||||
// A catalog entry without pricing data gets no key at all
|
||||
// (cost renders "—", never zero).
|
||||
if (!m.cost) continue;
|
||||
// First writer wins (an id can appear under two apis within one
|
||||
// file with identical pricing; cross-file collisions cannot
|
||||
// happen — keys are provider-qualified).
|
||||
priced.set(`${provider}:${plainId}`, m.cost);
|
||||
}
|
||||
}
|
||||
}
|
||||
return priced;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const priced = new Map<string, { cost: CatalogModel["cost"] }>(
|
||||
[...buildPrices()].map(([k, cost]) => [k, { cost }] as const),
|
||||
);
|
||||
|
||||
// Sort by key for a stable, diff-friendly generated table.
|
||||
const rows = [...priced.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, m]) => ({
|
||||
price: `\t${JSON.stringify(key)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`,
|
||||
}));
|
||||
|
||||
const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render
|
||||
* as absent ("—"), never zero. Keys are uniformly provider-qualified
|
||||
* ("provider:id") — NO legacy bare-slug compat (owner decision, sync
|
||||
* script 2026-09-20): pre-wave records stamped with bare slugs render
|
||||
* unpriced.
|
||||
* Context-window limits are NOT a table (owner amendment, 2026-09-23):
|
||||
* every meter's denominator reads pi's LIVE catalog (GET /api/models /
|
||||
* the activity feed's server-side join) — missing model, usage-only.
|
||||
* Refresh: bump @earendil-works/pi-ai, then npx tsx scripts/sync-pricing.ts
|
||||
* (reads pi-ai's shipped catalogs — the same source the engine and the
|
||||
* picker use). Forgetting the re-run is exactly how models go unpriced
|
||||
* (deepseek-v4.1-flash, 2026-09-29) — the drift-guard test
|
||||
* (tests/prices-sync.test.ts) fails the gates until you do. */
|
||||
export const PRICES: Record<string, ModelPrice> = {
|
||||
${rows.map((r) => r.price).join("\n")}
|
||||
};
|
||||
`;
|
||||
|
||||
const src = fs.readFileSync(OUT, "utf-8");
|
||||
const start = src.indexOf("/** Token prices");
|
||||
const end = src.indexOf("// ─── Pure helpers");
|
||||
if (start === -1 || end === -1) throw new Error("prices.ts markers not found — file structure changed");
|
||||
fs.writeFileSync(OUT, src.slice(0, start) + tables + "\n" + src.slice(end), "utf-8");
|
||||
|
||||
console.log(`prices.ts regenerated from pi-ai's catalogs: ${priced.size} keys priced.`);
|
||||
}
|
||||
|
||||
// Run only as a script, not when imported (the drift-guard test).
|
||||
if (process.argv[1] && import.meta.filename && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
main();
|
||||
}
|
||||
@@ -777,6 +777,7 @@ export function callUsage(usage: Usage): CallUsage {
|
||||
outputTokens: usage.output,
|
||||
cacheReadTokens: usage.cacheRead,
|
||||
cacheWriteTokens: usage.cacheWrite,
|
||||
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||
// Optional in pi's Usage (providers that never emit reasoning
|
||||
// tokens leave it unset) — persisted as 0, never dropped.
|
||||
reasoningTokens: usage.reasoning ?? 0,
|
||||
|
||||
+62
-51
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
|
||||
* mirroring pi's design (docs aside, this is OUR implementation — pi's
|
||||
* compaction lives in the coding-agent package we don't depend on).
|
||||
* with a bounded local port of Pi coding-agent 1.4.0 cutoff selection.
|
||||
* No coding-agent dependency. See docs/compaction-accounting.md for the
|
||||
* behavior boundary, native golden fixtures, and upstream MIT notice.
|
||||
*
|
||||
* THE CONTRACT: full history stays persisted in the record — extraction
|
||||
* and the user's transcript keep reading everything. A compaction marker
|
||||
@@ -38,7 +39,7 @@ import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts";
|
||||
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
|
||||
import { newMessageId } from "./native-messages.ts";
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import type { AssistantMessage, ToolResultMessage, SystemMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
// ─── constants (the tunables, owner-approved) ────────────────────────────────
|
||||
|
||||
@@ -77,9 +78,8 @@ export function calculateContextTokens(usage: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Estimate a message's context tokens from its WIRE-relevant text only
|
||||
* (pi's estimator): text + thinking + toolCall name/args for assistants,
|
||||
* content text for user/toolResult/custom. NEVER JSON.stringify of the
|
||||
/** Pi 1.4.0's heuristic: text + thinking + toolCall name/args for
|
||||
* assistants, content text plus a fixed image allowance for other roles. NEVER JSON.stringify of the
|
||||
* whole message — record scaffolding (ids, usage, responseId, app-side
|
||||
* details, escaped quotes/backslashes) inflates ~3x (measured on the
|
||||
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
|
||||
@@ -100,23 +100,32 @@ export function estimateMessageTokens(message: LhMessage | AgentMessage): number
|
||||
}
|
||||
} else {
|
||||
chars += textCharsOfContent(content);
|
||||
if (message.role === "system") {
|
||||
const system = message as SystemMessage;
|
||||
for (const section of Object.values(system.sections ?? {})) chars += section?.length ?? 0;
|
||||
if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Pi coding-agent 1.4.0 estimateTokens: fixed image allowance, NOT a
|
||||
// measurement of encrypted reasoning or actual provider vision tokens.
|
||||
const ESTIMATED_IMAGE_CHARS = 4_800;
|
||||
function textCharsOfContent(content: unknown): number {
|
||||
if (typeof content === "string") return content.length;
|
||||
if (!Array.isArray(content)) return 0;
|
||||
let chars = 0;
|
||||
for (const block of content as Array<string | { type?: string; text?: string }>) {
|
||||
if (typeof block === "string") chars += block.length;
|
||||
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
|
||||
else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS;
|
||||
else if (block?.type === "text") chars += block.text?.length ?? 0;
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/** The newest assistant message carrying VALID provider-reported usage
|
||||
* (pi's rule: skip aborted messages and all-zero usage — they carry no
|
||||
* (pi's rule: skip aborted/error messages and all-zero usage — they carry no
|
||||
* truth about the wire). The scan STOPS at the newest compaction
|
||||
* marker: usage captured before a compaction described the pre-cut
|
||||
* context — the next request carries the summary + kept tail instead,
|
||||
@@ -131,7 +140,7 @@ export function lastAssistantUsageIndex(
|
||||
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const a = m as AssistantMessage & { stopReason?: string };
|
||||
if (a.stopReason === "aborted") continue;
|
||||
if (a.stopReason === "aborted" || a.stopReason === "error") continue;
|
||||
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
|
||||
return i;
|
||||
}
|
||||
@@ -180,51 +189,53 @@ export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]):
|
||||
|
||||
// ─── the cut walk ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the compaction cut index: walk BACKWARDS from the end accumulating
|
||||
* estimates until ~keepRecentTokens are kept; the cut lands there.
|
||||
*
|
||||
* HARD INVARIANT — a tool result is never separated from its call: if the
|
||||
* walk stops on a toolResult, the KEPT region extends to the assistant
|
||||
* message carrying its call (keeping the result means keeping the call).
|
||||
* This is the provider protocol's shape law: dropping a call while
|
||||
* keeping its result strands an orphan the provider rejects. A PENDING
|
||||
* question/problem call (no result yet — the pose→answer window) sits at
|
||||
* the transcript end and is always kept: the walk never crosses it.
|
||||
*
|
||||
* Returns 0 when everything must be summarized (the whole history is the
|
||||
* span — valid), or messages.length when there is nothing meaningful to
|
||||
* cut (kept budget covers everything — caller checks before compacting).
|
||||
*/
|
||||
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
|
||||
let kept = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (kept >= keepRecentTokens) {
|
||||
// The cut candidate: extend past orphaned tool results to the
|
||||
// assistant message carrying their calls.
|
||||
let cut = i + 1;
|
||||
while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") {
|
||||
const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut);
|
||||
if (callIndex === -1) break; // no call found (defensive): stop extending
|
||||
cut = callIndex;
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
kept += estimateMessageTokens(messages[i]!);
|
||||
}
|
||||
return 0; // the whole history fits inside the kept budget — summarize all
|
||||
/** Model-visible entry adapter for the local Pi 1.4.0 cutoff port.
|
||||
* Compaction entries are boundaries, not ordinary cut candidates; UI-only
|
||||
* markers have no context, just like native metadata entries. */
|
||||
function cutMessage(message: LhMessage): LhMessage | null {
|
||||
if (message.role !== "custom") return message;
|
||||
if (["compaction", "plan-block", "problem-dismissed"].includes(message.customType)) return null;
|
||||
return message;
|
||||
}
|
||||
|
||||
/** The newest assistant message at or before `before` whose content
|
||||
* carries the tool call `toolCallId` — the call a result answers. */
|
||||
function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number {
|
||||
for (let i = before - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const assistant = m as AssistantMessage;
|
||||
if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i;
|
||||
/** Local port of pi-coding-agent 1.4.0 findCutPoint (MIT).
|
||||
* Walk backwards to >= budget, then choose the nearest valid cut AT OR
|
||||
* AFTER that entry. If oversized trailing results exhaust the budget,
|
||||
* retain their preceding assistant call, not the entire session.
|
||||
* No encrypted-signature sizing and no exact retained-token guarantee.
|
||||
* Returns the first kept record index; a zero/no-progress cut drops nothing.
|
||||
* Keep this and estimateMessageTokens aligned deliberately when upgrading Pi.
|
||||
*/
|
||||
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
|
||||
const previous = activeCompactionOf(messages);
|
||||
const previousKept = previous ? keptIndexFor(messages, previous) : -1;
|
||||
const start = Math.max(0, previousKept);
|
||||
const points: number[] = [];
|
||||
for (let i = start; i < messages.length; i++) {
|
||||
const message = cutMessage(messages[i]!);
|
||||
if (message && (message.role === "user" || message.role === "assistant" || message.role === "custom")) points.push(i);
|
||||
}
|
||||
return -1;
|
||||
if (!points.length) return start;
|
||||
let cut = points[0]!;
|
||||
let accumulated = 0;
|
||||
for (let i = messages.length - 1; i >= start; i--) {
|
||||
const message = cutMessage(messages[i]!);
|
||||
const tokens = message ? estimateMessageTokens(message) : 0;
|
||||
if (tokens === 0) continue;
|
||||
accumulated += tokens;
|
||||
if (accumulated >= keepRecentTokens) {
|
||||
cut = points.find((candidate) => candidate >= i) ?? points.at(-1)!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Native entries preserve adjacent non-context metadata, but never walk
|
||||
// backwards across a previous compaction entry.
|
||||
while (cut > start) {
|
||||
const before = messages[cut - 1]!;
|
||||
if ((before.role === "custom" && before.customType === "compaction") || cutMessage(before)) break;
|
||||
cut--;
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
// ─── the marker ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { LhMessage } from "./native-messages.ts";
|
||||
|
||||
/** Latest completed conversation measurement, never a request-size estimate.
|
||||
* Ordered compaction markers invalidate every earlier reading. Live callers
|
||||
* must exclude assistant slots that have not received message_end.
|
||||
* Reasoning is already inside output; totalTokens is deliberately not used.
|
||||
*/
|
||||
export function measuredContextTokens(
|
||||
messages: readonly LhMessage[],
|
||||
incompleteIds: ReadonlySet<string> = new Set(),
|
||||
): number | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "custom" && message.customType === "compaction") return null;
|
||||
if (message.role !== "assistant" || incompleteIds.has(message.id)) continue;
|
||||
const assistant = message as AssistantMessage;
|
||||
if (!["stop", "length", "toolUse"].includes(assistant.stopReason)) continue;
|
||||
const usage = assistant.usage;
|
||||
if (!usage) continue;
|
||||
const buckets = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
|
||||
if (buckets.some((value) => !Number.isFinite(value) || value < 0)) continue;
|
||||
const tokens = buckets.reduce((sum, value) => sum + value, 0);
|
||||
if (tokens > 0) return tokens;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -87,8 +87,8 @@ export interface ChatSettings {
|
||||
* billed (CallUsage per agent step). Steps, not a sum: cost and context
|
||||
* folds happen at read time (core/prices.ts). */
|
||||
export interface ExtractionStageUsage {
|
||||
/** The model that ran this stage, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
/** The model that ran this stage, provider-qualified ("provider:id")
|
||||
* for key-independent Pi catalog lookup at read time. */
|
||||
model: string;
|
||||
steps: CallUsage[];
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export interface ExtractionRunUsage {
|
||||
* 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. */
|
||||
/** Pi-normalized fresh input tokens; cache buckets are disjoint. */
|
||||
inputTokens: number | undefined;
|
||||
outputTokens: number | undefined;
|
||||
/** Cached input tokens READ (billed at the cache-read rate, if any). */
|
||||
@@ -126,6 +126,9 @@ export interface CallUsage {
|
||||
/** Input tokens written to the cache this call (OpenRouter bills writes
|
||||
* for some providers; undefined = not reported/not billed). */
|
||||
cacheWriteTokens: number | undefined;
|
||||
/** Subset of cacheWriteTokens written with one-hour retention. Missing
|
||||
* on older logs; new native captures explicitly record zero if absent. */
|
||||
cacheWrite1hTokens?: number;
|
||||
/** The reasoning portion of the output tokens. */
|
||||
reasoningTokens: number | undefined;
|
||||
}
|
||||
@@ -144,8 +147,8 @@ export interface TurnUsage {
|
||||
* entry to the transcript (the block this turn "belongs" to). */
|
||||
messageId: string;
|
||||
at: string;
|
||||
/** The model that ran the turn, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
/** The model that ran the turn, provider-qualified ("provider:id")
|
||||
* for key-independent Pi catalog lookup at read time. */
|
||||
model: string;
|
||||
/** One entry per agent step (model call) in this turn. */
|
||||
steps: CallUsage[];
|
||||
|
||||
+21
-10
@@ -43,7 +43,7 @@ export interface JobRecord {
|
||||
* money-channel doctrine; cost renders "—", never $0). */
|
||||
export interface JobUsage {
|
||||
/** The model that ran the sub-agent, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
* model slots wave, 2026-09-20; matches Pi catalog lookup keys). */
|
||||
model: string;
|
||||
/** Running totals summed over the run's model calls so far. */
|
||||
inputTokens: number | undefined;
|
||||
@@ -51,6 +51,10 @@ export interface JobUsage {
|
||||
cacheReadTokens: number | undefined;
|
||||
cacheWriteTokens: number | undefined;
|
||||
reasoningTokens: number | undefined;
|
||||
cacheWrite1hTokens?: number;
|
||||
/** Individual calls for current-catalog per-call tier pricing. Absent on
|
||||
* older jobs; never backfill them from cumulative totals. */
|
||||
calls?: CallUsage[];
|
||||
/** How many model calls the run has made (each bills separately). */
|
||||
steps: number;
|
||||
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
|
||||
@@ -67,7 +71,7 @@ export interface JobUsage {
|
||||
* object with these fields folds, including the real thing and test fakes). */
|
||||
export interface UsageStepLike {
|
||||
inputTokens?: number | undefined;
|
||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
|
||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number; cacheWrite1hTokens?: number } | undefined;
|
||||
outputTokens?: number | undefined;
|
||||
outputTokenDetails?: { reasoningTokens?: number } | undefined;
|
||||
}
|
||||
@@ -80,12 +84,13 @@ export function piUsageToStep(usage: {
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cacheWrite1h?: number;
|
||||
reasoning?: number;
|
||||
}): UsageStepLike {
|
||||
return {
|
||||
inputTokens: usage.input,
|
||||
outputTokens: usage.output,
|
||||
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite },
|
||||
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, cacheWrite1hTokens: usage.cacheWrite1h ?? 0 },
|
||||
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
|
||||
};
|
||||
}
|
||||
@@ -101,6 +106,14 @@ export function accumulateJobUsage(
|
||||
): JobUsage {
|
||||
const add = (a: number | undefined, b: number | undefined): number | undefined =>
|
||||
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
||||
const call: CallUsage = {
|
||||
inputTokens: step.inputTokens,
|
||||
outputTokens: step.outputTokens,
|
||||
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||
cacheWrite1hTokens: step.inputTokenDetails?.cacheWrite1hTokens,
|
||||
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||
};
|
||||
return {
|
||||
model,
|
||||
inputTokens: add(total?.inputTokens, step.inputTokens),
|
||||
@@ -108,14 +121,12 @@ export function accumulateJobUsage(
|
||||
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
|
||||
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
|
||||
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
|
||||
cacheWrite1hTokens: ((total?.cacheWriteTokens ?? 0) > 0 && total?.cacheWrite1hTokens === undefined) ||
|
||||
((call.cacheWriteTokens ?? 0) > 0 && call.cacheWrite1hTokens === undefined)
|
||||
? undefined : add(total?.cacheWrite1hTokens, call.cacheWrite1hTokens),
|
||||
steps: (total?.steps ?? 0) + 1,
|
||||
lastStep: {
|
||||
inputTokens: step.inputTokens,
|
||||
outputTokens: step.outputTokens,
|
||||
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||
},
|
||||
...(!total || total.calls ? { calls: [...(total?.calls ?? []), call] } : {}),
|
||||
lastStep: call,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { BackgroundModelRole, ModelSlotName } from "../settings.ts";
|
||||
|
||||
/** One picker row: everything the UI shows about a model, projected from
|
||||
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
|
||||
* units core/prices.ts's generated table carries). */
|
||||
* units as Pi's server-side pricing calculator). */
|
||||
export interface ModelCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* the same ruling as wave 7's stages.embedding.)
|
||||
*
|
||||
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
|
||||
* core/prices.ts — never stored anywhere. An unpriced model contributes
|
||||
* an injected server-side Pi pricer — never stored anywhere. An unpriced model contributes
|
||||
* ZERO dollars but is counted in `unpricedRuns` so the UI can render
|
||||
* "— unpriced" honestly, never a silent zero. Tokens always sum.
|
||||
*
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
|
||||
import { callCost, sumSteps, isChatGPTPlan } from "./prices.ts";
|
||||
import { sumSteps, isChatGPTPlan, type CallPricer } from "./prices.ts";
|
||||
|
||||
/** One pipeline stage's cumulative cost across every run. */
|
||||
export interface StageCost {
|
||||
@@ -26,7 +26,7 @@ export interface StageCost {
|
||||
subscriptionRuns?: number;
|
||||
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
|
||||
dollars: number;
|
||||
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
|
||||
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
|
||||
unpricedRuns: number;
|
||||
/** Runs that billed this stage. */
|
||||
runs: number;
|
||||
@@ -92,11 +92,11 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
|
||||
|
||||
/** One stage's dollars over its steps; null when the model is unpriced
|
||||
* (all-or-nothing: a stage runs one model). */
|
||||
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
|
||||
if (isChatGPTPlan(model)) return null;
|
||||
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
|
||||
if (isChatGPTPlan(model) || steps.length === 0) return null;
|
||||
let total = 0;
|
||||
for (const step of steps) {
|
||||
const c = callCost(step, model);
|
||||
const c = price(step, model);
|
||||
if (c === null) return null;
|
||||
total += c;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
|
||||
|
||||
/** Fold every usage channel into the pill's payload. Pure: same input,
|
||||
* same output — no clock, no fs, no store reads. */
|
||||
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
export function graphCostSummary(input: CostSummaryInput, price: CallPricer): GraphCostSummary {
|
||||
const extraction = emptyStage();
|
||||
const audit = emptyStage();
|
||||
const runs: RecentRun[] = [];
|
||||
@@ -125,14 +125,14 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
// Per-stage: tokens always count; dollars only when priced.
|
||||
extraction.runs++;
|
||||
mergeTokens(extraction.tokens, stages.extractor.steps);
|
||||
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
|
||||
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price);
|
||||
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
|
||||
else if (extDollars === null) extraction.unpricedRuns++;
|
||||
else extraction.dollars += extDollars;
|
||||
|
||||
audit.runs++;
|
||||
mergeTokens(audit.tokens, stages.auditor.steps);
|
||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
|
||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price);
|
||||
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
|
||||
else if (audDollars === null) audit.unpricedRuns++;
|
||||
else audit.dollars += audDollars;
|
||||
|
||||
@@ -48,6 +48,7 @@ export * from "./input-events.ts";
|
||||
export * from "./view.ts";
|
||||
export * from "./tool-parts.ts";
|
||||
export * from "./prices.ts";
|
||||
export * from "./context-usage.ts";
|
||||
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
||||
export * from "./cost-summary.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
|
||||
+38
-1742
File diff suppressed because it is too large
Load Diff
@@ -333,6 +333,7 @@ export async function handleChatInput(
|
||||
outputTokens: usage.output,
|
||||
cacheReadTokens: usage.cacheRead,
|
||||
cacheWriteTokens: usage.cacheWrite,
|
||||
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||
// Optional in pi's Usage — persisted as 0, never dropped.
|
||||
reasoningTokens: usage.reasoning ?? 0,
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
||||
if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited
|
||||
|
||||
const cut = compactionCutIndex(messages);
|
||||
if (cut >= messages.length) return; // nothing to drop (defensive)
|
||||
if (cut <= 0 || cut >= messages.length) return; // no history can be dropped
|
||||
|
||||
// The previous compaction's kept boundary (chained compactions
|
||||
// summarize the old summary + what rode after it).
|
||||
@@ -240,6 +240,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
||||
outputTokens: usage.output,
|
||||
cacheReadTokens: usage.cacheRead,
|
||||
cacheWriteTokens: usage.cacheWrite,
|
||||
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||
reasoningTokens: usage.reasoning ?? 0,
|
||||
},
|
||||
],
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import {
|
||||
} from "../core/graph-deletion.ts";
|
||||
import { extractionStatuses } from "../agent/subagents/extraction.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { callCost } from "./pricing.ts";
|
||||
|
||||
/**
|
||||
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
|
||||
@@ -41,7 +42,7 @@ export function graphCosts(): GraphCostSummary {
|
||||
.readSources()
|
||||
.filter((s) => s.extractions?.length)
|
||||
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
|
||||
return graphCostSummary({ chats, sources });
|
||||
return graphCostSummary({ chats, sources }, callCost);
|
||||
}
|
||||
|
||||
/** The graph's version stamp (session-deletion wave 6): the max mtime
|
||||
|
||||
@@ -43,3 +43,4 @@ export * from "./compaction.ts";
|
||||
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||
export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
export { sessionCost } from "./pricing.ts";
|
||||
|
||||
@@ -24,6 +24,7 @@ import { chatStore } from "../store/chats.ts";
|
||||
import { graphRevision } from "./graph.ts";
|
||||
import { catalogContextLimits } from "./model-catalog.ts";
|
||||
import { listJobs } from "./jobs.ts";
|
||||
import { jobCost } from "./pricing.ts";
|
||||
import type { JobRecord } from "../core/contracts.ts";
|
||||
|
||||
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
|
||||
@@ -36,6 +37,7 @@ export { chatStore } from "../store/chats.ts";
|
||||
* model is missing from the catalog (the blob then renders no context
|
||||
* line — never a fake window). */
|
||||
export interface ActivityJob extends JobRecord {
|
||||
costUsd: number | null;
|
||||
title: string;
|
||||
contextLimit: number | null;
|
||||
}
|
||||
@@ -56,9 +58,8 @@ export interface ActivityExtraction {
|
||||
|
||||
/** The dock's whole world in one payload: every running process, every view.
|
||||
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
|
||||
* the blob disappears when the process ends. Cost math is NOT done here:
|
||||
* raw usage rides on the records and the client folds it with the pure
|
||||
* helpers in core/prices.ts (transport stays thin). */
|
||||
* the blob disappears when the process ends. Costs are joined here through
|
||||
* the server-side Pi pricing adapter; clients never reconstruct rates. */
|
||||
/** The dock's whole world + the graph's version stamp (wave 6 session
|
||||
* deletion): the payload carries graphRevision (max mtime across the
|
||||
* graph data files — see server/graph.ts) so any view can detect "the
|
||||
@@ -78,6 +79,7 @@ export function activityFeed(): {
|
||||
if (job.status === "running") {
|
||||
jobs.push({
|
||||
...job,
|
||||
costUsd: jobCost(job),
|
||||
title: chat.title ?? chat.id,
|
||||
contextLimit: job.usage ? (limits.get(job.usage.model) ?? null) : null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Read-time API-dollar estimates. Pi owns both rates and arithmetic.
|
||||
* Catalog resolution is key-independent; no provider dispatch/auth is needed.
|
||||
* Never mutate saved native messages: calculateCost mutates its usage.cost.
|
||||
*/
|
||||
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
|
||||
import { isChatGPTPlan, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
||||
|
||||
function catalogModel(id: string) {
|
||||
const colon = id.indexOf(":");
|
||||
if (colon < 0 || isChatGPTPlan(id)) return undefined;
|
||||
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
|
||||
}
|
||||
|
||||
export function callCost(call: Partial<CallUsage>, modelId: string): number | null {
|
||||
const model = catalogModel(modelId);
|
||||
if (!model || call.inputTokens === undefined || call.outputTokens === undefined) return null;
|
||||
const input = call.inputTokens;
|
||||
const output = call.outputTokens;
|
||||
const cacheRead = call.cacheReadTokens ?? 0;
|
||||
const cacheWrite = call.cacheWriteTokens ?? 0;
|
||||
const cacheWrite1h = call.cacheWrite1hTokens ?? 0;
|
||||
if ([input, output, cacheRead, cacheWrite, cacheWrite1h].some((v) => !Number.isFinite(v) || v < 0) || cacheWrite1h > cacheWrite) return null;
|
||||
// Old records discarded this split. Only price them if it cannot affect
|
||||
// the result; do not invent short-retention precision from a missing field.
|
||||
if (cacheWrite > 0 && call.cacheWrite1hTokens === undefined &&
|
||||
[model.cost, ...(model.cost.tiers ?? [])].some((rate) => rate.cacheWrite !== 2 * rate.input)) return null;
|
||||
const usage: Usage = {
|
||||
input, output, cacheRead, cacheWrite, cacheWrite1h,
|
||||
totalTokens: input + output + cacheRead + cacheWrite,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
const total = calculateCost(model, usage).total;
|
||||
return Number.isFinite(total) && total >= 0 ? total : null;
|
||||
}
|
||||
|
||||
export function turnCost(turn: TurnUsage): number | null {
|
||||
return foldTurnCost(turn, callCost);
|
||||
}
|
||||
export function sessionCost(record: Pick<ChatRecord, "turns">) {
|
||||
return foldSessionCost(record, callCost);
|
||||
}
|
||||
export function jobCost(job: Pick<JobRecord, "usage">): number | null {
|
||||
const usage = job.usage;
|
||||
if (!usage || usage.steps < 1) return null;
|
||||
const hasCalls = usage.calls?.length === usage.steps;
|
||||
if (!hasCalls && usage.steps > 1 && catalogModel(usage.model)?.cost.tiers?.length) return null;
|
||||
return foldJobCost(job, callCost);
|
||||
}
|
||||
@@ -88,10 +88,13 @@ test("activityFeed: contextLimit is the server's live-catalog join (owner amendm
|
||||
// pi 1.0.2's catalog: glm-5.3-flash contextWindow 1_048_575 (was
|
||||
// 1_048_576 at 0.87.1 — upstream models.dev refresh).
|
||||
assert.equal(knownJob.contextLimit, 1_048_575, "pi's catalog, joined server-side onto the feed");
|
||||
assert.ok(knownJob.costUsd !== null && Math.abs(knownJob.costUsd - 0.0002) < 1e-12, "Pi's price is projected on the server");
|
||||
const unknownJob = feed.jobs.find((j) => j.id === unknown.id)!;
|
||||
assert.equal(unknownJob.contextLimit, null, "missing from the live catalog → null, never a guessed window");
|
||||
assert.equal(unknownJob.costUsd, null);
|
||||
const bareJob = feed.jobs.find((j) => j.id === bare.id)!;
|
||||
assert.equal(bareJob.contextLimit, null, "no usage yet → no context line");
|
||||
assert.equal(bareJob.costUsd, null);
|
||||
} finally {
|
||||
fs.unlinkSync(`${PATHS.chats}/${chat.id}.json`);
|
||||
const jobsDir = path.join(PATHS.data, "jobs");
|
||||
|
||||
@@ -10,7 +10,8 @@ import { notifySettingsSaved } from "../components/settings-signal.ts";
|
||||
import { SidebarProvider } from "../components/sidebar.tsx";
|
||||
import { ChatGPTConnection, ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx";
|
||||
import { GraphCostControl } from "../components/graph-view.tsx";
|
||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||
import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts";
|
||||
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, () => null);
|
||||
import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts";
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { callCost, sessionCost, turnCost, jobCost } from "../src/core/prices.ts";
|
||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||
import { callCost, sessionCost, turnCost, jobCost } from "../src/server/pricing.ts";
|
||||
import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts";
|
||||
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost);
|
||||
import type { TurnUsage } from "../src/core/contracts.ts";
|
||||
import { planCostLabel } from "../components/usage-format.ts";
|
||||
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
||||
@@ -12,7 +13,7 @@ test("subscription usage is neither API dollars nor an unpriced-model mistake, i
|
||||
assert.equal(callCost(step, plan.model), null);
|
||||
assert.equal(turnCost(turn(plan.model, [])), null);
|
||||
assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), null);
|
||||
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2 });
|
||||
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2, turnCosts: { "scratch-turn": null } });
|
||||
const api = turn("openai:gpt-5");
|
||||
const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] });
|
||||
assert.equal(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Golden outputs from the verified published pi-coding-agent 1.4.0
|
||||
* findCutPoint + estimateTokens helpers, executed in isolation on synthetic
|
||||
* message/metadata entries. No coding-agent dependency or private data.
|
||||
* Source compaction.js SHA256:
|
||||
* f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6
|
||||
* Update deliberately alongside the documented local port, not automatically.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { compactionCutIndex, estimateMessageTokens, newCompactionMarker, KEEP_RECENT_TOKENS, type LhMessage } from "../src/core/index.ts";
|
||||
|
||||
let serial = 0;
|
||||
const user = (chars: number): LhMessage => ({ id: `u${serial++}`, role: "user", content: "u".repeat(chars), timestamp: 1 });
|
||||
const assistant = (chars: number): LhMessage => ({ id: `a${serial++}`, role: "assistant", content: [{ type: "text", text: "a".repeat(chars) }], timestamp: 1 }) as LhMessage;
|
||||
const call = (chars: number): LhMessage => ({ ...assistant(chars), content: [{ type: "text", text: "a".repeat(chars) }, { type: "toolCall", name: "quiz", arguments: {}, id: "c" }] }) as LhMessage;
|
||||
const result = (chars: number): LhMessage => ({ id: `r${serial++}`, role: "toolResult", content: [{ type: "text", text: "r".repeat(chars) }], toolCallId: "c", toolName: "quiz", isError: false, timestamp: 1 });
|
||||
const image = (): LhMessage => ({ id: `i${serial++}`, role: "user", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], timestamp: 1 });
|
||||
const metadata = (): LhMessage => ({ id: `m${serial++}`, role: "custom", customType: "plan-block", content: "invisible".repeat(1000), display: false, details: {}, timestamp: 1 });
|
||||
|
||||
test("local cutoff matches published native golden boundaries and estimator values", () => {
|
||||
const cases: [string, LhMessage[], number, number, number[]][] = [
|
||||
["exact threshold", [user(40), user(40), user(40)], 20, 1, [10, 10, 10]],
|
||||
["under budget", [user(4), assistant(4)], 20, 0, [1, 1]],
|
||||
["split turn", [user(400), assistant(40), assistant(40)], 15, 1, [100, 10, 10]],
|
||||
["completed result boundary", [user(400), call(4), result(400), user(40)], 30, 3, [100, 3, 100, 10]],
|
||||
["oversized trailing result", [user(400), user(40), call(4), result(400)], 30, 2, [100, 10, 3, 100]],
|
||||
["multiple trailing results", [user(400), call(4), result(200), result(200)], 30, 1, [100, 3, 50, 50]],
|
||||
["image allowance", [user(400), image(), user(40)], 1000, 1, [100, 1200, 10]],
|
||||
["empty trailing text", [user(400), assistant(0), user(0)], 30, 0, [100, 0, 0]],
|
||||
["pending call", [user(400), user(400), call(4)], 10, 1, [100, 100, 3]],
|
||||
];
|
||||
for (const [name, messages, budget, expected, tokens] of cases) {
|
||||
assert.equal(compactionCutIndex(messages, budget), expected, name);
|
||||
assert.deepEqual(messages.map(estimateMessageTokens), tokens, name);
|
||||
}
|
||||
assert.equal(compactionCutIndex([user(400), metadata(), assistant(40), metadata(), user(40)], 15), 1, "adjacent invisible metadata stays with the cut");
|
||||
assert.equal(KEEP_RECENT_TOKENS, 20_000);
|
||||
});
|
||||
|
||||
test("chained cutoff operates on active retained history, never the previous summary or discarded prefix", () => {
|
||||
const old = user(400_000);
|
||||
const kept = user(40);
|
||||
const marker = newCompactionMarker("summary".repeat(100_000), { firstKeptMessageId: kept.id, tokensBefore: 100_000, at: "fixture" });
|
||||
const messages = [old, kept, assistant(40), marker, user(40)];
|
||||
assert.equal(compactionCutIndex(messages, 100), 1, "retained tail fits; no progress");
|
||||
assert.equal(compactionCutIndex(messages, 15), 2, "old summary does not consume the kept-tail budget");
|
||||
});
|
||||
|
||||
test("opaque signatures remain unmeasured and preserved; system/image allowances match native", () => {
|
||||
const signed = { ...assistant(4), content: [{ type: "thinking", thinking: "", thinkingSignature: "opaque".repeat(100_000) }] } as LhMessage;
|
||||
assert.equal(estimateMessageTokens(signed), 0);
|
||||
assert.equal((signed as { content: { thinkingSignature: string }[] }).content[0]!.thinkingSignature.length, 600_000);
|
||||
const system = { role: "system", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], sections: { a: "abcd" }, toolsAdded: [{ name: "fixture" }] };
|
||||
assert.equal(estimateMessageTokens(system as never), Math.ceil((4800 + 4 + JSON.stringify(system.toolsAdded).length) / 4));
|
||||
});
|
||||
@@ -32,7 +32,7 @@ const {
|
||||
} = await import("../src/server/compaction.ts");
|
||||
import type { CompactionDeps } from "../src/server/compaction.ts";
|
||||
const { subscribe } = await import("../src/core/events.ts");
|
||||
const { activeCompactionOf, newUserMessage, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
|
||||
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
|
||||
import type { LhMessage } from "../src/core/index.ts";
|
||||
|
||||
after(() => {
|
||||
@@ -204,6 +204,37 @@ describe("the compaction service", () => {
|
||||
kickCompaction(() => null, chatId);
|
||||
});
|
||||
|
||||
it("a usage-heavy zero cut does not spend or append a marker", async () => {
|
||||
const chat = chatStore.createChat("no-progress fixture");
|
||||
const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage;
|
||||
chatStore.saveMessages(chat.id, [newUserMessage("tiny visible history"), assistant]);
|
||||
const before = summarizeCalls;
|
||||
await ensureCompacted(depsOf(chat.id, 40_000), chat.id);
|
||||
assert.equal(summarizeCalls, before);
|
||||
assert.equal(activeCompactionOf(chatStore.getChat(chat.id)!.messages), null);
|
||||
assert.equal(measuredContextTokens(chatStore.getChat(chat.id)!.messages), 120_001);
|
||||
});
|
||||
|
||||
it("starting/failing compaction preserves the meter; committing its marker clears it, regardless of summary usage", async () => {
|
||||
const id = overThresholdChat("meter validity fixture");
|
||||
const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage;
|
||||
chatStore.saveMessages(id, [...chatStore.getChat(id)!.messages, assistant]);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((done) => { release = done; });
|
||||
const deps = depsOf(id, 40_000);
|
||||
const running = ensureCompacted({ ...deps, summarize: async () => {
|
||||
await gate;
|
||||
throw new Error("fixture failure");
|
||||
} }, id);
|
||||
assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001);
|
||||
release(); await running;
|
||||
assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001);
|
||||
await ensureCompacted(deps, id);
|
||||
const record = chatStore.getChat(id)!;
|
||||
assert.ok(record.turns!.at(-1)!.steps[0]!.inputTokens! > 0);
|
||||
assert.equal(measuredContextTokens(record.messages), null);
|
||||
});
|
||||
|
||||
it("a summarizer failure leaves the record untouched and the gate re-arms", async () => {
|
||||
const chatId = overThresholdChat("compact: fail");
|
||||
const before = summarizeCalls;
|
||||
|
||||
@@ -156,7 +156,7 @@ describe("compactionCutIndex", () => {
|
||||
assert.equal((messages[cut] as { content: string }).content.startsWith("m2"), true, "the oldest kept message sits at the cut");
|
||||
});
|
||||
|
||||
it("NEVER separates a tool result from its call — the cut extends to the call's assistant message", () => {
|
||||
it("native cut after an oversized result drops both call and result, never keeps an orphan", () => {
|
||||
const messages = [
|
||||
big("m1"),
|
||||
big("m2"),
|
||||
@@ -165,13 +165,13 @@ describe("compactionCutIndex", () => {
|
||||
big("m3"),
|
||||
big("m4"),
|
||||
];
|
||||
// Budget lands such that the naive stop is ON the toolResult (m2 kept,
|
||||
// result next): the walk must extend to a1's assistant message.
|
||||
// Budget lands on the result. Pi chooses the next valid cut AT OR
|
||||
// AFTER it, dropping the complete exchange rather than extending.
|
||||
const cut = compactionCutIndex(messages, estimateTokens([big("m3"), big("m4")]) + 1);
|
||||
assert.ok(cut <= 2, `the cut must not sit between the call (idx 2) and its result (idx 3) — got ${cut}`);
|
||||
const kept = messages.slice(cut);
|
||||
const keptIds = new Set(kept.map((m) => m.id));
|
||||
assert.ok(keptIds.has("a1") && keptIds.has("r_call_1"), "call and result stay together");
|
||||
assert.equal(cut, 4);
|
||||
const keptIds = new Set(messages.slice(cut).map((m) => m.id));
|
||||
assert.equal(keptIds.has("a1"), false);
|
||||
assert.equal(keptIds.has("r_call_1"), false);
|
||||
});
|
||||
|
||||
it("a PENDING question call (no result — the pose→answer window) always survives", () => {
|
||||
@@ -181,7 +181,7 @@ describe("compactionCutIndex", () => {
|
||||
assert.ok(kept.some((m) => m.id === "a_pending"), "the pending pose is never dropped");
|
||||
});
|
||||
|
||||
it("zero budget summarizes everything; whole-history-in-budget returns 0 only when nothing precedes", () => {
|
||||
it("whole history within budget keeps everything (zero cut)", () => {
|
||||
assert.equal(compactionCutIndex([newUserMessage("hi")], 100), 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { measuredContextTokens, newCompactionMarker, type LhMessage } from "../src/core/index.ts";
|
||||
import { SessionModel } from "../components/session-core.ts";
|
||||
|
||||
function assistant(id: string, stopReason: AssistantMessage["stopReason"] = "stop", total = 999_999): LhMessage {
|
||||
return { id, role: "assistant", content: [{ type: "text", text: "fixture" }], api: "openai-completions", provider: "openrouter", model: "fixture", stopReason, timestamp: 1,
|
||||
usage: { input: 10_000, output: 1_000, cacheRead: 70_000, cacheWrite: 2_000, reasoning: 500, totalTokens: total, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as LhMessage;
|
||||
}
|
||||
const marker = () => newCompactionMarker("educational summary", { firstKeptMessageId: "old", tokensBefore: 110_001, at: "fixture" });
|
||||
|
||||
test("meter sums four disjoint fields, ignoring reasoning subset and totalTokens overrides", () => {
|
||||
assert.equal(measuredContextTokens([assistant("old")]), 83_000);
|
||||
assert.equal(measuredContextTokens([]), null);
|
||||
for (const stop of ["stop", "length", "toolUse"] as const) assert.equal(measuredContextTokens([assistant("a", stop)]), 83_000);
|
||||
});
|
||||
|
||||
test("newest marker invalidates retained pre-cut usage; summarizer money log cannot supply a measurement", () => {
|
||||
const old = assistant("old");
|
||||
assert.equal(measuredContextTokens([old, marker()]), null);
|
||||
assert.equal(measuredContextTokens([old, marker(), assistant("fresh")]), 83_000);
|
||||
assert.equal(measuredContextTokens([old, marker(), assistant("fresh"), marker()]), null);
|
||||
// The summarizer is logged as a money-channel turn, never an assistant
|
||||
// in the native conversation list. No money-channel input exists here.
|
||||
});
|
||||
|
||||
test("errors, aborts, all-zero/invalid usage and unfinished slots preserve the previous valid reading", () => {
|
||||
const old = assistant("old");
|
||||
for (const stop of ["error", "aborted"] as const) assert.equal(measuredContextTokens([old, assistant("bad", stop)]), 83_000);
|
||||
const zero = assistant("zero") as AssistantMessage & { id: string };
|
||||
zero.usage = { ...zero.usage, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
assert.equal(measuredContextTokens([old, zero]), 83_000);
|
||||
assert.equal(measuredContextTokens([zero]), null);
|
||||
assert.equal(measuredContextTokens([old, assistant("partial")], new Set(["partial"])), 83_000);
|
||||
assert.equal(measuredContextTokens([marker(), assistant("bad", "error")]), null);
|
||||
});
|
||||
|
||||
test("live terminal-looking message_update is not completion; each message_end updates immediately", () => {
|
||||
const model = new SessionModel({ chatId: "fixture", initialMessages: [assistant("old")] });
|
||||
const fresh = assistant("live") as AssistantMessage & { id: string };
|
||||
fresh.usage = { ...fresh.usage, input: 20_000 };
|
||||
model.applyWireEvent({ type: "message_start", message: fresh });
|
||||
model.applyWireEvent({ type: "message_update", message: fresh });
|
||||
assert.equal(model.getState().contextTokens, 83_000);
|
||||
model.applyWireEvent({ type: "message_end", message: fresh });
|
||||
assert.equal(model.getState().contextTokens, 93_000);
|
||||
model.applyWireEvent({ type: "message_start", message: assistant("next") });
|
||||
assert.equal(model.getState().contextTokens, 93_000);
|
||||
model.applyWireEvent({ type: "message_end", message: assistant("next", "toolUse") });
|
||||
assert.equal(model.getState().contextTokens, 83_000);
|
||||
model.setMessages([assistant("old"), marker()]);
|
||||
assert.equal(model.getState().contextTokens, null);
|
||||
});
|
||||
@@ -8,16 +8,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it as test } from "node:test";
|
||||
import {
|
||||
graphCostSummary,
|
||||
graphCostSummary as foldGraphCostSummary,
|
||||
type CostSummaryInput,
|
||||
type GraphCostSummary,
|
||||
} from "../src/core/cost-summary.ts";
|
||||
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
|
||||
import { PRICES, callCost } from "../src/core/prices.ts";
|
||||
import { callCost } from "../src/server/pricing.ts";
|
||||
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost);
|
||||
|
||||
/** A model with full published pricing (from the generated registry). */
|
||||
const PRICED = "openrouter:z-ai/glm-5.3-flash";
|
||||
assert.ok(PRICES[PRICED], "fixture model must exist in PRICES");
|
||||
assert.notEqual(callCost({ inputTokens: 1, outputTokens: 1 }, PRICED), null);
|
||||
|
||||
const step = (input: number, output: number): CallUsage => ({
|
||||
inputTokens: input,
|
||||
|
||||
@@ -346,8 +346,11 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
cacheReadTokens: 4,
|
||||
cacheWriteTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
cacheWrite1hTokens: undefined,
|
||||
calls: [{ inputTokens: 10, outputTokens: 5, cacheReadTokens: 4, cacheWriteTokens: undefined, cacheWrite1hTokens: undefined, reasoningTokens: undefined }],
|
||||
steps: 1,
|
||||
lastStep: {
|
||||
cacheWrite1hTokens: undefined,
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 4,
|
||||
@@ -364,7 +367,9 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
assert.equal(total.cacheWriteTokens, undefined, "never reported stays absent — never zero");
|
||||
assert.equal(total.reasoningTokens, undefined);
|
||||
assert.equal(total.steps, 2, "one step per model call, reported or not");
|
||||
assert.equal(total.calls?.length, 2);
|
||||
assert.deepEqual(total.lastStep, {
|
||||
cacheWrite1hTokens: undefined,
|
||||
inputTokens: 20,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: undefined,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { normalizeContext } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
|
||||
import { modelCatalog, validateChatSettings, validateModelsSettings } from "../src/server/model-catalog.ts";
|
||||
import { callCost } from "../src/core/prices.ts";
|
||||
import { callCost } from "../src/server/pricing.ts";
|
||||
|
||||
const specimens = [
|
||||
{ id: "recursive/flint-v1.0:free", input: ["text"] },
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* THE PRICE-TABLE DRIFT GUARD (owner, 2026-09-29).
|
||||
*
|
||||
* Failure mode this kills: bumping @earendil-works/pi-ai adds new models
|
||||
* (and price corrections) to the shipped catalogs, but nobody re-runs
|
||||
* scripts/sync-pricing.ts — src/core/prices.ts stays a stale generation
|
||||
* and every run on a new model renders cost as "—" (deepseek-v4.1-flash,
|
||||
* 2026-09-29, commit df654fb). The table must stay a generated static
|
||||
* file (core is client-safe: activity-dock and stats-bar fold costs in
|
||||
* the renderer, and reading the catalog JSON needs fs), so staleness is
|
||||
* guarded instead of eliminated.
|
||||
*
|
||||
* The test asserts PRICES equals EXACTLY what the sync script would
|
||||
* generate right now — it imports the script's own builder, so the guard
|
||||
* and the generator can never disagree:
|
||||
* - a catalog model with pricing missing from PRICES → "run the sync"
|
||||
* - a price field that drifted in the catalog → "run the sync"
|
||||
* - a PRICES key whose model left the catalog → "run the sync"
|
||||
*
|
||||
* On failure: npx tsx scripts/sync-pricing.ts, commit the regenerated
|
||||
* file, re-derive any pinned-cost tests.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PRICES } from "../src/core/prices.ts";
|
||||
import { buildPrices } from "../scripts/sync-pricing.ts";
|
||||
|
||||
test("PRICES matches pi-ai's shipped catalogs exactly — the sync was run after the last bump", () => {
|
||||
const catalog = buildPrices();
|
||||
|
||||
const missing = [...catalog.keys()].filter((k) => !(k in PRICES));
|
||||
const stale = Object.keys(PRICES).filter((k) => !catalog.has(k));
|
||||
const drifted: string[] = [];
|
||||
for (const [key, cost] of catalog) {
|
||||
const price = PRICES[key];
|
||||
if (!price) continue;
|
||||
if (
|
||||
price.input !== cost.input ||
|
||||
price.output !== cost.output ||
|
||||
price.cacheRead !== cost.cacheRead ||
|
||||
price.cacheWrite !== cost.cacheWrite
|
||||
) {
|
||||
drifted.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
`${missing.length} / ${stale.length} / ${drifted.length}`,
|
||||
"0 / 0 / 0",
|
||||
`price table is stale: ${missing.length} unpriced catalog models (e.g. ${missing[0] ?? "—"}), ` +
|
||||
`${stale.length} keys gone from the catalog (e.g. ${stale[0] ?? "—"}), ` +
|
||||
`${drifted.length} drifted prices (e.g. ${drifted[0] ?? "—"}). ` +
|
||||
"Run: npx tsx scripts/sync-pricing.ts, commit the regenerated file, re-derive pinned-cost tests.",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Catalog/calculator parity replaces the old generated-table drift test. */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
import { callCost, jobCost } from "../src/server/pricing.ts";
|
||||
import { accumulateJobUsage, piUsageToStep } from "../src/core/contracts/jobs.ts";
|
||||
|
||||
const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.id));
|
||||
const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
||||
const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 });
|
||||
|
||||
test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => {
|
||||
assert.ok(models.length > 100);
|
||||
for (const model of models) {
|
||||
const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])];
|
||||
for (const input of sizes) {
|
||||
const usage = nativeUsage(input);
|
||||
const call = Object.freeze(saved(usage));
|
||||
const before = structuredClone(call);
|
||||
const expected = calculateCost(model, usage).total;
|
||||
assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`);
|
||||
assert.deepEqual(call, before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("job capture preserves individual tier sizes and one-hour writes; old tier aggregates are unknown", () => {
|
||||
const model = models.find((m) => m.cost.tiers?.length)!;
|
||||
assert.ok(model);
|
||||
const id = `${model.provider}:${model.id}`;
|
||||
const threshold = model.cost.tiers![0]!.inputTokensAbove;
|
||||
const first = nativeUsage(Math.ceil(threshold / 2));
|
||||
const second = nativeUsage(Math.ceil(threshold / 2));
|
||||
const total = accumulateJobUsage(accumulateJobUsage(undefined, id, piUsageToStep(first)), id, piUsageToStep(second));
|
||||
assert.equal(total.calls?.length, 2);
|
||||
assert.equal(total.cacheWrite1hTokens, 1000);
|
||||
assert.equal(total.lastStep?.cacheWrite1hTokens, 500);
|
||||
assert.equal(jobCost({ usage: total }), calculateCost(model, first).total + calculateCost(model, second).total);
|
||||
assert.equal(jobCost({ usage: { ...total, calls: undefined } }), null);
|
||||
const legacyOne = { ...total, ...saved(first), calls: undefined, steps: 1 };
|
||||
assert.equal(jobCost({ usage: legacyOne }), calculateCost(model, first).total);
|
||||
const resumedLegacy = accumulateJobUsage({ ...total, calls: undefined }, id, piUsageToStep(first));
|
||||
assert.equal(resumedLegacy.calls, undefined, "never pretend the missing old calls were reconstructed");
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pricing-transport-"));
|
||||
process.env.LH_DATA_DIR = scratch;
|
||||
process.env.LH_AUTH_FILE = path.join(scratch, "empty-auth.json");
|
||||
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
|
||||
const { chatStore } = await import("../src/store/chats.ts");
|
||||
const { GET } = await import("../app/api/chats/[id]/route.ts");
|
||||
const { sessionCost } = await import("../src/server/pricing.ts");
|
||||
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
||||
|
||||
test("chat GET supplies derived Pi costs without persisting dollars or changing native history", async () => {
|
||||
const record = chatStore.createChat("price projection fixture");
|
||||
chatStore.saveMessages(record.id, [{ id: "fixture-input", role: "user", content: "synthetic", timestamp: 1 }]);
|
||||
const step = { inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000, cacheWriteTokens: 0, cacheWrite1hTokens: 0, reasoningTokens: 500 };
|
||||
for (const [id, model] of [["api", "openrouter:openai/gpt-6.1-sol"], ["plan", "chatgpt:fixture"], ["unknown", "missing:fixture"]]) {
|
||||
chatStore.appendTurnUsage(record.id, { id: id!, model: model!, messageId: "fixture-input", at: "fixture", steps: [step] });
|
||||
}
|
||||
const file = path.join(scratch, "chats", `${record.id}.json`);
|
||||
const before = fs.readFileSync(file, "utf8");
|
||||
const response = await GET(new Request(`http://localhost/api/chats/${record.id}`), { params: Promise.resolve({ id: record.id }) });
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.deepEqual(body.messages, chatStore.getChat(record.id)!.messages);
|
||||
assert.deepEqual(body.pricing, sessionCost(chatStore.getChat(record.id)!));
|
||||
assert.equal(body.pricing.cost, 0.037);
|
||||
assert.equal(body.pricing.subscription, 1);
|
||||
assert.equal(body.pricing.unpriced, 1);
|
||||
assert.deepEqual(body.pricing.turnCosts, { api: 0.037, plan: null, unknown: null });
|
||||
assert.equal(fs.readFileSync(file, "utf8"), before, "read-time estimates must never rewrite the usage log");
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed, type FeedExtraction } from "../components/activity-dock.tsx";
|
||||
import { formatTokens, formatUsd } from "../components/usage-format.ts";
|
||||
import { jobCost } from "../src/server/pricing.ts";
|
||||
|
||||
// The section's pure layer: folds, formatting, and blob order — then the SSR
|
||||
// render (blobs, "—" doctrine, the abort affordance, the always-present
|
||||
@@ -27,6 +28,7 @@ function job(over: Partial<ActivityFeed["jobs"][number]> = {}): ActivityFeed["jo
|
||||
updatedAt: new Date(NOW - 1000).toISOString(),
|
||||
title: "Limits lesson",
|
||||
contextLimit: null,
|
||||
costUsd: jobCost({ usage: over.usage }),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -57,7 +59,7 @@ describe("activity dock pure layer", () => {
|
||||
assert.ok(blobs.every((b) => b.chatId === "src_a"), "abort/link identity stays source-scoped");
|
||||
});
|
||||
|
||||
it("folds cost with jobCost; absent usage is null, never zero", () => {
|
||||
it("uses the server's Pi price projection; absent pricing is null, never zero", () => {
|
||||
const priced = job({
|
||||
usage: {
|
||||
model: "openrouter:z-ai/glm-5.3-flash",
|
||||
@@ -70,10 +72,10 @@ describe("activity dock pure layer", () => {
|
||||
},
|
||||
});
|
||||
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
|
||||
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
|
||||
// (pi-ai catalog prices — glm-5.3-flash cacheRead corrected
|
||||
// 0.05 → 0.03 in the pi 1.0.2 catalog refresh)
|
||||
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
|
||||
// Pi-normalized fresh input is priced directly, never reduced by reads.
|
||||
assert.equal(blob.cost, 0.00215);
|
||||
const missingProjection = toBlobs({ jobs: [{ ...priced, costUsd: undefined }], extractions: [] })[0]!;
|
||||
assert.equal(missingProjection.cost, null, "the browser must not invent a fallback price");
|
||||
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
|
||||
assert.equal(absent.cost, null);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { StatsBar } from "../components/stats-bar.tsx";
|
||||
import { StatsBar, formatContextMeasurement } from "../components/stats-bar.tsx";
|
||||
import { sessionCost } from "../src/server/pricing.ts";
|
||||
import { catalogLimits } from "../components/model-picker.tsx";
|
||||
import type { ModelCatalogEntry, ModelCatalogResponse } from "../src/core/contracts.ts";
|
||||
import { prettyModel } from "../components/usage-format.ts";
|
||||
@@ -50,7 +51,7 @@ describe("stats bar", () => {
|
||||
|
||||
it("folds context + cost from the turns log (core/prices, no local math)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatsBar turns={[turn(), turn()]} />,
|
||||
<StatsBar turns={[turn(), turn()]} contextTokens={11_000} pricing={sessionCost({ turns: [turn(), turn()] })} />,
|
||||
);
|
||||
// last turn's last call: 10k in + 1k out = 11k. The LIMIT is the
|
||||
// live catalog's — and the bar doesn't hold the index yet (the
|
||||
@@ -68,7 +69,7 @@ describe("stats bar", () => {
|
||||
|
||||
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatsBar turns={[turn({ model: "nope/unknown" })]} />,
|
||||
<StatsBar turns={[turn({ model: "nope/unknown" })]} contextTokens={11_000} pricing={sessionCost({ turns: [turn({ model: "nope/unknown" })] })} />,
|
||||
);
|
||||
assert.match(html, /data-testid="stats-cost">—</);
|
||||
// unknown model: no catalog limit exists, but the footprint is
|
||||
@@ -77,6 +78,17 @@ describe("stats bar", () => {
|
||||
assert.match(html, /data-testid="stats-context">11k</);
|
||||
});
|
||||
|
||||
it("unknown context is explicit; changing the selected limit preserves the measurement", () => {
|
||||
assert.equal(formatContextMeasurement(null, 100_000), "? / 100k");
|
||||
assert.equal(formatContextMeasurement(83_000, 100_000), "83k / 100k");
|
||||
assert.equal(formatContextMeasurement(83_000, 200_000), "83k / 200k");
|
||||
assert.equal(formatContextMeasurement(83_000, null), "83k");
|
||||
const html = renderToStaticMarkup(<StatsBar chatId="fixture" turns={[turn()]} contextTokens={null} />);
|
||||
assert.match(html, /data-testid="stats-context">\?</);
|
||||
const empty = renderToStaticMarkup(<StatsBar chatId="fixture" turns={[]} />);
|
||||
assert.match(empty, /data-testid="stats-context">\?</);
|
||||
});
|
||||
|
||||
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
|
||||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
|
||||
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { act } from "react";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { StatsBar } from "../components/stats-bar.tsx";
|
||||
import { notifySettingsSaved } from "../components/settings-signal.ts";
|
||||
import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts";
|
||||
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
test("meter keeps its measured numerator across selected-model/catalog changes, and renders post-compaction unknown", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let selected = "a";
|
||||
const catalog: ModelCatalogResponse = {
|
||||
models: ["a", "b"].map((id, i) => ({ id, provider: "fixture", name: id, input: ["text"], reasoning: false, supportedLevels: ["off"], maxTokens: 1000, contextWindow: 100_000 * (i + 1), cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } })),
|
||||
providers: [], defaults: null, slots: { strong: null, cheap: null, vision: null }, rolePins: {} as ModelCatalogResponse["rolePins"],
|
||||
};
|
||||
globalThis.fetch = async (input) => {
|
||||
if (input === "/api/models") return Response.json(catalog);
|
||||
if (input === "/api/settings") return Response.json({ settings: { models: {} } });
|
||||
if (input === "/api/chats/fixture") return Response.json({ settings: { model: { provider: "fixture", id: selected }, thinkingLevel: "off" } });
|
||||
throw new Error(`unexpected fixture request ${String(input)}`);
|
||||
};
|
||||
const host = document.createElement("div"); document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const turn: TurnUsage = { id: "summary", messageId: "marker", at: "fixture", model: "fixture:a", steps: [{ inputTokens: 4300, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }] };
|
||||
const pricing = { cost: 0.003, unpriced: 0, turnCosts: { summary: 0.003 } };
|
||||
try {
|
||||
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={83_000} pricing={pricing} />));
|
||||
const context = () => host.querySelector('[data-testid="stats-context"]')?.textContent;
|
||||
assert.equal(context(), "83k / 100k", "summarizer's 4.3k money log is not the meter source");
|
||||
selected = "b";
|
||||
await act(async () => notifySettingsSaved("models"));
|
||||
assert.equal(context(), "83k / 200k", "only denominator changes");
|
||||
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={null} pricing={pricing} />));
|
||||
assert.equal(context(), "? / 200k");
|
||||
assert.equal(host.querySelector('[data-testid="stats-cost"]')?.textContent, "$0.0030", "summary spending is still visible");
|
||||
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={72_000} pricing={pricing} />));
|
||||
assert.equal(context(), "72k / 200k");
|
||||
} finally {
|
||||
await act(async () => root.unmount()); host.remove(); globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
+69
-262
@@ -1,307 +1,114 @@
|
||||
/**
|
||||
* Usage-accounting tests — the money channel's pure helpers (core/prices.ts)
|
||||
* and the ChatStore usage-log seam (appendTurnUsage).
|
||||
*
|
||||
* Pure helper tests are pure. The store test uses the real fs chatStore with
|
||||
* a fixture chat, cleaned up in after().
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { graphStore } from "../src/store/graph.ts";
|
||||
import { PATHS, SOURCES_FILE } from "../config.ts";
|
||||
import { callCost, contextWindow, jobCost, sessionCost, sumSteps, turnCost } from "../src/core/prices.ts";
|
||||
import { callCost, jobCost, sessionCost, turnCost } from "../src/server/pricing.ts";
|
||||
import { sumSteps } from "../src/core/prices.ts";
|
||||
import type { CallUsage, ExtractionRunUsage, JobRecord, TurnUsage } from "../src/core/contracts.ts";
|
||||
|
||||
const call = (over: Partial<CallUsage> = {}): CallUsage => ({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
...over,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined, reasoningTokens: undefined, ...over,
|
||||
});
|
||||
|
||||
test("sumSteps folds steps; absent stays absent until a value appears", () => {
|
||||
const sum = sumSteps([
|
||||
call({ inputTokens: 100, outputTokens: 20 }),
|
||||
call({ inputTokens: 50, outputTokens: 10, cacheReadTokens: 30 }),
|
||||
]);
|
||||
test("sumSteps: absent stays absent; unknown long-cache detail stays unknown", () => {
|
||||
const sum = sumSteps([call({ inputTokens: 100, outputTokens: 20 }), call({ inputTokens: 50, outputTokens: 10, cacheReadTokens: 30 })]);
|
||||
assert.equal(sum.inputTokens, 150);
|
||||
assert.equal(sum.outputTokens, 30);
|
||||
assert.equal(sum.cacheReadTokens, 30);
|
||||
assert.equal(sum.cacheWriteTokens, undefined); // never reported → stays undefined
|
||||
// all-absent → all-undefined (helper defaults input/output to 0, so
|
||||
// explicit undefined here)
|
||||
const empty = sumSteps([call({ inputTokens: undefined, outputTokens: undefined }), call({ inputTokens: undefined, outputTokens: undefined })]);
|
||||
assert.equal(empty.inputTokens, undefined);
|
||||
assert.equal(sum.cacheWriteTokens, undefined);
|
||||
assert.equal(sumSteps([call({ inputTokens: undefined, outputTokens: undefined })]).inputTokens, undefined);
|
||||
assert.equal(sumSteps([call({ cacheWriteTokens: 10 }), call({ cacheWriteTokens: 20, cacheWrite1hTokens: 5 })]).cacheWrite1hTokens, undefined);
|
||||
});
|
||||
|
||||
test("callCost: glm-5.3-flash math — fresh input, cached read, output", () => {
|
||||
test("callCost prices normalized fresh input directly, without subtracting caches again", () => {
|
||||
const cost = callCost(call({ inputTokens: 100_000, outputTokens: 20_000, cacheReadTokens: 10_000 }), "openrouter:z-ai/glm-5.3-flash");
|
||||
assert.ok(cost !== null);
|
||||
// fresh 90k @ $0.15/M + cached 10k @ $0.03/M + output 20k @ $0.5/M
|
||||
// (pi-ai catalog prices — regenerated tables, scripts/sync-pricing;
|
||||
// glm-5.3-flash cacheRead corrected 0.05 → 0.03 in pi 1.0.2)
|
||||
assert.ok(Math.abs(cost! - (0.0135 + 0.0003 + 0.01)) < 1e-9);
|
||||
assert.ok(Math.abs(cost - (0.015 + 0.0003 + 0.01)) < 1e-9);
|
||||
assert.equal(callCost(call({ inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000 }), "openrouter:openai/gpt-6.1-sol"), 0.037);
|
||||
});
|
||||
|
||||
test("callCost: cache writes bill at the cacheWrite rate", () => {
|
||||
const cost = callCost(
|
||||
call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }),
|
||||
"anthropic:claude-haiku-4-5",
|
||||
);
|
||||
assert.ok(cost !== null);
|
||||
// Native 1.4 catalog: fresh 1M @ $1/M + cache write 500k @ $1.25/M.
|
||||
assert.ok(Math.abs(cost! - (1 + 0.625)) < 1e-9);
|
||||
test("cache writes: precise short/one-hour costs and honest unknown historical splits", () => {
|
||||
const usage = call({ inputTokens: 1_000_000, cacheWriteTokens: 500_000, cacheWrite1hTokens: 0 });
|
||||
assert.equal(callCost(usage, "anthropic:claude-haiku-4-5"), 1.625);
|
||||
assert.equal(callCost({ ...usage, cacheWrite1hTokens: 200_000 }, "anthropic:claude-haiku-4-5"), 1.775);
|
||||
assert.equal(callCost({ ...usage, cacheWrite1hTokens: undefined }, "anthropic:claude-haiku-4-5"), null);
|
||||
});
|
||||
|
||||
test("callCost: unknown model and absent fields are null, never zero", () => {
|
||||
assert.equal(callCost(call({ inputTokens: 100, outputTokens: 5 }), "not/a-model"), null);
|
||||
assert.equal(callCost(call({ inputTokens: 100, outputTokens: undefined }), "openrouter:z-ai/glm-5.3-flash"), null);
|
||||
test("unknown model, absent or invalid required usage is null, never fake zero", () => {
|
||||
assert.equal(callCost(call(), "not/a-model"), null);
|
||||
assert.equal(callCost(call({ outputTokens: undefined }), "openai:gpt-5"), null);
|
||||
for (const value of [-1, NaN, Infinity]) assert.equal(callCost(call({ inputTokens: value }), "openai:gpt-5"), null);
|
||||
});
|
||||
|
||||
test("turnCost sums steps; sessionCost reports unpriced turns", () => {
|
||||
const turn: TurnUsage = {
|
||||
id: "t1",
|
||||
messageId: "m1",
|
||||
at: "2026-09-09T00:00:00Z",
|
||||
model: "openrouter:z-ai/glm-5.3-flash",
|
||||
steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })],
|
||||
};
|
||||
assert.ok(turnCost(turn) !== null);
|
||||
const totals = sessionCost({
|
||||
turns: [turn, { id: "t2", messageId: "m2", at: "x", model: "not/a-model", steps: [call({ inputTokens: 1, outputTokens: 1 })] }],
|
||||
});
|
||||
test("turn/session costs sum calls and count unpriced separately", () => {
|
||||
const turn: TurnUsage = { id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })] };
|
||||
const totals = sessionCost({ turns: [turn, { ...turn, id: "t2", model: "not/a-model" }] });
|
||||
assert.equal(totals.unpriced, 1);
|
||||
assert.ok(totals.cost > 0);
|
||||
assert.equal(totals.cost, turnCost(turn));
|
||||
assert.equal(totals.turnCosts.t1, totals.cost);
|
||||
assert.equal(totals.turnCosts.t2, null);
|
||||
});
|
||||
|
||||
test("contextWindow: last call's footprint against the live index's limit", () => {
|
||||
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
|
||||
const cw = contextWindow(
|
||||
{
|
||||
turns: [
|
||||
{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] },
|
||||
{ id: "t2", messageId: "m2", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 90_000, outputTokens: 10_000 })] },
|
||||
],
|
||||
},
|
||||
limits,
|
||||
);
|
||||
assert.ok(cw);
|
||||
assert.equal(cw.model, "openrouter:z-ai/glm-5.3-flash");
|
||||
assert.equal(cw.usedTokens, 100_000);
|
||||
assert.equal(cw.limit, 1_048_576);
|
||||
assert.equal(contextWindow({ turns: [] }, limits), null);
|
||||
});
|
||||
|
||||
test("contextWindow NUMERATOR: cache tokens count (owner audit, 2026-09-23)", () => {
|
||||
// The audit's live case: a cache-heavy session read 40k on the meter
|
||||
// while the real footprint was ~190k — provider `input` EXCLUDES the
|
||||
// cached portion, so input+output alone is wildly low. The fold is
|
||||
// pi's totalTokens semantics: all four fields.
|
||||
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
|
||||
const cw = contextWindow(
|
||||
{
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
messageId: "m1",
|
||||
at: "x",
|
||||
model: "openrouter:z-ai/glm-5.3-flash",
|
||||
steps: [call({ inputTokens: 40_000, outputTokens: 1_000, cacheReadTokens: 145_000, cacheWriteTokens: 4_000 })],
|
||||
},
|
||||
],
|
||||
},
|
||||
limits,
|
||||
);
|
||||
assert.ok(cw);
|
||||
assert.equal(cw.usedTokens, 40_000 + 1_000 + 145_000 + 4_000, "input + output + cacheRead + cacheWrite");
|
||||
// Cache fields absent (non-caching providers): the fold is unchanged.
|
||||
const plain = contextWindow(
|
||||
{ turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] }] },
|
||||
limits,
|
||||
);
|
||||
assert.ok(plain);
|
||||
assert.equal(plain.usedTokens, 10_000);
|
||||
});
|
||||
|
||||
test("contextWindow DENOMINATOR: the live index is the ONLY source — no fallback (owner amendment, 2026-09-23)", () => {
|
||||
const turns = [
|
||||
{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 1_000 })] },
|
||||
];
|
||||
const live = new Map([["openrouter:z-ai/glm-5.3-flash", 65_536]]);
|
||||
const cw = contextWindow({ turns }, live);
|
||||
assert.ok(cw);
|
||||
assert.equal(cw.limit, 65_536, "pi's catalog, via the index, is the one source");
|
||||
// No index at all (a client that hasn't fetched the catalog): the
|
||||
// meter renders usage-only — limit null, never a guessed window.
|
||||
const noIndex = contextWindow({ turns });
|
||||
assert.ok(noIndex);
|
||||
assert.equal(noIndex.usedTokens, 11_000);
|
||||
assert.equal(noIndex.limit, null);
|
||||
const emptyIndex = contextWindow({ turns }, new Map<string, number>());
|
||||
assert.ok(emptyIndex);
|
||||
assert.equal(emptyIndex.limit, null, "an empty index (failed fetch) is usage-only too");
|
||||
// A model missing from the live catalog (historical session, package
|
||||
// update removed it): usage-only, never a fake window.
|
||||
const missing = contextWindow({ turns: [{ ...turns[0]!, model: "nope/gone-model" }] }, live);
|
||||
assert.ok(missing);
|
||||
assert.equal(missing.limit, null);
|
||||
assert.equal(missing.usedTokens, 11_000, "the footprint still reads honestly");
|
||||
});
|
||||
|
||||
test("contextWindow ride-along: a zeroed/aborted last step falls back to the previous step", () => {
|
||||
// An aborted last call reports zeroed usage — that is not the session
|
||||
// footprint. The honest reading is the previous step's usage.
|
||||
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
|
||||
const cw = contextWindow(
|
||||
{
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
messageId: "m1",
|
||||
at: "x",
|
||||
model: "openrouter:z-ai/glm-5.3-flash",
|
||||
steps: [
|
||||
call({ inputTokens: 80_000, outputTokens: 5_000 }),
|
||||
call({ inputTokens: 0, outputTokens: 0 }), // aborted — zeroed
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
limits,
|
||||
);
|
||||
assert.ok(cw);
|
||||
assert.equal(cw.usedTokens, 85_000, "the previous step's footprint, not a bogus tiny meter");
|
||||
// ALL steps zeroed (a first-call abort): no reading at all — null.
|
||||
assert.equal(
|
||||
contextWindow(
|
||||
{ turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 0, outputTokens: 0 })] }] },
|
||||
limits,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("jobCost: null without usage, computed with it", () => {
|
||||
test("legacy flat-rate jobs remain priceable; missing usage stays absent", () => {
|
||||
assert.equal(jobCost({}), null);
|
||||
const job: Pick<JobRecord, "usage"> = {
|
||||
usage: {
|
||||
model: "openrouter:z-ai/glm-5.3-flash",
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 100_000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
steps: 4,
|
||||
},
|
||||
};
|
||||
assert.ok(jobCost(job) !== null);
|
||||
const job: Pick<JobRecord, "usage"> = { usage: { model: "openrouter:z-ai/glm-5.3-flash", inputTokens: 1_000_000, outputTokens: 100_000, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, steps: 4 } };
|
||||
assert.ok(jobCost(job)! > 0);
|
||||
});
|
||||
|
||||
// ─── the store seam ──────────────────────────────────────────────────────────
|
||||
|
||||
test("appendTurnUsage: appends, idempotent per turn id, continuation turns (same message) all kept, survives saveMessages", () => {
|
||||
const record = chatStore.createChat("usage test chat");
|
||||
const entry: TurnUsage = {
|
||||
id: "turn_1",
|
||||
messageId: "msg_a",
|
||||
at: "2026-09-09T00:00:00Z",
|
||||
model: "openrouter:z-ai/glm-5.3-flash",
|
||||
steps: [call({ inputTokens: 100, outputTokens: 10 })],
|
||||
};
|
||||
chatStore.appendTurnUsage(record.id, entry);
|
||||
chatStore.appendTurnUsage(record.id, entry); // replay (abort/retry) → no-op
|
||||
// continuation turn: SAME response message (SDK coalesces), new turn id —
|
||||
// usage must survive, not collapse into the first entry
|
||||
chatStore.appendTurnUsage(record.id, { ...entry, id: "turn_2" });
|
||||
let reread = chatStore.getChat(record.id);
|
||||
assert.equal(reread?.turns?.length, 2);
|
||||
|
||||
// saveMessages must not clobber the log (read-mutate-write record) —
|
||||
// Wave B: a NATIVE message list (the record holds agent history).
|
||||
chatStore.saveMessages(record.id, [
|
||||
{ id: "msg_a", role: "user", content: "native", timestamp: Date.now() } as never,
|
||||
]);
|
||||
reread = chatStore.getChat(record.id);
|
||||
assert.equal(reread?.turns?.length, 2);
|
||||
test("appendTurnUsage: idempotent per turn; continuations survive saveMessages; list omits log", () => {
|
||||
const record = chatStore.createChat("usage fixture");
|
||||
try {
|
||||
const entry: TurnUsage = { id: "turn_1", messageId: "msg_a", at: "x", model: "openai:gpt-5", steps: [call({ inputTokens: 100, outputTokens: 10, cacheWrite1hTokens: 0 })] };
|
||||
chatStore.appendTurnUsage(record.id, entry);
|
||||
chatStore.appendTurnUsage(record.id, entry);
|
||||
chatStore.appendTurnUsage(record.id, { ...entry, id: "turn_2" });
|
||||
assert.equal(chatStore.getChat(record.id)?.turns?.length, 2);
|
||||
chatStore.saveMessages(record.id, [{ id: "msg_a", role: "user", content: "fixture", timestamp: 1 }]);
|
||||
assert.deepEqual(chatStore.getChat(record.id)?.turns?.[0]?.steps, JSON.parse(JSON.stringify(entry.steps)));
|
||||
assert.equal(chatStore.getChat(record.id)?.turns?.length, 2);
|
||||
assert.equal("turns" in chatStore.listChats().find((c) => c.id === record.id)!, false);
|
||||
} finally { fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); }
|
||||
});
|
||||
|
||||
// and the list payload must NOT leak turns
|
||||
const listed = chatStore.listChats().find((c) => c.id === record.id);
|
||||
assert.ok(listed);
|
||||
assert.equal("turns" in listed, false);
|
||||
|
||||
fs.unlinkSync(`${PATHS.chats}/${record.id}.json`);
|
||||
const runEntry = (startedAt: number): ExtractionRunUsage => ({
|
||||
startedAt, at: "2026-09-09T00:00:00Z", durationMs: 42_000,
|
||||
stats: { nodes: 1, chains: 2 }, stages: {
|
||||
extractor: { model: "openai:gpt-5", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
|
||||
auditor: { model: "openai:gpt-5", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
|
||||
},
|
||||
});
|
||||
|
||||
test("appendExtractionUsage: appends, idempotent per startedAt, not in list payload", () => {
|
||||
const record = chatStore.createChat("extraction usage test chat");
|
||||
const runEntry = (startedAt: number): ExtractionRunUsage => ({
|
||||
startedAt,
|
||||
at: "2026-09-09T00:00:00Z",
|
||||
durationMs: 42_000,
|
||||
stats: { nodes: 1, chains: 2 },
|
||||
stages: {
|
||||
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
|
||||
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
|
||||
},
|
||||
});
|
||||
chatStore.appendExtractionUsage(record.id, runEntry(1));
|
||||
chatStore.appendExtractionUsage(record.id, runEntry(1)); // same run appended twice → no-op
|
||||
chatStore.appendExtractionUsage(record.id, runEntry(2)); // a retried run is a NEW spend
|
||||
let reread = chatStore.getChat(record.id);
|
||||
assert.equal(reread?.extractions?.length, 2);
|
||||
// failed runs (stats absent) persist with usage — money was spent
|
||||
chatStore.appendExtractionUsage(record.id, { ...runEntry(3), stats: undefined });
|
||||
reread = chatStore.getChat(record.id);
|
||||
assert.equal(reread?.extractions?.length, 3);
|
||||
assert.equal(reread?.extractions?.at(-1)?.stats, undefined);
|
||||
|
||||
// the list payload must NOT leak the extractions log
|
||||
const listed = chatStore.listChats().find((c) => c.id === record.id);
|
||||
assert.ok(listed);
|
||||
assert.equal("extractions" in listed, false);
|
||||
|
||||
fs.unlinkSync(`${PATHS.chats}/${record.id}.json`);
|
||||
test("appendExtractionUsage: idempotent; failed runs persist; list omits log", () => {
|
||||
const record = chatStore.createChat("extraction usage fixture");
|
||||
try {
|
||||
chatStore.appendExtractionUsage(record.id, runEntry(1));
|
||||
chatStore.appendExtractionUsage(record.id, runEntry(1));
|
||||
chatStore.appendExtractionUsage(record.id, runEntry(2));
|
||||
chatStore.appendExtractionUsage(record.id, { ...runEntry(3), stats: undefined });
|
||||
assert.equal(chatStore.getChat(record.id)?.extractions?.length, 3);
|
||||
assert.equal(chatStore.getChat(record.id)?.extractions?.at(-1)?.stats, undefined);
|
||||
assert.equal("extractions" in chatStore.listChats().find((c) => c.id === record.id)!, false);
|
||||
} finally { fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); }
|
||||
});
|
||||
|
||||
test("appendSourceUsage: appends to the source record, idempotent per startedAt, upsert keeps history", () => {
|
||||
// The real fs graphStore — sources.json backed up and restored around
|
||||
// this test (same discipline as the chat fixture files).
|
||||
test("appendSourceUsage: idempotent; reimport keeps history; absent source is a no-op", () => {
|
||||
fs.mkdirSync(PATHS.graph, { recursive: true });
|
||||
let bak: string | undefined;
|
||||
if (fs.existsSync(SOURCES_FILE)) bak = `${SOURCES_FILE}.w5test.bak`;
|
||||
if (bak) fs.renameSync(SOURCES_FILE, bak);
|
||||
const backup = fs.existsSync(SOURCES_FILE) ? fs.readFileSync(SOURCES_FILE) : undefined;
|
||||
try {
|
||||
const runEntry = (startedAt: number): ExtractionRunUsage => ({
|
||||
startedAt,
|
||||
at: "2026-09-13T00:00:00Z",
|
||||
durationMs: 42_000,
|
||||
stats: { nodes: 1, chains: 2 },
|
||||
stages: {
|
||||
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
|
||||
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
|
||||
},
|
||||
});
|
||||
const id = "src_usagetest01";
|
||||
graphStore.upsertSource({ id, filePath: "/vault/usagetest.md", importedAt: "2026-09-13T00:00:00Z" });
|
||||
graphStore.upsertSource({ id, filePath: "/fixture/notes.md", importedAt: "2026-09-13T00:00:00Z" });
|
||||
graphStore.appendSourceUsage(id, runEntry(1));
|
||||
graphStore.appendSourceUsage(id, runEntry(1)); // same run appended twice → no-op
|
||||
graphStore.appendSourceUsage(id, runEntry(2)); // a retried run is a NEW spend
|
||||
graphStore.appendSourceUsage(id, runEntry(1));
|
||||
graphStore.appendSourceUsage(id, runEntry(2));
|
||||
assert.equal(graphStore.readSources().find((s) => s.id === id)?.extractions?.length, 2);
|
||||
|
||||
// An upsert (re-import) KEEPS the usage history.
|
||||
graphStore.upsertSource({ id, filePath: "/vault/usagetest.md", importedAt: "2026-09-14T00:00:00Z" });
|
||||
graphStore.upsertSource({ id, filePath: "/fixture/notes.md", importedAt: "2026-09-14T00:00:00Z" });
|
||||
assert.equal(graphStore.readSources().find((s) => s.id === id)?.extractions?.length, 2);
|
||||
|
||||
// Unknown ids are a silent no-op (nothing to bill against).
|
||||
graphStore.appendSourceUsage("src_nope", runEntry(1));
|
||||
graphStore.appendSourceUsage("src_missing", runEntry(1));
|
||||
} finally {
|
||||
fs.rmSync(SOURCES_FILE, { force: true });
|
||||
if (bak) fs.renameSync(bak, SOURCES_FILE);
|
||||
if (backup) fs.writeFileSync(SOURCES_FILE, backup);
|
||||
else fs.rmSync(SOURCES_FILE, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user