Merge compaction and native usage accounting fixes
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -303,7 +303,7 @@ function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">): string {
|
||||
const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0);
|
||||
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
|
||||
const dollars = displayCostTotals(costs).totalDollars;
|
||||
return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : `$${dollars.toFixed(2)}`;
|
||||
return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : priced ? `$${dollars.toFixed(2)}` : "—";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user