mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
838 lines
28 KiB
TypeScript
838 lines
28 KiB
TypeScript
/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */
|
|
import { app } from 'electron'
|
|
import { join } from 'node:path'
|
|
import type {
|
|
CodexUsageBreakdownKind,
|
|
CodexUsageBreakdownRow,
|
|
CodexUsageDailyPoint,
|
|
CodexUsageRange,
|
|
CodexUsageScope,
|
|
CodexUsageSessionRow,
|
|
CodexUsageSnapshot,
|
|
CodexUsageSummary
|
|
} from '../../shared/codex-usage-types'
|
|
import type { AutomationRunUsage } from '../../shared/automations-types'
|
|
import type { Store } from '../persistence'
|
|
import type { CodexUsagePersistedState } from './types'
|
|
import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider'
|
|
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
|
|
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
|
|
|
|
const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION
|
|
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
|
|
|
|
let _codexUsageFile: string | null = null
|
|
|
|
type TieredPrice = { threshold: number; price: number }
|
|
type CodexModelPricing = {
|
|
input: number
|
|
cachedInput: number
|
|
output: number
|
|
inputTiers?: TieredPrice[]
|
|
cachedInputTiers?: TieredPrice[]
|
|
outputTiers?: TieredPrice[]
|
|
}
|
|
|
|
type AutomationUsageLookupInput = {
|
|
worktreeId: string | null
|
|
terminalSessionId: string | null
|
|
startedAt: number | null
|
|
completedAt: number | null
|
|
}
|
|
|
|
const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000
|
|
|
|
const MODEL_PRICING: Record<string, CodexModelPricing> = {
|
|
'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 },
|
|
'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 },
|
|
'gpt-5.1-codex': { input: 1.25, cachedInput: 0.125, output: 10 },
|
|
'gpt-5.1-codex-max': { input: 1.25, cachedInput: 0.125, output: 10 },
|
|
'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 },
|
|
'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
|
|
'gpt-5.3': { input: 1.75, cachedInput: 0.175, output: 14 },
|
|
'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
|
|
'gpt-5.3-codex-spark': { input: 1.75, cachedInput: 0.175, output: 14 },
|
|
'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.5 },
|
|
'gpt-5.4-nano': { input: 0.2, cachedInput: 0.02, output: 1.25 },
|
|
'gpt-5.4-pro': {
|
|
input: 30,
|
|
cachedInput: 30,
|
|
output: 180,
|
|
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
|
|
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
|
|
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
|
|
},
|
|
'gpt-5.4': {
|
|
input: 2.5,
|
|
cachedInput: 0.25,
|
|
output: 15,
|
|
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
|
|
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
|
|
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
|
|
},
|
|
'gpt-5.5-pro': {
|
|
input: 30,
|
|
cachedInput: 30,
|
|
output: 180,
|
|
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
|
|
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
|
|
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
|
|
},
|
|
'gpt-5.5': {
|
|
input: 5,
|
|
cachedInput: 0.5,
|
|
output: 30,
|
|
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
|
|
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
|
|
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
|
|
},
|
|
'gpt-5.6-sol': {
|
|
input: 5,
|
|
cachedInput: 0.5,
|
|
output: 30,
|
|
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
|
|
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
|
|
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
|
|
},
|
|
'gpt-5.6-terra': {
|
|
input: 2.5,
|
|
cachedInput: 0.25,
|
|
output: 15,
|
|
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
|
|
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
|
|
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
|
|
},
|
|
'gpt-5.6-luna': {
|
|
input: 1,
|
|
cachedInput: 0.1,
|
|
output: 6,
|
|
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }],
|
|
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }],
|
|
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }]
|
|
}
|
|
}
|
|
|
|
const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none']
|
|
|
|
function getDefaultState(): CodexUsagePersistedState {
|
|
return {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
worktreeFingerprint: null,
|
|
processedFiles: [],
|
|
sessions: [],
|
|
dailyAggregates: [],
|
|
scanState: {
|
|
enabled: false,
|
|
lastScanStartedAt: null,
|
|
lastScanCompletedAt: null,
|
|
lastScanError: null
|
|
}
|
|
}
|
|
}
|
|
|
|
export function normalizePersistedState(state: CodexUsagePersistedState): CodexUsagePersistedState {
|
|
if (state.schemaVersion !== SCHEMA_VERSION) {
|
|
// Why: Orca-scoped Codex projections now depend on locationModelBreakdown.
|
|
// Reusing an older cache would silently serve wrong model/session rows
|
|
// until the next forced rescan, so schema changes must invalidate stale
|
|
// persisted analytics instead of best-effort patching partial data.
|
|
// Preserve scanState.enabled so existing users keep tracking on across
|
|
// schema bumps; the next refresh will repopulate the analytics.
|
|
const defaults = getDefaultState()
|
|
return {
|
|
...defaults,
|
|
scanState: {
|
|
...defaults.scanState,
|
|
enabled: state.scanState?.enabled ?? defaults.scanState.enabled
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
...state,
|
|
sessions: state.sessions.map((session) => ({
|
|
...session,
|
|
locationModelBreakdown: session.locationModelBreakdown ?? []
|
|
}))
|
|
}
|
|
}
|
|
|
|
export function initCodexUsagePath(): void {
|
|
_codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
|
|
}
|
|
|
|
function getCodexUsageFile(): string {
|
|
if (!_codexUsageFile) {
|
|
_codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
|
|
}
|
|
return _codexUsageFile
|
|
}
|
|
|
|
function stripParenthesizedReasoningTier(model: string): string | null {
|
|
const match = model.match(/^(.*)\(([^()]*)\)$/)
|
|
if (!match) {
|
|
return model
|
|
}
|
|
const tier = match[2].trim().toLowerCase()
|
|
if (!REASONING_TIER_SUFFIXES.includes(tier)) {
|
|
return null
|
|
}
|
|
return match[1]
|
|
}
|
|
|
|
function stripDashReasoningTiers(model: string): string {
|
|
let current = model
|
|
for (let index = 0; index < 4; index++) {
|
|
const suffix = REASONING_TIER_SUFFIXES.find((tier) => current.endsWith(`-${tier}`))
|
|
if (!suffix) {
|
|
return current
|
|
}
|
|
current = current.slice(0, -suffix.length - 1)
|
|
}
|
|
return current
|
|
}
|
|
|
|
function normalizeModelForPricing(model: string | null): string | null {
|
|
if (!model) {
|
|
return null
|
|
}
|
|
|
|
const lower = stripParenthesizedReasoningTier(model.toLowerCase().trim())
|
|
if (!lower) {
|
|
return null
|
|
}
|
|
|
|
const normalized = stripDashReasoningTiers(lower)
|
|
if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') {
|
|
return 'gpt-5'
|
|
}
|
|
if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) {
|
|
return 'gpt-5.1-codex-max'
|
|
}
|
|
if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) {
|
|
return 'gpt-5.1-codex'
|
|
}
|
|
if (normalized === 'gpt-5.1' || normalized.startsWith('gpt-5.1-')) {
|
|
return 'gpt-5.1'
|
|
}
|
|
if (normalized === 'gpt-5.2-codex' || normalized.startsWith('gpt-5.2-codex-')) {
|
|
return 'gpt-5.2-codex'
|
|
}
|
|
if (normalized === 'gpt-5.2' || normalized.startsWith('gpt-5.2-')) {
|
|
return 'gpt-5.2'
|
|
}
|
|
if (normalized === 'gpt-5.3-codex-spark' || normalized.startsWith('gpt-5.3-codex-spark-')) {
|
|
return 'gpt-5.3-codex-spark'
|
|
}
|
|
if (normalized === 'gpt-5.3-codex' || normalized.startsWith('gpt-5.3-codex-')) {
|
|
return 'gpt-5.3-codex'
|
|
}
|
|
if (normalized === 'gpt-5.3' || normalized.startsWith('gpt-5.3-')) {
|
|
return 'gpt-5.3'
|
|
}
|
|
if (normalized === 'gpt-5.4-mini' || normalized.startsWith('gpt-5.4-mini-')) {
|
|
return 'gpt-5.4-mini'
|
|
}
|
|
if (normalized === 'gpt-5.4-nano' || normalized.startsWith('gpt-5.4-nano-')) {
|
|
return 'gpt-5.4-nano'
|
|
}
|
|
if (normalized === 'gpt-5.4-pro' || normalized.startsWith('gpt-5.4-pro-')) {
|
|
return 'gpt-5.4-pro'
|
|
}
|
|
if (normalized === 'gpt-5.4' || normalized.startsWith('gpt-5.4-')) {
|
|
return 'gpt-5.4'
|
|
}
|
|
if (normalized === 'gpt-5.5-pro' || normalized.startsWith('gpt-5.5-pro-')) {
|
|
return 'gpt-5.5-pro'
|
|
}
|
|
if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) {
|
|
return 'gpt-5.5'
|
|
}
|
|
if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) {
|
|
return 'gpt-5.6-sol'
|
|
}
|
|
if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) {
|
|
return 'gpt-5.6-terra'
|
|
}
|
|
if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) {
|
|
return 'gpt-5.6-luna'
|
|
}
|
|
// Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a
|
|
// `gpt-5.6-` prefix match would swallow the tier IDs above and any future
|
|
// cheaper variant.
|
|
if (normalized === 'gpt-5.6') {
|
|
return 'gpt-5.6-sol'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function calculateTieredCost(tokens: number, basePrice: number, tiers: TieredPrice[] = []): number {
|
|
let cost = 0
|
|
let lowerBound = 0
|
|
let activePrice = basePrice
|
|
for (const tier of tiers) {
|
|
if (tokens <= tier.threshold) {
|
|
return cost + Math.max(tokens - lowerBound, 0) * activePrice
|
|
}
|
|
cost += (tier.threshold - lowerBound) * activePrice
|
|
lowerBound = tier.threshold
|
|
activePrice = tier.price
|
|
}
|
|
return cost + Math.max(tokens - lowerBound, 0) * activePrice
|
|
}
|
|
|
|
function estimateCostUsd(
|
|
model: string | null,
|
|
inputTokens: number,
|
|
cachedInputTokens: number,
|
|
outputTokens: number
|
|
): number | null {
|
|
const normalized = normalizeModelForPricing(model)
|
|
if (!normalized) {
|
|
return null
|
|
}
|
|
const pricing = MODEL_PRICING[normalized]
|
|
const clampedCached = Math.min(cachedInputTokens, inputTokens)
|
|
// Why: Codex cached tokens are part of the input bucket. Charge uncached
|
|
// input on (input-cached) so cached tokens are not billed once at full input
|
|
// price and again at cache-read price.
|
|
const nonCachedInputTokens = Math.max(inputTokens - clampedCached, 0)
|
|
return (
|
|
(calculateTieredCost(nonCachedInputTokens, pricing.input, pricing.inputTiers) +
|
|
calculateTieredCost(clampedCached, pricing.cachedInput, pricing.cachedInputTiers) +
|
|
calculateTieredCost(outputTokens, pricing.output, pricing.outputTiers)) /
|
|
1_000_000
|
|
)
|
|
}
|
|
|
|
type ScopedCodexUsageModelRow = {
|
|
modelKey: string
|
|
modelLabel: string
|
|
hasInferredPricing: boolean
|
|
eventCount: number
|
|
inputTokens: number
|
|
cachedInputTokens: number
|
|
outputTokens: number
|
|
reasoningOutputTokens: number
|
|
totalTokens: number
|
|
}
|
|
|
|
export class CodexUsageStore extends UsageProviderStoreLifecycle<
|
|
'processedFiles',
|
|
CodexUsagePersistedState,
|
|
'hasAnyCodexData'
|
|
> {
|
|
constructor(store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
|
|
super(store, {
|
|
logTag: '[codex-usage]',
|
|
resolveCacheFile: getCodexUsageFile,
|
|
createDefaultState: getDefaultState,
|
|
normalizeState: normalizePersistedState,
|
|
sourceKey: 'processedFiles',
|
|
dataPresenceKey: 'hasAnyCodexData',
|
|
scan: codexUsageProvider.scan
|
|
})
|
|
}
|
|
|
|
getSnapshot(
|
|
scope: CodexUsageScope,
|
|
range: CodexUsageRange,
|
|
recentSessionLimit = 10
|
|
): CodexUsageSnapshot {
|
|
return {
|
|
scanState: this.getScanState(),
|
|
summary: this.buildSummary(scope, range),
|
|
daily: this.buildDaily(scope, range),
|
|
modelBreakdown: this.buildBreakdown(scope, range, 'model'),
|
|
projectBreakdown: this.buildBreakdown(scope, range, 'project'),
|
|
recentSessions: this.buildRecentSessions(scope, range, recentSessionLimit)
|
|
}
|
|
}
|
|
|
|
async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise<CodexUsageSummary> {
|
|
await this.refresh(false)
|
|
return this.buildSummary(scope, range)
|
|
}
|
|
|
|
private buildSummary(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageSummary {
|
|
const filteredDaily = this.getFilteredDaily(scope, range)
|
|
const filteredSessions = this.getFilteredSessions(scope, range)
|
|
|
|
let inputTokens = 0
|
|
let cachedInputTokens = 0
|
|
let outputTokens = 0
|
|
let reasoningOutputTokens = 0
|
|
let totalTokens = 0
|
|
let events = 0
|
|
let estimatedCostUsd = 0
|
|
let hasAnyBillableCost = false
|
|
const byModel = new Map<string, number>()
|
|
const byProject = new Map<string, number>()
|
|
|
|
for (const row of filteredDaily) {
|
|
inputTokens += row.inputTokens
|
|
cachedInputTokens += row.cachedInputTokens
|
|
outputTokens += row.outputTokens
|
|
reasoningOutputTokens += row.reasoningOutputTokens
|
|
totalTokens += row.totalTokens
|
|
events += row.eventCount
|
|
byModel.set(
|
|
row.model ?? 'Unknown model',
|
|
(byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens
|
|
)
|
|
byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens)
|
|
const cost = estimateCostUsd(
|
|
row.model,
|
|
row.inputTokens,
|
|
row.cachedInputTokens,
|
|
row.outputTokens
|
|
)
|
|
if (cost !== null) {
|
|
hasAnyBillableCost = true
|
|
estimatedCostUsd += cost
|
|
}
|
|
}
|
|
|
|
const topModel =
|
|
[...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
|
|
const topProject =
|
|
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
|
|
|
|
return {
|
|
scope,
|
|
range,
|
|
sessions: filteredSessions.length,
|
|
events,
|
|
inputTokens,
|
|
cachedInputTokens,
|
|
outputTokens,
|
|
reasoningOutputTokens,
|
|
totalTokens,
|
|
estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null,
|
|
topModel,
|
|
topProject,
|
|
hasAnyCodexData: filteredSessions.length > 0 || filteredDaily.length > 0
|
|
}
|
|
}
|
|
|
|
async getDaily(scope: CodexUsageScope, range: CodexUsageRange): Promise<CodexUsageDailyPoint[]> {
|
|
await this.refresh(false)
|
|
return this.buildDaily(scope, range)
|
|
}
|
|
|
|
private buildDaily(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageDailyPoint[] {
|
|
const byDay = new Map<string, CodexUsageDailyPoint>()
|
|
for (const row of this.getFilteredDaily(scope, range)) {
|
|
const existing = byDay.get(row.day) ?? {
|
|
day: row.day,
|
|
inputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningOutputTokens: 0,
|
|
totalTokens: 0
|
|
}
|
|
existing.inputTokens += row.inputTokens
|
|
existing.cachedInputTokens += row.cachedInputTokens
|
|
existing.outputTokens += row.outputTokens
|
|
existing.reasoningOutputTokens += row.reasoningOutputTokens
|
|
existing.totalTokens += row.totalTokens
|
|
byDay.set(row.day, existing)
|
|
}
|
|
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
|
|
}
|
|
|
|
async getBreakdown(
|
|
scope: CodexUsageScope,
|
|
range: CodexUsageRange,
|
|
kind: CodexUsageBreakdownKind
|
|
): Promise<CodexUsageBreakdownRow[]> {
|
|
await this.refresh(false)
|
|
return this.buildBreakdown(scope, range, kind)
|
|
}
|
|
|
|
private buildBreakdown(
|
|
scope: CodexUsageScope,
|
|
range: CodexUsageRange,
|
|
kind: CodexUsageBreakdownKind
|
|
): CodexUsageBreakdownRow[] {
|
|
const rows = new Map<string, CodexUsageBreakdownRow>()
|
|
const filteredDaily = this.getFilteredDaily(scope, range)
|
|
const filteredSessions = this.getFilteredSessions(scope, range)
|
|
|
|
for (const daily of filteredDaily) {
|
|
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
|
|
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
|
|
const existing = rows.get(key) ?? {
|
|
key,
|
|
label,
|
|
sessions: 0,
|
|
events: 0,
|
|
inputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningOutputTokens: 0,
|
|
totalTokens: 0,
|
|
estimatedCostUsd: null,
|
|
hasInferredPricing: false
|
|
}
|
|
existing.events += daily.eventCount
|
|
existing.inputTokens += daily.inputTokens
|
|
existing.cachedInputTokens += daily.cachedInputTokens
|
|
existing.outputTokens += daily.outputTokens
|
|
existing.reasoningOutputTokens += daily.reasoningOutputTokens
|
|
existing.totalTokens += daily.totalTokens
|
|
existing.hasInferredPricing ||= daily.hasInferredPricing
|
|
rows.set(key, existing)
|
|
}
|
|
|
|
for (const session of filteredSessions) {
|
|
if (kind === 'model') {
|
|
const seen = new Set<string>()
|
|
for (const model of this.getScopedSessionModels(session, scope)) {
|
|
if (seen.has(model.modelKey)) {
|
|
continue
|
|
}
|
|
seen.add(model.modelKey)
|
|
const row = rows.get(model.modelKey)
|
|
if (row) {
|
|
row.sessions++
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
const matchingLocations = session.locationBreakdown.filter((entry) =>
|
|
scope === 'all' ? true : entry.worktreeId !== null
|
|
)
|
|
const seen = new Set<string>()
|
|
for (const location of matchingLocations) {
|
|
if (seen.has(location.locationKey)) {
|
|
continue
|
|
}
|
|
seen.add(location.locationKey)
|
|
const row = rows.get(location.locationKey)
|
|
if (row) {
|
|
row.sessions++
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const row of rows.values()) {
|
|
row.estimatedCostUsd = estimateCostUsd(
|
|
kind === 'model' ? row.key : null,
|
|
row.inputTokens,
|
|
row.cachedInputTokens,
|
|
row.outputTokens
|
|
)
|
|
}
|
|
|
|
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
|
|
}
|
|
|
|
async getRecentSessions(
|
|
scope: CodexUsageScope,
|
|
range: CodexUsageRange,
|
|
limit = 12
|
|
): Promise<CodexUsageSessionRow[]> {
|
|
await this.refresh(false)
|
|
return this.buildRecentSessions(scope, range, limit)
|
|
}
|
|
|
|
private buildRecentSessions(
|
|
scope: CodexUsageScope,
|
|
range: CodexUsageRange,
|
|
limit = 12
|
|
): CodexUsageSessionRow[] {
|
|
return this.getFilteredSessions(scope, range)
|
|
.slice(0, limit)
|
|
.map((session) => {
|
|
const matchingLocations = session.locationBreakdown.filter((entry) =>
|
|
scope === 'all' ? true : entry.worktreeId !== null
|
|
)
|
|
const scopedLocations =
|
|
matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown
|
|
const totals = scopedLocations.reduce(
|
|
(acc, entry) => {
|
|
acc.events += entry.eventCount
|
|
acc.inputTokens += entry.inputTokens
|
|
acc.cachedInputTokens += entry.cachedInputTokens
|
|
acc.outputTokens += entry.outputTokens
|
|
acc.reasoningOutputTokens += entry.reasoningOutputTokens
|
|
acc.totalTokens += entry.totalTokens
|
|
acc.hasInferredPricing ||= entry.hasInferredPricing
|
|
return acc
|
|
},
|
|
{
|
|
events: 0,
|
|
inputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningOutputTokens: 0,
|
|
totalTokens: 0,
|
|
hasInferredPricing: false
|
|
}
|
|
)
|
|
const durationMinutes = Math.max(
|
|
0,
|
|
Math.round(
|
|
(new Date(session.lastTimestamp).getTime() -
|
|
new Date(session.firstTimestamp).getTime()) /
|
|
60_000
|
|
)
|
|
)
|
|
return {
|
|
sessionId: session.sessionId,
|
|
lastActiveAt: session.lastTimestamp,
|
|
durationMinutes,
|
|
projectLabel:
|
|
scopedLocations.length > 1
|
|
? 'Multiple locations'
|
|
: (scopedLocations[0]?.projectLabel ?? session.primaryProjectLabel),
|
|
model: this.getScopedSessionPrimaryModel(session, scope),
|
|
events: totals.events,
|
|
inputTokens: totals.inputTokens,
|
|
cachedInputTokens: totals.cachedInputTokens,
|
|
outputTokens: totals.outputTokens,
|
|
reasoningOutputTokens: totals.reasoningOutputTokens,
|
|
totalTokens: totals.totalTokens,
|
|
hasInferredPricing: session.hasInferredPricing || totals.hasInferredPricing
|
|
}
|
|
})
|
|
}
|
|
|
|
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
|
|
const collectedAt = Date.now()
|
|
const unavailable = (
|
|
unavailableReason: AutomationRunUsage['unavailableReason'],
|
|
unavailableMessage: string
|
|
): AutomationRunUsage => ({
|
|
status: 'unavailable',
|
|
provider: 'codex',
|
|
model: null,
|
|
inputTokens: null,
|
|
outputTokens: null,
|
|
cacheReadTokens: null,
|
|
cacheWriteTokens: null,
|
|
reasoningOutputTokens: null,
|
|
totalTokens: null,
|
|
estimatedCostUsd: null,
|
|
estimatedCostSource: null,
|
|
providerSessionId: null,
|
|
attribution: null,
|
|
collectedAt,
|
|
unavailableReason,
|
|
unavailableMessage
|
|
})
|
|
|
|
if (!this.state.scanState.enabled) {
|
|
return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.')
|
|
}
|
|
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
|
|
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
|
|
}
|
|
|
|
const scanState = await this.refresh(this.shouldForceAutomationUsageScan(input.completedAt))
|
|
if (scanState.lastScanError) {
|
|
return unavailable('scan_failed', scanState.lastScanError)
|
|
}
|
|
|
|
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
|
|
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
|
|
const candidates = this.state.sessions.filter((session) => {
|
|
const first = new Date(session.firstTimestamp).getTime()
|
|
const last = new Date(session.lastTimestamp).getTime()
|
|
if (!Number.isFinite(first) || !Number.isFinite(last)) {
|
|
return false
|
|
}
|
|
if (session.sessionId === input.terminalSessionId) {
|
|
return true
|
|
}
|
|
if (first < windowStart || first > windowEnd || last > windowEnd) {
|
|
return false
|
|
}
|
|
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
|
|
})
|
|
|
|
if (candidates.length === 0) {
|
|
return unavailable('no_matching_session', 'No Codex usage session matched this run.')
|
|
}
|
|
if (candidates.length > 1) {
|
|
return unavailable(
|
|
'ambiguous_session',
|
|
'Multiple Codex usage sessions matched this run window.'
|
|
)
|
|
}
|
|
|
|
const session = candidates[0]
|
|
const scopedLocations = session.locationBreakdown.filter(
|
|
(entry) => entry.worktreeId === input.worktreeId
|
|
)
|
|
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
|
|
const totals = locations.reduce(
|
|
(acc, entry) => {
|
|
acc.events += entry.eventCount
|
|
acc.inputTokens += entry.inputTokens
|
|
acc.cachedInputTokens += entry.cachedInputTokens
|
|
acc.outputTokens += entry.outputTokens
|
|
acc.reasoningOutputTokens += entry.reasoningOutputTokens
|
|
acc.totalTokens += entry.totalTokens
|
|
return acc
|
|
},
|
|
{
|
|
events: 0,
|
|
inputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningOutputTokens: 0,
|
|
totalTokens: 0
|
|
}
|
|
)
|
|
const scopedModelRows = session.locationModelBreakdown.filter(
|
|
(entry) => entry.worktreeId === input.worktreeId
|
|
)
|
|
const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown
|
|
const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))]
|
|
let estimatedCostUsd = 0
|
|
let hasKnownCost = false
|
|
if (scopedModelRows.length > 0) {
|
|
for (const modelRow of scopedModelRows) {
|
|
const cost = estimateCostUsd(
|
|
modelRow.modelKey,
|
|
modelRow.inputTokens,
|
|
modelRow.cachedInputTokens,
|
|
modelRow.outputTokens
|
|
)
|
|
if (cost !== null) {
|
|
hasKnownCost = true
|
|
estimatedCostUsd += cost
|
|
}
|
|
}
|
|
} else if (!session.hasMixedModels) {
|
|
const cost = estimateCostUsd(
|
|
session.primaryModel,
|
|
totals.inputTokens,
|
|
totals.cachedInputTokens,
|
|
totals.outputTokens
|
|
)
|
|
if (cost !== null) {
|
|
hasKnownCost = true
|
|
estimatedCostUsd += cost
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 'known',
|
|
provider: 'codex',
|
|
model:
|
|
modelLabels.length === 1
|
|
? modelLabels[0]
|
|
: session.hasMixedModels
|
|
? 'Mixed models'
|
|
: session.primaryModel,
|
|
inputTokens: totals.inputTokens,
|
|
outputTokens: totals.outputTokens,
|
|
cacheReadTokens: totals.cachedInputTokens,
|
|
cacheWriteTokens: null,
|
|
reasoningOutputTokens: totals.reasoningOutputTokens,
|
|
totalTokens: totals.totalTokens,
|
|
estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null,
|
|
estimatedCostSource: hasKnownCost ? 'api_equivalent' : null,
|
|
providerSessionId: session.sessionId,
|
|
// Why: Orca terminal tab ids and Codex usage session ids are different
|
|
// systems today, so attribution is intentionally limited to one local
|
|
// provider session in the run's worktree/time window.
|
|
attribution: 'provider_session_time_window',
|
|
collectedAt,
|
|
unavailableReason: null,
|
|
unavailableMessage: null
|
|
}
|
|
}
|
|
|
|
private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) {
|
|
const cutoff = getUsageRangeCutoff(range)
|
|
return this.state.dailyAggregates.filter((entry) => {
|
|
if (cutoff && entry.day < cutoff) {
|
|
return false
|
|
}
|
|
if (scope === 'orca' && entry.worktreeId === null) {
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) {
|
|
const cutoff = getUsageRangeCutoff(range)
|
|
return this.state.sessions.filter((session) => {
|
|
const day = getLocalUsageDay(session.lastTimestamp)
|
|
if (!day) {
|
|
return false
|
|
}
|
|
if (cutoff && day < cutoff) {
|
|
return false
|
|
}
|
|
if (scope === 'orca') {
|
|
return session.locationBreakdown.some((entry) => entry.worktreeId !== null)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
private getScopedSessionModels(
|
|
session: CodexUsagePersistedState['sessions'][number],
|
|
scope: CodexUsageScope
|
|
): ScopedCodexUsageModelRow[] {
|
|
if (scope === 'all' || session.locationModelBreakdown.length === 0) {
|
|
return session.modelBreakdown
|
|
}
|
|
|
|
const rows = new Map<string, ScopedCodexUsageModelRow>()
|
|
for (const entry of session.locationModelBreakdown) {
|
|
if (entry.worktreeId === null) {
|
|
continue
|
|
}
|
|
const existing = rows.get(entry.modelKey) ?? {
|
|
modelKey: entry.modelKey,
|
|
modelLabel: entry.modelLabel,
|
|
hasInferredPricing: false,
|
|
eventCount: 0,
|
|
inputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningOutputTokens: 0,
|
|
totalTokens: 0
|
|
}
|
|
existing.hasInferredPricing ||= entry.hasInferredPricing
|
|
existing.eventCount += entry.eventCount
|
|
existing.inputTokens += entry.inputTokens
|
|
existing.cachedInputTokens += entry.cachedInputTokens
|
|
existing.outputTokens += entry.outputTokens
|
|
existing.reasoningOutputTokens += entry.reasoningOutputTokens
|
|
existing.totalTokens += entry.totalTokens
|
|
rows.set(entry.modelKey, existing)
|
|
}
|
|
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
|
|
}
|
|
|
|
private getScopedSessionPrimaryModel(
|
|
session: CodexUsagePersistedState['sessions'][number],
|
|
scope: CodexUsageScope
|
|
): string | null {
|
|
const scopedModels = this.getScopedSessionModels(session, scope)
|
|
if (scopedModels.length === 0) {
|
|
return session.primaryModel
|
|
}
|
|
if (scopedModels.length === 1) {
|
|
return scopedModels[0]?.modelLabel ?? null
|
|
}
|
|
return 'Mixed models'
|
|
}
|
|
|
|
private shouldForceAutomationUsageScan(completedAt: number): boolean {
|
|
const { lastScanCompletedAt, lastScanError } = this.state.scanState
|
|
// Why: attribution needs a scan after the run finishes, but repeated
|
|
// lookups after that point should not rescan all Codex session history.
|
|
return (
|
|
Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
|
|
)
|
|
}
|
|
}
|