fix(cline-cli): stop billing cache tokens twice (#1675)

Cline CLI's metrics.inputTokens is the full prompt, cache reads and writes
included; Cline's own pricing bills max(0, input - cacheRead - cacheWrite)
at the input rate. We treated it as uncached input, so every cache read was
also charged at the full input rate. The split now happens in parseMetrics,
covering per-message and rollup calls.

Bumps the cline-cli parse version so cached calls re-parse and the daily
cache to v63 so settled days re-derive. Call counts are unchanged.
This commit is contained in:
Resham Joshi 2026-10-07 23:51:10 +02:00 • committed by GitHub
parent 4332b1762a
commit d049b38255
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 34 additions and 7 deletions

View file

@ -304,7 +304,11 @@ import type { DateRange, ProjectSummary } from './types.js'
// v62: Devin's SWE-2 prices at Cognition's list rate and swe-1-7-lightning as
// swe-1.7-lightning instead of $0. Only cost rises; call counts are unchanged,
// so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
export const DAILY_CACHE_VERSION = 62
// v63: Cline CLI input tokens include cache reads and writes, which were then
// billed again at the cache rates. Only input tokens and estimated cost fall;
// call counts are unchanged, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is
// needed.
export const DAILY_CACHE_VERSION = 63
const MIN_SUPPORTED_VERSION = 28
/// Providers whose per-day CALL COUNT means something different at

View file

@ -89,11 +89,15 @@ type ClineCliMetrics = {
function parseMetrics(value: unknown): ClineCliMetrics | null {
if (!isRecord(value)) return null
const cacheReadTokens = safeTokenCount(value['cacheReadTokens'])
const cacheWriteTokens = safeTokenCount(value['cacheWriteTokens'])
const metrics: ClineCliMetrics = {
inputTokens: safeTokenCount(value['inputTokens']),
// Cline's inputTokens is the full prompt, cache read and write included
// (its own pricing bills max(0, input - cacheRead - cacheWrite) at the input rate).
inputTokens: Math.max(0, safeTokenCount(value['inputTokens']) - cacheReadTokens - cacheWriteTokens),
outputTokens: safeTokenCount(value['outputTokens']),
cacheReadTokens: safeTokenCount(value['cacheReadTokens']),
cacheWriteTokens: safeTokenCount(value['cacheWriteTokens']),
cacheReadTokens,
cacheWriteTokens,
cost: safeNonNegativeNumber(value['cost']),
// A negative cost is not a credit we can represent — treat it as absent and
// fall back to token pricing, rather than reporting a clamped $0 as metered.

View file

@ -377,7 +377,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// reported-cost-v1: the CLI reports its own per-message cost, so entries
// cached before cline-cli joined the reported-cost allowlist in parser.ts
// hold costUSD: undefined and get re-priced from tokens on every read.
'cline-cli': 'reported-cost-v1-est-reprice-v1',
// cache-inclusive-input-v1: cached calls hold inputTokens with cache reads
// and writes still inside it, so they must re-parse.
'cline-cli': 'reported-cost-v1-est-reprice-v1-cache-inclusive-input-v1',
codewhale: 'aggregate-session-v1-est-cost',
'command-code': 'cache-inclusive-input-v1',
// Bump when the Codex parser changes attribution so unchanged, already-cached

View file

@ -4,6 +4,7 @@ import { join } from 'path'
import { tmpdir } from 'os'
import { clineCli, createClineCliProvider, getClineCliSessionsDir } from '../../src/providers/cline-cli.js'
import { calculateCost } from '../../src/models.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
@ -211,7 +212,7 @@ describe('cline-cli provider - parsing', () => {
const calls = await collect(tmpDir)
expect(calls).toHaveLength(2)
expect(calls.map(c => c.inputTokens)).toEqual([100, 200])
expect(calls.map(c => c.inputTokens)).toEqual([93, 200])
expect(calls.map(c => c.outputTokens)).toEqual([10, 20])
expect(calls[0]?.cacheReadInputTokens).toBe(5)
expect(calls[0]?.cacheCreationInputTokens).toBe(2)
@ -328,6 +329,22 @@ describe('cline-cli provider - parsing', () => {
expect(call?.costUSD).toBeGreaterThan(0)
})
it('takes cache reads out of the cache-inclusive input before estimating', async () => {
await writeSession(tmpDir, 'sess-a', {
messages: [{
role: 'assistant', text: 'a', model: 'cline-pass/deepseek-v4.1-flash',
metrics: { inputTokens: 14689, outputTokens: 220, cacheReadTokens: 11136, cacheWriteTokens: 0 },
}],
})
const [call] = await collect(tmpDir)
expect(call?.inputTokens).toBe(3553)
expect(call?.cacheReadInputTokens).toBe(11136)
expect(call?.costUSD).toBeCloseTo(calculateCost('cline-pass/deepseek-v4.1-flash', 3553, 220, 0, 11136, 0), 12)
expect(call?.costUSD).toBeGreaterThan(0)
})
it('keeps a metered $0 cost reported instead of re-estimating it', async () => {
await writeSession(tmpDir, 'sess-a', {
messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 1000, outputTokens: 100, cost: 0 } }],
@ -428,7 +445,7 @@ describe('cline-cli provider - rollup fallback', () => {
const calls = await collect(tmpDir)
expect(calls).toHaveLength(1)
expect(calls[0]?.inputTokens).toBe(5483)
expect(calls[0]?.inputTokens).toBe(5433)
expect(calls[0]?.outputTokens).toBe(133)
expect(calls[0]?.cacheReadInputTokens).toBe(50)
expect(calls[0]?.costUSD).toBeCloseTo(0.0081984, 7)