From 81709afd2248a75ca8c6ac818c4bfa02004292ad Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 24 Sep 2026 02:15:23 +0300 Subject: [PATCH] Stats-bar catalog join + dock numerator consistency (context-meter close-out) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform's context-meter pair (04e336d, 18cc493) left two client seams in my band; this closes the arc: 1. STATS-BAR CATALOG JOIN: contextWindow's denominator resolves ONLY from pi's live catalog — stats-bar now builds the provider:id -> contextWindow index client-side (catalogLimits in model-picker.tsx, the client twin of the server's catalogContextLimits join) over the picker's existing /api/models fetch/cache (useModelCatalog exported; module-level dedupe, one fetch feeds both ModelControl and the meter). Catalog pending or failed -> undefined index -> limit null -> USAGE-ONLY (Platform's interim render IS the null path; the honest-render precedent, never a fake window). 2. DOCK NUMERATOR CONSISTENCY: toBlobs' context fold was still last-step input+output only — now the same cache-inclusive footprint the session meter uses (all four directions; provider "input" EXCLUDES cacheRead/cacheWrite, so the old fold read wildly low on cache-heavy jobs). Same metric everywhere. Review of Platform's interim edits in this band: verified as-shipped — stats-bar's usage-only render (summary + popover), FeedJob.contextLimit with the line-hides null treatment, and the render-test rewrites all fit. Only fix needed: none beyond the above. Tests: catalogLimits pinned pure (index shape, provider:id keying, missing-model absence); toBlobs pinned on the cache-inclusive numerator (10k+0.5k+120k+30k = 160.5k against a 200k feed limit). 889/889. Live-verified on :3001: seedplan's bar reads "18.7k / 1M" (footprint 18725, catalog 1048576); a nope/unknown-model chat renders "12.8k" usage-only with cost "-"; the popover detail matches; the dock blob's context line renders "946 / 1M" from a server-joined feed (an unintentionally real runner-adopted job — the full chain, live). --- components/activity-dock.tsx | 13 +++++++- components/model-picker.tsx | 13 +++++++- components/stats-bar.tsx | 15 +++++++-- tests/render-activity-dock.test.tsx | 24 ++++++++++++++ tests/render-stats-bar.test.tsx | 49 ++++++++++++++++++++++++++++- 5 files changed, 108 insertions(+), 6 deletions(-) diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 659bf7f..8eea8c6 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -180,7 +180,18 @@ 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) { - context = { used: lastStep.inputTokens + (lastStep.outputTokens ?? 0), limit }; + // Same metric as the session meter (prices.ts stepFootprint — 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. + context = { + used: + lastStep.inputTokens + + (lastStep.outputTokens ?? 0) + + (lastStep.cacheReadTokens ?? 0) + + (lastStep.cacheWriteTokens ?? 0), + limit, + }; } return { id: job.id, diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 63fda83..984b7ee 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -39,7 +39,7 @@ function fetchCatalog(force = false): Promise { return catalogInflight; } -function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } { +export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } { const [catalog, setCatalog] = useState(catalogCache); useEffect(() => { if (catalogCache) return; @@ -64,6 +64,17 @@ function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh return { catalog, refresh }; } +/** provider:id → contextWindow over the cached catalog — the client-side + * twin of the server's catalogContextLimits join (context-meter + * amendment, owner 2026-09-23: pi's live catalog is the ONLY limit + * source — no hardcoded fallback exists anywhere, so a model missing + * from the index reads limit-null and the meter renders usage-only, + * the honest-render precedent). Consumed by the stats bar: the same + * cached /api/models fetch the picker rides feeds the denominator. */ +export function catalogLimits(catalog: Pick): Map { + return new Map(catalog.models.map((m) => [`${m.provider}:${m.id}`, m.contextWindow])); +} + /** * THE STRONG-SLOT CONTROL (fix wave, owner ruling: "one and the same"). * The composer's model + thinking pills ARE the strong slot — the tier diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index a5d64d5..2492eed 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core"; import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format"; -import { ModelControl } from "@/components/model-picker"; +import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; /** * The below-composer stats bar (wave 4 observability): the session's @@ -56,7 +56,16 @@ export function StatsBar({ document.removeEventListener("keydown", onKeyDown); }; }, [open]); - const ctx = contextWindow({ turns }); + // 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. + const { catalog } = useModelCatalog(); + const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]); + const ctx = contextWindow({ turns }, limits); const { cost, unpriced } = sessionCost({ turns }); // Honest cost: show dollars only when at least one turn priced; // otherwise "—" (never a fake $0.0000). diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index 1f9128a..2189c36 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -109,6 +109,30 @@ describe("activity dock pure layer", () => { extractions: [], })[0]!; assert.equal(uncatalogued.context, null); + // The numerator is the same cache-inclusive fold the session meter + // uses (context-meter audit, owner 2026-09-23): provider "input" + // EXCLUDES cacheRead/cacheWrite, so input+output alone reads wildly + // low on cache-heavy jobs — 10k+0.5k+120k+30k = 160.5k, not 10.5k. + const cached = toBlobs({ + jobs: [ + job({ + usage: { + ...base, + lastStep: { + inputTokens: 10_000, + outputTokens: 500, + cacheReadTokens: 120_000, + cacheWriteTokens: 30_000, + reasoningTokens: undefined, + }, + }, + contextLimit: 200_000, + }), + ], + extractions: [], + })[0]!; + assert.equal(cached.context?.used, 160_500); + assert.equal(cached.context?.limit, 200_000); }); it("extraction blobs carry no money/context (—, the doctrine)", () => { diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx index d4dca6d..72c5bf9 100644 --- a/tests/render-stats-bar.test.tsx +++ b/tests/render-stats-bar.test.tsx @@ -2,6 +2,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 { catalogLimits } from "../components/model-picker.tsx"; +import type { ModelCatalogEntry, ModelCatalogResponse } from "../src/core/contracts.ts"; import { prettyModel } from "../components/usage-format.ts"; import type { ChatSettings, TurnUsage } from "../src/core/contracts.ts"; @@ -90,4 +92,49 @@ describe("stats bar", () => { // qualified on a bare id: no suffix invented assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "Glm 5.3 Flash"); }); -}); \ No newline at end of file +}); + +describe("catalogLimits (the stats-bar live-limits join, pure)", () => { + // The client-side twin of the server's catalogContextLimits join: + // provider:id → contextWindow straight from the cached /api/models + // payload. A static render holds no catalog (the fetch is a live + // concern — Playwright verifies the wiring), so the pure index shape + // is pinned here: one entry per model, the exact key contextWindow() + // looks up, missing models absent (→ limit null → usage-only). + const entry = (over: Pick): ModelCatalogEntry => ({ + name: over.id, + reasoning: false, + input: ["text"], + supportedLevels: [], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 32_768, + ...over, + }); + const catalog: Pick = { + models: [ + entry({ id: "z-ai/glm-5.3-flash", provider: "openrouter", contextWindow: 1_048_576 }), + entry({ id: "gpt-x", provider: "openai", contextWindow: 400_000 }), + ], + }; + + it("builds provider:id → contextWindow over every catalog model", () => { + const limits = catalogLimits(catalog); + assert.equal(limits.get("openrouter:z-ai/glm-5.3-flash"), 1_048_576); + assert.equal(limits.get("openai:gpt-x"), 400_000); + assert.equal(limits.size, 2); + }); + + it("a model missing from the index is absent — limit null, usage-only (never a fallback)", () => { + const limits = catalogLimits(catalog); + assert.equal(limits.has("nope/unknown"), false); + }); + + it("same provider+model on different providers keys apart", () => { + const twin: Pick = { + models: [...catalog.models, entry({ id: "z-ai/glm-5.3-flash", provider: "zai", contextWindow: 200_000 })], + }; + const limits = catalogLimits(twin); + assert.equal(limits.get("openrouter:z-ai/glm-5.3-flash"), 1_048_576); + assert.equal(limits.get("zai:z-ai/glm-5.3-flash"), 200_000); + }); +});