mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 03:05:24 +00:00
fix(opencode): Vertex sessions no longer show $0 (#1552)
* fix(opencode): price Vertex turns on a Vertex row and keep an unpriceable recorded cost through the cache (#1547) OpenCode records the cost of each turn. For an id CodeBurn cannot price the parser fell back to that cost, but the session cache stored no cost and re-priced the cached tokens on every read, so the call showed $0. The recorded cost now rides on the cached call as fallbackCostUSD and applies only while the tokens price at $0, so a later catalog row or price override still wins. The session-level rollup used provider/model while per-message turns use the bare id since #1543; it now uses the bare id too. google-vertex and google-vertex-anthropic map to a metered Vertex route. OpenCode and Kilo Code re-parse once; daily cache v37 re-derives settled days. * fix(opencode): keep a declared-free id at $0 instead of its recorded cost A model the user marked flat-rate, priced at a zero rate, or that looks local prices at $0 on purpose. The recorded-cost fallback now skips those ids at parse time and on cache read, through one guard in models.ts.
This commit is contained in:
parent
fe560f7f4d
commit
aa9b8c9a40
14 changed files with 211 additions and 22 deletions
|
|
@ -6,6 +6,7 @@
|
|||
- Roo Code support. Roo Code was discontinued in May 2026 (its repository is archived).
|
||||
|
||||
### Fixed
|
||||
- **OpenCode: Vertex turns and turns CodeBurn cannot price no longer show $0.** OpenCode 2 records a Vertex model as `{"id":"claude-sonnet-5@default","providerID":"google-vertex-anthropic"}`. Per-message turns already price on the bare id; the session-level rollup still used `provider/model` and now uses the bare id too. Turns through `google-vertex` and `google-vertex-anthropic` land on their own row, like "Sonnet 5 (Vertex)". A turn whose model CodeBurn cannot price used the cost OpenCode recorded only until it reached the session cache, which re-priced it from tokens to $0 on every read; that recorded cost now survives the cache, and a price CodeBurn learns later still wins. Applies to Kilo Code too. OpenCode and Kilo Code sessions re-parse once and settled days re-derive. Reported in #1547, thanks @bulwahn.
|
||||
- **Cursor Agent counts every assistant message instead of one turn per user message.** Agentic loops emit dozens of assistant messages per user message and the parser kept only the first (on one real corpus: 247 of 4,946 messages, ~4% of assistant text). Each message now carries the last user message forward, tool_use inputs join the output text, and input tokens use the full user text, billed once per user message, while the display stays truncated at 500 chars. The parse version bump re-parses cached sessions, and the daily cache moves to v36 so settled days re-derive.
|
||||
- **Windows Store installs: no update offer the Store cannot deliver, and `codeburn menubar` opens the Store copy.** The desktop app read GitHub releases for its update banner, so a Store copy was told about a version still waiting on Store certification and sent to a Store page that did not have it. Store installs now skip the GitHub check and the About update button, like the tray app already did; the Store updates them. `codeburn menubar` spawned the tray exe inside `WindowsApps`, which Windows refuses from outside the package (`spawn EPERM`); it now opens the Store app by its app id, and the desktop app starts the tray. Reported in #1520, thanks @Brandin.
|
||||
- **Menu bar apps 0.9.14 to 0.9.24 load Today again with a 0.9.25 CLI.** Homebrew and npm update the CLI on their own, and 0.9.25 left `avgCostPerSession` out of a project row whose session count is a lower bound, a key those apps require, so the popover showed "Couldn't load Today". The payload always carries it again, with `sessionCountBasis` still telling 0.9.25 and later not to show a partial average. A new test checks the payload against the keys every released menu bar app requires. Reported in #1541, thanks @AleksandrLiadov.
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ Every report keys a model row on `modelRowKey(model, route)`, never on the raw i
|
|||
A **route** is the door, and it has two sources feeding one `route` field on the call (`ParsedProviderCall` → `ParsedApiCall` → `CachedCall`):
|
||||
|
||||
- the model id, when the door renames the model. `getModelRoute(id)` recognises Bedrock's `<vendor>.<model>[-vN:M]` with an optional cross-region profile prefix (`us.`, `eu.`, `global.`, …) for the vendors with coding sessions on disk (`anthropic`, `openai`). The profile is a dearer SKU and stays a distinct row (the `(Bedrock us)` variant).
|
||||
- the provider's own endpoint field, when it does not. `routeFromProviderField(value)` maps Hermes' `billing_provider` and OpenCode's `providerID`: `bedrock`, exact `amazon-bedrock`, and exact `openrouter` become registered routes; direct doors (`anthropic`, `openai`, …) map to nothing, because the unsuffixed row *is* the direct row. Only doors with usage-bearing sessions on disk are registered, the same rule the id shapes follow.
|
||||
- the provider's own endpoint field, when it does not. `routeFromProviderField(value)` maps Hermes' `billing_provider` and OpenCode's `providerID`: `bedrock`, exact `amazon-bedrock`, exact `openrouter`, and exact `google-vertex` / `google-vertex-anthropic` become registered routes; direct doors (`anthropic`, `openai`, …) map to nothing, because the unsuffixed row *is* the direct row. Only doors with usage-bearing sessions on disk are registered, the same rule the id shapes follow.
|
||||
|
||||
A call can also carry an optional **billing mode**: `metered` for a provider-recorded actual charge or a registered metered route, and `subscription` only when the provider records that the usage was included. Absence stays unknown; neither provider name, model name, calculated cost, nor plan ownership fills it. `models`, `sessions`, `export`, and `audit` expose call-level `--route` and `--billing` filters. `--route direct` is the complement of registered routes and therefore includes unknown doors. Filtered work-unit grouping is rejected rather than inferring a missing root; durable `today`/`status` and payload filtering wait for a cache schema that preserves this dimension.
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,9 @@ preserves the exact usage-bearing values across legacy SQLite, v2
|
|||
|
||||
- `openrouter` → OpenRouter, metered
|
||||
- `amazon-bedrock` → Bedrock, metered
|
||||
- `google-vertex`, `google-vertex-anthropic` → Vertex, metered
|
||||
|
||||
Case or whitespace variants of those two values are not inferred. The Bedrock
|
||||
Case or whitespace variants of those values are not inferred. The Bedrock
|
||||
model-id detector remains a fallback for recognised Anthropic and OpenAI
|
||||
foundation-model ids, but `providerID=amazon-bedrock` also covers model families
|
||||
such as Nova whose ids do not match that detector. Direct provider values remain
|
||||
|
|
@ -84,6 +85,11 @@ this shared provider-field mapping changes, so warm and cold reads agree.
|
|||
token, and tool usage back to the root session.
|
||||
- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness.
|
||||
- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics.
|
||||
- A turn or session rollup whose model CodeBurn cannot price keeps OpenCode's
|
||||
recorded `cost` (as `fallbackCostUSD` on the cached call), unless the user
|
||||
declared the id free (`model-flat-rate`, a zero-rate price override, a local
|
||||
model). Tokens still win whenever the id prices, including after a catalog
|
||||
update or price override.
|
||||
- Assistant messages with missing router usage are kept as zero-cost calls
|
||||
when their parts contain non-empty text or tool activity. Empty zero-usage
|
||||
assistant placeholders are still skipped.
|
||||
|
|
|
|||
|
|
@ -207,7 +207,11 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// provider parse version alone re-parses sessions but never reopens a
|
||||
// finalized day, so the bump re-derives surviving days. Call counts only
|
||||
// rise, so no PENDING_REDERIVE entry is needed.
|
||||
export const DAILY_CACHE_VERSION = 36
|
||||
// v37: #1547 OpenCode/KiloCode. A turn CodeBurn cannot price keeps the cost
|
||||
// OpenCode recorded instead of $0, and Vertex turns land on a "(Vertex)" row.
|
||||
// Days finalized at v36 hold $0 and the unrouted row; the bump re-derives
|
||||
// surviving days. Call counts are unchanged.
|
||||
export const DAILY_CACHE_VERSION = 37
|
||||
const MIN_SUPPORTED_VERSION = 28
|
||||
|
||||
/// Providers whose per-day CALL COUNT means something different at
|
||||
|
|
|
|||
|
|
@ -1162,6 +1162,12 @@ export function isExpectedFreeModel(model: string): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
/// The cost a tool recorded for a call CodeBurn prices at $0, unless $0 is the
|
||||
/// declared price (local, local-savings, flat-rate, zero-rate override).
|
||||
export function recordedCostFallback(model: string, costUSD: number, recorded: number | undefined): number | undefined {
|
||||
return costUSD === 0 && typeof recorded === 'number' && recorded > 0 && !isExpectedFreeModel(model) ? recorded : undefined
|
||||
}
|
||||
|
||||
export function findUnpricedModels(
|
||||
rows: Iterable<{ model: string; calls: number; cost: number; tokens?: number }>,
|
||||
): UnpricedModelUsage[] {
|
||||
|
|
@ -1470,6 +1476,7 @@ type RouteEntry = ModelRoute & {
|
|||
const ROUTES: readonly RouteEntry[] = [
|
||||
{ id: 'bedrock', label: 'Bedrock', billing: 'metered', providerFields: ['bedrock', 'amazon-bedrock'] },
|
||||
{ id: 'openrouter', label: 'OpenRouter', billing: 'metered', providerFields: ['openrouter'] },
|
||||
{ id: 'vertex', label: 'Vertex', billing: 'metered', providerFields: ['google-vertex', 'google-vertex-anthropic'] },
|
||||
]
|
||||
|
||||
const ROUTES_BY_ID = new Map(ROUTES.map(route => [route.id, route]))
|
||||
|
|
@ -1518,7 +1525,7 @@ export function routeFromProviderField(value: string | null | undefined): ModelR
|
|||
// Only the literal values exist in usage-bearing OpenCode/OpenRouter sessions.
|
||||
// Keep Hermes' shipped `bedrock` case/whitespace normalization, but do not
|
||||
// invent aliases for the provider spellings OpenCode records.
|
||||
if ((normalized === 'openrouter' || normalized === 'amazon-bedrock') && value !== normalized) return undefined
|
||||
if (normalized !== 'bedrock' && value !== normalized) return undefined
|
||||
return ROUTES_BY_FIELD.get(normalized)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createHash } from 'crypto'
|
|||
import { performance } from 'node:perf_hooks'
|
||||
import { basename, dirname, join, resolve, sep } from 'path'
|
||||
import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionLines } from './fs-utils.js'
|
||||
import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, modelRowKey, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash } from './models.js'
|
||||
import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, modelRowKey, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash, recordedCostFallback } from './models.js'
|
||||
import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js'
|
||||
import { normalizeContentBlocks, flatSlice, flatString } from './content-utils.js'
|
||||
import { discoverAllSessions, discoverAllSessionsWithFailures, getProvider } from './providers/index.js'
|
||||
|
|
@ -2645,6 +2645,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
|
|||
cacheCreationOneHourTokens: 0,
|
||||
},
|
||||
costUSD: (REPORTED_COST_PROVIDERS.has(call.provider) || call.costFromBilling === true) ? call.costUSD : undefined,
|
||||
...(call.fallbackCostUSD ? { fallbackCostUSD: call.fallbackCostUSD } : {}),
|
||||
isEstimated: call.costIsEstimated || undefined,
|
||||
speed: call.speed,
|
||||
timestamp: call.timestamp,
|
||||
|
|
@ -2817,7 +2818,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
|
|||
reasoningTokens: u.reasoningTokens,
|
||||
webSearchRequests: u.webSearchRequests,
|
||||
},
|
||||
costUSD: call.costUSD ?? costUSD,
|
||||
costUSD: call.costUSD ?? recordedCostFallback(call.model, costUSD, call.fallbackCostUSD) ?? costUSD,
|
||||
isEstimated: call.isEstimated,
|
||||
tools: call.tools,
|
||||
mcpTools: extractMcpTools(call.tools),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { billableOutputTokens, calculateCost, routeFromProviderField } from '../models.js'
|
||||
import { billableOutputTokens, calculateCost, recordedCostFallback, routeFromProviderField } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { ParsedProviderCall } from './types.js'
|
||||
|
||||
|
|
@ -144,9 +144,8 @@ export function buildAssistantCall(opts: {
|
|||
0,
|
||||
)
|
||||
|
||||
if (costUSD === 0 && typeof data.cost === 'number' && data.cost > 0) {
|
||||
costUSD = data.cost
|
||||
}
|
||||
const fallbackCostUSD = recordedCostFallback(model, costUSD, data.cost)
|
||||
if (fallbackCostUSD) costUSD = fallbackCostUSD
|
||||
|
||||
return {
|
||||
provider: opts.providerName,
|
||||
|
|
@ -160,6 +159,7 @@ export function buildAssistantCall(opts: {
|
|||
reasoningTokens: tokens.reasoning,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
...(fallbackCostUSD ? { fallbackCostUSD } : {}),
|
||||
// A call with output but no usage recorded has unknown tokens, not zero.
|
||||
...(allZero && costUSD === 0 ? { costIsEstimated: true } : {}),
|
||||
tools,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
import { billableOutputTokens, calculateCost, routeFromProviderField } from '../models.js'
|
||||
import { billableOutputTokens, calculateCost, recordedCostFallback, routeFromProviderField } from '../models.js'
|
||||
import {
|
||||
isSqliteAvailable,
|
||||
getSqliteLoadError,
|
||||
|
|
@ -174,7 +174,8 @@ function parseSessionModel(value: Uint8Array | string | undefined): { model: str
|
|||
const id = typeof model['id'] === 'string' ? model['id'].trim() : ''
|
||||
const providerID = typeof model['providerID'] === 'string' ? model['providerID'] : ''
|
||||
const normalizedProviderID = providerID.trim()
|
||||
return id && normalizedProviderID ? { model: `${normalizedProviderID}/${id}`, providerID } : undefined
|
||||
// Bare id, the same one the per-message path prices.
|
||||
return id && normalizedProviderID ? { model: id, providerID } : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -431,7 +432,8 @@ export function createSqliteSessionParser(
|
|||
// per-message pricing in buildAssistantCall. (#1334)
|
||||
const outputForCost = billableOutputTokens(config.providerName, sessionTokens.output, sessionTokens.reasoning)
|
||||
let costUSD = calculateCost(model, sessionTokens.input, outputForCost, sessionTokens.cacheWrite, sessionTokens.cacheRead, 0)
|
||||
if (costUSD === 0 && sessionTokens.cost > 0) costUSD = sessionTokens.cost
|
||||
const fallbackCostUSD = recordedCostFallback(model, costUSD, sessionTokens.cost)
|
||||
if (fallbackCostUSD) costUSD = fallbackCostUSD
|
||||
yield {
|
||||
provider: config.providerName,
|
||||
model,
|
||||
|
|
@ -444,6 +446,7 @@ export function createSqliteSessionParser(
|
|||
reasoningTokens: sessionTokens.reasoning,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
...(fallbackCostUSD ? { fallbackCostUSD } : {}),
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: parseTimestamp(messages[0]!.time_created),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ export type ParsedProviderCall = {
|
|||
// update still reaches it. Orthogonal to `costIsEstimated`: a credit rate is
|
||||
// an estimate of dollars, but it is still billing-derived.
|
||||
costFromBilling?: boolean
|
||||
// A cost the tool recorded for this call, used only while CodeBurn prices
|
||||
// the call's tokens at $0 (an id the catalog does not know). Kept on the
|
||||
// cached call so the fallback survives the cache, while a later catalog row
|
||||
// or price override for the id still wins.
|
||||
fallbackCostUSD?: number
|
||||
tools: string[]
|
||||
bashCommands: string[]
|
||||
// Subagent types spawned in this call (e.g. 'general-purpose'). Feeds the
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export type CachedCall = {
|
|||
model: string
|
||||
usage: CachedUsage
|
||||
costUSD?: number
|
||||
/// See ParsedProviderCall.fallbackCostUSD.
|
||||
fallbackCostUSD?: number
|
||||
/// True when `costUSD` (or the tokens it is priced from) is estimated rather
|
||||
/// than metered. Persisted so the estimated-cost marker survives the cache.
|
||||
isEstimated?: boolean
|
||||
|
|
@ -508,7 +510,11 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// the frozen 1.x tables now reads its legacy turns too. Cached parses of an
|
||||
// upgraded database hold only what the 2.x migration carried over, and v2
|
||||
// turns now carry the bare model id 1.x used instead of `provider/model`.
|
||||
opencode: 'session-model-v1-archived-subtree-v1-billing-routes-v2-v2-legacy-union-v1-unknown-usage-v1',
|
||||
// vertex-fallback-cost-v1 (#1547): the recorded `cost` of a turn CodeBurn
|
||||
// cannot price rides on the cached call as `fallbackCostUSD`, the session-level
|
||||
// rollup uses the bare model id, and `google-vertex`/`google-vertex-anthropic`
|
||||
// ride as the `vertex` route. Cached calls hold none of these.
|
||||
opencode: 'session-model-v1-archived-subtree-v1-billing-routes-v2-v2-legacy-union-v1-unknown-usage-v1-vertex-fallback-cost-v1',
|
||||
quickdesk: 'emf-sqlite-v2-est-cost',
|
||||
// session-lineage-capture-v1: SessionLineage (CB-1, slice 1) is now carried
|
||||
// on the cached file for every kimicode wire. Child evidence is the
|
||||
|
|
@ -521,7 +527,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
kimicode: 'wire-usage-v1-est-cost-session-lineage-capture-v1',
|
||||
// archived-subtree-v1: KiloCode shares the SQLite parser and the same schema.
|
||||
// billing-routes-v2: its warm cache must move with both shared route fields.
|
||||
'kilo-code': 'worktree-project-grouping-v1-session-model-v1-archived-subtree-v1-billing-routes-v2-v2-legacy-union-v1-unknown-usage-v1',
|
||||
'kilo-code': 'worktree-project-grouping-v1-session-model-v1-archived-subtree-v1-billing-routes-v2-v2-legacy-union-v1-unknown-usage-v1-vertex-fallback-cost-v1',
|
||||
// billing-cost-v1: Warp's own billing record (total_provider_cost_in_cents,
|
||||
// total_charged_usage, credits_spent) now rides on each call as
|
||||
// `costFromBilling` and is preserved by providerCallToCachedCall. Entries
|
||||
|
|
@ -1090,6 +1096,7 @@ function validateCall(c: unknown): c is CachedCall {
|
|||
&& typeof o['timestamp'] === 'string'
|
||||
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
|
||||
&& isOptionalNum(o['costUSD'])
|
||||
&& isOptionalNum(o['fallbackCostUSD'])
|
||||
&& isOptionalBool(o['isEstimated'])
|
||||
&& isOptionalNum(o['activeDurationMs'])
|
||||
&& isOptionalNum(o['activeGeneratedTokens'])
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ describe('codeburn --route / --billing: validation happens before anything is re
|
|||
const badRoute = runCli([command, '--route', 'bedrok'], home)
|
||||
expect(badRoute.status, command).toBe(1)
|
||||
expect(badRoute.stderr, command).toContain(`codeburn ${command}: unknown route "bedrok"`)
|
||||
expect(badRoute.stderr, command).toContain('Valid values: direct, bedrock, openrouter.')
|
||||
expect(badRoute.stderr, command).toContain('Valid values: direct, bedrock, openrouter, vertex.')
|
||||
|
||||
const badBilling = runCli([command, '--billing', 'included'], home)
|
||||
expect(badBilling.status, command).toBe(1)
|
||||
|
|
@ -259,7 +259,7 @@ describe('codeburn --route / --billing: validation happens before anything is re
|
|||
for (const command of ['models', 'sessions', 'export', 'audit']) {
|
||||
const help = runCli([command, '--help'], home)
|
||||
expect(help.status, command).toBe(0)
|
||||
expect(help.stdout, command).toContain('direct includes unknown')
|
||||
expect(help.stdout.replace(/\s+/g, ' '), command).toContain('direct includes unknown')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ describe('routeFromProviderField - the provider column', () => {
|
|||
expect(routeFromProviderField(' openrouter ')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps OpenCode Vertex provider ids exactly (#1547)', () => {
|
||||
expect(routeFromProviderField('google-vertex-anthropic')).toMatchObject({ id: 'vertex', label: 'Vertex', billing: 'metered' })
|
||||
expect(routeFromProviderField('google-vertex')).toMatchObject({ id: 'vertex', label: 'Vertex' })
|
||||
expect(routeFromProviderField('Google-Vertex')).toBeUndefined()
|
||||
expect(routeFromProviderField(' google-vertex ')).toBeUndefined()
|
||||
expect(modelRowKey('claude-sonnet-5@default', 'vertex')).toBe('Sonnet 5 (Vertex)')
|
||||
})
|
||||
|
||||
it('returns undefined for the direct doors, subscription doors and doors with no sessions yet', () => {
|
||||
// Direct: the unsuffixed row IS the direct row. Subscription: a ChatGPT
|
||||
// plan does not change which row a model lands on. `bedrock-mantle` is a
|
||||
|
|
@ -201,7 +209,7 @@ describe('effectiveRouteId - the door that actually applies', () => {
|
|||
})
|
||||
|
||||
it('lists the registered ids for the CLI to validate against', () => {
|
||||
expect(registeredRouteIds()).toEqual(['bedrock', 'openrouter'])
|
||||
expect(registeredRouteIds()).toEqual(['bedrock', 'openrouter', 'vertex'])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
121
tests/opencode-vertex-cost-cache.test.ts
Normal file
121
tests/opencode-vertex-cost-cache.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// #1547: OpenCode 2.x turns routed through Google Vertex (`google-vertex`,
|
||||
// `google-vertex-anthropic`) record `{"id":"claude-sonnet-5@default", ...}`.
|
||||
// They must price from tokens like the direct ids, land on a "(Vertex)" row,
|
||||
// and a turn CodeBurn cannot price must keep the cost OpenCode recorded through
|
||||
// the session cache, cold and warm, until a catalog row or override prices it.
|
||||
|
||||
import { afterAll, afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { mkdirSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { isSqliteAvailable } from '../src/sqlite.js'
|
||||
import { modelRowKey, setFlatRateModels, setPriceOverrides } from '../src/models.js'
|
||||
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
||||
|
||||
const root = join(tmpdir(), `opencode-vertex-cost-${process.pid}-${Date.now()}`)
|
||||
const DATA_DIR = join(root, 'opencode')
|
||||
const CACHE_DIR = join(root, 'cache')
|
||||
|
||||
// Bundled snapshot rates. Sonnet 5: 2 / 10 / cache write 2.5 / cache read 0.2
|
||||
// per million; Haiku 4.5: 1 / 5; Gemini 2.5 Pro (<=200k): 1.25 / 10.
|
||||
const SONNET = 1000 * 2e-6 + 200 * 10e-6 + 10_000 * 0.2e-6 + 500 * 2.5e-6
|
||||
const HAIKU = 2000 * 1e-6 + 100 * 5e-6
|
||||
const GEMINI = 1000 * 1.25e-6 + 100 * 10e-6
|
||||
const RECORDED = 0.37
|
||||
const tokens = (input: number, output: number, read = 0, write = 0) => ({ input, output, reasoning: 0, cache: { read, write } })
|
||||
|
||||
function writeDb(): void {
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
const { DatabaseSync } = require('node:sqlite')
|
||||
const db = new DatabaseSync(join(DATA_DIR, 'opencode.db'))
|
||||
db.exec(`
|
||||
CREATE TABLE session_v2 (
|
||||
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
||||
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT, version TEXT NOT NULL,
|
||||
cost REAL NOT NULL DEFAULT 0, tokens_input INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_output INTEGER NOT NULL DEFAULT 0, tokens_reasoning INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_cache_read INTEGER NOT NULL DEFAULT 0, tokens_cache_write INTEGER NOT NULL DEFAULT 0,
|
||||
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
|
||||
time_archived INTEGER
|
||||
);
|
||||
CREATE TABLE session_message (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, type TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
const t0 = Date.UTC(2026, 8, 23, 10, 0, 0)
|
||||
db.prepare(`INSERT INTO session_v2 (id, project_id, slug, directory, title, version, time_created, time_updated)
|
||||
VALUES ('ses_vertex', 'p', 's', '/home/user/proj', 't', '2.0.16', ?, ?)`).run(t0, t0)
|
||||
const msg = (id: string, seq: number, model: { id: string; providerID: string }, cost: number, tok: ReturnType<typeof tokens>) =>
|
||||
db.prepare(`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?, 'ses_vertex', 'assistant', ?, ?, ?, ?)`)
|
||||
.run(id, seq, t0 + seq * 1000, t0 + seq * 1000, JSON.stringify({ model, cost, tokens: tok, content: [{ type: 'text', text: 'ok' }] }))
|
||||
// Recorded costs on priceable ids are ignored: tokens-first.
|
||||
msg('m1', 1, { id: 'claude-sonnet-5@default', providerID: 'google-vertex-anthropic' }, 0.99, tokens(1000, 200, 10_000, 500))
|
||||
msg('m2', 2, { id: 'claude-haiku-4-5@default', providerID: 'google-vertex-anthropic' }, 0.99, tokens(2000, 100))
|
||||
msg('m3', 3, { id: 'gemini-2.5-pro', providerID: 'google-vertex' }, 0.99, tokens(1000, 100))
|
||||
msg('m4', 4, { id: 'vertex-private-model-x@default', providerID: 'google-vertex' }, RECORDED, tokens(1000, 100))
|
||||
db.close()
|
||||
}
|
||||
|
||||
async function costByRow(): Promise<Record<string, number>> {
|
||||
const rows: Record<string, number> = {}
|
||||
for (const project of await parseAllSessions(undefined, 'opencode')) {
|
||||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
for (const call of turn.assistantCalls) {
|
||||
const key = modelRowKey(call.model, call.route)
|
||||
rows[key] = (rows[key] ?? 0) + call.costUSD
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
process.env['OPENCODE_DATA_DIR'] = DATA_DIR
|
||||
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
|
||||
mkdirSync(CACHE_DIR, { recursive: true })
|
||||
writeDb()
|
||||
})
|
||||
|
||||
afterEach(() => { setPriceOverrides({}); setFlatRateModels([]) })
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
it.skipIf(!isSqliteAvailable())('prices Vertex ids from tokens on a Vertex row and keeps an unpriceable recorded cost cold and warm', async () => {
|
||||
clearSessionCache()
|
||||
const cold = await costByRow()
|
||||
clearSessionCache()
|
||||
const warm = await costByRow()
|
||||
|
||||
expect(Object.keys(cold).sort()).toEqual(['Gemini 2.5 Pro (Vertex)', 'Haiku 4.5 (Vertex)', 'Sonnet 5 (Vertex)', 'vertex-private-model-x (Vertex)'])
|
||||
expect(cold['Sonnet 5 (Vertex)']).toBeCloseTo(SONNET, 12)
|
||||
expect(cold['Haiku 4.5 (Vertex)']).toBeCloseTo(HAIKU, 12)
|
||||
expect(cold['Gemini 2.5 Pro (Vertex)']).toBeCloseTo(GEMINI, 12)
|
||||
expect(cold['vertex-private-model-x (Vertex)']).toBeCloseTo(RECORDED, 12)
|
||||
expect(warm).toEqual(cold)
|
||||
})
|
||||
|
||||
it.skipIf(!isSqliteAvailable())('lets a later price override win over the recorded cost on a warm cache', async () => {
|
||||
clearSessionCache()
|
||||
await costByRow()
|
||||
setPriceOverrides({ 'vertex-private-model-x': { input: 1, output: 2 } })
|
||||
clearSessionCache()
|
||||
const warm = await costByRow()
|
||||
expect(warm['vertex-private-model-x (Vertex)']).toBeCloseTo(1000 * 1e-6 + 100 * 2e-6, 12)
|
||||
})
|
||||
|
||||
it.skipIf(!isSqliteAvailable())('drops the recorded cost once the user declares the id free (flat-rate or zero-rate override)', async () => {
|
||||
clearSessionCache()
|
||||
await costByRow()
|
||||
setFlatRateModels(['vertex-private-model-x'])
|
||||
clearSessionCache()
|
||||
expect((await costByRow())['vertex-private-model-x (Vertex)']).toBe(0)
|
||||
setFlatRateModels([])
|
||||
setPriceOverrides({ 'vertex-private-model-x': { input: 0, output: 0 } })
|
||||
clearSessionCache()
|
||||
expect((await costByRow())['vertex-private-model-x (Vertex)']).toBe(0)
|
||||
})
|
||||
|
|
@ -895,7 +895,7 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
|
|||
expect(calls[0]!.cacheReadInputTokens).toBe(3000)
|
||||
expect(calls[0]!.cacheCreationInputTokens).toBe(1000)
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
expect(calls[0]!.model).toBe('anthropic/claude-sonnet-4-20250514')
|
||||
expect(calls[0]!.model).toBe('claude-sonnet-4-20250514')
|
||||
expect(calls[0]!.deduplicationKey).toBe('opencode:sess-1:session-level')
|
||||
})
|
||||
|
||||
|
|
@ -1481,7 +1481,7 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'ses_spaced_provider')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('openai/gpt-4o')
|
||||
expect(calls[0]!.model).toBe('gpt-4o')
|
||||
expect(calls[0]!.route).toBeUndefined()
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
|
@ -1504,13 +1504,39 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'ses_openrouter_roll')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
model: 'openrouter/cohere/north-mini-code:free',
|
||||
model: 'cohere/north-mini-code:free',
|
||||
route: 'openrouter',
|
||||
inputTokens: 22_928,
|
||||
costUSD: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('prices a Vertex session-level rollup on the bare id and keeps an unpriceable recorded cost as the fallback (#1547)', async () => {
|
||||
const dbPath = createV2TestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version,
|
||||
cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
model, time_created, time_updated, time_archived)
|
||||
VALUES (?, 'proj-1', NULL, 'slug-1', '/home/user/myproject', 't', '2.0.16',
|
||||
?, 1000, 100, 0, 0, 0, ?, 1700000000000, 1700000000000, NULL)
|
||||
`)
|
||||
insert.run('ses_vx_sonnet', 0.5, JSON.stringify({ id: 'claude-sonnet-5@default', providerID: 'google-vertex-anthropic' }))
|
||||
insert.run('ses_vx_private', 0.5, JSON.stringify({ id: 'vertex-private-model-x@default', providerID: 'google-vertex' }))
|
||||
insertV2Message(db, 'msg_vx_1', 'ses_vx_sonnet', 'assistant', 1, 1700000000100, { model: { id: 'claude-sonnet-5@default', providerID: 'google-vertex-anthropic' } })
|
||||
insertV2Message(db, 'msg_vx_2', 'ses_vx_private', 'assistant', 1, 1700000000100, { model: { id: 'vertex-private-model-x@default', providerID: 'google-vertex' } })
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const [sonnet] = await collectCalls(provider, dbPath, 'ses_vx_sonnet')
|
||||
expect(sonnet).toMatchObject({ model: 'claude-sonnet-5@default', route: 'vertex' })
|
||||
expect(sonnet!.costUSD).toBeCloseTo(1000 * 2e-6 + 100 * 10e-6, 12)
|
||||
expect(sonnet!.fallbackCostUSD).toBeUndefined()
|
||||
|
||||
const [priv] = await collectCalls(provider, dbPath, 'ses_vx_private')
|
||||
expect(priv).toMatchObject({ model: 'vertex-private-model-x@default', route: 'vertex', costUSD: 0.5, fallbackCostUSD: 0.5 })
|
||||
})
|
||||
|
||||
it('falls back to session_v2 rollups when v2 messages carry no tokens', async () => {
|
||||
const dbPath = createV2TestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
|
|
@ -1530,7 +1556,7 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const calls = await collectCalls(provider, dbPath, 'ses_roll')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('opencode/glm-5.3-flash')
|
||||
expect(calls[0]!.model).toBe('glm-5.3-flash')
|
||||
expect(calls[0]!.inputTokens).toBe(21410)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(112128)
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(0.0077, 4)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue