Stats-bar catalog join + dock numerator consistency (context-meter close-out)
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).
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -39,7 +39,7 @@ function fetchCatalog(force = false): Promise<ModelCatalogResponse> {
|
||||
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<ModelCatalogResponse, "models">): Map<string, number> {
|
||||
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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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, "id" | "provider" | "contextWindow">): 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<ModelCatalogResponse, "models"> = {
|
||||
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<ModelCatalogResponse, "models"> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user