Repository navigation
feat(web): show each thread's context window use in the sidebar #35282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import { | |
| type UsageSummary, | ||
| type UsageSummaryInput, | ||
| UsageReadError, | ||
| ThreadTokenUsageSnapshot, | ||
| } from "@t3tools/contracts"; | ||
| import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| import * as Cause from "effect/Cause"; | ||
|
|
@@ -26,6 +27,8 @@ import * as Path from "effect/Path"; | |
| import * as Schema from "effect/Schema"; | ||
| import * as Semaphore from "effect/Semaphore"; | ||
| import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| import * as SqlClient from "effect/sql/SqlClient"; | ||
| import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
|
|
||
| import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | ||
| import { formatMalformedUsageWarning } from "@t3tools/shared/usageFormat"; | ||
|
|
@@ -105,6 +108,15 @@ const encodeRatesCache = Schema.encodeEffect(Schema.fromJsonString(RatesCacheFil | |
| const ScanCacheJson = Schema.fromJsonString(Schema.Unknown); | ||
| const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); | ||
| const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); | ||
| const decodeThreadContext = Schema.decodeUnknownOption( | ||
| Schema.fromJsonString( | ||
| Schema.Struct({ | ||
| ...ThreadTokenUsageSnapshot.fields, | ||
| updatedAt: Schema.optional(Schema.String), | ||
| maxTokens: Schema.optional(Schema.NullOr(Schema.Number)), | ||
| }), | ||
| ), | ||
| ); | ||
| const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); | ||
|
|
||
| function isLaterRead(a: CachedFile, b: CachedFile): boolean { | ||
|
|
@@ -156,6 +168,7 @@ export const make = Effect.gen(function* () { | |
| const httpClient = yield* HttpClient.HttpClient; | ||
| const hostEnvironment = yield* HostProcess.Environment; | ||
| const hostname = yield* HostProcess.Hostname; | ||
| const database = yield* SqlClient.SqlClient; | ||
| const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>(); | ||
|
|
||
| const fileCache: ScanCache = new Map(); | ||
|
|
@@ -763,7 +776,78 @@ export const make = Effect.gen(function* () { | |
| { concurrency: 2 }, | ||
| ); | ||
|
|
||
| let threadIdsBySession: Map<string, readonly string[]> | undefined; | ||
| const threadContexts: NonNullable<UsageSummary["threadContexts"]>[number][] = []; | ||
| if (input.includeThreadUsage === true) { | ||
| // Context size for idle threads, from each thread's latest reported provider turn. | ||
| const contexts = yield* database<{ | ||
| readonly threadId: string; | ||
| readonly usage: string | null; | ||
| readonly updatedAt: string; | ||
| }>` | ||
| SELECT t.thread_id AS threadId, p.updated_at AS updatedAt, | ||
| COALESCE((SELECT json_extract(pt.payload_json, '$.tokenUsage') | ||
| FROM orchestration_v2_projection_provider_turns pt | ||
| WHERE pt.provider_thread_id = p.provider_thread_id | ||
| AND json_extract(pt.payload_json, '$.tokenUsage.usedTokens') IS NOT NULL | ||
| ORDER BY pt.ordinal DESC LIMIT 1), json_extract(p.payload_json, '$.contextUsage')) AS usage | ||
| FROM orchestration_v2_projection_threads t | ||
| JOIN orchestration_v2_projection_provider_threads p ON p.provider_thread_id = t.active_provider_thread_id | ||
| WHERE t.deleted_at IS NULL | ||
| `.pipe( | ||
| NodeSqliteClient.readOnly, | ||
| Effect.mapError( | ||
| (cause) => | ||
| new UsageReadError({ | ||
| reason: "scanFailed", | ||
| detail: "Could not read session context usage.", | ||
| cause, | ||
| }), | ||
| ), | ||
| ); | ||
| for (const row of contexts) { | ||
| const decoded = row.usage === null ? Option.none() : decodeThreadContext(row.usage); | ||
| if (Option.isNone(decoded)) continue; | ||
| const { maxTokens, updatedAt, ...usage } = decoded.value; | ||
| threadContexts.push({ | ||
| threadId: row.threadId, | ||
| usage: { ...usage, ...(maxTokens != null && maxTokens > 0 ? { maxTokens } : {}) }, | ||
| updatedAt: updatedAt ?? row.updatedAt, | ||
| }); | ||
| } | ||
| const links = yield* database<{ | ||
| readonly threadId: string; | ||
| readonly driver: string; | ||
| readonly sessionId: string; | ||
| }>` | ||
| SELECT DISTINCT thread_id AS threadId, driver, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High After recovery replaces a provider thread's 🤖 Copy this AI Prompt to have your agent fix this: |
||
| json_extract(payload_json, '$.nativeThreadRef.nativeId') AS sessionId | ||
| FROM orchestration_v2_projection_provider_threads | ||
| WHERE json_extract(payload_json, '$.nativeThreadRef.nativeId') IS NOT NULL | ||
| `.pipe( | ||
| NodeSqliteClient.readOnly, | ||
| Effect.mapError( | ||
| (cause) => | ||
| new UsageReadError({ | ||
| reason: "scanFailed", | ||
| detail: "Could not match native usage to sessions.", | ||
| cause, | ||
| }), | ||
| ), | ||
| ); | ||
| threadIdsBySession = new Map(); | ||
| for (const link of links) { | ||
| const provider = BUILT_IN_USAGE_DRIVERS.find((driver) => driver.driverKind === link.driver) | ||
| ?.usage?.provider; | ||
| if (!provider) continue; | ||
| const key = `${provider}:${link.sessionId}`; | ||
| const previous = threadIdsBySession.get(key) ?? []; | ||
| if (!previous.includes(link.threadId)) | ||
| threadIdsBySession.set(key, [...previous, link.threadId]); | ||
| } | ||
| } | ||
| const aggregator = new UsageAggregator({ | ||
| ...(threadIdsBySession === undefined ? {} : { threadIdsBySession }), | ||
| timeZone: input.timeZone, | ||
| sinceDay: input.sinceDay, | ||
| untilDay: input.untilDay, | ||
|
|
@@ -927,6 +1011,8 @@ export const make = Effect.gen(function* () { | |
| sinceDay: input.sinceDay, | ||
| untilDay: input.untilDay, | ||
| buckets: aggregated.buckets, | ||
| ...(aggregated.threadUsage === undefined ? {} : { threadUsage: aggregated.threadUsage }), | ||
| ...(input.includeThreadUsage ? { threadContexts } : {}), | ||
| sources, | ||
| pricing: pricing(), | ||
| scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), | ||
|
|
@@ -947,6 +1033,7 @@ export const make = Effect.gen(function* () { | |
| settings, | ||
| input.awaitRefresh === true, | ||
| input.modelFilter ?? null, | ||
| input.includeThreadUsage ?? false, | ||
| ]); | ||
|
|
||
| const readSummary = Effect.fn("UsageService.readSummary")(function* ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import type { | |
| UsageModelSelector, | ||
| UsageResolution, | ||
| UsageTokenTotals, | ||
| UsageThreadTotals, | ||
| } from "@t3tools/contracts"; | ||
|
|
||
| import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage"; | ||
|
|
@@ -64,6 +65,7 @@ interface MutableBucket { | |
| } | ||
|
|
||
| export interface AggregateOptions { | ||
| readonly threadIdsBySession?: ReadonlyMap<string, readonly string[]>; | ||
| readonly timeZone: string; | ||
| readonly sinceDay: string; | ||
| readonly untilDay: string; | ||
|
|
@@ -125,6 +127,7 @@ export function canonicalModelFilter( | |
| } | ||
|
|
||
| export interface AggregateResult { | ||
| readonly threadUsage?: readonly UsageThreadTotals[]; | ||
| readonly buckets: readonly UsageBucket[]; | ||
| readonly duplicatesDropped: number; | ||
| readonly outOfWindow: number; | ||
|
|
@@ -172,8 +175,10 @@ interface Contribution { | |
|
|
||
| /** Deduplicates across the whole scan; Claude copies records into resumed/forked transcripts. */ | ||
| export class UsageAggregator { | ||
| readonly #threadUsage = new Map<string, UsageThreadTotals>(); | ||
| readonly #buckets = new Map<string, MutableBucket>(); | ||
| readonly #seen = new Map<string, Contribution>(); | ||
| readonly #threadSeen = new Map<string, Set<string>>(); | ||
| readonly #toDay: (timestampMs: number) => string; | ||
| readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; | ||
| readonly #options: AggregateOptions; | ||
|
|
@@ -207,6 +212,8 @@ export class UsageAggregator { | |
| add(input: UsageRecord, sourcePath?: string): boolean { | ||
| if (!keepsUsageRecord(this.#options.modelFilter, input)) return false; | ||
| const record = this.#mapModel(input); | ||
| // Counted before the scan-wide dedupe: a copy in another thread's transcript still belongs to it. | ||
| this.#addThreadUsage(record); | ||
| if (record.dedupeKey !== null) { | ||
| const held = this.#seen.get(record.dedupeKey); | ||
| if (held !== undefined) { | ||
|
|
@@ -228,7 +235,46 @@ export class UsageAggregator { | |
| return true; | ||
| } | ||
|
|
||
| #placeInWindow(record: UsageRecord, source: string): MutableBucket | null { | ||
| #addThreadUsage(record: UsageRecord): void { | ||
| const threadIds = | ||
| this.#options.threadIdsBySession?.get(`${record.provider}:${record.sessionId}`) ?? []; | ||
| if (threadIds.length === 0 || this.#dayInWindow(record) === null) return; | ||
| const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); | ||
| const costs = priced.categoryCostUsd; | ||
| for (const threadId of threadIds) { | ||
| if (record.dedupeKey !== null) { | ||
| const seen = this.#threadSeen.get(threadId); | ||
| if (seen?.has(record.dedupeKey)) continue; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High When a thread-linked record is followed by a more complete copy with the same 🤖 Copy this AI Prompt to have your agent fix this: |
||
| if (seen) seen.add(record.dedupeKey); | ||
| else this.#threadSeen.set(threadId, new Set([record.dedupeKey])); | ||
| } | ||
| const previous = this.#threadUsage.get(threadId); | ||
| const add = <K extends keyof UsageTokenTotals>(key: K) => | ||
| (previous?.totals[key] ?? 0) + record.totals[key]; | ||
| this.#threadUsage.set(threadId, { | ||
| threadId, | ||
| totals: { | ||
| uncachedInputTokens: add("uncachedInputTokens"), | ||
| cachedInputTokens: add("cachedInputTokens"), | ||
| cacheCreationTokens: add("cacheCreationTokens"), | ||
| outputTokens: add("outputTokens"), | ||
| reasoningTokens: add("reasoningTokens"), | ||
| }, | ||
| costUsd: (previous?.costUsd ?? 0) + priced.costUsd, | ||
| categoryCostUsd: { | ||
| input: (previous?.categoryCostUsd.input ?? 0) + (costs?.input ?? 0), | ||
| cacheRead: (previous?.categoryCostUsd.cacheRead ?? 0) + (costs?.cacheRead ?? 0), | ||
| cacheWrite: (previous?.categoryCostUsd.cacheWrite ?? 0) + (costs?.cacheWrite ?? 0), | ||
| output: (previous?.categoryCostUsd.output ?? 0) + (costs?.output ?? 0), | ||
| }, | ||
| unsplitCostUsd: (previous?.unsplitCostUsd ?? 0) + (costs === null ? priced.costUsd : 0), | ||
| unpricedRecords: | ||
| (previous?.unpricedRecords ?? 0) + (priced.costSource === "unpriced" ? 1 : 0), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| #dayInWindow(record: UsageRecord): string | null { | ||
| if ( | ||
| this.#hourlyWindow !== null && | ||
| (record.timestampMs < this.#hourlyWindow.sinceTimeMs || | ||
|
|
@@ -244,6 +290,12 @@ export class UsageAggregator { | |
| ) { | ||
| return null; | ||
| } | ||
| return day; | ||
| } | ||
|
|
||
| #placeInWindow(record: UsageRecord, source: string): MutableBucket | null { | ||
| const day = this.#dayInWindow(record); | ||
| if (day === null) return null; | ||
|
|
||
| const hourIndex = | ||
| this.#hourlyWindow === null | ||
|
|
@@ -400,6 +452,9 @@ export class UsageAggregator { | |
|
|
||
| return { | ||
| buckets, | ||
| ...(this.#options.threadIdsBySession === undefined | ||
| ? {} | ||
| : { threadUsage: [...this.#threadUsage.values()] }), | ||
| duplicatesDropped: this.#duplicatesDropped, | ||
| outOfWindow: this.#outOfWindow, | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
usage/UsageService.ts:791After recovery reuses a
provider_thread_idfor a fresh native session, this query returns the previous session’s latest turn usage as the current context, so the sidebar shows stale occupancy until the new session reports usage. The subquery matches turns only byprovider_thread_id; filter them by the active native identity through run attempts, aslatestNativeContextUsagedoes, before falling back to storedcontextUsage.🤖 Copy this AI Prompt to have your agent fix this: