fix: align compaction cuts and native usage accounting
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user