Count OMP model_usage side calls (find, judge, cache warming) (#1575)

OMP logs decision and warming calls outside the transcript as model_usage entries. The parser read only assistant messages, so that spend never reached a report. Each side call now counts at its reported cost, with its purpose in the tools column. Bumps the omp parse version and the daily cache to v41.
This commit is contained in:
sm0keyyy 2026-09-28 12:47:35 -04:00 • committed by GitHub
parent 37f3ff4ed1
commit 1795fbdfa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 163 additions and 2 deletions

View file

@ -9,6 +9,7 @@
- Roo Code support. Roo Code was discontinued in May 2026 (its repository is archived).
### Fixed
- **OMP: side calls outside the chat transcript now count.** OMP logs `find`, `judge`, TTSR checks and cache warming as `model_usage` entries, not assistant messages, and the parser read only messages, so that spend was missing. On one real week that was 605 `find` decisions on TypeSafe Jev and a $0.048 cache warm. Each side call now counts at its reported cost, with its purpose in the tools column as `omp:find`, `omp:cache-warm` and so on, and a zero reported cost is priced from tokens. A side call never duplicates an assistant turn. OMP sessions re-parse once and settled days re-derive (daily cache v41).
- **Text selection works again in the terminal dashboard.** Mouse tracking, added in 0.9.20 so the wheel scrolls the view, makes the terminal send mouse events to the application, and in most terminals that stops click and drag from selecting text, so copying a number out of the dashboard needed Shift held down. Tracking is now off by default and `m` turns it on and off while the dashboard runs.
- **Cursor Enterprise and team seats connect in Plan & Usage instead of showing an unrecognized quota response.** Their `usage-summary` carries no individual meter, only a team on-demand meter, which now fills the bar under the Enterprise plan label. On other plans it is listed beside the monthly window. In the macOS app a rate limit, outage or unrecognized response no longer adds sign-in guidance, which is only shown when the session is missing or rejected. Reported in #1546, thanks @paulodearaujo.
- **Windows: the Claude quota shows for Claude Code installed only inside WSL.** The tray's Plan panel and `codeburn quota` read the credential from the Windows profile only, so a WSL-only login showed nothing. Both now also read `~/.claude/.credentials.json` in each running distro's home over `\\wsl$`, never waking a stopped distro and never writing to it; when both sides are logged in, the credential that expires last wins. A slow or unreachable share times out instead of holding up the poll. `CODEBURN_WSL=off` skips it. (#1061)

View file

@ -223,7 +223,10 @@ import type { DateRange, ProjectSummary } from './types.js'
// v40: Codex fork usage after the copied-history burst was clipped by the old
// 5s window. Re-derive Codex slices from the updated session cache so settled
// days recover those calls and tokens.
export const DAILY_CACHE_VERSION = 40
// v41: OMP side calls (`model_usage` entries: find, judge, cache warming) now
// count. Days finalized at v40 miss them; the bump re-derives surviving days.
// Call counts and cost only rise.
export const DAILY_CACHE_VERSION = 41
const MIN_SUPPORTED_VERSION = 28
/// Providers whose per-day CALL COUNT means something different at

View file

@ -70,6 +70,17 @@ type PiEntry = {
timestamp?: string
cwd?: string
model?: string
// `model_usage` entries (OMP side calls) carry these at the top level.
provider?: string
purpose?: string
stopReason?: string
usage?: {
input?: number
output?: number
cacheRead?: number
cacheWrite?: number
cost?: { total?: number }
}
message?: {
role?: string
content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record<string, unknown> }> | string
@ -90,6 +101,56 @@ type PiEntry = {
}
}
// OMP logs side calls outside the assistant transcript as `model_usage`
// entries: find and judge decisions (Jev), cache warming, and similar. They
// never duplicate an assistant message, so each one counts as its own call.
// The tool column carries the purpose, such as `find`, so a report can split
// decision spend from chat spend.
function modelUsageCall(entry: PiEntry, source: SessionSource, sessionId: string, lineIdx: number,
sessionTimestamp: string, seenKeys: Set<string>): ParsedProviderCall | null {
const usage = entry.usage
if (!usage) return null
const input = usage.input ?? 0
const output = usage.output ?? 0
const cacheRead = usage.cacheRead ?? 0
const cacheWrite = usage.cacheWrite ?? 0
if (input === 0 && output === 0 && cacheRead === 0) return null
const bare = entry.model ?? ''
// OMP writes routed ids such as `~typesafe/jev-latest` beside provider
// `openrouter`. The provider prefix keeps them distinct and priceable.
const model = !entry.provider || bare.startsWith(`${entry.provider}/`) ? bare : `${entry.provider}/${bare}`
if (!model) return null
const dedupKey = `${source.provider}:${source.path}:model_usage:${entry.id || entry.timestamp || String(lineIdx)}`
if (seenKeys.has(dedupKey)) return null
seenKeys.add(dedupKey)
const reported = usage.cost?.total
const costUSD = typeof reported === 'number' && Number.isFinite(reported) && reported !== 0
? reported
: calculateCost(model, input, output, cacheWrite, cacheRead, 0)
const timestamp = entry.timestamp || sessionTimestamp
if (!timestamp) return null
return {
provider: source.provider,
model,
inputTokens: input,
outputTokens: output,
cacheCreationInputTokens: cacheWrite,
cacheReadInputTokens: cacheRead,
cachedInputTokens: cacheRead,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
tools: [entry.purpose ? `omp:${entry.purpose}` : 'omp:side-call'],
bashCommands: [],
skills: [],
timestamp,
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: '',
sessionId,
}
}
function getPiSessionsDir(override?: string): string {
return override ?? join(homedir(), '.pi', 'agent', 'sessions')
}
@ -249,6 +310,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
continue
}
if (entry.type === 'model_usage') {
const call = modelUsageCall(entry, source, sessionId, lineIdx, sessionTimestamp, seenKeys)
if (call) yield call
continue
}
if (entry.type !== 'message') continue
const msg = entry.message

View file

@ -502,7 +502,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// costUSD: undefined, so they must re-parse once.
pi: 'cwd-project-path-v1-project-group-by-abs-v1-reported-cost-v1',
// project-group-by-abs-v1: shared Pi/OMP serve grouping uses abs identity.
omp: 'nested-agent-v1-reported-cost-v2-cwd-project-path-v1-project-group-by-abs-v1',
// model-usage-v1: counts OMP side calls (`model_usage` entries: find, judge,
// cache warming), so cached sessions must re-parse to gain them.
omp: 'nested-agent-v1-reported-cost-v2-cwd-project-path-v1-project-group-by-abs-v1-model-usage-v1',
// archived-subtree-v1 (#1362): the subtree walk no longer filters
// `time_archived IS NULL`. An archived ROOT self-heals — it was evicted as an
// undiscovered non-durable source and comes back new — but a root whose CHILD

View file

@ -399,3 +399,91 @@ describe('omp provider - JSONL parsing', () => {
expect(calls).toHaveLength(0)
})
})
function modelUsage(opts: {
id: string
purpose?: string
provider?: string
model?: string
input?: number
output?: number
cost?: number
stopReason?: string
}) {
return JSON.stringify({
type: 'model_usage',
id: opts.id,
parentId: 'parent',
timestamp: '2026-04-14T10:01:00.000Z',
purpose: opts.purpose ?? 'find',
role: 'typesafe',
api: 'openrouter-decisions',
provider: opts.provider ?? 'openrouter',
model: opts.model ?? '~typesafe/jev-latest',
usage: {
input: opts.input ?? 6244,
output: opts.output ?? 1220,
cacheRead: 0,
cacheWrite: 0,
totalTokens: (opts.input ?? 6244) + (opts.output ?? 1220),
cost: { input: opts.cost ?? 0.000262248, output: 0, cacheRead: 0, cacheWrite: 0, total: opts.cost ?? 0.000262248 },
},
stopReason: opts.stopReason ?? 'stop',
})
}
async function parseLines(lines: string[], seen = new Set<string>()): Promise<ParsedProviderCall[]> {
const filePath = await writeSession(join(tmpDir, '--Users-test-myproject--'), 'session.jsonl', lines)
const provider = createOmpProvider(tmpDir)
const source = { path: filePath, project: 'myproject', provider: 'omp' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call)
return calls
}
describe('omp provider - model_usage side calls', () => {
it('counts a side call beside the assistant turn, with its reported cost and purpose', async () => {
const calls = await parseLines([
sessionMeta(),
userMessage('find the parser'),
assistantMessage({ cost: 0.5 }),
modelUsage({ id: 'mu-1' }),
])
expect(calls).toHaveLength(2)
const side = calls.find(call => call.tools.includes('omp:find'))!
expect(side.model).toBe('openrouter/~typesafe/jev-latest')
expect(side.inputTokens).toBe(6244)
expect(side.outputTokens).toBe(1220)
expect(side.costUSD).toBeCloseTo(0.000262248, 12)
expect(side.userMessage).toBe('')
expect(side.timestamp).toBe('2026-04-14T10:01:00.000Z')
expect(calls.find(call => !call.tools.includes('omp:find'))!.userMessage).toBe('find the parser')
})
it('prices a zero reported cost through the price table', async () => {
setPriceOverrides({ 'openrouter/~typesafe/jev-latest': { input: 0.042, output: 0 } })
try {
const calls = await parseLines([sessionMeta(), modelUsage({ id: 'mu-2', input: 1_000_000, output: 5, cost: 0 })])
expect(calls[0]!.costUSD).toBeCloseTo(0.042, 12)
} finally {
setPriceOverrides({})
}
})
it('skips an errored side call that consumed no tokens', async () => {
const calls = await parseLines([
sessionMeta(),
modelUsage({ id: 'mu-3', input: 0, output: 0, cost: 0, stopReason: 'error' }),
])
expect(calls).toHaveLength(0)
})
it('counts a repeated side call once across parses that share dedup keys', async () => {
const seen = new Set<string>()
const first = await parseLines([sessionMeta(), modelUsage({ id: 'mu-4' })], seen)
const second = await parseLines([sessionMeta(), modelUsage({ id: 'mu-4' })], seen)
expect(first).toHaveLength(1)
expect(second).toHaveLength(0)
})
})