Files
orca/src/main/codex-usage/scanner.test.ts
T
Jinwoo HongandClaude Opus 5.5 2155673da3 fix(usage): price Codex long context per request, not per aggregate (#22360)
* fix(usage): price Codex long context per request, not per aggregate

OpenAI bills a request whose prompt exceeds 272K input tokens at the
long-context rates for that whole request. Orca applied 272K as a
marginal threshold to daily, per-range and per-session token totals,
so ordinary days of short requests were billed mostly at long-context
rates and the summary disagreed with the model breakdown.

Classify each token_count at parse time and carry long-context token
counts through every rollup; the estimate bills that subset at the
long-context rates and the rest at base. Bump the cache schema to 6 so
existing caches rescan and pick up the split.

* fix(usage): never class a total-only Codex delta as long context

A token_count with only running totals yields the increase since the
previous total, which can span several short requests; classing that
aggregate as one >272K request would bill short requests at long-context
rates. Only a last_token_usage record is one response. Also note that
OpenAI's GPT-5.4/5.5 pages say "full session" where 5.6/6 say "full
request"; the per-request reading is the stated assumption.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 20:33:18 -04:00

336 lines
9.3 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import { attributeCodexUsageEvent } from './codex-usage-event-attribution'
import { parseCodexUsageRecord } from './codex-usage-record-parser'
describe('parseCodexUsageRecord', () => {
it('uses token totals only as a duplicate baseline', () => {
const context = {
sessionId: 'session-1',
sessionCwd: null,
currentCwd: null,
currentModel: null,
previousTotals: null
}
expect(
parseCodexUsageRecord(
JSON.stringify({
type: 'session_meta',
payload: { id: 'session-1', cwd: '/workspace/repo' }
}),
context
)
).toBeNull()
expect(
parseCodexUsageRecord(
JSON.stringify({
type: 'turn_context',
payload: { cwd: '/workspace/repo/packages/app', model: 'gpt-5.2-codex' }
}),
context
)
).toBeNull()
const first = parseCodexUsageRecord(
JSON.stringify({
timestamp: '2026-04-09T10:00:00.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 1000,
cached_input_tokens: 400,
output_tokens: 250,
reasoning_output_tokens: 100,
total_tokens: 1250
},
last_token_usage: {
input_tokens: 1000,
cached_input_tokens: 400,
output_tokens: 250,
reasoning_output_tokens: 100,
total_tokens: 1250
}
}
}
}),
context
)
const duplicate = parseCodexUsageRecord(
JSON.stringify({
timestamp: '2026-04-09T10:00:01.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 1000,
cached_input_tokens: 400,
output_tokens: 250,
reasoning_output_tokens: 100,
total_tokens: 1250
},
last_token_usage: {
input_tokens: 1000,
cached_input_tokens: 400,
output_tokens: 250,
reasoning_output_tokens: 100,
total_tokens: 1250
}
}
}
}),
context
)
expect(first).toEqual({
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: expect.any(String),
cwd: '/workspace/repo/packages/app',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
longContextInputTokens: 0,
longContextCachedInputTokens: 0,
longContextOutputTokens: 0,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1250
})
expect(duplicate).toBeNull()
})
it('preserves unknown model metadata instead of assigning fallback pricing', () => {
const context = {
sessionId: 'session-1',
sessionCwd: '/workspace/repo',
currentCwd: '/workspace/repo',
currentModel: null,
previousTotals: null
}
const parsed = parseCodexUsageRecord(
JSON.stringify({
timestamp: '2026-04-09T10:00:00.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
last_token_usage: {
input_tokens: 120,
cached_input_tokens: 20,
output_tokens: 50,
reasoning_output_tokens: 10,
total_tokens: 170
}
}
}
}),
context
)
expect(parsed?.model).toBeNull()
expect(parsed?.hasInferredPricing).toBe(true)
})
it('uses last token usage for the first resumed-session event', () => {
const context = {
sessionId: 'session-1',
sessionCwd: '/workspace/repo',
currentCwd: '/workspace/repo',
currentModel: 'gpt-5.5',
previousTotals: null
}
const parsed = parseCodexUsageRecord(
JSON.stringify({
timestamp: '2026-04-09T10:00:00.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 50_000,
cached_input_tokens: 40_000,
output_tokens: 5_000,
reasoning_output_tokens: 1_000,
total_tokens: 55_000
},
last_token_usage: {
input_tokens: 2_000,
cached_input_tokens: 1_500,
output_tokens: 500,
reasoning_output_tokens: 100,
total_tokens: 2_500
}
}
}
}),
context
)
expect(parsed).toMatchObject({
inputTokens: 2_000,
cachedInputTokens: 1_500,
outputTokens: 500,
reasoningOutputTokens: 100,
totalTokens: 2_500
})
})
})
describe('attributeCodexUsageEvent', () => {
it('attributes nested cwd paths to the nearest containing worktree', async () => {
const attributed = await attributeCodexUsageEvent(
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: 'event-1',
cwd: '/workspace/repo/app2/subdir',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
longContextInputTokens: 0,
longContextCachedInputTokens: 0,
longContextOutputTokens: 0,
inputTokens: 100,
cachedInputTokens: 10,
outputTokens: 25,
reasoningOutputTokens: 10,
totalTokens: 125
},
await createUsageWorktreeResolver([
{
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo/app',
path: '/workspace/repo/app',
displayName: 'App'
},
{
repoId: 'repo-2',
worktreeId: 'repo-2::/workspace/repo/app2',
path: '/workspace/repo/app2',
displayName: 'App 2'
}
])
)
expect(attributed?.projectKey).toBe('worktree:repo-2::/workspace/repo/app2')
expect(attributed?.projectLabel).toBe('App 2')
expect(attributed?.worktreeId).toBe('repo-2::/workspace/repo/app2')
})
it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => {
const attributed = await attributeCodexUsageEvent(
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: 'event-1',
cwd: '/workspace/repo/..fixtures/session',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
longContextInputTokens: 0,
longContextCachedInputTokens: 0,
longContextOutputTokens: 0,
inputTokens: 100,
cachedInputTokens: 10,
outputTokens: 25,
reasoningOutputTokens: 10,
totalTokens: 125
},
await createUsageWorktreeResolver([
{
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
path: '/workspace/repo',
displayName: 'Repo'
}
])
)
expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo')
expect(attributed?.projectLabel).toBe('Repo')
expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo')
})
it('does not attribute true parent-directory escapes to the worktree', async () => {
const attributed = await attributeCodexUsageEvent(
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: 'event-1',
cwd: '/workspace/repo/../other/session',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
longContextInputTokens: 0,
longContextCachedInputTokens: 0,
longContextOutputTokens: 0,
inputTokens: 100,
cachedInputTokens: 10,
outputTokens: 25,
reasoningOutputTokens: 10,
totalTokens: 125
},
await createUsageWorktreeResolver([
{
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
path: '/workspace/repo',
displayName: 'Repo'
}
])
)
expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session')
expect(attributed?.worktreeId).toBeNull()
})
it('does not treat different Windows drives as containing paths', async () => {
const attributed = await attributeCodexUsageEvent(
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: 'event-1',
cwd: 'D:\\other\\repo',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
longContextInputTokens: 0,
longContextCachedInputTokens: 0,
longContextOutputTokens: 0,
inputTokens: 100,
cachedInputTokens: 10,
outputTokens: 25,
reasoningOutputTokens: 10,
totalTokens: 125
},
await createUsageWorktreeResolver([
{
repoId: 'repo-1',
worktreeId: 'repo-1::C:\\repo',
path: 'C:\\repo',
displayName: 'Repo'
}
])
)
expect(attributed?.projectKey).toBe('cwd:d:/other/repo')
expect(attributed?.worktreeId).toBeNull()
})
})