From 06404107261cae24394d6abe39984fb445c5aff5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 05:24:38 -0400 Subject: [PATCH 1/3] fix(desktop): zoom shortcuts no longer die when the preview browser has focus (#5691) Co-authored-by: Claude Fable 5 --- apps/desktop/src/app/DesktopLifecycle.test.ts | 1 + .../src/backend/DesktopBackendPool.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 80 ++++++++++++++----- .../src/window/DesktopApplicationMenu.ts | 29 ++++++- apps/desktop/src/window/DesktopWindow.ts | 20 +++++ 5 files changed, 108 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index be9d7f3451f..45e1c82460c 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -78,6 +78,7 @@ describe("DesktopLifecycle", () => { handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, syncAppearance: Effect.void, }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 523e8764697..98bd4065fbe 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -91,6 +91,7 @@ function makePoolLayer( handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), + zoomMain: () => Effect.die("unexpected zoom"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 22a24b908b6..0c826e36dd9 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -81,6 +81,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + zoomMain: (direction) => + Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); @@ -94,6 +96,30 @@ const makeElectronMenuLayer = ( showContextMenu: () => Effect.succeed(Option.none()), } satisfies ElectronMenu.ElectronMenu["Service"]); +const configureMenu = ( + selectedAction: Deferred.Deferred, + applicationMenuTemplate: Deferred.Deferred, +) => + Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ), + ), + ), + ), + ); + describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { @@ -101,25 +127,7 @@ describe("DesktopApplicationMenu", () => { const applicationMenuTemplate = yield* Deferred.make(); - yield* Effect.gen(function* () { - const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; - yield* menu.configure; - }).pipe( - Effect.provide( - DesktopApplicationMenu.layer.pipe( - Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), - Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), - Layer.provideMerge(desktopUpdatesLayer), - Layer.provideMerge(electronDialogLayer), - Layer.provideMerge(electronAppLayer), - Layer.provideMerge( - DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), - ), - ), - ), - ), - ); + yield* configureMenu(selectedAction, applicationMenuTemplate); const template = yield* Deferred.await(applicationMenuTemplate); const fileMenu = template.find((item) => item.label === "File"); @@ -138,4 +146,38 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "open-settings"); }), ); + + // Zoom must route through DesktopWindow.zoomMain instead of the Electron + // zoom roles: the roles zoom whichever webContents has focus, which breaks + // app zoom while an embedded preview WebContentsView holds focus. + it.effect("routes View menu zoom to the main window instead of zoom roles", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + assert.isDefined(viewMenu); + if (!Array.isArray(viewMenu.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + + assert.isUndefined( + viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")), + ); + + const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); + assert.isDefined(zoomIn); + assert.equal(zoomIn.accelerator, "CmdOrCtrl+="); + if (typeof zoomIn.click !== "function") { + throw new Error("Expected Zoom In menu item to have a click handler."); + } + + zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index a52707627b0..66244534deb 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function yield* desktopWindow.dispatchMenuAction(action); }); +const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( + direction: DesktopWindow.MainWindowZoomDirection, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.zoomMain(direction); +}); + const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -127,6 +134,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { + runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { @@ -181,10 +191,21 @@ export const make = Effect.gen(function* () { { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, + /* + Not the zoom roles: those act on the focused webContents, so with + an embedded preview WebContentsView focused they zoom the guest + page and the app UI appears stuck. These always zoom the main + window (see DesktopWindow.zoomMain). + */ + { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") }, + { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+Plus", + visible: false, + click: zoomClick("in"), + }, + { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3bf746a8e9b..bf8c681448f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -61,6 +61,8 @@ export type DesktopWindowError = | ElectronWindow.ElectronWindowCreateError | PreviewManager.PreviewManagerError; +export type MainWindowZoomDirection = "in" | "out" | "reset"; + export class DesktopWindow extends Context.Service< DesktopWindow, { @@ -87,6 +89,12 @@ export class DesktopWindow extends Context.Service< readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; + // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut` + // menu roles act on whichever webContents has keyboard focus, so with an + // embedded preview WebContentsView (or DevTools) focused they zoom the + // guest page instead of the app UI. The menu routes here to always target + // the main window. + readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -836,6 +844,18 @@ export const make = Effect.gen(function* () { send(); }), + zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { + yield* Effect.annotateCurrentSpan({ direction }); + const window = yield* focusedMainWindow; + if (Option.isNone(window) || window.value.isDestroyed()) { + return; + } + const webContents = window.value.webContents; + // Same step size as the Electron zoomIn/zoomOut menu roles. + webContents.setZoomLevel( + direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), + ); + }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => From 30164cb1ba8ea05fdd6be69215dba2cc2f0e2aa8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 06:51:20 -0400 Subject: [PATCH 2/3] feat(mobile): one sheet for model and thread settings (#5625) Co-authored-by: Claude Fable 5 --- .../features/threads/NewTaskDraftScreen.tsx | 173 ++--- .../src/features/threads/ThreadComposer.tsx | 181 ++--- .../features/threads/ThreadSettingsSheet.tsx | 678 ++++++++++++++++++ .../threads/new-task-flow-provider.tsx | 22 +- .../thread-settings-sheet-state.test.ts | 64 ++ .../threads/thread-settings-sheet-state.ts | 13 + .../use-thread-settings-sheet-presentation.ts | 98 +++ apps/mobile/src/lib/modelOptions.test.ts | 89 ++- apps/mobile/src/lib/modelOptions.ts | 71 +- apps/mobile/src/lib/providerOptions.test.ts | 52 +- apps/mobile/src/lib/providerOptions.ts | 107 +-- 11 files changed, 1079 insertions(+), 469 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadSettingsSheet.tsx create mode 100644 apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts create mode 100644 apps/mobile/src/features/threads/thread-settings-sheet-state.ts create mode 100644 apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6b121d85108..bf2dfa8f4d4 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -25,15 +25,12 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import { ComposerSurface } from "./ThreadComposer"; +import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; +import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; -import { - applyProviderOptionMenuEvent, - buildProviderOptionMenuActions, - providerOptionsConfigurationLabel, - resolveProviderOptionDescriptors, -} from "../../lib/providerOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, @@ -43,7 +40,7 @@ import { type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { resolveSelectableModelSelection } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -103,6 +100,10 @@ export function NewTaskDraftScreen(props: { const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const settingsSheetPresentation = useThreadSettingsSheetPresentation({ + editorRef: promptInputRef, + isEditorFocused: isComposerFocused, + }); const [importingShareKey, setImportingShareKey] = useState(null); const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); @@ -521,7 +522,15 @@ export function NewTaskDraftScreen(props: { let focusFrame: ReturnType | null = null; const interaction = InteractionManager.runAfterInteractions(() => { - focusFrame = requestAnimationFrame(() => promptInputRef.current?.focus()); + focusFrame = requestAnimationFrame(() => { + // The delayed focus can land after the settings sheet opened, which + // would pop the keyboard underneath its modal. + if (!settingsSheetPresentation.isActiveRef.current) { + promptInputRef.current?.focus(); + } else { + settingsSheetPresentation.restoreFocusAfterSave(); + } + }); }); return () => { @@ -530,7 +539,11 @@ export function NewTaskDraftScreen(props: { cancelAnimationFrame(focusFrame); } }; - }, [selectedProject]); + }, [ + selectedProject, + settingsSheetPresentation.isActiveRef, + settingsSheetPresentation.restoreFocusAfterSave, + ]); const environmentMenuActions = useMemo( () => @@ -544,10 +557,6 @@ export function NewTaskDraftScreen(props: { [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], ); - const modelMenuActions = useMemo( - () => buildModelMenuActions(flow.providerGroups, flow.selectedModel), - [flow.providerGroups, flow.selectedModel], - ); const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ @@ -557,54 +566,6 @@ export function NewTaskDraftScreen(props: { [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], ); - const optionsMenuActions = useMemo( - () => [ - ...buildProviderOptionMenuActions(providerOptionDescriptors), - { - id: "options-runtime", - title: "Runtime", - subtitle: - flow.runtimeMode === "approval-required" - ? "Approve actions" - : flow.runtimeMode === "auto-accept-edits" - ? "Auto-accept edits" - : flow.runtimeMode === "auto" - ? "Auto" - : "Full access", - subactions: [ - { id: "options:runtime:approval-required", title: "Approve actions" }, - { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, - { id: "options:runtime:auto", title: "Auto" }, - { id: "options:runtime:full-access", title: "Full access" }, - ].map((option) => { - const value = option.id.replace("options:runtime:", ""); - return { - id: option.id, - title: option.title, - state: flow.runtimeMode === value ? ("on" as const) : undefined, - }; - }), - }, - { - id: "options-interaction", - title: "Interaction", - subtitle: flow.interactionMode === "plan" ? "Plan" : "Default", - subactions: [ - { id: "options:interaction:default", title: "Default" }, - { id: "options:interaction:plan", title: "Plan" }, - ].map((option) => { - const value = option.id.replace("options:interaction:", ""); - return { - id: option.id, - title: option.title, - state: flow.interactionMode === value ? ("on" as const) : undefined, - }; - }), - }, - ], - [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors], - ); - const workspaceMenuActions = useMemo(() => { const branchActions = flow.availableBranches.length === 0 @@ -675,10 +636,12 @@ export function NewTaskDraftScreen(props: { flow.availableBranches.find((branch) => branch.current)?.name ?? flow.availableBranches.find((branch) => branch.isDefault)?.name ?? null; - const configurationLabel = useMemo( - () => providerOptionsConfigurationLabel(providerOptionDescriptors), - [providerOptionDescriptors], - ); + const settingsSummaryLabel = threadSettingsSummaryLabel({ + modelLabel: flow.selectedModelOption?.label ?? "Model", + optionDescriptors: providerOptionDescriptors, + runtimeMode: flow.runtimeMode, + interactionMode: flow.interactionMode, + }); const workspaceLabel = useMemo( () => formatWorkspaceLabel({ @@ -688,13 +651,6 @@ export function NewTaskDraftScreen(props: { }), [currentBranchName, flow.selectedBranchName, flow.workspaceMode], ); - function handleModelMenuAction(event: string) { - if (isIncomingShareTransferPending || !event.startsWith("model:")) { - return; - } - flow.setSelectedModelKey(event.slice("model:".length)); - } - function handleEnvironmentMenuAction(event: string) { if (isIncomingShareTransferPending || !event.startsWith("environment:")) { return; @@ -702,28 +658,6 @@ export function NewTaskDraftScreen(props: { flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); } - function handleOptionsMenuAction(event: string) { - if (isIncomingShareTransferPending) { - return; - } - const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); - if (providerOptions) { - flow.setSelectedModelOptions(providerOptions); - return; - } - if (event.startsWith("options:runtime:")) { - flow.setRuntimeMode( - event.slice("options:runtime:".length) as Parameters[0], - ); - return; - } - if (event.startsWith("options:interaction:")) { - flow.setInteractionMode( - event.slice("options:interaction:".length) as Parameters[0], - ); - } - } - function handleWorkspaceMenuAction(event: string) { if (isIncomingShareTransferPending) { return; @@ -930,7 +864,9 @@ export function NewTaskDraftScreen(props: { const isDarkMode = colorScheme === "dark"; // Android expansion follows native editor focus so relayout cannot race // the touch gesture that opens the keyboard. - const isExpanded = !isAndroid || isComposerFocused; + // The settings sheet dismisses the keyboard, so its flag keeps the Android + // draft composer expanded through the blur (mirrors ThreadComposer). + const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive; const canStart = Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && @@ -984,28 +920,14 @@ export function NewTaskDraftScreen(props: { showChevron={false} disabled={isIncomingShareTransferPending} /> - handleModelMenuAction(nativeEvent.event)} - > - } - label={flow.selectedModelOption?.label ?? "Model"} - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> handleEnvironmentMenuAction(nativeEvent.event)} @@ -1031,6 +953,21 @@ export function NewTaskDraftScreen(props: { ); + const settingsSheet = ( + flow.setSelectedModelKey(option.key, option.selection.options)} + optionDescriptors={providerOptionDescriptors} + onUpdateOptionSelections={flow.setSelectedModelOptions} + runtimeMode={flow.runtimeMode} + onUpdateRuntimeMode={flow.setRuntimeMode} + /> + ); + const startButton = ( + {settingsSheet} ); } @@ -1153,6 +1091,7 @@ export function NewTaskDraftScreen(props: { + {settingsSheet} ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1b026964c92..c846dca287a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -51,10 +51,10 @@ import { ComposerToolbarScroller, ComposerToolbarTrigger, } from "../../components/ComposerToolbarTrigger"; -import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; +import { ControlPill } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { @@ -62,14 +62,11 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; -import { - applyProviderOptionMenuEvent, - buildProviderOptionMenuActions, - providerOptionsConfigurationLabel, - resolveProviderOptionDescriptors, -} from "../../lib/providerOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; +import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -273,15 +270,28 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const settingsSheetPresentation = useThreadSettingsSheetPresentation({ + editorRef: inputRef, + isEditorFocused: isFocused, + }); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - const isExpanded = isFocused; + // Opening and closing count as active so the composer stays expanded while + // focus moves between its native editor and the settings modal. + const isExpanded = isFocused || settingsSheetPresentation.isActive; const canSend = hasContent; + // Notify the parent from the derived value, not focus events: the parent + // sizes the feed inset from this, and blur-during-sheet would otherwise + // report collapsed while the composer still renders expanded. + useEffect(() => { + onExpandedChange?.(isExpanded); + }, [isExpanded, onExpandedChange]); + const onPressImage = useCallback( (uri: string) => { wasExpandedBeforePreviewRef.current = isFocused; @@ -299,13 +309,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleFocus = useCallback(() => { setIsFocused(true); - onExpandedChange?.(true); - }, [onExpandedChange]); + }, []); const handleBlur = useCallback(() => { setIsFocused(false); - onExpandedChange?.(false); - }, [onExpandedChange]); + }, []); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; @@ -588,6 +596,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); + // An existing thread is bound to its harness: sessions can't move between + // provider instances, so the picker only offers the thread's own group. + const threadProviderGroups = useMemo( + () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), + [providerGroups, currentModelSelection.instanceId], + ); const currentModelOption = modelOptions.find( (option) => @@ -602,95 +616,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [currentModelOption?.capabilities, currentModelSelection.options], ); - const configurationLabel = useMemo( - () => providerOptionsConfigurationLabel(providerOptionDescriptors), - [providerOptionDescriptors], - ); - const modelMenuActions = useMemo( - () => buildModelMenuActions(providerGroups, currentModelSelection), - [providerGroups, currentModelSelection], - ); - - // ── Options menu ───────────────────────────────────────── - const optionsMenuActions = useMemo( - () => [ - ...buildProviderOptionMenuActions(providerOptionDescriptors), - { - id: "options-runtime", - title: "Runtime", - subtitle: - currentRuntimeMode === "approval-required" - ? "Approve actions" - : currentRuntimeMode === "auto-accept-edits" - ? "Auto-accept edits" - : currentRuntimeMode === "auto" - ? "Auto" - : "Full access", - subactions: [ - { id: "options:runtime:approval-required", title: "Approve actions" }, - { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, - { id: "options:runtime:auto", title: "Auto" }, - { id: "options:runtime:full-access", title: "Full access" }, - ].map((option) => { - const value = option.id.replace("options:runtime:", ""); - return { - id: option.id, - title: option.title, - state: currentRuntimeMode === value ? ("on" as const) : undefined, - }; - }), - }, - { - id: "options-interaction", - title: "Interaction", - subtitle: currentInteractionMode === "plan" ? "Plan" : "Default", - subactions: [ - { id: "options:interaction:default", title: "Default" }, - { id: "options:interaction:plan", title: "Plan" }, - ].map((option) => { - const value = option.id.replace("options:interaction:", ""); - return { - id: option.id, - title: option.title, - state: currentInteractionMode === value ? ("on" as const) : undefined, - }; - }), - }, - ], - [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], - ); - - // ── Menu handlers ──────────────────────────────────────── - function handleModelMenuAction(event: string) { - if (!event.startsWith("model:")) { - return; - } - const modelKey = event.slice("model:".length); - const option = modelOptions.find((o) => o.key === modelKey); - if (option) { - props.onUpdateModelSelection(option.selection); - } - } - - function handleOptionsMenuAction(event: string) { - const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); - if (providerOptions) { - props.onUpdateModelSelection({ - ...currentModelSelection, - options: providerOptions, - }); - return; - } - if (event.startsWith("options:runtime:")) { - const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; - props.onUpdateRuntimeMode(runtimeMode); - return; - } - if (event.startsWith("options:interaction:")) { - const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; - props.onUpdateInteractionMode(interactionMode); - } - } + const settingsSummaryLabel = threadSettingsSummaryLabel({ + modelLabel: currentModelOption?.label ?? currentModelSelection.model, + optionDescriptors: providerOptionDescriptors, + runtimeMode: currentRuntimeMode, + interactionMode: currentInteractionMode, + }); return ( void props.onPickDraftImages()} showChevron={false} /> - handleModelMenuAction(nativeEvent.event)} - > - - } - label={currentModelOption?.label ?? currentModelSelection.model} - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - + + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> {showStopAction ? ( + props.onUpdateModelSelection(option.selection)} + optionDescriptors={providerOptionDescriptors} + onUpdateOptionSelections={(options) => + props.onUpdateModelSelection({ ...currentModelSelection, options }) + } + runtimeMode={currentRuntimeMode} + onUpdateRuntimeMode={props.onUpdateRuntimeMode} + /> + = new Set(["claudeAgent", "codex"]); + +/** + * Desktop-oriented effort keywords that don't belong in the phone picker. + * Prompt-injected values (ultrathink and friends) are filtered from the + * descriptor metadata; ultracode is a real option but a workflow trigger, not + * a reasoning level. A value set elsewhere still displays, it just isn't + * offered. + */ +const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); + +const RUNTIME_MODE_CHOICES: ReadonlyArray<{ + readonly mode: RuntimeMode; + readonly label: string; + readonly shortLabel: string; +}> = [ + { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, + { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, + { mode: "auto", label: "Auto", shortLabel: "Auto" }, + { mode: "full-access", label: "Full access", shortLabel: "Full" }, +]; + +/** + * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, + * covering model, provider options, runtime mode, and plan mode in one label. + */ +export function threadSettingsSummaryLabel(input: { + readonly modelLabel: string; + readonly optionDescriptors: ReadonlyArray; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +}): string { + const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); + return [ + input.modelLabel, + ...providerOptionValueLabels(input.optionDescriptors), + ...(runtime ? [runtime.shortLabel] : []), + ...(input.interactionMode === "plan" ? ["Plan"] : []), + ].join(" · "); +} + +function selectableChoices(descriptor: Extract) { + const injected = new Set(descriptor.promptInjectedValues ?? []); + return descriptor.options.filter( + (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), + ); +} + +function ModelRow(props: { + readonly option: ModelOption; + readonly selected: boolean; + readonly onPress: () => void; +}) { + const primaryFg = useThemeColor("--color-primary-foreground"); + return ( + + + {props.option.label} + + {props.option.isDefault ? ( + + Default + + ) : null} + {props.option.isLegacy ? ( + + Legacy + + ) : null} + + {props.selected ? ( + + ) : null} + + ); +} + +/** + * Provider section header with the harness logo. Secondary providers render + * as a tappable fold (count + chevron while collapsed); primary providers + * and the group holding the current selection are static headers. + */ +function ProviderHeader(props: { + readonly driver: string | undefined; + readonly label: string; + readonly collapsible: boolean; + readonly collapsed: boolean; + readonly modelCount: number; + readonly onToggle: () => void; +}) { + const iconSubtle = useThemeColor("--color-icon-subtle"); + return ( + + + + {props.label} + + {props.collapsible ? ( + <> + + {props.collapsed ? ( + + {props.modelCount} + + ) : null} + + + ) : null} + + ); +} + +/** Compact row that opens a single-choice submenu panel. */ +function DisclosureRow(props: { + readonly label: string; + readonly value: string | undefined; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + const iconSubtle = useThemeColor("--color-icon-subtle"); + return ( + + {props.label} + + {props.value ? ( + + {props.value} + + ) : null} + + + ); +} + +/** Single option inside a submenu panel. */ +function ChoiceRow(props: { + readonly label: string; + readonly selected: boolean; + readonly onPress: () => void; +}) { + const primaryFg = useThemeColor("--color-primary-foreground"); + return ( + + + {props.label} + + + {props.selected ? ( + + ) : null} + + ); +} + +function SwitchRow(props: { + readonly label: string; + readonly value: boolean; + readonly disabled?: boolean; + readonly onValueChange: (value: boolean) => void; +}) { + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + return ( + + {props.label} + + + ); +} + +type SubmenuPage = + | { readonly kind: "descriptor"; readonly id: string } + | { readonly kind: "runtime" }; + +/** + * Unified thread settings: the sheet is the provider-grouped model list + * (primary harnesses expanded, other providers folded, legacy behind the + * top-right pill) with a Save button, plus compact disclosure rows whose + * single-choice submenus stack in a small panel over the sheet so it never + * changes size. Model changes stage until Save — while staged, the settings + * rows edit the staged model's options and Save applies everything together. + * + * Callers control which harnesses are offered via providerGroups: an + * existing thread must pass only its own provider's group, since a session + * can't switch harness mid-thread. + * + * Rendered through an RN Modal (not the root OverlayPortal) so it also + * presents above natively-presented form sheets like the new-task draft. + * Callers must dismiss the keyboard when opening — the iOS keyboard window + * would otherwise cover the lower half of the sheet. + */ +export function ThreadSettingsSheet(props: { + readonly visible: boolean; + /** + * "save" = the Save/Done button (the user is finished configuring); + * "dismiss" = backdrop, grabber, or system back. Hosts only restore the + * keyboard for "save" so a stray tap outside a control never pops it. + */ + readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void; + readonly onDismissed: () => void; + readonly providerGroups: ReadonlyArray; + readonly selectedModel: ModelSelection | null; + readonly onSelectModel: (option: ModelOption) => void; + readonly optionDescriptors: ReadonlyArray; + readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; + readonly runtimeMode: RuntimeMode; + readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; +}) { + const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const [showLegacyToggle, setShowLegacyToggle] = useState(false); + const [expandedProviders, setExpandedProviders] = useState>(() => new Set()); + const [pendingModel, setPendingModel] = useState(null); + const [submenu, setSubmenu] = useState(null); + const wasPresentedRef = useRef(false); + const notifyDismissed = useCallback(() => { + if (!wasPresentedRef.current) { + return; + } + wasPresentedRef.current = false; + props.onDismissed(); + }, [props.onDismissed]); + + // Every open starts fresh: no staged model, no submenu, legacy hidden, + // secondary providers folded. The sheet stays mounted between opens, so + // state would otherwise stick around. + useEffect(() => { + if (props.visible) { + wasPresentedRef.current = true; + setShowLegacyToggle(false); + setExpandedProviders(new Set()); + setPendingModel(null); + setSubmenu(null); + } else if (Platform.OS === "android" && wasPresentedRef.current) { + // React Native only emits Modal.onDismiss on iOS. Android uses no exit + // animation below, so the post-commit effect is its dismissal boundary. + notifyDismissed(); + } + }, [notifyDismissed, props.visible]); + + const isApplied = (option: ModelOption) => + option.selection.instanceId === props.selectedModel?.instanceId && + option.selection.model === props.selectedModel.model; + // The list highlights the staged pick; Save turns it into the applied one. + const isDisplayed = (option: ModelOption) => + pendingModel ? option.key === pendingModel.key : isApplied(option); + + // While a model is staged, the settings rows describe and edit the staged + // model's options (kept on its pending selection); Save applies model and + // options together. Otherwise they edit the applied selection directly. + const displayedDescriptors = pendingModel + ? pendingModel.capabilities + ? getProviderOptionDescriptors({ + caps: pendingModel.capabilities, + selections: pendingModel.selection.options, + }) + : [] + : props.optionDescriptors; + + const hasLegacyModels = props.providerGroups.some((group) => + group.models.some((model) => model.isLegacy), + ); + // Legacy stays hidden unless the pill is toggled this open; a highlighted + // legacy model is exempted from the filter instead of forcing the whole + // legacy list visible. + const showLegacy = showLegacyToggle; + + // Stable settings rows: the union of descriptors across the primary + // harnesses' current models (plus whatever the displayed model advertises) + // always renders, with unsupported rows disabled instead of vanishing when + // the selection changes. Keyed by label, not id — Claude and Codex use + // different ids for the same "Reasoning" concept. + const descriptorTemplate = (() => { + const seen = new Map(); + for (const group of props.providerGroups) { + const driver = group.models[0]?.providerDriver; + if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { + continue; + } + for (const model of group.models) { + if (model.isLegacy) { + continue; + } + for (const descriptor of model.capabilities?.optionDescriptors ?? []) { + if (!seen.has(descriptor.label)) { + seen.set(descriptor.label, { type: descriptor.type }); + } + } + } + } + for (const descriptor of displayedDescriptors) { + if (!seen.has(descriptor.label)) { + seen.set(descriptor.label, { type: descriptor.type }); + } + } + return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); + })(); + + const handleSave = () => { + if (pendingModel) { + void Haptics.selectionAsync(); + props.onSelectModel(pendingModel); + } + props.onClose("save"); + }; + + const handleOptionChange = (id: string, value: string | boolean) => { + const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); + if (!next) { + return; + } + if (pendingModel) { + setPendingModel({ + ...pendingModel, + selection: { ...pendingModel.selection, options: next }, + }); + } else { + props.onUpdateOptionSelections(next); + } + }; + + const toggleProvider = (providerKey: string) => { + setExpandedProviders((current) => { + const next = new Set(current); + if (!next.delete(providerKey)) { + next.add(providerKey); + } + return next; + }); + }; + + const activeDescriptor = + submenu?.kind === "descriptor" + ? displayedDescriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, + ) + : undefined; + + const submenuContent = + submenu?.kind === "runtime" + ? { + title: "Runtime", + rows: RUNTIME_MODE_CHOICES.map((choice) => ({ + id: choice.mode, + label: choice.label, + selected: choice.mode === props.runtimeMode, + onPress: () => { + void Haptics.selectionAsync(); + props.onUpdateRuntimeMode(choice.mode); + setSubmenu(null); + }, + })), + } + : activeDescriptor?.type === "select" + ? { + title: activeDescriptor.label, + rows: selectableChoices(activeDescriptor).map((choice) => ({ + id: choice.id, + label: choice.label, + selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), + onPress: () => { + void Haptics.selectionAsync(); + handleOptionChange(activeDescriptor.id, choice.id); + setSubmenu(null); + }, + })), + } + : null; + + return ( + setSubmenu(null) : () => props.onClose("dismiss")} + > + + props.onClose("dismiss")} + /> + + {/* The grabber doubles as the accessible close control: the dim + backdrop above a tall sheet is a sliver, and VoiceOver can't + reach it at all. */} + props.onClose("dismiss")} + className="items-center pb-1 pt-2.5" + > + + + {hasLegacyModels ? ( + + { + void Haptics.selectionAsync(); + setShowLegacyToggle(!showLegacy); + }} + className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" + > + + {showLegacy ? "Hide legacy models" : "Show legacy models"} + + + + ) : null} + {/* Only the model list scrolls. Provider catalogs can run to + hundreds of models (OpenRouter), so the rows below stay pinned + and reachable instead of living at the end of that scroll. */} + + {props.providerGroups.map((group) => { + const driver = group.models[0]?.providerDriver; + const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); + const visibleModels = showLegacy + ? group.models + : group.models.filter((model) => !model.isLegacy || isDisplayed(model)); + if (visibleModels.length === 0) { + return null; + } + const containsSelection = group.models.some(isDisplayed); + const collapsible = !isPrimary && !containsSelection; + const collapsed = collapsible && !expandedProviders.has(group.providerKey); + return ( + + toggleProvider(group.providerKey)} + /> + {collapsed + ? null + : visibleModels.map((option) => ( + { + void Haptics.selectionAsync(); + // Re-tapping the applied model cancels staging. + setPendingModel((current) => + pendingModelAfterPress({ + current, + pressed: option, + pressedIsApplied: isApplied(option), + }), + ); + }} + /> + ))} + + ); + })} + + + + + + {descriptorTemplate.map((entry) => { + const live = displayedDescriptors.find( + (descriptor) => descriptor.label === entry.label, + ); + if ((live?.type ?? entry.type) === "select") { + return ( + { + if (live) { + setSubmenu({ kind: "descriptor", id: live.id }); + } + }} + /> + ); + } + return ( + { + if (live) { + handleOptionChange(live.id, value); + } + }} + /> + ); + })} + choice.mode === props.runtimeMode)?.label + } + onPress={() => setSubmenu({ kind: "runtime" })} + /> + + + {pendingModel ? "Save" : "Done"} + + + + + + {/* Submenus stack over the sheet instead of replacing its content, + so the main sheet keeps its size while drilling in and out. */} + {submenuContent ? ( + + setSubmenu(null)} + /> + + setSubmenu(null)} + className="items-center pb-1 pt-2.5" + > + + + + {submenuContent.title} + + + {submenuContent.rows.map((row) => ( + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 18bacd12577..e3170eef000 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -25,6 +25,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, groupByProvider, + resolveDefaultableModelSelection, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; @@ -147,7 +148,10 @@ type NewTaskFlowContextValue = { readonly reset: () => void; readonly setProject: (project: EnvironmentProject) => void; readonly selectEnvironment: (environmentId: EnvironmentId) => void; - readonly setSelectedModelKey: (key: string | null) => void; + readonly setSelectedModelKey: ( + key: string | null, + options?: ReadonlyArray, + ) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; readonly selectBranch: (branch: VcsRef) => void; readonly setStartFromOrigin: (value: boolean) => void; @@ -359,14 +363,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; - // Stored selections (draft and project default) only count while their - // provider is usable on the server; otherwise the server's default model - // wins instead of silently targeting a disabled provider. + // Stored selections only count while their provider is usable on the + // server; otherwise the server's default model wins instead of silently + // targeting a disabled provider. The draft selection is an explicit pick + // and passes through as-is; the project default (last used, possibly from + // desktop) is implicit and additionally never resolves to a legacy model. const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, selectedProjectDraft.modelSelection ?? null, ); - const projectDefaultModelSelection = resolveSelectableModelSelection( + const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ); @@ -404,7 +410,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); const setSelectedModelKey = useCallback( - (key: string | null) => { + // Options ride along in the same write: a follow-up setSelectedModelOptions + // call would rebuild the selection from the stale pre-switch model. + (key: string | null, options?: ReadonlyArray) => { if (!key || !selectedProjectDraftKey) { return; } @@ -413,7 +421,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { return; } updateComposerDraftSettings(selectedProjectDraftKey, { - modelSelection: option.selection, + modelSelection: options ? { ...option.selection, options } : option.selection, }); }, [modelOptions, selectedProjectDraftKey], diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts new file mode 100644 index 00000000000..1264c75cd33 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; + +import type { ModelOption } from "../../lib/modelOptions"; +import { pendingModelAfterPress } from "./thread-settings-sheet-state"; + +function modelOption( + model: string, + options: ReadonlyArray = [], +): ModelOption { + return { + key: `codex:${model}`, + label: model, + subtitle: "Codex", + providerKey: "codex", + providerLabel: "Codex", + providerDriver: "codex", + isDefault: false, + isLegacy: false, + capabilities: null, + selection: { + instanceId: ProviderInstanceId.make("codex"), + model, + options, + }, + }; +} + +describe("thread settings sheet state", () => { + it("clears staging when the applied model is pressed", () => { + expect( + pendingModelAfterPress({ + current: modelOption("gpt-next"), + pressed: modelOption("gpt-current"), + pressedIsApplied: true, + }), + ).toBeNull(); + }); + + it("preserves staged options when the highlighted model is pressed again", () => { + const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]); + + expect( + pendingModelAfterPress({ + current: pending, + pressed: modelOption("gpt-next"), + pressedIsApplied: false, + }), + ).toBe(pending); + }); + + it("stages a different model", () => { + const pressed = modelOption("gpt-other"); + + expect( + pendingModelAfterPress({ + current: modelOption("gpt-next"), + pressed, + pressedIsApplied: false, + }), + ).toBe(pressed); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts new file mode 100644 index 00000000000..f0540dc5a97 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -0,0 +1,13 @@ +import type { ModelOption } from "../../lib/modelOptions"; + +/** Preserve staged provider options when the highlighted model is tapped again. */ +export function pendingModelAfterPress(input: { + readonly current: ModelOption | null; + readonly pressed: ModelOption; + readonly pressedIsApplied: boolean; +}): ModelOption | null { + if (input.pressedIsApplied) { + return null; + } + return input.current?.key === input.pressed.key ? input.current : input.pressed; +} diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts new file mode 100644 index 00000000000..3cc2ed18468 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { KeyboardController } from "react-native-keyboard-controller"; + +import type { ComposerEditorHandle } from "../../components/ComposerEditor"; + +export type ThreadSettingsSheetCloseReason = "save" | "dismiss"; + +type PresentationPhase = "closed" | "opening" | "visible" | "closing"; + +/** + * Keeps the custom native composer and the settings modal from owning focus at + * the same time. Opening waits for the keyboard dismissal to finish, while + * focus restoration waits for the modal's dismissal callback. + */ +export function useThreadSettingsSheetPresentation(input: { + readonly editorRef: RefObject; + readonly isEditorFocused: boolean; +}) { + const [phase, setPhase] = useState("closed"); + const isActiveRef = useRef(false); + const isMountedRef = useRef(true); + const openingIdRef = useRef(0); + const restoreFocusOnSaveRef = useRef(false); + const shouldRestoreAfterDismissRef = useRef(false); + + useEffect( + () => () => { + isMountedRef.current = false; + isActiveRef.current = false; + openingIdRef.current += 1; + }, + [], + ); + + const open = useCallback(() => { + if (isActiveRef.current) { + return; + } + + isActiveRef.current = true; + restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible(); + shouldRestoreAfterDismissRef.current = false; + setPhase("opening"); + + const openingId = openingIdRef.current + 1; + openingIdRef.current = openingId; + + // Keyboard.dismiss() only tracks React Native TextInputs. The composer is + // a custom native text view, so explicitly resign its first responder too. + input.editorRef.current?.blur(); + void KeyboardController.dismiss().then(() => { + if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { + return; + } + setPhase("visible"); + }); + }, [input.editorRef, input.isEditorFocused]); + + const close = useCallback((reason: ThreadSettingsSheetCloseReason) => { + if (!isActiveRef.current) { + return; + } + + openingIdRef.current += 1; + shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current; + setPhase("closing"); + }, []); + + const onDismissed = useCallback(() => { + const shouldRestoreFocus = shouldRestoreAfterDismissRef.current; + shouldRestoreAfterDismissRef.current = false; + restoreFocusOnSaveRef.current = false; + isActiveRef.current = false; + setPhase("closed"); + + if (shouldRestoreFocus) { + input.editorRef.current?.focus(); + } + }, [input.editorRef]); + + // The new-task screen can have an autofocus queued before the sheet opens. + // Preserve that intent for Save without allowing it to focus under the modal. + const restoreFocusAfterSave = useCallback(() => { + if (isActiveRef.current) { + restoreFocusOnSaveRef.current = true; + } + }, []); + + return { + isActive: phase !== "closed", + isActiveRef, + isVisible: phase === "visible", + open, + close, + onDismissed, + restoreFocusAfterSave, + } as const; +} diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 2ec8566b4e4..8a9dabbe034 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; import { - buildModelMenuActions, buildModelOptions, groupByProvider, + resolveDefaultableModelSelection, resolveSelectableModelSelection, } from "./modelOptions"; describe("mobile model options", () => { - it("folds legacy models into a provider-scoped menu", () => { + it("groups models by provider and flags legacy entries", () => { const config = { providers: [ { @@ -39,51 +39,14 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; - const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null); - - expect(actions).toMatchObject([ - { - title: "Codex", - subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }], - }, + expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([ { - id: "legacy-models:codex", - title: "Codex legacy models", - subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }], - }, - ]); - }); - - it("omits an empty provider menu when every model is legacy", () => { - const config = { - providers: [ - { - instanceId: "codex", - driver: "codex", - displayName: "Codex", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - models: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - isLegacy: true, - capabilities: null, - }, - ], - }, - ], - } as unknown as ServerConfig; - - expect( - buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null), - ).toMatchObject([ - { - id: "legacy-models:codex", - title: "Codex legacy models", - subactions: [{ id: "model:codex:gpt-5.4" }], + providerKey: "codex", + providerLabel: "Codex", + models: [ + { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", isLegacy: false }, + { key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true }, + ], }, ]); }); @@ -174,4 +137,38 @@ describe("mobile model options", () => { // No config (environment offline) — nothing to validate against. expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + + it("keeps legacy models out of implicit defaults", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + isLegacy: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; + const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + + expect(resolveDefaultableModelSelection(config, current)).toBe(current); + // A legacy last-used selection falls through to the provider default. + expect(resolveDefaultableModelSelection(config, legacy)).toBeNull(); + // Offline: nothing to validate against, selection passes through. + expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); + }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 951b74f7d51..cb7a8c4198e 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -3,7 +3,6 @@ import type { ModelSelection, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, @@ -85,6 +84,26 @@ export function resolveSelectableModelSelection( : null; } +/** + * Like resolveSelectableModelSelection, but additionally rejects legacy + * models. Used for implicit defaults (stored draft, project last-used): a + * new thread should never quietly start on a legacy model, so those fall + * through to the provider's default instead. Explicit picks in the settings + * sheet are unaffected. + */ +export function resolveDefaultableModelSelection( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null, +): ModelSelection | null { + const usable = resolveSelectableModelSelection(config, selection); + if (!usable || !config) { + return usable; + } + const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === usable.model); + return model?.isLegacy === true ? null : usable; +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -168,53 +187,3 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr models: group.models, })); } - -function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction { - return { - id: `model:${option.key}`, - title: option.label, - state: - option.selection.instanceId === selectedModel?.instanceId && - option.selection.model === selectedModel.model - ? "on" - : undefined, - }; -} - -export function buildModelMenuActions( - groups: ReadonlyArray, - selectedModel: ModelSelection | null, -): MenuAction[] { - return groups.flatMap((group) => { - const currentModels = group.models.filter((model) => !model.isLegacy); - const legacyModels = group.models.filter((model) => model.isLegacy); - const selected = group.models.find( - (model) => - model.selection.instanceId === selectedModel?.instanceId && - model.selection.model === selectedModel.model, - ); - - return [ - ...(currentModels.length > 0 - ? [ - { - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: selected && !selected.isLegacy ? selected.label : undefined, - subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)), - }, - ] - : []), - ...(legacyModels.length > 0 - ? [ - { - id: `legacy-models:${group.providerKey}`, - title: `${group.providerLabel} legacy models`, - subtitle: selected?.isLegacy ? selected.label : undefined, - subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)), - }, - ] - : []), - ]; - }); -} diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d7f99a3dab7..d87df6baaf1 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import type { ModelCapabilities } from "@t3tools/contracts"; import { - applyProviderOptionMenuEvent, - buildProviderOptionMenuActions, - providerOptionsConfigurationLabel, + applyProviderOptionSelection, + providerOptionValueLabels, resolveProviderOptionDescriptors, } from "./providerOptions"; @@ -35,31 +34,13 @@ const CODEX_CAPABILITIES: ModelCapabilities = { }; describe("mobile provider options", () => { - it("renders the option descriptors advertised by the selected model", () => { + it("summarizes the option values currently in effect", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: CODEX_CAPABILITIES, selections: undefined, }); - expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ - { - title: "Reasoning", - subtitle: "Medium", - subactions: [ - { title: "Medium (default)", state: "on" }, - { title: "High", state: undefined }, - ], - }, - { - title: "Service Tier", - subtitle: "Standard", - subactions: [ - { title: "Standard (default)", state: "on" }, - { title: "Fast", state: undefined }, - ], - }, - ]); - expect(providerOptionsConfigurationLabel(descriptors)).toBe("Medium · Standard"); + expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); }); it("updates generic select options without knowing provider-specific ids", () => { @@ -67,14 +48,18 @@ describe("mobile provider options", () => { capabilities: CODEX_CAPABILITIES, selections: undefined, }); - const actions = buildProviderOptionMenuActions(descriptors); - const fastEvent = actions[1]?.subactions?.[1]?.id; - expect(fastEvent).toBeDefined(); - expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ + expect( + applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "priority" }), + ).toEqual([ { id: "reasoningEffort", value: "medium" }, { id: "serviceTier", value: "priority" }, ]); + // Choices the model doesn't advertise are rejected, not stored. + expect( + applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "turbo" }), + ).toBeNull(); + expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); }); it("treats an unspecified boolean capability as off", () => { @@ -85,16 +70,9 @@ describe("mobile provider options", () => { selections: undefined, }); - expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ - { - title: "Fast Mode", - subtitle: "Off", - subactions: [ - { title: "Off", state: "on" }, - { title: "On", state: undefined }, - ], - }, + expect(providerOptionValueLabels(descriptors)).toEqual([]); + expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ + { id: "fastMode", value: true }, ]); - expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); }); }); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index ae195498962..593f5a37442 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -3,48 +3,12 @@ import type { ProviderOptionDescriptor, ProviderOptionSelection, } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, - getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:"; - -function providerOptionEvent(id: string, value: string | boolean): string { - return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`; -} - -function parseProviderOptionEvent( - event: string, -): { readonly id: string; readonly value: string | boolean } | null { - if (!event.startsWith(PROVIDER_OPTION_EVENT_PREFIX)) { - return null; - } - - try { - const parsed: unknown = JSON.parse( - decodeURIComponent(event.slice(PROVIDER_OPTION_EVENT_PREFIX.length)), - ); - if ( - typeof parsed === "object" && - parsed !== null && - "id" in parsed && - typeof parsed.id === "string" && - "value" in parsed && - (typeof parsed.value === "string" || typeof parsed.value === "boolean") - ) { - return { id: parsed.id, value: parsed.value }; - } - } catch { - return null; - } - - return null; -} - export function resolveProviderOptionDescriptors(input: { readonly capabilities: ModelCapabilities | null | undefined; readonly selections: ReadonlyArray | null | undefined; @@ -58,72 +22,41 @@ export function resolveProviderOptionDescriptors(input: { }); } -export function buildProviderOptionMenuActions( - descriptors: ReadonlyArray, -): ReadonlyArray { - return descriptors.map((descriptor) => { - const currentValue = - descriptor.type === "boolean" - ? (descriptor.currentValue ?? false) - : getProviderOptionCurrentValue(descriptor); - const choices = - descriptor.type === "select" - ? descriptor.options.map((option) => ({ - id: providerOptionEvent(descriptor.id, option.id), - title: `${option.label}${option.isDefault ? " (default)" : ""}`, - state: currentValue === option.id ? ("on" as const) : undefined, - })) - : ([false, true] as const).map((value) => ({ - id: providerOptionEvent(descriptor.id, value), - title: value ? "On" : "Off", - state: currentValue === value ? ("on" as const) : undefined, - })); - - return { - id: `provider-option-menu:${descriptor.id}`, - title: descriptor.label, - subtitle: - descriptor.type === "boolean" - ? currentValue - ? "On" - : "Off" - : getProviderOptionCurrentLabel(descriptor), - subactions: choices, - }; - }); -} - -export function providerOptionsConfigurationLabel( +/** + * Labels for the option values currently in effect (select values plus + * enabled booleans), used to summarize the thread configuration in the + * composer trigger pill. + */ +export function providerOptionValueLabels( descriptors: ReadonlyArray, -): string { - const labels = descriptors.flatMap((descriptor) => { +): ReadonlyArray { + return descriptors.flatMap((descriptor) => { if (descriptor.type === "boolean") { return descriptor.currentValue ? [descriptor.label] : []; } const label = getProviderOptionCurrentLabel(descriptor); return label ? [label] : []; }); - return labels.length > 0 ? labels.join(" · ") : "Configuration"; } -export function applyProviderOptionMenuEvent( +/** + * Applies one option change (by descriptor id) and returns the full selection + * list to store on the model selection, or null when the change doesn't match + * an advertised descriptor / choice. + */ +export function applyProviderOptionSelection( descriptors: ReadonlyArray, - event: string, + change: ProviderOptionSelection, ): ReadonlyArray | null { - const selection = parseProviderOptionEvent(event); - if (!selection) { - return null; - } - - const descriptor = descriptors.find((candidate) => candidate.id === selection.id); + const descriptor = descriptors.find((candidate) => candidate.id === change.id); if (!descriptor) { return null; } if ( - (descriptor.type === "boolean" && typeof selection.value !== "boolean") || + (descriptor.type === "boolean" && typeof change.value !== "boolean") || (descriptor.type === "select" && - (typeof selection.value !== "string" || - !descriptor.options.some((option) => option.id === selection.value))) + (typeof change.value !== "string" || + !descriptor.options.some((option) => option.id === change.value))) ) { return null; } @@ -132,7 +65,7 @@ export function applyProviderOptionMenuEvent( candidate.id === descriptor.id ? { ...candidate, - currentValue: selection.value, + currentValue: change.value, } : candidate, ) as ReadonlyArray; From 8101cd044911c7dc2a2adf7c7a9ba7962abf57b6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 06:59:04 -0400 Subject: [PATCH 3/3] feat(usage): usage page reading provider transcripts across environments (#5684) Co-authored-by: Claude Opus 5 (1M context) --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 4 + apps/server/src/usage/UsageService.ts | 420 ++++++++++++++++ .../server/src/usage/usageAggregation.test.ts | 132 +++++ apps/server/src/usage/usageAggregation.ts | Bin 0 -> 6019 bytes apps/server/src/usage/usagePricing.ts | 148 ++++++ apps/server/src/usage/usageScanCache.test.ts | 206 ++++++++ apps/server/src/usage/usageScanCache.ts | 253 ++++++++++ .../server/src/usage/usageTranscriptReader.ts | 141 ++++++ .../server/src/usage/usageTranscripts.test.ts | 151 ++++++ apps/server/src/usage/usageTranscripts.ts | 246 ++++++++++ apps/server/src/ws.ts | 6 + .../src/components/sidebar/SidebarChrome.tsx | 15 +- apps/web/src/components/usage/UsagePage.tsx | 454 ++++++++++++++++++ .../usage/UsageProviderChart.test.ts | 91 ++++ .../components/usage/UsageProviderChart.tsx | 411 ++++++++++++++++ .../src/components/usage/usageProviders.ts | 32 ++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/usage.tsx | 7 + apps/web/src/state/usage.ts | 126 +++++ apps/web/src/usage/usageFormat.ts | 107 +++++ apps/web/src/usage/usageMerge.test.ts | 258 ++++++++++ apps/web/src/usage/usageMerge.ts | 353 ++++++++++++++ packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/usage.ts | 194 ++++++++ 28 files changed, 3795 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/usage/UsageService.ts create mode 100644 apps/server/src/usage/usageAggregation.test.ts create mode 100644 apps/server/src/usage/usageAggregation.ts create mode 100644 apps/server/src/usage/usagePricing.ts create mode 100644 apps/server/src/usage/usageScanCache.test.ts create mode 100644 apps/server/src/usage/usageScanCache.ts create mode 100644 apps/server/src/usage/usageTranscriptReader.ts create mode 100644 apps/server/src/usage/usageTranscripts.test.ts create mode 100644 apps/server/src/usage/usageTranscripts.ts create mode 100644 apps/web/src/components/usage/UsagePage.tsx create mode 100644 apps/web/src/components/usage/UsageProviderChart.test.ts create mode 100644 apps/web/src/components/usage/UsageProviderChart.tsx create mode 100644 apps/web/src/components/usage/usageProviders.ts create mode 100644 apps/web/src/routes/usage.tsx create mode 100644 apps/web/src/state/usage.ts create mode 100644 apps/web/src/usage/usageFormat.ts create mode 100644 apps/web/src/usage/usageMerge.test.ts create mode 100644 apps/web/src/usage/usageMerge.ts create mode 100644 packages/contracts/src/usage.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2aa057ee0ca..4ad28691a4f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -45,6 +45,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4ddb01e09dd..d982c2e192c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -149,6 +149,7 @@ import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryR import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -825,6 +826,7 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), + Layer.provide(UsageService.layerTest), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d824afbd1b..ff21c07a861 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -101,6 +101,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -158,6 +159,8 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provideMerge(ServerSettingsLayerLive), ); +const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); + const ResourceDiagnosticsLayerLive = Layer.mergeAll( ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), @@ -410,6 +413,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), + Layer.provideMerge(UsageLayerLive), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts new file mode 100644 index 00000000000..2ad2a729ecb --- /dev/null +++ b/apps/server/src/usage/UsageService.ts @@ -0,0 +1,420 @@ +/** + * UsageService - scans provider transcripts and returns priced daily usage. + * + * The scan reads the provider CLIs' own session files rather than T3 Code's + * orchestration projections, so usage covers turns driven outside T3 Code too. + * This is the approach `ccusage` takes. + * + * Transcripts are append-only, so parsed records are memoised per file by + * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm + * scans only reparse files that changed. + * + * @module UsageService + */ +import * as NodeOS from "node:os"; + +import { + USAGE_CONTRACT_VERSION, + type UsageProviderKind, + type UsageSource, + type UsageSummary, + type UsageSummaryInput, + UsageReadError, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { UsageAggregator } from "./usageAggregation.ts"; +import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import { + listTranscriptFiles, + readDirectoryVolumeId, + readTranscriptRecords, +} from "./usageTranscriptReader.ts"; +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const LITELLM_RATES_URL = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; + +/** Rates move rarely; a day-old table keeps the page working offline. */ +const RATES_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * Files are filtered by mtime before opening. The slack covers a session whose + * last write lands just before local midnight on the window's first day. + */ +const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; + +/** Longest window the UI offers, plus slack. Older entries are pruned. */ +const CACHE_RETENTION_DAYS = 90; + +/** On-disk shape of the rate snapshot. */ +const RatesCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + document: Schema.Unknown, +}); +const decodeRatesCache = Schema.decodeUnknownEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); +const encodeRatesCache = Schema.encodeEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); + +/** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ +const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); +const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); +const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); + +export class UsageService extends Context.Service< + UsageService, + { + readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + } +>()("t3/usage/UsageService") {} + +/** Empty summary, for suites that only need the RPC surface to resolve. */ +export const layerTest = Layer.succeed( + UsageService, + UsageService.of({ + readSummary: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: [], + sources: [], + pricing: { + status: "unavailable", + source: LITELLM_RATES_URL, + fetchedAt: null, + knownModels: 0, + }, + scanDurationMs: 0, + }), + }), +); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + + const fileCache: ScanCache = new Map(); + let cacheDirty = false; + + const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); + const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); + let rates: RateTable = new Map(); + let ratesFetchedAtMs: number | null = null; + let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; + + /** + * Loads the LiteLLM rate table, preferring a fresh copy and falling back to + * the on-disk snapshot. With neither, every model reports as unpriced rather + * than the page failing. + */ + const ensureRates = Effect.fn("UsageService.ensureRates")(function* () { + const now = yield* Clock.currentTimeMillis; + if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return; + + if (ratesFetchedAtMs === null) { + const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( + Effect.flatMap((raw) => decodeRatesCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk !== null) { + const parsed = parseRateTable(fromDisk.document); + if (parsed.size > 0) { + rates = parsed; + ratesFetchedAtMs = fromDisk.fetchedAtMs; + ratesStatus = "cached"; + if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return; + } + } + } + + const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout(10_000), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) { + // The refresh failed; whatever we are serving is now past its TTL and + // must not keep claiming to be fresh. + if (rates.size > 0) ratesStatus = "cached"; + return; + } + + const parsed = parseRateTable(fetched); + if (parsed.size === 0) return; + + rates = parsed; + ratesFetchedAtMs = now; + ratesStatus = "fresh"; + + yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)), + Effect.catchCause(() => Effect.void), + ); + }); + + /** + * Claude's config dir is the home itself when overridden, but a default + * install nests transcripts under `~/.claude/projects`. Probe both. + */ + const resolveClaudeTranscriptDir = (homePath: string) => + Effect.gen(function* () { + const nested = path.join(homePath, ".claude", "projects"); + const nestedExists = yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + return nestedExists ? nested : path.join(homePath, "projects"); + }); + + /** Resolves the transcript directory for each provider. */ + const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { + // A settings failure must surface as an error: swallowing it here would + // present "zero usage from every provider" as a valid answer. + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + // Bounded description; the squashed failure travels as the cause. + // Squashed, not the Cause tree: a full tree in a Defect field is + // the unbounded wire payload the bounded detail exists to avoid. + detail: "Server settings could not be read.", + cause: Cause.squash(cause), + }), + ), + ); + + const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); + const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); + const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + + return [ + { provider: "claude" as const, dir: claudeDir }, + { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + ]; + }); + + /** + * Loads the persisted scan cache exactly once per process. + * + * `Effect.cached` makes concurrent first readers await the same load rather + * than each seeing a "loaded" flag set before the read finished and cold + * scanning against an empty cache. + */ + const ensureScanCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const document = yield* fileSystem.readFileString(scanCachePath).pipe( + Effect.flatMap((raw) => decodeScanCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (document === null) return; + for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + }), + ); + + const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { + if (!cacheDirty) return; + // Cleared only after the write lands, so a failed persist is retried on + // the next scan instead of leaving disk permanently stale. + yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), + Effect.map(() => { + cacheDirty = false; + }), + // A cache we cannot write is a slower next start, not a failed read. + Effect.catchCause(() => Effect.void), + ); + }); + + /** Parses one transcript, reusing the cached result when it is unchanged. */ + const readFileRecords = ( + filePath: string, + size: number, + mtimeMs: number, + provider: UsageProviderKind, + ): Effect.Effect => + Effect.gen(function* () { + const cached = fileCache.get(filePath); + // Provider is part of the identity: if both providers were ever pointed + // at one directory, a hit parsed by the other parser must not be reused. + if ( + cached && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return cached.records; + } + + const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // A read failure is not an empty transcript: caching it under this + // (size, mtime) would silently drop the file's usage until it changes. + if (parsed === null) return []; + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. + const records = dedupeWithinFile(parsed); + + fileCache.set(filePath, { size, mtimeMs, provider, records }); + cacheDirty = true; + return records; + }); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + + const startedAtMs = yield* Clock.currentTimeMillis; + yield* ensureRates(); + yield* ensureScanCacheLoaded; + + const hostId = NodeOS.hostname(); + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so `readSummary` stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + if (Option.isNone(windowStart)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is not a valid date`, + }); + } + const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS; + + const aggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rates, + }); + + const sources: UsageSource[] = []; + const livePaths = new Set(); + const walkedRoots: string[] = []; + + for (const { provider, dir } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + + if (!exists) { + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + status: "missing", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 0, + message: "No transcript directory on this environment.", + }); + continue; + } + + walkedRoots.push(dir); + const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + let scannedFiles = 0; + let skippedFiles = 0; + // Distinct per directory. Buckets carry per-cell session counts, but a + // session spans days and models, so clients total this figure instead. + const sessionIds = new Set(); + + for (const file of files) { + livePaths.add(file.path); + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + if (records.length === 0) { + skippedFiles += 1; + continue; + } + scannedFiles += 1; + for (const record of records) { + // Only sessions that contributed in-window count: the mtime slack + // admits boundary files whose records fall outside the range. + if (aggregator.add(record) && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + } + } + + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + status: "ok", + scannedFiles, + skippedFiles, + malformedRecords: 0, + distinctSessions: sessionIds.size, + message: null, + }); + } + + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + + const aggregated = aggregator.finish(); + const readAt = yield* DateTime.now; + const finishedAtMs = yield* Clock.currentTimeMillis; + + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: aggregated.buckets, + sources, + pricing: { + status: ratesStatus, + source: LITELLM_RATES_URL, + fetchedAt: + ratesFetchedAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), + knownModels: rates.size, + }, + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageSummary; + }); + + return { readSummary } as const; +}); + +export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts new file mode 100644 index 00000000000..9117e216f12 --- /dev/null +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { UsageAggregator } from "./usageAggregation.ts"; +import type { RateTable } from "./usagePricing.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const rates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheReadCostPerToken: 1e-6, + cacheCreationCostPerToken: 1.25e-5, + }, + ], +]); + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + // 2026-08-07T04:05Z is still Aug 6 in Los Angeles. + timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: null, + ...overrides, + }; +} + +function aggregate(records: readonly UsageRecord[], timeZone = "UTC") { + const aggregator = new UsageAggregator({ + timeZone, + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + for (const item of records) aggregator.add(item); + return aggregator.finish(); +} + +describe("UsageAggregator", () => { + it("keeps only the first record for a repeated dedupe key", () => { + const result = aggregate([ + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:" }), + ]); + + expect(result.duplicatesDropped).toBe(2); + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.records).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("still sums records that carry no dedupe key", () => { + const result = aggregate([record(), record()]); + + expect(result.duplicatesDropped).toBe(0); + expect(result.buckets[0]?.totals.outputTokens).toBe(100); + }); + + it("buckets by the day in the requested time zone", () => { + const utc = aggregate([record()], "UTC"); + const losAngeles = aggregate([record()], "America/Los_Angeles"); + + expect(utc.buckets[0]?.day).toBe("2026-08-07"); + expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); + }); + + it("prices against the rate table", () => { + const result = aggregate([record()]); + + // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5 + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9); + expect(result.buckets[0]?.costSource).toBe("modelPriced"); + }); + + it("counts tokens but not cost for a model with no rate", () => { + const result = aggregate([record({ model: "kimi-k3" })]); + + expect(result.buckets[0]?.costUsd).toBe(0); + expect(result.buckets[0]?.costSource).toBe("unpriced"); + expect(result.buckets[0]?.unpricedRecords).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("prefers a reported cost over the rate table", () => { + const result = aggregate([record({ reportedCostUsd: 1.25 })]); + + expect(result.buckets[0]?.costUsd).toBe(1.25); + expect(result.buckets[0]?.costSource).toBe("providerReported"); + }); + + it("drops records outside the window", () => { + const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]); + + expect(result.outOfWindow).toBe(1); + expect(result.buckets).toHaveLength(0); + }); + + it("reports whether a record contributed", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); + expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); + }); + + it("separates providers and models into their own buckets", () => { + const result = aggregate([ + record(), + record({ provider: "codex", model: "gpt-5.6-sol" }), + record({ model: "claude-opus-5" }), + ]); + + expect(result.buckets).toHaveLength(3); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f04a318c529c8498a200c3f21a5caed2330ee3e GIT binary patch literal 6019 zcmb_gZEqX75x&p<6;tJaN~yd`+i%L*h|lf;M(ZZ99TeBVsOVivT6ncoNp5sHpZ|NG z8It=V`K}+@d`Kd4b~rE3%sk|5Mj!NYsdIl?lxkHQ=gZvDsxptNx>8=x&2o7%d;9i; z-qH`IDjaQe#xPVEVrt#+pRs!3s?s!sFDx;i%(9fpUQ>+(Mdh+T?JAAv~BNrLnua)QHc55idkyh>E9xKaD z?YwPrUz(bwaG=@2>$SG}J=Mmq%X)=_@hNuUVgoy^R)_*C6WfxfpBf7@oROUn1^$@r zD)ed5Y^yR?a*%}o@NNoFmH9fM z3*ZAr8ZH`!50Higa?Vu1t#f+wSTQ6xu?%Pla{wx>wD;ON8UqIo;D-YI$DacIs**yP zuwAng{gdQ$+0+5G8vq`843z~H5Y#z0p(AjyEpyv~! zN_$#LdCuvTntcxukBVbaih^`TwcfoZJswHyM$;&dZ)0?)@;%XBD?6tVY|+TvC@pUQ zwKdlNgg&(@eY=iu9j(4h*r*qv#v=>Cu-S|*YHI4I+U#oS4CGK)71!X(ssbfv==1$$ zKRdKkWhF`3tqtni<*G)k|0g$LEKX_7|NnUlyR7z&tp`b!WQOcz;h!;QhI?Q?_Y*|y z1tnYqCrg!Ux@n~Sgr*=N^wI<7M_qwKodb|4%Y-0V8dhw7D~^l)Qd3Jo7ojh-P-I1E zklbp9w|-5;Y3I(*VO-my{)=h1+BKCQS{8@ryfa6dU&zmw2R0N#nqvH@ux7hOzj@TT zLgV2+hq9_lw4D%r)P}VsB)H``wL#y}m_(YM^giQm(sMjbDDAF-+J5Hxr=m)57AlgK zx+gGPe`cV%6e-QgLK{kZ&xY<1d!$jCe{^1RtJ6~{3;fNq^z<1l+w zjq`XKruKX_9IEKOfzG5a*yXOEr##JRi^#B&IbBw&fzg*>ErAGZOW_UZlRPGW-x+{< z+9?Z<)*946+D?C^rSJcoJo^gqP+;={;S?E&|uNfO3Wh-(xZ?!5GQKrXfqcJ{r0n&k$e@bem{53gN;aobWU)6#Sc4JoWT%Iaump9(+;q%4qQ(1cWBWu2iOxg#^l)H3ry1wfY? zu*p3ek1_n%peTeLVw_hP^lT3JCn^?<>M(YPqV(lO-|y0Z z4`=kpkWtQR9EeMhSDjX0#H^%2PV0Q>Y?KUxL~xEH(4@1SMH~rKl8qd;$fDwR><>4> z?}o6;(h#9vei3!&2m5J`biPK|cKwn1J|25Guo&GOa9(`-3|n5l`Sv+vEK4)BwOM(4GOFqlAmmB<3ee zE4T3pqpPjoP;&^OPTk?o>uO^zBg(FetkU($uj!oLwT2}KX1(Ureh@-E_{pJ2A<9 zFOM2uMoDD!(0w*2rLHSQ;0}wb*On5n`*kHFVu*CiK{-1+fO~g7mrDRAvwC^j;$jU; zu@EPncm#T@H~#Jf4xro$9;|mCB#5<=X#rE}XPxB2Wtt};dx>lGU3i$17Y}~4c+z^y zuY&u^@bgzuE|>BS!|z+JR$E-N@zR9(2(AuZfKQf96|T`CL@sl<*`*#hRsb8b$2Pwz zL;f>)@6eZKgC`a}e&n?Yrf5$pCHY7+Z5+r80FH!ZHO3vd6#cQ#5+I}5DMCjqWFAZ8 zXd`{mXwuCFo?ks32|NdU;Wu?1elud7=^)9|Cw{*yu>NxLAN~=u9smFU literal 0 HcmV?d00001 diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts new file mode 100644 index 00000000000..f0e59a87439 --- /dev/null +++ b/apps/server/src/usage/usagePricing.ts @@ -0,0 +1,148 @@ +/** + * Model rate lookup and cost arithmetic. + * + * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same + * table `ccusage` prices against. Everything here is pure: fetching and caching + * the table lives in `UsageService`. + * + * @module usagePricing + */ +import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts"; + +/** + * The subset of a LiteLLM entry we price against. All values are USD per token. + * + * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`, + * `*_priority`, `*_batches`). We deliberately price at the base tier: the + * transcripts don't record which tier served a request, so anything else would + * be a guess dressed up as precision. + */ +export interface ModelRate { + readonly inputCostPerToken: number; + readonly outputCostPerToken: number; + readonly cacheReadCostPerToken: number; + readonly cacheCreationCostPerToken: number; +} + +export type RateTable = ReadonlyMap; + +/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ +interface LiteLlmEntry { + readonly input_cost_per_token?: unknown; + readonly output_cost_per_token?: unknown; + readonly cache_read_input_token_cost?: unknown; + readonly cache_creation_input_token_cost?: unknown; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * Projects the LiteLLM document into a rate table. + * + * Entries without both an input and an output rate are dropped: a half-priced + * model would silently under-report cost, which is worse than reporting the + * model as unpriced. + */ +export function parseRateTable(document: unknown): RateTable { + const table = new Map(); + if (typeof document !== "object" || document === null) return table; + + for (const [name, raw] of Object.entries(document as Record)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as LiteLlmEntry; + const input = finiteNumber(entry.input_cost_per_token); + const output = finiteNumber(entry.output_cost_per_token); + if (input === null || output === null) continue; + + table.set(normalizeModelName(name), { + inputCostPerToken: input, + outputCostPerToken: output, + // Anthropic bills cache reads at a discount and cache writes at a + // premium. When a model omits them, cached input is priced as plain + // input rather than as free. + cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, + cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, + }); + } + return table; +} + +/** + * Canonicalises a model name for lookup. + * + * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-5` and + * `anthropic/claude-opus-5`) and lowercases, since transcripts are inconsistent + * about casing. + */ +export function normalizeModelName(model: string): string { + const trimmed = model.trim().toLowerCase(); + const slash = trimmed.lastIndexOf("/"); + return slash === -1 ? trimmed : trimmed.slice(slash + 1); +} + +/** + * Models we never price, regardless of the table. + * + * `` marks locally generated messages that were never billed. Bare + * family names ("opus", "sonnet") are genuinely ambiguous across generations, + * so we report them as unpriced instead of guessing a generation. + */ +const UNPRICEABLE_MODELS = new Set([ + "", + "synthetic", + "opus", + "sonnet", + "haiku", + "fable", +]); + +export function lookupRate(table: RateTable, model: string): ModelRate | null { + const normalized = normalizeModelName(model); + if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null; + return table.get(normalized) ?? null; +} + +export interface PricedUsage { + readonly costUsd: number; + readonly costSource: UsageCostSource; +} + +/** + * Prices a bucket's tokens. + * + * `reasoningTokens` is intentionally not charged separately: it is already + * counted inside `outputTokens`. + */ +export function priceUsage( + table: RateTable, + model: string, + totals: UsageTokenTotals, + reportedCostUsd: number | null, +): PricedUsage { + if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { + return { costUsd: reportedCostUsd, costSource: "providerReported" }; + } + + const rate = lookupRate(table, model); + if (rate === null) return { costUsd: 0, costSource: "unpriced" }; + + const costUsd = + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.cachedInputTokens * rate.cacheReadCostPerToken + + totals.cacheCreationTokens * rate.cacheCreationCostPerToken + + totals.outputTokens * rate.outputCostPerToken; + + return { costUsd, costSource: "modelPriced" }; +} + +/** + * What the cached input would have cost at full input rates, minus what it + * actually cost. Drives the "cache savings" figure. + */ +export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { + const rate = lookupRate(table, model); + if (rate === null) return 0; + return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); +} diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts new file mode 100644 index 00000000000..64673e96c09 --- /dev/null +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + timestampMs: 1_786_000_000_000, + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: "msg_1:", + ...overrides, + }; +} + +function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { + const cache: ScanCache = new Map(); + for (const [path, mtimeMs, records] of entries) { + cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + } + return cache; +} + +describe("scan cache round trip", () => { + it("restores records unchanged", () => { + const original = cacheWith([ + ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], + ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], + ]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.size).toBe(2); + expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); + expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + }); + + it("interns repeated model and session strings", () => { + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), + ); + + expect(encoded.models).toEqual(["claude-fable-5"]); + expect(encoded.sessions).toEqual(["session-a"]); + }); + + it("treats a corrupt or foreign document as an empty cache", () => { + // A bad cache should cost one cold scan, never a broken page. + expect(decodeScanCache(null).size).toBe(0); + expect(decodeScanCache("nonsense").size).toBe(0); + expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0); + }); + + it("skips malformed file entries but keeps good ones", () => { + const encoded = encodeScanCache(cacheWith([["/good.jsonl", 100, [record()]]])); + const withJunk = { + ...encoded, + files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk))); + expect([...restored.keys()]).toEqual(["/good.jsonl"]); + }); + + it("rejects the whole cache when an intern table holds a non-string", () => { + // models: [1] would pass the undefined guard, put a number in a record's + // model, and crash normalizeModelName at aggregate time. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { ...encoded, models: [1] }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0); + }); + + it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { + // Keeping the surviving rows under the original (size, mtime) would read + // as a valid warm hit and the file would never be re-parsed. + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" })]]]), + ); + const rows = encoded.files["/a.jsonl"]!.r; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [rows[0]!, [...rows[1]!.slice(0, 3), "not-a-number", ...rows[1]!.slice(4)]], + }, + }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); + expect(restored.has("/a.jsonl")).toBe(false); + }); +}); + +describe("pruneScanCache", () => { + const retentionCutoffMs = 1000; + + it("drops entries older than retention", () => { + const cache = cacheWith([["/old.jsonl", 500, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 400, + retentionCutoffMs, + }); + + expect(removed).toBe(1); + expect(cache.size).toBe(0); + }); + + it("drops in-window entries whose file has disappeared", () => { + const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); + + pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(cache.size).toBe(0); + }); + + it("keeps entries outside the walked window that are still within retention", () => { + // Viewing 7 days must not evict the 30-day entries, which that walk never + // looked for and so cannot prove are gone. + const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); + + it("keeps entries the walk saw", () => { + const cache = cacheWith([["/live.jsonl", 5000, [record()]]]); + + pruneScanCache(cache, { + livePaths: new Set(["/live.jsonl"]), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(cache.size).toBe(1); + }); +}); + +describe("pruneScanCache with an unwalked root", () => { + it("keeps in-window entries for a provider whose directory was not walked", () => { + // A missing provider root or failed settings read leaves livePaths without + // that provider's files. Its warm entries must survive the pass. + const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs: 1000, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); +}); + +describe("dedupeWithinFile", () => { + it("keeps the first record per dedupe key", () => { + const kept = dedupeWithinFile([ + record({ totals: { ...record().totals, outputTokens: 1 } }), + record({ totals: { ...record().totals, outputTokens: 999 } }), + record({ dedupeKey: "msg_2:" }), + ]); + + expect(kept).toHaveLength(2); + expect(kept[0]?.totals.outputTokens).toBe(1); + }); + + it("keeps every record that has no dedupe key", () => { + expect( + dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]), + ).toHaveLength(2); + }); +}); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts new file mode 100644 index 00000000000..0dafa7a6daf --- /dev/null +++ b/apps/server/src/usage/usageScanCache.ts @@ -0,0 +1,253 @@ +/** + * Durable per-file scan cache. + * + * Transcripts are append-only and a file that has not changed can never yield + * different usage, so parsed records are keyed by `(size, mtime)` and reused. + * Without this every server restart re-parses the whole window: roughly 3.5s + * for a 30-day scan here, against ~11ms to reload this cache. + * + * Caching *per file* rather than per day is deliberate. It is timezone + * independent, so changing the reporting zone does not invalidate anything, and + * it keeps cross-file de-duplication exact: cached entries are de-duplicated + * within their own file only, and the aggregator still applies the global + * dedupe pass over the small surviving key set. + * + * @module usageScanCache + */ +import type { UsageProviderKind } from "@t3tools/contracts"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +export const USAGE_SCAN_CACHE_VERSION = 1 as const; + +export interface CachedFile { + readonly size: number; + readonly mtimeMs: number; + readonly provider: UsageProviderKind; + readonly records: readonly UsageRecord[]; +} + +export type ScanCache = Map; + +/** + * Row layout for the serialised form. Positional and interned rather than + * object-per-record: on a 30-day window that is the difference between a file + * measured in tens of megabytes and one under six. + */ +type SerializedRecord = readonly [ + timestampMs: number, + modelIndex: number, + sessionIndex: number, + uncachedInputTokens: number, + cachedInputTokens: number, + cacheCreationTokens: number, + outputTokens: number, + reasoningTokens: number, + dedupeKey: string | null, + reportedCostUsd: number | null, +]; + +interface SerializedFile { + readonly s: number; + readonly m: number; + readonly p: UsageProviderKind; + readonly r: readonly SerializedRecord[]; +} + +interface SerializedCache { + readonly version: number; + readonly models: readonly string[]; + readonly sessions: readonly string[]; + readonly files: Readonly>; +} + +/** Serialises the cache, interning the repeated model and session strings. */ +export function encodeScanCache(cache: ScanCache): SerializedCache { + const models: string[] = []; + const sessions: string[] = []; + const modelIndex = new Map(); + const sessionIndex = new Map(); + + const intern = (table: string[], index: Map, value: string): number => { + const existing = index.get(value); + if (existing !== undefined) return existing; + const next = table.length; + table.push(value); + index.set(value, next); + return next; + }; + + const files: Record = {}; + for (const [path, entry] of cache) { + files[path] = { + s: entry.size, + m: entry.mtimeMs, + p: entry.provider, + r: entry.records.map((record) => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]), + }; + } + + return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; +} + +function isRecordArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +/** + * Rebuilds the cache from a parsed document. + * + * Anything malformed yields an empty cache rather than an error: a corrupt + * cache should cost one cold scan, never a broken page. + */ +export function decodeScanCache(document: unknown): ScanCache { + const cache: ScanCache = new Map(); + if (typeof document !== "object" || document === null) return cache; + + const root = document as Partial; + if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; + if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; + if (typeof root.files !== "object" || root.files === null) return cache; + + // The intern tables must be all strings: a numeric entry would pass the + // undefined guard below, land in a record's model, and crash the aggregate + // at normalizeModelName. A corrupt table rejects the whole cache. + if (!root.models.every((value) => typeof value === "string")) return cache; + if (!root.sessions.every((value) => typeof value === "string")) return cache; + const models = root.models as readonly string[]; + const sessions = root.sessions as readonly string[]; + + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex") continue; + if (!isRecordArray(entry.r)) continue; + + const provider: UsageProviderKind = entry.p; + const records: UsageRecord[] = []; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + let corrupt = false; + for (const row of entry.r) { + if (!isRecordArray(row) || row.length < 10) { + corrupt = true; + break; + } + const [ + timestampMs, + modelIndex, + sessionIndex, + uncached, + cached, + cacheCreation, + output, + reasoning, + dedupeKey, + reportedCostUsd, + ] = row as SerializedRecord; + + const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + model === undefined || + !Number.isFinite(uncached) || + !Number.isFinite(cached) || + !Number.isFinite(cacheCreation) || + !Number.isFinite(output) || + !Number.isFinite(reasoning) + ) { + corrupt = true; + break; + } + + records.push({ + provider, + timestampMs, + model, + sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", + totals: { + uncachedInputTokens: uncached, + cachedInputTokens: cached, + cacheCreationTokens: cacheCreation, + outputTokens: output, + reasoningTokens: reasoning, + }, + reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, + dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, + }); + } + + if (corrupt) continue; + cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + } + + return cache; +} + +export interface PruneOptions { + /** Files the walk just saw. Only meaningful inside the walked window. */ + readonly livePaths: ReadonlySet; + /** + * Roots the walk actually completed. Absence from `livePaths` only proves a + * file is gone when its root was walked: a provider whose directory failed to + * resolve this pass must not have its warm entries purged. + */ + readonly walkedRoots: readonly string[]; + /** Start of the walked window; entries older than this were not looked for. */ + readonly windowStartMs: number; + /** Entries older than this are dropped regardless. */ + readonly retentionCutoffMs: number; +} + +/** + * Drops aged-out entries, and entries for files that have disappeared. + * + * The walk only covers the requested window, so absence from `livePaths` only + * proves deletion for entries *inside* that window. Pruning everything the walk + * missed would evict the 30-day entries every time someone looked at 7 days. + * + * Replaces an earlier record cap that cleared the whole cache once exceeded, + * which meant a large enough window never warmed up at all. + */ +export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { + let removed = 0; + for (const [path, entry] of cache) { + const agedOut = entry.mtimeMs < options.retentionCutoffMs; + const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const deleted = + underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); + if (agedOut || deleted) { + cache.delete(path); + removed += 1; + } + } + return removed; +} + +/** Within-file de-duplication, applied before an entry is cached. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const seen = new Set(); + const kept: UsageRecord[] = []; + for (const record of records) { + if (record.dedupeKey !== null) { + if (seen.has(record.dedupeKey)) continue; + seen.add(record.dedupeKey); + } + kept.push(record); + } + return kept; +} diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts new file mode 100644 index 00000000000..c72f0c24db6 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -0,0 +1,141 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Raw filesystem access for transcript scanning. + * + * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. + * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB + * across ~1,500 files, and `readline` over a read stream is roughly an order of + * magnitude cheaper than materialising each file. The equivalent Effect stream + * pipeline is idiomatic but not fast enough to sit behind a page load. + * + * @module usageTranscriptReader + */ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; + +import type { UsageProviderKind } from "@t3tools/contracts"; + +import { + initialCodexScanState, + mightCarryUsage, + parseClaudeLine, + parseCodexLine, + type UsageRecord, +} from "./usageTranscripts.ts"; + +export interface TranscriptFile { + readonly path: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. + * + * Errors on individual entries are swallowed: session files rotate and get + * removed while the walk is in flight, and a partial listing is far better than + * failing the page. + */ +export async function listTranscriptFiles( + root: string, + sinceMs: number, +): Promise { + const found: TranscriptFile[] = []; + + const walk = async (dir: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = NodePath.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(child); + continue; + } + if (!entry.name.endsWith(".jsonl")) continue; + try { + const stats = await NodeFSP.stat(child); + if (stats.mtimeMs >= sinceMs) { + found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + } + } catch { + // Vanished between readdir and stat. + } + } + }; + + await walk(root); + return found; +} + +/** + * Filesystem identity of a directory, as `device:inode`. + * + * Used to tell "two servers reading the same transcript directory" apart from + * "two machines whose hostname and home path happen to match". Returns an empty + * string when the directory cannot be stat'd. + */ +export async function readDirectoryVolumeId(path: string): Promise { + try { + const stats = await NodeFSP.stat(path); + return `${stats.dev}:${stats.ino}`; + } catch { + return ""; + } +} + +/** + * Streams one transcript and returns the usage records it contains, or `null` + * when the file could not be read. + * + * The distinction matters to the caller's cache: a genuinely empty transcript + * is a stable fact worth memoising, while a transient read failure memoised + * under the same `(size, mtime)` key would silently drop that file's usage + * until the file next changes. + * + * Codex carries the active model on `turn_context` lines that hold no usage of + * their own, so those still have to pass through the reducer to keep model + * attribution correct. + */ +export async function readTranscriptRecords( + filePath: string, + provider: UsageProviderKind, +): Promise { + const records: UsageRecord[] = []; + const codexState = initialCodexScanState(); + + try { + const lines = NodeReadline.createInterface({ + input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + for await (const line of lines) { + if (provider === "codex") { + if ( + !mightCarryUsage(line, provider) && + !line.includes('"turn_context"') && + !line.includes('"session_meta"') + ) { + continue; + } + const record = parseCodexLine(line, codexState); + if (record !== null) records.push(record); + continue; + } + + if (!mightCarryUsage(line, provider)) continue; + const record = parseClaudeLine(line); + if (record !== null) records.push(record); + } + } catch { + return null; + } + + return records; +} diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts new file mode 100644 index 00000000000..1fec9d28d9b --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + initialCodexScanState, + parseClaudeLine, + parseCodexLine, + totalTokens, +} from "./usageTranscripts.ts"; + +/** Shaped after a real Claude Code assistant record. */ +function claudeLine(overrides: { + messageId: string; + contentType: string; + model?: string; + outputTokens?: number; +}): string { + return JSON.stringify({ + type: "assistant", + timestamp: "2026-08-07T04:05:13.944Z", + sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", + cwd: "/home/theo/project", + message: { + id: overrides.messageId, + role: "assistant", + model: overrides.model ?? "claude-fable-5", + content: [{ type: overrides.contentType }], + usage: { + input_tokens: 2, + cache_creation_input_tokens: 66818, + cache_read_input_tokens: 1000, + output_tokens: overrides.outputTokens ?? 286, + }, + }, + }); +} + +describe("parseClaudeLine", () => { + it("extracts token totals and a dedupe key", () => { + const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); + + expect(record).not.toBeNull(); + expect(record?.provider).toBe("claude"); + expect(record?.model).toBe("claude-fable-5"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 66818, + outputTokens: 286, + reasoningTokens: 0, + }); + expect(record?.dedupeKey).toBe("msg_1:"); + }); + + it("gives every content block of one message the same dedupe key", () => { + // T3 Code writes one record per content block, each repeating the parent + // message's full usage. Summing them would overcount ~2.4x on real data. + const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); + const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); + + expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); + expect(text?.totals).toEqual(toolUse?.totals); + }); + + it("ignores records that are not assistant messages", () => { + expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); + expect(parseClaudeLine("not json")).toBeNull(); + }); +}); + +describe("parseCodexLine", () => { + const sessionMeta = JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T05:17:41.289Z", + payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + }); + const turnContext = JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T05:17:42.694Z", + payload: { type: "turn_context", model: "gpt-5.6-sol" }, + }); + const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:49.919Z", + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: cached, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: reasoning, + }, + }, + }, + }); + + it("attributes usage to the model from the preceding turn context", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state); + + expect(record?.provider).toBe("codex"); + expect(record?.model).toBe("gpt-5.6-sol"); + expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); + // Codex reports input_tokens inclusive of the cached portion. + expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); + expect(record?.totals.cachedInputTokens).toBe(11008); + expect(record?.totals.reasoningTokens).toBe(116); + }); + + it("skips a repeated token_count so deltas are not double counted", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); + const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(first).not.toBeNull(); + expect(repeat).toBeNull(); + }); + + it("drops usage that arrives before any model is known", () => { + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + }); + + it("does not let a pre-model event poison the duplicate signature", () => { + // A token_count before its turn_context is dropped; the identical event + // re-emitted once the model is known must still be counted. + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + parseCodexLine(turnContext, state); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); + }); +}); + +describe("totalTokens", () => { + it("does not add reasoning on top of output", () => { + expect( + totalTokens({ + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 30, + outputTokens: 40, + reasoningTokens: 25, + }), + ).toBe(100); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts new file mode 100644 index 00000000000..338713d8b1b --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.ts @@ -0,0 +1,246 @@ +/** + * Pure parsers for the provider CLIs' on-disk session transcripts. + * + * Both parsers are line-at-a-time reducers so callers can stream large files + * without materialising them. Neither touches the filesystem. + * + * @module usageTranscripts + */ +import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; + +export interface UsageRecord { + readonly provider: UsageProviderKind; + readonly timestampMs: number; + readonly model: string; + readonly sessionId: string; + readonly totals: UsageTokenTotals; + readonly reportedCostUsd: number | null; + /** + * Key for cross-file de-duplication, or `null` when the record is inherently + * unique and needs no dedup. + */ + readonly dedupeKey: string | null; +} + +const EMPTY_TOTALS: UsageTokenTotals = { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, +}; + +function int(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function parseTimestampMs(value: unknown): number | null { + if (typeof value !== "string") return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { + return { + uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, + cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, + cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, + outputTokens: a.outputTokens + b.outputTokens, + reasoningTokens: a.reasoningTokens + b.reasoningTokens, + }; +} + +export function totalTokens(totals: UsageTokenTotals): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens + ); +} + +/** + * Cheap substring gate applied before `JSON.parse`. + * + * Transcripts are mostly tool output; only a minority of lines carry usage. On + * a 30-day window this skips roughly half the lines outright and is worth about + * an order of magnitude. + */ +export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { + return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); +} + +/* -------------------------------------------------------------------------- */ +/* Claude Code */ +/* -------------------------------------------------------------------------- */ + +/** + * Parses one line of a Claude Code transcript. + * + * T3 Code writes one record per assistant *content block*, and every one of + * those records repeats the same complete `usage` object for the parent + * message. Summing them overcounts by roughly 2.4x on a real workload, so the + * caller must drop repeats by `dedupeKey` and keep the first. + */ +export function parseClaudeLine(line: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (record["type"] !== "assistant") return null; + + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const messageRecord = message as Record; + + const usage = messageRecord["usage"]; + if (typeof usage !== "object" || usage === null) return null; + const usageRecord = usage as Record; + + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + + const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; + if (model.length === 0) return null; + + const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; + const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; + // Matches ccusage: prefer the message/request pair, fall back to whichever + // half exists. Records with neither cannot be de-duplicated. + const dedupeKey = + messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; + + const cost = record["costUSD"]; + + return { + provider: "claude", + timestampMs, + model, + sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + totals: { + uncachedInputTokens: int(usageRecord["input_tokens"]), + cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), + cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]), + outputTokens: int(usageRecord["output_tokens"]), + // Anthropic folds thinking tokens into output and does not break them out. + reasoningTokens: 0, + }, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, + dedupeKey, + }; +} + +/* -------------------------------------------------------------------------- */ +/* Codex */ +/* -------------------------------------------------------------------------- */ + +/** + * Rolling state for a single Codex rollout file. + * + * Codex `token_count` events carry no model, so the model is carried forward + * from the most recent `turn_context`. Sessions that switch models mid-run + * attribute correctly from the switch onward. + */ +export interface CodexScanState { + model: string; + sessionId: string; + lastUsageSignature: string | null; +} + +export function initialCodexScanState(): CodexScanState { + return { model: "", sessionId: "", lastUsageSignature: null }; +} + +/** + * Feeds one line of a Codex rollout into `state`, returning a record when the + * line was a usage event. + * + * Deltas come from `last_token_usage`. Summing those across a session + * reconciles with the session's final `total_token_usage`, provided + * consecutive duplicate events are dropped, which this does. + */ +export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + const payload = record["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const payloadRecord = payload as Record; + const payloadType = payloadRecord["type"]; + + if (record["type"] === "session_meta") { + const id = payloadRecord["id"] ?? payloadRecord["session_id"]; + if (typeof id === "string") state.sessionId = id; + return null; + } + + if (record["type"] === "turn_context") { + if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + return null; + } + + if (payloadType !== "token_count") return null; + + const info = payloadRecord["info"]; + if (typeof info !== "object" || info === null) return null; + const last = (info as Record)["last_token_usage"]; + if (typeof last !== "object" || last === null) return null; + const lastRecord = last as Record; + + // Only an event that is otherwise eligible may consume the duplicate + // signature. A token_count arriving before its turn_context (no model yet) + // must not poison it, or the re-emitted copy after the model is known would + // be skipped as a duplicate and those tokens never counted. + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + if (state.model.length === 0) return null; + + // Codex re-emits an unchanged token_count on some stream boundaries. Summing + // those would double count, so identical consecutive payloads are skipped. + const signature = JSON.stringify(lastRecord); + if (signature === state.lastUsageSignature) return null; + state.lastUsageSignature = signature; + + const inputTokens = int(lastRecord["input_tokens"]); + const cachedInputTokens = int(lastRecord["cached_input_tokens"]); + const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); + const outputTokens = int(lastRecord["output_tokens"]); + + const totals: UsageTokenTotals = { + // Codex reports `input_tokens` inclusive of the cached portion. + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + // Reported inside output_tokens, surfaced separately for the token mix. + reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])), + }; + + if (totalTokens(totals) === 0) return null; + + return { + provider: "codex", + timestampMs, + model: state.model, + sessionId: state.sessionId, + totals, + // Codex does not report cost in the rollout. + reportedCostUsd: null, + // Rollout files are unique per session, so events need no global dedup. + dedupeKey: null, + }; +} + +export { EMPTY_TOTALS }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6b155c296f..6d518fe16cf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -105,6 +105,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -412,6 +413,7 @@ const makeWsRpcLayer = ( const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; + const usage = yield* UsageService.UsageService; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1529,6 +1531,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetUsageSummary]: (input) => + observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index df06c431fd2..a8d3ef41416 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,4 @@ -import { SettingsIcon } from "lucide-react"; +import { ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; import { memo, useCallback } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -118,11 +118,24 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { void navigate({ to: "/settings" }); }, [isMobile, navigate, setOpenMobile]); + const handleUsageClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/usage" }); + }, [isMobile, navigate, setOpenMobile]); + return ( + + + + Usage + + diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx new file mode 100644 index 00000000000..2f3ab4b574c --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -0,0 +1,454 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; +import { RefreshCwIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { useUsage } from "../../state/usage"; +import { + enumerateDays, + formatCount, + formatDayShort, + formatPercent, + formatTokens, + formatUsd, + makeWindow, +} from "../../usage/usageFormat"; +import { ScrollArea } from "../ui/scroll-area"; +import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; + +const WINDOW_OPTIONS = [ + { days: 7, label: "7 days" }, + { days: 30, label: "30 days" }, + { days: 90, label: "90 days" }, +] as const; + +export function UsagePage() { + const [windowDays, setWindowDays] = useState(30); + const [metric, setMetric] = useState("cost"); + const [breakdown, setBreakdown] = useState<"model" | "day">("model"); + + // Recomputed only when the window length changes, so a re-render does not + // shift the range and refetch every environment. + const window = useMemo(() => makeWindow(windowDays), [windowDays]); + const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + + const days = useMemo( + () => enumerateDays(window.sinceDay, window.untilDay), + [window.sinceDay, window.untilDay], + ); + const recentDays = useMemo(() => merged.daily.toReversed().slice(0, 8), [merged.daily]); + + // Ranked by whatever the toggle is showing, so the bars always descend. + const orderedProviders = useMemo( + () => + merged.providers.toSorted((a, b) => + metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + ), + [merged.providers, metric], + ); + + const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; + const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; + const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; + const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; + + return ( + +
+
+
+

Usage

+

+ {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} +

+
+
+
+ {WINDOW_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ + + + {isPending ? ( +

+ Scanning provider transcripts… +

+ ) : ( + <> + {/* Cost first: the financial answer, then the provider split. */} +
+ {/* The summary follows the chart toggle, so the headline and the + series are always reading the same units. */} +
+
+ + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" + ? `${formatUsd(merged.costUsd)}*` + : formatTokens(merged.totalTokens)} + + + {metric === "cost" + ? "* if billed at full API rate" + : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} + +
+ + {orderedProviders.map((provider) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + return ( +
+
+ + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)} + +
+
+
+
+ + {metric === "cost" + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + +
+ ); + })} +
+ +
+
+

+ Daily {metric === "tokens" ? "processed tokens" : "cost"} +

+
+
+ {(["cost", "tokens"] as const).map((option) => ( + + ))} +
+ +
+
+ +
+
+ +
+ + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` + : "vs full input rates" + } + /> +
+ +
+
+
+

Breakdown

+
+ {(["model", "day"] as const).map((option) => ( + + ))} +
+
+ + {breakdown === "model" ? ( + + + + + + + + + + + {merged.models.length === 0 ? ( + + + + ) : ( + merged.models.map((model) => ( + + + + + + + )) + )} + +
ModelCostShareTokens
+ No activity in this window. +
+ + + {model.model} + + + {formatUsd(model.costUsd)} + + {formatPercent(model.costShare)} + + {formatTokens(model.totalTokens)} +
+ ) : ( + + + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + + + {recentDays.length === 0 ? ( + + + + ) : ( + recentDays.map((day) => ( + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + )) + )} + +
Day + {PROVIDER_LABEL[provider]} + TotalTokens
+ No activity in this window. +
{formatDayShort(day.day)} + {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + + {formatUsd(day.costUsd)} + + {formatTokens(day.totalTokens)} +
+ )} +
+ +
+

Cost quality

+
+ + + + +
+
+
+ + )} +
+
+ ); +} + +/** Brand mark for the harness a row belongs to. */ +function ProviderMark({ + provider, + className, +}: { + readonly provider: UsageProviderKind; + readonly className: string; +}) { + const Mark = PROVIDER_MARK[provider]; + return ; +} + +function Metric({ + label, + value, + detail, +}: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { + return ( +
+ {label} + {value} + {detail} +
+ ); +} + +function QualityRow({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +/** + * Says plainly when the totals are incomplete: an environment still answering, + * one that failed, or one whose transcripts another environment already + * reported. + */ +function UsageCoverageNotice({ + environments, + duplicateSources, + staleEnvironments, + isPartial, +}: { + readonly environments: readonly { + environmentId: string; + label: string; + error: string | null; + isPending: boolean; + }[]; + readonly duplicateSources: readonly string[]; + readonly staleEnvironments: readonly string[]; + readonly isPartial: boolean; +}) { + const failed = environments.filter((environment) => environment.error !== null); + const stale = environments.filter((environment) => + staleEnvironments.includes(environment.environmentId), + ); + if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && !isPartial) { + return null; + } + + return ( +
+ {isPartial ? Some environments are still reporting. Totals are partial. : null} + {failed.map((environment) => ( + {environment.label} could not report usage. + ))} + {stale.map((environment) => ( + + {environment.label} runs an older server version and is excluded from totals. + + ))} + {duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {duplicateSources.join(", ")} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts new file mode 100644 index 00000000000..2b647153f20 --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildDayColumns, niceScale } from "./UsageProviderChart"; + +describe("niceScale", () => { + it("never puts the peak above the top of the scale", () => { + // Regression: an earlier version stopped at the last step below the peak, + // so the tallest day was drawn past the plot and clipped. + for (const peak of [1122.71, 999, 1, 0.04, 1_400_000_000, 37.5, 5000, 100.001]) { + const { max } = niceScale(peak, 4); + expect(max, `peak ${peak}`).toBeGreaterThanOrEqual(peak); + } + }); + + it("starts at zero and ends at the maximum", () => { + const { max, ticks } = niceScale(1122.71, 4); + + expect(ticks[0]).toBe(0); + expect(ticks[ticks.length - 1]).toBeCloseTo(max, 6); + }); + + it("uses evenly spaced 1/2/5 steps", () => { + const { ticks } = niceScale(1122.71, 4); + const steps = ticks.slice(1).map((tick, index) => tick - (ticks[index] ?? 0)); + + for (const step of steps) expect(step).toBeCloseTo(steps[0] ?? 0, 6); + const [first = 0] = steps; + const normalized = first / 10 ** Math.floor(Math.log10(first)); + expect([1, 2, 5, 10]).toContain(Math.round(normalized)); + }); + + it("keeps the tick count near the requested resolution", () => { + const { ticks } = niceScale(1122.71, 4); + expect(ticks.length).toBeGreaterThanOrEqual(3); + expect(ticks.length).toBeLessThanOrEqual(7); + }); + + it("degrades to a single zero tick with no data", () => { + expect(niceScale(0, 4)).toEqual({ max: 0, ticks: [0] }); + }); +}); + +describe("buildDayColumns", () => { + const days = ["2026-08-01", "2026-08-02", "2026-08-03"]; + const byDay = new Map([ + [ + "2026-08-01", + { + day: "2026-08-01", + costUsd: 30, + totalTokens: 300, + byProvider: new Map([ + ["codex" as const, { costUsd: 10, totalTokens: 100 }], + ["claude" as const, { costUsd: 20, totalTokens: 200 }], + ]), + }, + ], + // 2026-08-02 is deliberately absent: a day with no activity. + [ + "2026-08-03", + { + day: "2026-08-03", + costUsd: 5, + totalTokens: 50, + byProvider: new Map([["claude" as const, { costUsd: 5, totalTokens: 50 }]]), + }, + ], + ]); + + it("plots each day on its own", () => { + expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([30, 0, 5]); + }); + + it("reads the requested metric", () => { + expect(buildDayColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ + 300, 0, 50, + ]); + }); + + it("keeps the bands contiguous so the areas stay additive", () => { + for (const column of buildDayColumns(days, byDay, "cost")) { + let expectedBase = 0; + for (const band of column.bands) { + expect(band.base).toBeCloseTo(expectedBase, 9); + expect(band.top).toBeCloseTo(band.base + band.value, 9); + expectedBase = band.top; + } + expect(column.total).toBeCloseTo(expectedBase, 9); + } + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx new file mode 100644 index 00000000000..d1ffce25e65 --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -0,0 +1,411 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import type { DailyTotals } from "../../usage/usageMerge"; +import { formatDayShort, formatTokens, formatUsd } from "../../usage/usageFormat"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; + +const VIEW_WIDTH = 960; +const VIEW_HEIGHT = 260; +const TICK_COUNT = 4; +const PLOT_TOP = 8; + +export type UsageChartMetric = "tokens" | "cost"; + +interface UsageProviderChartProps { + readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; + readonly metric: UsageChartMetric; +} + +/** One day's stacked bands, shared by the paths and the hover readout. */ +export interface DayColumn { + readonly bands: readonly { + readonly provider: UsageProviderKind; + readonly value: number; + readonly base: number; + readonly top: number; + }[]; + readonly total: number; +} + +interface Point { + readonly x: number; + readonly y: number; +} + +function valueFor( + daily: DailyTotals | undefined, + provider: UsageProviderKind, + metric: UsageChartMetric, +): number { + const entry = daily?.byProvider.get(provider); + if (entry === undefined) return 0; + return metric === "tokens" ? entry.totalTokens : entry.costUsd; +} + +/** + * Monotone cubic tangents (Fritsch-Carlson). + * + * Plain cubic smoothing overshoots on spiky daily data and would dip the area + * below zero between points, which reads as negative spend. This variant is + * shape-preserving, so a smoothed series never leaves the range of its samples. + */ +function monotoneTangents(points: readonly Point[]): readonly number[] { + const count = points.length; + if (count < 2) return [0]; + + const slopes: number[] = []; + for (let index = 0; index < count - 1; index += 1) { + const dx = (points[index + 1]?.x ?? 0) - (points[index]?.x ?? 0); + const dy = (points[index + 1]?.y ?? 0) - (points[index]?.y ?? 0); + slopes.push(dx === 0 ? 0 : dy / dx); + } + + const tangents: number[] = Array.from({ length: count }, () => 0); + tangents[0] = slopes[0] ?? 0; + tangents[count - 1] = slopes[count - 2] ?? 0; + for (let index = 1; index < count - 1; index += 1) { + const previous = slopes[index - 1] ?? 0; + const next = slopes[index] ?? 0; + tangents[index] = previous * next <= 0 ? 0 : (previous + next) / 2; + } + + for (let index = 0; index < count - 1; index += 1) { + const slope = slopes[index] ?? 0; + if (slope === 0) { + tangents[index] = 0; + tangents[index + 1] = 0; + continue; + } + const a = (tangents[index] ?? 0) / slope; + const b = (tangents[index + 1] ?? 0) / slope; + const magnitude = a * a + b * b; + if (magnitude > 9) { + const scale = 3 / Math.sqrt(magnitude); + tangents[index] = scale * a * slope; + tangents[index + 1] = scale * b * slope; + } + } + + return tangents; +} + +/** One cubic segment of a smoothed boundary. */ +interface CurveSegment { + readonly from: Point; + readonly c1: Point; + readonly c2: Point; + readonly to: Point; +} + +/** Smoothed polyline through `points`, as explicit cubic control points. */ +function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { + if (points.length < 2) return []; + const tangents = monotoneTangents(points); + const segments: CurveSegment[] = []; + + for (let index = 0; index < points.length - 1; index += 1) { + const from = points[index]; + const to = points[index + 1]; + if (from === undefined || to === undefined) continue; + const dx = to.x - from.x; + segments.push({ + from, + c1: { x: from.x + dx / 3, y: from.y + ((tangents[index] ?? 0) * dx) / 3 }, + c2: { x: to.x - dx / 3, y: to.y - ((tangents[index + 1] ?? 0) * dx) / 3 }, + to, + }); + } + return segments; +} + +function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { + const first = segments[0]; + if (first === undefined) return ""; + let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + for (const segment of segments) { + path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; + } + return path; +} + +/** + * The same curve walked end to start. A cubic reverses exactly by swapping its + * control points, so this traces the identical geometry. + * + * Bands must use this rather than re-smoothing their base points in reverse: + * the tangent clamp in `monotoneTangents` runs left to right, so smoothing is + * not perfectly symmetric under reversal, and independently smoothed edges of + * adjacent bands could hairline-gap or overlap. Sharing one curve per stack + * boundary makes that geometrically impossible. + */ +function reversedCurvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { + const last = segments[segments.length - 1]; + if (last === undefined) return ""; + let path = `${startCommand}${last.to.x.toFixed(2)},${last.to.y.toFixed(2)}`; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (segment === undefined) continue; + path += ` C${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.from.x.toFixed(2)},${segment.from.y.toFixed(2)}`; + } + return path; +} + +/** + * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the + * peak. + * + * Rounding the maximum *up* is the point: stopping at the last step below the + * peak leaves the tallest day drawn past the top of the plot, where it is + * clipped. + */ +export function niceScale(peak: number, count: number): { max: number; ticks: readonly number[] } { + if (peak <= 0) return { max: 0, ticks: [0] }; + + const rawStep = peak / count; + const magnitude = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / magnitude; + const step = (normalized > 5 ? 10 : normalized > 2 ? 5 : normalized > 1 ? 2 : 1) * magnitude; + + const max = Math.ceil(peak / step) * step; + const ticks: number[] = []; + for (let value = 0; value <= max + step * 1e-6; value += step) ticks.push(value); + return { max, ticks }; +} + +/** + * Turns the merged daily totals into stacked bands, one column per day. + * + * The chart paths and the hover readout both consume this, so the number under + * the cursor is by construction the number that was plotted rather than a + * second derivation that can drift from it. + */ +export function buildDayColumns( + days: readonly string[], + byDay: ReadonlyMap, + metric: UsageChartMetric, +): readonly DayColumn[] { + return days.map((day) => { + const entry = byDay.get(day); + let stackTop = 0; + const bands = PROVIDER_ORDER.map((provider) => { + const value = valueFor(entry, provider, metric); + const base = stackTop; + stackTop += value; + return { provider, value, base, top: stackTop }; + }); + return { bands, total: stackTop }; + }); +} + +export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) { + const byDay = useMemo(() => new Map(daily.map((entry) => [entry.day, entry])), [daily]); + const [hoverIndex, setHoverIndex] = useState(null); + const plotRef = useRef(null); + + const { paths, ticks, stepX, toY, series } = useMemo(() => { + if (days.length === 0) { + return { + paths: [], + ticks: [0] as readonly number[], + stepX: 0, + toY: () => VIEW_HEIGHT, + series: [] as readonly DayColumn[], + }; + } + + const stacked = buildDayColumns(days, byDay, metric); + + const peak = stacked.reduce((max, column) => Math.max(max, column.total), 0); + const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); + const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); + // Reserve a sliver above the top gridline so the series stroke, which is + // drawn at constant screen width, is not shaved off at a peak. + const toY = (value: number) => + max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); + + // One smoothed curve per stack boundary (baseline, then each provider's + // cumulative top). Band k is the region between boundary k and k+1, both + // drawn from these shared control points. + const boundaries = [ + stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })), + ...PROVIDER_ORDER.map((_, providerIndex) => + stacked.map((column, dayIndex) => ({ + x: dayIndex * step, + y: toY(column.bands[providerIndex]?.top ?? 0), + })), + ), + ].map(smoothCurve); + + const built = PROVIDER_ORDER.map((provider, providerIndex) => { + const top = boundaries[providerIndex + 1] ?? []; + const base = boundaries[providerIndex] ?? []; + return { + provider, + area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`, + line: curvePath(top, "M"), + }; + }); + + return { paths: built, ticks: tickValues, stepX: step, toY, series: stacked }; + }, [byDay, days, metric]); + + const format = metric === "tokens" ? formatTokens : formatUsd; + + const handleMove = useCallback( + (event: React.MouseEvent) => { + const bounds = plotRef.current?.getBoundingClientRect(); + if (bounds === undefined || bounds.width === 0 || days.length === 0) return; + const fraction = (event.clientX - bounds.left) / bounds.width; + const index = Math.round(fraction * (days.length - 1)); + setHoverIndex(Math.min(days.length - 1, Math.max(0, index))); + }, + [days.length], + ); + + const hoveredDay = hoverIndex === null ? undefined : days[hoverIndex]; + const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; + const hoverLeft = days.length <= 1 ? 0 : ((hoverIndex ?? 0) / (days.length - 1)) * 100; + + return ( +
+
+ {/* Axis labels sit outside the plot so they stay aligned to gridlines. */} +
+ {ticks.map((tick) => ( + + {tick === 0 ? "0" : format(tick)} + + ))} +
+ +
setHoverIndex(null)} + > + + {ticks.map((tick) => { + const y = toY(tick); + return ( + + ); + })} + + {paths.map(({ provider, area, line }) => ( + + + + + ))} + + {hoverIndex === null ? null : ( + + )} + + + {hoveredDay === undefined ? null : ( +
60 ? "translateX(-100%)" : "translateX(0)", + }} + > +
{formatDayShort(hoveredDay)}
+ {PROVIDER_ORDER.map((provider) => { + const Mark = PROVIDER_MARK[provider]; + return ( +
+ + + {PROVIDER_LABEL[provider]} + + + {format( + hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0, + )} + +
+ ); + })} +
+ Total + + {format(hoveredColumn?.total ?? 0)} + +
+
+ )} +
+
+ +
+ {days[0] === undefined ? "" : formatDayShort(days[0])} + + {days[Math.floor(days.length / 2)] === undefined + ? "" + : formatDayShort(days[Math.floor(days.length / 2)] ?? "")} + + + {days[days.length - 1] === undefined ? "" : formatDayShort(days[days.length - 1] ?? "")} + +
+
+ ); +} + +export function UsageChartLegend() { + return ( +
+ {PROVIDER_ORDER.map((provider) => { + // The marks carry the same fills as the bands, so they key the chart + // just as a colour swatch would. + const Mark = PROVIDER_MARK[provider]; + return ( + + + {PROVIDER_LABEL[provider]} + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts new file mode 100644 index 00000000000..5356f96edc7 --- /dev/null +++ b/apps/web/src/components/usage/usageProviders.ts @@ -0,0 +1,32 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; + +import { ClaudeAI, type Icon, OpenAI } from "../Icons"; + +/** + * Stacking and table order. Codex sits under Claude Code so the larger band + * reads as the top surface, matching the reference layout. + */ +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; + +export const PROVIDER_LABEL: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +/** Claude's brand orange against a neutral white for Codex. */ +export const PROVIDER_COLOR: Record = { + claude: "#d97757", + codex: "#e6e6e6", +}; + +/** + * Brand marks, reused from the provider picker. + * + * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), + * which are the same colours as the chart bands, so swapping a colour dot for a + * mark keeps the series association intact rather than trading it away. + */ +export const PROVIDER_MARK: Record = { + claude: ClaudeAI, + codex: OpenAI, +}; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3da96820ab9..eb31a8de91c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' @@ -26,6 +27,11 @@ import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +const UsageRoute = UsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -112,6 +118,7 @@ export interface FileRoutesByFullPath { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -128,6 +135,7 @@ export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -147,6 +155,7 @@ export interface FileRoutesById { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect_/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -167,6 +176,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -183,6 +193,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -201,6 +212,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect_/callback' | '/settings/appearance' | '/settings/archived' @@ -220,11 +232,19 @@ export interface RootRouteChildren { ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + UsageRoute: typeof UsageRoute ConnectCallbackRoute: typeof ConnectCallbackRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/usage': { + id: '/usage' + path: '/usage' + fullPath: '/usage' + preLoaderRoute: typeof UsageRouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -385,6 +405,7 @@ const rootRouteChildren: RootRouteChildren = { ConnectRoute: ConnectRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + UsageRoute: UsageRoute, ConnectCallbackRoute: ConnectCallbackRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/usage.tsx b/apps/web/src/routes/usage.tsx new file mode 100644 index 00000000000..c617e434b2e --- /dev/null +++ b/apps/web/src/routes/usage.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { UsagePage } from "../components/usage/UsagePage"; + +export const Route = createFileRoute("/usage")({ + component: UsagePage, +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts new file mode 100644 index 00000000000..57114ade152 --- /dev/null +++ b/apps/web/src/state/usage.ts @@ -0,0 +1,126 @@ +/** + * Multi-environment usage state. + * + * Every connected environment answers the same typed query; the client merges + * the results. Raw transcripts never leave the machine that produced them. + * + * @module state/usage + */ +import { useAtomValue } from "@effect/atom-react"; +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageSummary, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "../usage/usageMerge"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +export interface EnvironmentUsageStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +/** + * Reads every environment's summary for one window. + * + * Keyed by the serialised window so switching ranges does not thrash the atom + * cache, and so each environment's query is shared with any other reader of the + * same window. + */ +const usageByWindowAtom = Atom.family((windowKey: string) => + Atom.make((get): readonly EnvironmentUsageStatus[] => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + const presentations = get(environmentPresentations.presentationsAtom); + + const statuses: EnvironmentUsageStatus[] = []; + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.usageSummary({ environmentId, input })); + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: result.waiting, + error: result._tag === "Failure" ? "This environment could not report usage." : null, + summary: Option.getOrNull(AsyncResult.value(result)), + }); + } + return statuses; + }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)), +); + +export interface UsageView { + readonly merged: MergedUsage; + readonly environments: readonly EnvironmentUsageStatus[]; + /** True until at least one environment has answered. */ + readonly isPending: boolean; + /** + * True while environments that have not failed are still answering. Failed + * environments are reported through their own error rows: totals will not + * improve by waiting on them, so they must not read as "still reporting". + */ + readonly isPartial: boolean; + readonly refresh: () => void; +} + +export function useUsage(input: UsageSummaryInput): UsageView { + const windowKey = useMemo( + () => + JSON.stringify({ + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + }), + [input.sinceDay, input.untilDay, input.timeZone], + ); + const atom = usageByWindowAtom(windowKey); + const environments = useAtomValue(atom); + + // Refreshing only the derived atom would re-read the per-environment SWR + // queries within their stale window and change nothing. Refresh each + // environment's query so the button always rescans. + const refresh = useCallback(() => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + ); + } + }, [environments, windowKey]); + + const merged = useMemo(() => { + const answered: EnvironmentUsage[] = environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); + }, [environments]); + + const answeredCount = environments.filter((environment) => environment.summary !== null).length; + const stillReporting = environments.filter( + (environment) => environment.summary === null && environment.error === null, + ).length; + + return { + merged, + environments, + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + refresh, + }; +} diff --git a/apps/web/src/usage/usageFormat.ts b/apps/web/src/usage/usageFormat.ts new file mode 100644 index 00000000000..c7c21605837 --- /dev/null +++ b/apps/web/src/usage/usageFormat.ts @@ -0,0 +1,107 @@ +/** + * Display formatting for the usage page. + * + * @module usageFormat + */ +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; + +const CURRENCY = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const INTEGER = new Intl.NumberFormat("en-US"); + +export function formatUsd(value: number): string { + return CURRENCY.format(value); +} + +export function formatCount(value: number): string { + return INTEGER.format(Math.round(value)); +} + +/** + * Compacts a token count to three significant figures with a unit suffix, so + * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`). + */ +export function formatTokens(value: number): string { + const abs = Math.abs(value); + if (abs >= 1e12) return `${trim(value / 1e12)}T`; + if (abs >= 1e9) return `${trim(value / 1e9)}B`; + if (abs >= 1e6) return `${trim(value / 1e6)}M`; + if (abs >= 1e3) return `${trim(value / 1e3)}K`; + return INTEGER.format(Math.round(value)); +} + +function trim(value: number): string { + const abs = Math.abs(value); + const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2; + return value.toFixed(digits).replace(/\.0+$/, ""); +} + +export function formatPercent(share: number, digits = 1): string { + return `${(share * 100).toFixed(digits)}%`; +} + +/** `2026-08-07` to `Aug 7`. */ +export function formatDayShort(day: string): string { + const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); + if (year === undefined || month === undefined || dayOfMonth === undefined) return day; + const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; +} + +/** Inclusive day list between two `YYYY-MM-DD` bounds. */ +export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { + const days: string[] = []; + const start = Date.parse(`${sinceDay}T00:00:00Z`); + const end = Date.parse(`${untilDay}T00:00:00Z`); + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days; + + for (let cursor = start; cursor <= end; cursor += 86_400_000) { + days.push(new Date(cursor).toISOString().slice(0, 10)); + } + return days; +} + +/** + * The window the page requests, expressed in the viewer's own time zone so days + * line up with what they actually experienced. + */ +export function makeWindow(days: number, now = new Date()): UsageSummaryInput { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const untilDay = format.format(now); + // Subtracting fixed milliseconds from `now` lands on the wrong calendar day + // around a DST transition. Only "today" needs the zone; the window start is + // pure calendar arithmetic on that day, done in UTC where days are uniform. + const [year = 0, month = 1, dayOfMonth = 1] = untilDay + .split("-") + .map((part) => Number.parseInt(part, 10)); + const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1))); + return { + sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), + untilDay: UsageDay.make(untilDay), + timeZone, + }; +} diff --git a/apps/web/src/usage/usageMerge.test.ts b/apps/web/src/usage/usageMerge.test.ts new file mode 100644 index 00000000000..7e44631cf5d --- /dev/null +++ b/apps/web/src/usage/usageMerge.test.ts @@ -0,0 +1,258 @@ +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageBucket, + type UsageDay, + type UsageProviderKind, + type UsageSummary, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeUsage, type EnvironmentUsage } from "./usageMerge"; + +function bucket(overrides: Partial = {}): UsageBucket { + return { + day: "2026-08-07" as UsageDay, + provider: "claude", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + costUsd: 10, + cacheSavingsUsd: 2, + costSource: "modelPriced", + records: 5, + unpricedRecords: 0, + sessions: 1, + ...overrides, + }; +} + +function summary( + buckets: readonly UsageBucket[], + sources: readonly { + provider: UsageProviderKind; + hostId: string; + homePath: string; + volumeId?: string; + distinctSessions?: number; + }[], + contractVersion: number = USAGE_CONTRACT_VERSION, +): UsageSummary { + return { + contractVersion, + readAt: "2026-08-07T00:00:00.000Z", + timeZone: "UTC", + sinceDay: "2026-08-01" as UsageDay, + untilDay: "2026-08-31" as UsageDay, + buckets, + sources: sources.map((source) => ({ + fingerprint: { + hostId: source.hostId, + provider: source.provider, + resolvedHomePath: source.homePath, + volumeId: source.volumeId ?? `vol-${source.hostId}`, + }, + status: "ok" as const, + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: source.distinctSessions ?? 1, + message: null, + })), + pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, + scanDurationMs: 1, + }; +} + +function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { + return { environmentId: id as EnvironmentId, label: id, summary: usageSummary }; +} + +describe("mergeUsage", () => { + it("sums environments that read different transcript directories", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]), + ), + environment( + "env-b", + summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.records).toBe(10); + expect(merged.duplicateSources).toHaveLength(0); + }); + + it("counts a shared transcript directory once", () => { + // Two worktree servers on one machine resolve the same provider home. + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [shared])), + environment("env-b", summary([bucket()], [shared])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.records).toBe(5); + expect(merged.sessions).toBe(1); + expect(merged.duplicateSources).toHaveLength(1); + expect(merged.contributingEnvironments).toEqual(["env-a"]); + }); + + it("drops only the duplicated provider, keeping the environment's other one", () => { + const sharedClaude = { + provider: "claude" as const, + hostId: "mac", + homePath: "/home/theo/.claude", + }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [sharedClaude])), + environment( + "env-b", + summary( + [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })], + [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + // env-b's claude bucket is dropped, its codex bucket survives. + expect(merged.costUsd).toBe(14); + expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([ + "claude", + "codex", + ]); + }); + + it("excludes an environment reporting an older contract version", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]), + ), + environment( + "env-b", + summary( + [bucket()], + [{ provider: "claude", hostId: "linux", homePath: "/b" }], + USAGE_CONTRACT_VERSION - 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.staleEnvironments).toEqual(["env-b"]); + }); + + it("derives provider shares and cost quality", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 75 }), + bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.providers[0]?.provider).toBe("claude"); + expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5); + expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5); + expect(merged.costQuality.cacheSavingsUsd).toBe(4); + }); + + it("keeps two machines apart when hostname and home path collide", () => { + // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make + // one machine's usage vanish. Filesystem identity separates them. + const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])), + environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.duplicateSources).toHaveLength(0); + }); + + it("still collapses two servers reading the same directory", () => { + const same = { + provider: "claude" as const, + hostId: "mac", + homePath: "/Users/theo/.claude", + volumeId: "16777220:1234", + }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [same])), + environment("env-b", summary([bucket()], [same])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.duplicateSources).toHaveLength(1); + }); + + it("totals sessions from per-directory distinct counts, not per-bucket sums", () => { + // One session that spans two days appears in two buckets. Summing bucket + // sessions would say 2; the source's distinct count says 1. + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })], + [ + { + provider: "claude", + hostId: "mac", + homePath: "/a/.claude", + distinctSessions: 1, + }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.sessions).toBe(1); + }); + + it("returns empty totals with no environments", () => { + const merged = mergeUsage([], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(0); + expect(merged.daily).toHaveLength(0); + }); +}); diff --git a/apps/web/src/usage/usageMerge.ts b/apps/web/src/usage/usageMerge.ts new file mode 100644 index 00000000000..fd73c0a31c0 --- /dev/null +++ b/apps/web/src/usage/usageMerge.ts @@ -0,0 +1,353 @@ +/** + * Merges per-environment usage summaries into the single view the page renders. + * + * Pure, so the de-duplication and derivation rules can be tested without a + * connected environment. + * + * @module usageMerge + */ +import type { + EnvironmentId, + UsageBucket, + UsageProviderKind, + UsageSourceFingerprint, + UsageSummary, +} from "@t3tools/contracts"; + +export interface EnvironmentUsage { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly summary: UsageSummary; +} + +export interface ProviderTotals { + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; + readonly tokenShare: number; +} + +export interface ModelTotals { + readonly model: string; + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; +} + +export interface DailyTotals { + readonly day: string; + readonly costUsd: number; + readonly totalTokens: number; + readonly byProvider: ReadonlyMap; +} + +export interface CostQuality { + readonly providerReportedShare: number; + readonly modelPricedShare: number; + readonly unpricedShare: number; + readonly cacheSavingsUsd: number; +} + +export interface MergedUsage { + readonly costUsd: number; + readonly uncachedInputTokens: number; + readonly cachedInputTokens: number; + readonly cacheCreationTokens: number; + readonly outputTokens: number; + readonly reasoningTokens: number; + readonly totalTokens: number; + readonly records: number; + readonly sessions: number; + readonly providers: readonly ProviderTotals[]; + readonly models: readonly ModelTotals[]; + readonly daily: readonly DailyTotals[]; + readonly costQuality: CostQuality; + /** Environments whose data was dropped as a duplicate of another's. */ + readonly duplicateSources: readonly string[]; + readonly contributingEnvironments: readonly EnvironmentId[]; + readonly staleEnvironments: readonly EnvironmentId[]; +} + +/** + * Two sources are the same physical transcript directory only when host, + * provider, path and filesystem identity all agree. + * + * `volumeId` is what stops two machines that happen to share a hostname and a + * home path, which is every Mac in a fleet, from collapsing into one source and + * having one of them silently dropped. + */ +function fingerprintKey(fingerprint: UsageSourceFingerprint): string { + return [ + fingerprint.hostId, + fingerprint.provider, + fingerprint.resolvedHomePath, + fingerprint.volumeId, + ].join(" "); +} + +/** + * Decides which environment owns each physical transcript directory. + * + * Several environments on one machine (worktree servers, for instance) resolve + * the same provider home and would otherwise double count every token. The + * first environment in a stable order claims a fingerprint; the rest have that + * provider's buckets dropped. Environments are sorted by id so the winner does + * not change between renders. + */ +function claimSources(environments: readonly EnvironmentUsage[]): { + readonly ownerByFingerprint: ReadonlyMap; + readonly duplicates: readonly string[]; +} { + const ownerByFingerprint = new Map(); + const duplicates: string[] = []; + + const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); + + for (const environment of ordered) { + for (const source of environment.summary.sources) { + if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.has(key)) { + duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); + continue; + } + ownerByFingerprint.set(key, environment.environmentId); + } + } + + return { ownerByFingerprint, duplicates }; +} + +/** Sources this environment owns after fingerprint claims, plus their buckets. */ +function ownedContribution( + environment: EnvironmentUsage, + ownerByFingerprint: ReadonlyMap, +): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { + const ownedProviders = new Set(); + let sessions = 0; + for (const source of environment.summary.sources) { + if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.get(key) === environment.environmentId) { + ownedProviders.add(source.fingerprint.provider); + // Distinct within a directory. Summing per-bucket session counts instead + // would count a session once per day and model it spans. + sessions += source.distinctSessions; + } + } + return { + buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + sessions, + }; +} + +function bucketTokens(bucket: UsageBucket): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + bucket.totals.uncachedInputTokens + + bucket.totals.cachedInputTokens + + bucket.totals.cacheCreationTokens + + bucket.totals.outputTokens + ); +} + +const EMPTY_MERGED: MergedUsage = { + costUsd: 0, + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + records: 0, + sessions: 0, + providers: [], + models: [], + daily: [], + costQuality: { + providerReportedShare: 0, + modelPricedShare: 0, + unpricedShare: 0, + cacheSavingsUsd: 0, + }, + duplicateSources: [], + contributingEnvironments: [], + staleEnvironments: [], +}; + +/** + * Merges every connected environment's summary. + * + * `expectedContractVersion` guards against an environment running older server + * code: rather than blocking the page, its data is excluded and its id is + * reported so the UI can say coverage is partial. + */ +export function mergeUsage( + environments: readonly EnvironmentUsage[], + expectedContractVersion: number, +): MergedUsage { + if (environments.length === 0) return EMPTY_MERGED; + + const current: EnvironmentUsage[] = []; + const staleEnvironments: EnvironmentId[] = []; + for (const environment of environments) { + if (environment.summary.contractVersion === expectedContractVersion) { + current.push(environment); + } else { + staleEnvironments.push(environment.environmentId); + } + } + + const { ownerByFingerprint, duplicates } = claimSources(current); + + let costUsd = 0; + let uncachedInputTokens = 0; + let cachedInputTokens = 0; + let cacheCreationTokens = 0; + let outputTokens = 0; + let reasoningTokens = 0; + let records = 0; + let sessions = 0; + let cacheSavingsUsd = 0; + let providerReportedRecords = 0; + let unpricedRecords = 0; + + const providerAccumulator = new Map< + UsageProviderKind, + { costUsd: number; totalTokens: number; records: number } + >(); + const modelAccumulator = new Map< + string, + { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } + >(); + const dailyAccumulator = new Map< + string, + { + costUsd: number; + totalTokens: number; + byProvider: Map; + } + >(); + const contributingEnvironments: EnvironmentId[] = []; + + for (const environment of current) { + const { buckets, sessions: environmentSessions } = ownedContribution( + environment, + ownerByFingerprint, + ); + if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); + sessions += environmentSessions; + + for (const bucket of buckets) { + const tokens = bucketTokens(bucket); + + costUsd += bucket.costUsd; + cacheSavingsUsd += bucket.cacheSavingsUsd; + uncachedInputTokens += bucket.totals.uncachedInputTokens; + cachedInputTokens += bucket.totals.cachedInputTokens; + cacheCreationTokens += bucket.totals.cacheCreationTokens; + outputTokens += bucket.totals.outputTokens; + reasoningTokens += bucket.totals.reasoningTokens; + records += bucket.records; + unpricedRecords += bucket.unpricedRecords; + if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; + + const provider = providerAccumulator.get(bucket.provider) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + }; + provider.costUsd += bucket.costUsd; + provider.totalTokens += tokens; + provider.records += bucket.records; + providerAccumulator.set(bucket.provider, provider); + + const modelKey = `${bucket.provider} ${bucket.model}`; + const model = modelAccumulator.get(modelKey) ?? { + provider: bucket.provider, + costUsd: 0, + totalTokens: 0, + records: 0, + }; + model.costUsd += bucket.costUsd; + model.totalTokens += tokens; + model.records += bucket.records; + modelAccumulator.set(modelKey, model); + + const day = dailyAccumulator.get(bucket.day) ?? { + costUsd: 0, + totalTokens: 0, + byProvider: new Map(), + }; + day.costUsd += bucket.costUsd; + day.totalTokens += tokens; + const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 }; + dayProvider.costUsd += bucket.costUsd; + dayProvider.totalTokens += tokens; + day.byProvider.set(bucket.provider, dayProvider); + dailyAccumulator.set(bucket.day, day); + } + } + + const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens; + + const providers: ProviderTotals[] = [...providerAccumulator.entries()] + .map(([provider, totals]) => ({ + provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, + })) + .sort((a, b) => b.costUsd - a.costUsd); + + const models: ModelTotals[] = [...modelAccumulator.entries()] + .map(([key, totals]) => ({ + model: key.slice(key.indexOf(" ") + 1), + provider: totals.provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + })) + .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); + + const daily: DailyTotals[] = [...dailyAccumulator.entries()] + .map(([day, totals]) => ({ + day, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + byProvider: totals.byProvider, + })) + .sort((a, b) => a.day.localeCompare(b.day)); + + return { + costUsd, + uncachedInputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens, + totalTokens, + records, + sessions, + providers, + models, + daily, + costQuality: { + providerReportedShare: records === 0 ? 0 : providerReportedRecords / records, + unpricedShare: records === 0 ? 0 : unpricedRecords / records, + modelPricedShare: + records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, + cacheSavingsUsd, + }, + duplicateSources: duplicates, + contributingEnvironments, + staleEnvironments, + }; +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 8c61a939e9e..f579453c27f 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -707,6 +707,13 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetResourceTelemetryHistory, staleTimeMs: 5_000, }), + // A cold transcript scan is measured in seconds, so keep the result around + // long enough that switching windows or re-rendering does not rescan. + usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:usage-summary", + tag: WS_METHODS.serverGetUsageSummary, + staleTimeMs: 60_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..6181391eca3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -28,4 +28,5 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; +export * from "./usage.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index db40b10fed9..59255639995 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -152,6 +152,7 @@ import { ResourceTelemetryRetryResult, ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; +import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { SourceControlCloneRepositoryInput, @@ -244,6 +245,7 @@ export const WS_METHODS = { serverReportClientActivity: "server.reportClientActivity", serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", + serverGetUsageSummary: "server.getUsageSummary", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -381,6 +383,12 @@ export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetry error: EnvironmentAuthorizationError, }); +export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { + payload: UsageSummaryInput, + success: UsageSummary, + error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), +}); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -819,6 +827,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, + WsServerGetUsageSummaryRpc, WsServerSignalProcessRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts new file mode 100644 index 00000000000..1aa639fe4a0 --- /dev/null +++ b/packages/contracts/src/usage.ts @@ -0,0 +1,194 @@ +/** + * Usage reporting contract. + * + * Each environment scans the provider CLIs' own on-disk session transcripts + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than + * relying on T3 Code's own orchestration projections, so usage stays complete + * even for turns that were never driven through T3 Code. This mirrors the + * approach `ccusage` takes. + * + * Environments return pre-aggregated `(day, provider, model)` buckets. Raw + * transcript records never cross the wire. + * + * @module usage + */ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The + * client renders partial coverage when an environment reports an older version + * rather than failing the whole page. + */ +export const USAGE_CONTRACT_VERSION = 3 as const; + +export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export type UsageProviderKind = typeof UsageProviderKind.Type; + +/** + * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`. + * + * Days are bucketed server-side so that a turn always lands on the day the user + * experienced it, not the UTC day. + */ +const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe( + Schema.brand("UsageDay"), +); +export type UsageDay = typeof UsageDay.Type; + +/** + * Why a bucket's cost is what it is. + * + * - `providerReported` - the transcript carried an explicit cost figure. + * - `modelPriced` - we matched the model against the LiteLLM rate table. + * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded + * from cost. + */ +export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]); +export type UsageCostSource = typeof UsageCostSource.Type; + +/** + * Token counts for a bucket. + * + * `cachedInputTokens` and `cacheCreationTokens` are disjoint from + * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens` + * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic + * folds thinking into output), so it must never be added on top. + */ +export const UsageTokenTotals = Schema.Struct({ + uncachedInputTokens: NonNegativeInt, + cachedInputTokens: NonNegativeInt, + cacheCreationTokens: NonNegativeInt, + outputTokens: NonNegativeInt, + reasoningTokens: NonNegativeInt, +}); +export type UsageTokenTotals = typeof UsageTokenTotals.Type; + +/** + * One `(day, provider, model)` cell. + * + * `costUsd` is the raw API-equivalent cost of these tokens. It is not money + * spent: subscription plans bill separately. `unpricedRecords` counts records + * whose tokens are included in the token totals but which contributed nothing + * to `costUsd`. + */ +export const UsageBucket = Schema.Struct({ + day: UsageDay, + provider: UsageProviderKind, + model: TrimmedNonEmptyString, + totals: UsageTokenTotals, + costUsd: Schema.Number, + /** + * What the cached input would have cost at full input rates minus what it + * actually cost. Requires the rate table, so it is computed alongside cost + * rather than derived on the client. + */ + cacheSavingsUsd: Schema.Number, + costSource: UsageCostSource, + /** Distinct assistant responses, after de-duplication. */ + records: NonNegativeInt, + unpricedRecords: NonNegativeInt, + /** Distinct transcript sessions that contributed to this cell. */ + sessions: NonNegativeInt, +}); +export type UsageBucket = typeof UsageBucket.Type; + +/** + * Identifies the physical transcript directory a source read from. + * + * Two environments on the same machine (worktree servers, for example) resolve + * the same provider home and would otherwise double count. The client drops + * duplicate fingerprints before merging. + */ +export const UsageSourceFingerprint = Schema.Struct({ + hostId: TrimmedNonEmptyString, + provider: UsageProviderKind, + resolvedHomePath: TrimmedNonEmptyString, + /** + * Filesystem identity of the transcript directory, as `device:inode`. + * + * Hostname and path alone are not enough: every Mac in a fleet resolves + * `/Users//.claude`, so two machines that happen to share a hostname + * would look like one source and have their usage silently dropped. The + * device/inode pair is stable for two servers reading the same directory and + * effectively never collides across machines. Empty when it cannot be read. + */ + volumeId: Schema.String, +}); +export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type; + +export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]); +export type UsageSourceStatus = typeof UsageSourceStatus.Type; + +export const UsageSource = Schema.Struct({ + fingerprint: UsageSourceFingerprint, + status: UsageSourceStatus, + scannedFiles: NonNegativeInt, + skippedFiles: NonNegativeInt, + /** Records that parsed but carried no recognisable usage payload. */ + malformedRecords: NonNegativeInt, + /** + * Distinct transcript sessions seen under this directory. Buckets also carry + * per-bucket session counts, but a session spans days and models, so summing + * those overcounts; this is the figure clients should total. + */ + distinctSessions: NonNegativeInt, + message: Schema.NullOr(TrimmedNonEmptyString), +}); +export type UsageSource = typeof UsageSource.Type; + +export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]); +export type UsagePricingStatus = typeof UsagePricingStatus.Type; + +/** + * Provenance for the rate table, so the UI can be honest about how good the + * cost figures are. + */ +export const UsagePricing = Schema.Struct({ + status: UsagePricingStatus, + source: TrimmedNonEmptyString, + fetchedAt: Schema.NullOr(Schema.String), + knownModels: NonNegativeInt, +}); +export type UsagePricing = typeof UsagePricing.Type; + +export const UsageSummaryInput = Schema.Struct({ + /** Inclusive first day of the window, in `timeZone`. */ + sinceDay: UsageDay, + /** Inclusive last day of the window, in `timeZone`. */ + untilDay: UsageDay, + /** + * IANA zone the client wants days bucketed in. An offset would be wrong for + * any window that crosses a DST boundary. + */ + timeZone: TrimmedNonEmptyString, +}); +export type UsageSummaryInput = typeof UsageSummaryInput.Type; + +export const UsageSummary = Schema.Struct({ + contractVersion: Schema.Number, + readAt: Schema.String, + timeZone: TrimmedNonEmptyString, + sinceDay: UsageDay, + untilDay: UsageDay, + buckets: Schema.Array(UsageBucket), + sources: Schema.Array(UsageSource), + pricing: UsagePricing, + /** Wall-clock cost of the scan, surfaced in diagnostics. */ + scanDurationMs: NonNegativeInt, +}); +export type UsageSummary = typeof UsageSummary.Type; + +export class UsageReadError extends Schema.TaggedErrorClass()("UsageReadError", { + reason: Schema.Literals(["scanFailed", "invalidWindow"]), + /** Stable, bounded description. The underlying failure travels in `cause`. */ + detail: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Usage read failed (${this.reason}): ${this.detail}`; + } +}