diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 13:57:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 13:57:41 +0900 |
| commit | d27d97bb3aa0c13f4032bab54703ebb9e1c84c81 (patch) | |
| tree | b5bcfed65be5a4d27a7bbe6b46e338dcd489c2e0 /packages/core/src | |
| parent | 3671b82cc624117476e30b95eaf7d2bc3b34ae28 (diff) | |
| parent | c1439ea8c677ddfd11c219de39c3e77c7e297a9b (diff) | |
| download | dispatch-d27d97bb3aa0c13f4032bab54703ebb9e1c84c81.tar.gz dispatch-d27d97bb3aa0c13f4032bab54703ebb9e1c84c81.zip | |
Merge branch 'dev' into u3/agent-effort-level
# Conflicts:
# packages/api/tests/agent-manager.test.ts
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/chunks/transform.ts | 5 | ||||
| -rw-r--r-- | packages/core/src/db/chunks.ts | 85 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 7 | ||||
| -rw-r--r-- | packages/core/src/models/catalog.ts | 179 | ||||
| -rw-r--r-- | packages/core/src/models/index.ts | 4 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 61 |
6 files changed, 330 insertions, 11 deletions
diff --git a/packages/core/src/chunks/transform.ts b/packages/core/src/chunks/transform.ts index a4c6fc8..e8f4a18 100644 --- a/packages/core/src/chunks/transform.ts +++ b/packages/core/src/chunks/transform.ts @@ -209,6 +209,11 @@ export function groupRowsToMessages(rows: ChunkRow[]): MessageRow[] { continue; } + // Usage rows are an invisible side channel (persisted for the backend + // aggregate only). They're already query-excluded from getChunksForTab, + // so this is defensive insurance: never let one leak into render grouping. + if (row.type === "usage") continue; + // assistant / tool rows → part of the current assistant message const c = ensureAssistant(row); switch (row.type) { diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts index 077259d..e0aadf3 100644 --- a/packages/core/src/db/chunks.ts +++ b/packages/core/src/db/chunks.ts @@ -5,7 +5,14 @@ import { groupRowsToMessages, type MessageRow, } from "../chunks/transform.js"; -import type { ChunkData, ChunkRow, ChunkRowDraft, TextData } from "../types/index.js"; +import type { + ChunkData, + ChunkRow, + ChunkRowDraft, + TextData, + UsageData, + UsageStats, +} from "../types/index.js"; import { getDatabase } from "./index.js"; // Re-export the DB-free transforms so existing barrel consumers @@ -101,7 +108,7 @@ export function getChunksForTab( const db = getDatabase(); if (!options) { const rows = db - .query("SELECT * FROM chunks WHERE tab_id = $tabId ORDER BY seq ASC") + .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC") .all({ $tabId: tabId }) as Array<Record<string, unknown>>; return rows.map(mapRow); } @@ -110,24 +117,28 @@ export function getChunksForTab( if (limit !== undefined) { const rows = db .query( - "SELECT * FROM chunks WHERE tab_id = $tabId AND seq < $before ORDER BY seq DESC LIMIT $limit", + "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit", ) .all({ $tabId: tabId, $before: before, $limit: limit }) as Array<Record<string, unknown>>; return rows.map(mapRow).reverse(); } const rows = db - .query("SELECT * FROM chunks WHERE tab_id = $tabId AND seq < $before ORDER BY seq DESC") + .query( + "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC", + ) .all({ $tabId: tabId, $before: before }) as Array<Record<string, unknown>>; return rows.map(mapRow).reverse(); } if (limit !== undefined) { const rows = db - .query("SELECT * FROM chunks WHERE tab_id = $tabId ORDER BY seq DESC LIMIT $limit") + .query( + "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit", + ) .all({ $tabId: tabId, $limit: limit }) as Array<Record<string, unknown>>; return rows.map(mapRow).reverse(); } const rows = db - .query("SELECT * FROM chunks WHERE tab_id = $tabId ORDER BY seq ASC") + .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC") .all({ $tabId: tabId }) as Array<Record<string, unknown>>; return rows.map(mapRow); } @@ -145,11 +156,71 @@ export function getMessagesForTab(tabId: string): MessageRow[] { export function getTotalChunkCount(tabId: string): number { const db = getDatabase(); const row = db - .query("SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId") + .query("SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'") .get({ $tabId: tabId }) as { count: number } | null; return row?.count ?? 0; } +/** + * Aggregate per-tab token/cache usage across ALL persisted `usage` chunk rows. + * + * Usage rows are written as an invisible side channel (one row per `usage` + * AgentEvent) and are query-excluded from `getChunksForTab`/`getTotalChunkCount`, + * so this aggregate is the read path. Because it sums server-side over every + * row, it stays complete even after the frontend evicts/pages out old turns + * (eviction is in-memory only). The return shape is structurally identical to + * the frontend `CacheStats`, so reload can seed it directly. + * + * - cumulative `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens` + * = SUM over all usage rows; + * - `requests` = COUNT of usage rows; + * - `last` = the highest-seq usage row's split (most recent request); + * - `null` when the tab has no usage rows. + * + * Sums in JS after selecting the rows (mirroring `mapRow`) to avoid relying on + * `json_extract` over the freeform `data_json`. + */ +export function getUsageStatsForTab(tabId: string): UsageStats | null { + const db = getDatabase(); + const rows = db + .query("SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC") + .all({ $tabId: tabId }) as Array<{ data_json: string }>; + if (rows.length === 0) return null; + + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; + let last: UsageData | null = null; + for (const row of rows) { + let u: UsageData; + try { + u = JSON.parse(row.data_json) as UsageData; + } catch { + continue; + } + inputTokens += u.inputTokens ?? 0; + outputTokens += u.outputTokens ?? 0; + cacheReadTokens += u.cacheReadTokens ?? 0; + cacheWriteTokens += u.cacheWriteTokens ?? 0; + last = { + inputTokens: u.inputTokens ?? 0, + outputTokens: u.outputTokens ?? 0, + cacheReadTokens: u.cacheReadTokens ?? 0, + cacheWriteTokens: u.cacheWriteTokens ?? 0, + }; + } + + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + requests: rows.length, + last, + }; +} + export function clearChunksForTab(tabId: string): void { const db = getDatabase(); db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 327b0a5..7818024 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,7 @@ export { getChunksForTab, getMessagesForTab, getTotalChunkCount, + getUsageStatsForTab, groupRowsToMessages, type MessageRow, } from "./db/chunks.js"; @@ -67,7 +68,11 @@ export { } from "./llm/debug-logger.js"; export { createProvider } from "./llm/provider.js"; // Models -export { ModelRegistry } from "./models/index.js"; +export { + getModelsCatalog, + ModelRegistry, + resolveContextLimit, +} from "./models/index.js"; // Notifications (ntfy.sh) export * from "./notifications/index.js"; export * from "./permission/index.js"; diff --git a/packages/core/src/models/catalog.ts b/packages/core/src/models/catalog.ts new file mode 100644 index 0000000..dea4647 --- /dev/null +++ b/packages/core/src/models/catalog.ts @@ -0,0 +1,179 @@ +import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +/** + * models.dev-backed model catalog. Resolves a model's MAXIMUM context window + * (`limit.context`) dynamically from the public models.dev API, mirroring how + * opencode determines per-model context limits — no hardcoded table. + * + * The catalog is fetched once, cached on disk with a short TTL, and reused. On + * fetch failure we fall back to a stale-but-present cache so the lookup keeps + * working offline. Lookups never throw: an unknown/unreachable model resolves + * to `null`, which the UI renders as "max unknown". + */ + +/** Shape of the slice of models.dev's `/api.json` we consume. */ +interface ModelsDevModel { + limit?: { + context?: number; + output?: number; + }; +} + +interface ModelsDevProvider { + id: string; + models: Record<string, ModelsDevModel | undefined>; +} + +type ModelsDevCatalog = Record<string, ModelsDevProvider | undefined>; + +/** Where models.dev's API lives. Overridable for tests / private mirrors. */ +const MODELS_URL = process.env.DISPATCH_MODELS_URL || "https://models.dev"; + +/** Disk cache path (reuses the repo's `/tmp/dispatch` convention). */ +const CACHE_PATH = "/tmp/dispatch/models-dev.json"; + +/** How long a cached catalog stays fresh before we re-fetch. */ +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** Network timeout for the catalog fetch. */ +const FETCH_TIMEOUT_MS = 10_000; + +/** + * After a failed fetch we memoize the fallback for this long before retrying, + * so a sustained outage doesn't make every lookup hang on a fresh timeout. + */ +const FETCH_PENALTY_MS = 60_000; + +/** + * Dispatch provider id → models.dev provider ids to search, in priority order. + * We only support Claude-backed providers (per product scope). `anthropic` and + * `opencode-anthropic` are both Claude; we try the first-party `anthropic` + * catalog first, then the `opencode` gateway catalog as a fallback. + */ +const PROVIDER_MAP: Record<string, string[]> = { + anthropic: ["anthropic", "opencode"], + "opencode-anthropic": ["anthropic", "opencode"], +}; + +/** In-process memoized catalog promise (one fetch/parse per TTL window). */ +let cached: { catalog: ModelsDevCatalog; fetchedAt: number } | null = null; +let inflight: Promise<ModelsDevCatalog> | null = null; + +function readDiskCache(): { catalog: ModelsDevCatalog; mtimeMs: number } | null { + try { + const stat = statSync(CACHE_PATH); + const text = readFileSync(CACHE_PATH, "utf-8"); + return { catalog: JSON.parse(text) as ModelsDevCatalog, mtimeMs: stat.mtimeMs }; + } catch { + return null; + } +} + +function writeDiskCache(text: string): void { + try { + mkdirSync(dirname(CACHE_PATH), { recursive: true }); + // Write-then-rename so a concurrent reader never sees a half-written + // file (rename is atomic on the same filesystem). The temp name is + // process-scoped to avoid two writers clobbering each other's temp. + const tmp = `${CACHE_PATH}.${process.pid}.tmp`; + writeFileSync(tmp, text, "utf-8"); + renameSync(tmp, CACHE_PATH); + } catch { + // Best-effort: a read-only /tmp shouldn't break lookups. + } +} + +async function fetchCatalog(): Promise<ModelsDevCatalog> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(`${MODELS_URL}/api.json`, { signal: controller.signal }); + if (!res.ok) throw new Error(`models.dev returned HTTP ${res.status}`); + const text = await res.text(); + const catalog = JSON.parse(text) as ModelsDevCatalog; + writeDiskCache(text); + return catalog; + } finally { + clearTimeout(timer); + } +} + +/** + * Load the models.dev catalog, preferring in-process memo, then a fresh disk + * cache, then a network fetch. On network failure, falls back to any stale + * disk cache; if nothing is available, returns an empty catalog. + */ +export async function getModelsCatalog(): Promise<ModelsDevCatalog> { + if (process.env.DISPATCH_DISABLE_MODELS_FETCH) { + const disk = readDiskCache(); + return disk?.catalog ?? {}; + } + + const now = Date.now(); + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.catalog; + + // Fresh disk cache satisfies the request without a network round-trip. + const disk = readDiskCache(); + if (disk && now - disk.mtimeMs < CACHE_TTL_MS) { + // Inherit the file's mtime as `fetchedAt` so loading a disk cache into + // a fresh process doesn't reset its TTL (which would otherwise double + // the worst-case staleness across process boundaries). + cached = { catalog: disk.catalog, fetchedAt: disk.mtimeMs }; + return disk.catalog; + } + + if (!inflight) { + inflight = fetchCatalog() + .then((catalog) => { + cached = { catalog, fetchedAt: Date.now() }; + return catalog; + }) + .catch((err) => { + // Network failed — serve a stale cache if we have one. + console.warn( + `dispatch: failed to fetch models.dev catalog: ${err instanceof Error ? err.message : String(err)}`, + ); + const fallback = disk?.catalog ?? ({} as ModelsDevCatalog); + // Memoize the fallback with a short "penalty" TTL so a sustained + // outage doesn't make every lookup hang on a fresh 10s timeout. + // `fetchedAt` is backdated so the memo expires after FETCH_PENALTY_MS. + cached = { + catalog: fallback, + fetchedAt: Date.now() - CACHE_TTL_MS + FETCH_PENALTY_MS, + }; + return fallback; + }) + .finally(() => { + inflight = null; + }); + } + return inflight; +} + +/** + * Resolve a model's maximum context window (in tokens) for the given Dispatch + * provider + model id. Returns `null` when the provider is unsupported, the + * model is unknown, or the catalog is unavailable — callers should render that + * as "max unknown" (no denominator / percentage). + */ +export async function resolveContextLimit( + provider: string, + modelId: string, +): Promise<number | null> { + const candidates = PROVIDER_MAP[provider]; + if (!candidates || !modelId) return null; + + const catalog = await getModelsCatalog(); + for (const providerId of candidates) { + const ctx = catalog[providerId]?.models?.[modelId]?.limit?.context; + if (typeof ctx === "number" && ctx > 0) return ctx; + } + return null; +} + +/** Test-only: reset the in-process memo so a test can re-exercise loading. */ +export function __resetCatalogCacheForTests(): void { + cached = null; + inflight = null; +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index cf59749..2fcd657 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -1 +1,5 @@ +export { + getModelsCatalog, + resolveContextLimit, +} from "./catalog.js"; export { ModelRegistry } from "./registry.js"; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index bab17f1..30afbd9 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -90,7 +90,14 @@ export interface ChatMessage { export type ChunkRole = "user" | "assistant" | "tool" | "system"; /** Discriminator for a persisted chunk row's payload. */ -export type ChunkType = "text" | "thinking" | "tool_call" | "tool_result" | "error" | "system"; +export type ChunkType = + | "text" + | "thinking" + | "tool_call" + | "tool_result" + | "error" + | "system" + | "usage"; export interface TextData { text: string; @@ -119,6 +126,45 @@ export interface SystemData { kind: SystemChunkKind; text: string; } +/** + * Per-request token usage persisted as a SIDE-CHANNEL chunk row (one row per + * `usage` AgentEvent, i.e. one per LLM round-trip). These rows are deliberately + * EXCLUDED from `getChunksForTab`/`getTotalChunkCount` so they never enter the + * render, pagination, eviction, or agent-history-rebuild paths — they exist + * only to feed the backend aggregate `getUsageStatsForTab`, which seeds the + * frontend's `cacheStats` on reload. `inputTokens` is the TOTAL prompt + * (cached + fresh); `cacheReadTokens`/`cacheWriteTokens` are Anthropic's + * prompt-cache split. Mirrors the `usage` AgentEvent payload. + */ +export interface UsageData { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; +} + +/** + * Aggregate per-tab usage telemetry: the cumulative sum across ALL persisted + * `usage` rows, the request count, and the most recent request's split. This is + * the server-side source of truth (complete regardless of frontend + * eviction/pagination) returned by `getUsageStatsForTab`. Structurally + * identical to the frontend `CacheStats` so it can seed it directly. `null` when + * the tab has no usage rows. + */ +export interface UsageStats { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + /** Number of LLM requests (usage rows) counted. */ + requests: number; + last: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + } | null; +} export type ChunkData = | TextData @@ -126,7 +172,8 @@ export type ChunkData = | ToolCallData | ToolResultData | ErrorData - | SystemData; + | SystemData + | UsageData; /** * A persisted chunk row — the append-only unit of conversation storage and @@ -225,8 +272,16 @@ export type AgentEvent = * fold its transient live representation into the sealed chunk log. Emitted * after `status: idle`/`error` (which fire before the DB write). Display/sync * only — not conversation content. + * + * Carries `usageStats`: the tab's authoritative usage aggregate read from the + * DB AFTER the turn's usage rows were written. The frontend REPLACES (not adds) + * its live `cacheStats` with this, reconciling the live accumulator to the + * persisted truth every turn. This self-heals the live overshoot that occurs + * when a rate-limited fallback attempt's usage is streamed live but then + * discarded server-side (never persisted). `null` ⇒ tab has no usage rows; + * absent ⇒ leave `cacheStats` untouched (back-compat). */ - | { type: "turn-sealed"; turnId: string } + | { type: "turn-sealed"; turnId: string; usageStats?: UsageStats | null } | { type: "text-delta"; delta: string } | { type: "reasoning-delta"; delta: string } /** |
