From 11e7dae19d47961b49ba198b8c1b07992a332782 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 20 Mar 2026 17:58:23 -0300 Subject: [PATCH] feat(provider): add provider usage freshness signals --- packages/opencode/src/provider/usage.ts | 77 +++++++++++++ packages/opencode/test/provider/usage.test.ts | 106 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 packages/opencode/src/provider/usage.ts create mode 100644 packages/opencode/test/provider/usage.test.ts diff --git a/packages/opencode/src/provider/usage.ts b/packages/opencode/src/provider/usage.ts new file mode 100644 index 000000000000..64f4dfd94e5a --- /dev/null +++ b/packages/opencode/src/provider/usage.ts @@ -0,0 +1,77 @@ +import z from "zod" +import { MessageV2 } from "../session/message-v2" +import { MessageTable } from "../session/session.sql" +import { Database, desc } from "../storage/db" +import { ProviderID } from "./schema" + +export namespace ProviderUsage { + export const Info = z.object({ + state: z.enum(["fresh", "stale", "missing"]), + observedAt: z.string().optional(), + ageMinutes: z.number().optional(), + recentInputTokens: z.number().optional(), + recentOutputTokens: z.number().optional(), + }) + + export type Info = z.infer + + const stale = 60 * 60 * 1000 + + export function summarize( + rows: { data: unknown; time_created: number }[], + now = Date.now(), + ): Record { + const map = rows.reduce( + (acc, row) => { + const parsed = MessageV2.Info.safeParse(row.data) + if (!parsed.success) return acc + if (parsed.data.role !== "assistant") return acc + const item = acc[parsed.data.providerID] + if (item) { + item.time = Math.max(item.time, row.time_created) + item.input += parsed.data.tokens.input + item.output += parsed.data.tokens.output + return acc + } + acc[parsed.data.providerID] = { + time: row.time_created, + input: parsed.data.tokens.input, + output: parsed.data.tokens.output, + } + return acc + }, + {} as Record, + ) + + return Object.fromEntries( + Object.entries(map).map(([id, item]) => { + const age = Math.max(0, Math.floor((now - item.time) / 60_000)) + return [ + id, + { + state: now - item.time > stale ? "stale" : "fresh", + observedAt: new Date(item.time).toISOString(), + ageMinutes: age, + recentInputTokens: item.input, + recentOutputTokens: item.output, + } satisfies Info, + ] + }), + ) as Record + } + + export async function list(limit = 200) { + const rows = Database.use((db) => + db + .select({ + data: MessageTable.data, + time_created: MessageTable.time_created, + }) + .from(MessageTable) + .orderBy(desc(MessageTable.time_created)) + .limit(limit) + .all(), + ) + return summarize(rows) + } +} diff --git a/packages/opencode/test/provider/usage.test.ts b/packages/opencode/test/provider/usage.test.ts new file mode 100644 index 000000000000..6e0b5d73317a --- /dev/null +++ b/packages/opencode/test/provider/usage.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test" +import { ProviderUsage } from "../../src/provider/usage" +import { MessageID, SessionID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" + +function assistant(input: { providerID: ProviderID; time: number; input: number; output: number }) { + return { + id: MessageID.ascending(), + sessionID: SessionID.descending(), + role: "assistant" as const, + time: { + created: input.time, + }, + parentID: MessageID.ascending(), + modelID: ModelID.make("test"), + providerID: input.providerID, + mode: "build", + agent: "build", + path: { + cwd: "/tmp", + root: "/tmp", + }, + cost: 0, + tokens: { + input: input.input, + output: input.output, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + } +} + +test("summarize aggregates usage by provider", () => { + const now = new Date("2026-03-20T20:00:00.000Z").getTime() + const rows = [ + { + time_created: now - 5 * 60 * 1000, + data: assistant({ + providerID: ProviderID.openai, + time: now - 5 * 60 * 1000, + input: 100, + output: 50, + }), + }, + { + time_created: now - 4 * 60 * 1000, + data: assistant({ + providerID: ProviderID.openai, + time: now - 4 * 60 * 1000, + input: 20, + output: 10, + }), + }, + ] + + const result = ProviderUsage.summarize(rows, now) + expect(result[ProviderID.openai]).toBeDefined() + expect(result[ProviderID.openai].state).toBe("fresh") + expect(result[ProviderID.openai].ageMinutes).toBe(4) + expect(result[ProviderID.openai].recentInputTokens).toBe(120) + expect(result[ProviderID.openai].recentOutputTokens).toBe(60) +}) + +test("summarize marks stale usage", () => { + const now = new Date("2026-03-20T20:00:00.000Z").getTime() + const rows = [ + { + time_created: now - 2 * 60 * 60 * 1000, + data: assistant({ + providerID: ProviderID.anthropic, + time: now - 2 * 60 * 60 * 1000, + input: 10, + output: 5, + }), + }, + ] + + const result = ProviderUsage.summarize(rows, now) + expect(result[ProviderID.anthropic]).toBeDefined() + expect(result[ProviderID.anthropic].state).toBe("stale") + expect(result[ProviderID.anthropic].ageMinutes).toBe(120) +}) + +test("summarize ignores invalid and non-assistant rows", () => { + const now = new Date("2026-03-20T20:00:00.000Z").getTime() + const rows = [ + { + time_created: now - 60_000, + data: { + role: "user", + }, + }, + { + time_created: now - 60_000, + data: { + role: "assistant", + }, + }, + ] + + const result = ProviderUsage.summarize(rows, now) + expect(Object.keys(result)).toHaveLength(0) +})