fix: count OpenRouter cache writes and drop unverifiable rates

This commit is contained in:
hugocasa
2026-08-13 20:57:50 +02:00
parent 2c8ad2df6a
commit c427784e60
5 changed files with 29 additions and 13 deletions
+6
View File
@@ -190,6 +190,12 @@ pub const MAX_MODEL_RATE: f64 = 1000.0;
/// which never deserializes it into `AIConfig`, so the typed check on the
/// workspace path does not cover it.
pub fn validate_model_pricing_json(ai_config: &serde_json::Value) -> Result<(), String> {
// The container itself has to be checked too: a non-object `ai_config` persists
// here and then fails to deserialize as `AIConfig`, which drops the whole
// instance config back to its default for every workspace inheriting it.
if !ai_config.is_null() && !ai_config.is_object() {
return Err("ai_config must be an object".to_string());
}
let pricing = match ai_config.get("model_pricing") {
None | Some(serde_json::Value::Null) => return Ok(()),
// A present-but-wrong shape must be rejected, not skipped: it would persist
@@ -193,7 +193,12 @@ export function openAICompletionsUsageToChatTokenUsage(
prompt_tokens?: number | null
completion_tokens?: number | null
total_tokens?: number | null
prompt_tokens_details?: { cached_tokens?: number | null } | null
prompt_tokens_details?: {
cached_tokens?: number | null
/** OpenRouter surfaces Anthropic's cache creation here; OpenAI, whose
* caching is automatic and unbilled, reports no such field. */
cache_write_tokens?: number | null
} | null
/** OpenRouter reports what it actually charged when the request opts in. */
cost?: number | null
}
@@ -208,7 +213,7 @@ export function openAICompletionsUsageToChatTokenUsage(
completion,
total: usage?.total_tokens ?? prompt + completion,
cacheRead: usage?.prompt_tokens_details?.cached_tokens ?? 0,
cacheWrite: 0,
cacheWrite: usage?.prompt_tokens_details?.cache_write_tokens ?? 0,
...(typeof usage?.cost === 'number' ? { cost: usage.cost } : {})
}
}
@@ -145,11 +145,14 @@ export function buildModelMatchers<T>(
// its own price, not another route to this one — so under strictVariants an
// entry does not match when a further *name* segment follows. What follows
// is only a decoration when it is a date (`-20251101`), Bedrock's `-v1`, or
// one of the alias words below (`claude-3-5-haiku-latest` is the same model
// as `claude-3-5-haiku`, and is a shipped default). Off by default: for a
// context window an inherited value is a safe approximation, for a price it
// is a wrong number.
strictVariants ? `(?!-(?!v\\d|${DECORATIVE_SUFFIXES.join('|')})[a-z])` : ''
// one of the alias words below, at the very end of the id
// (`claude-3-5-haiku-latest` is the same model as `claude-3-5-haiku`, and is
// a shipped default; `gpt-5-preview-pro` would be a different one again).
// Off by default: for a context window an inherited value is a safe
// approximation, for a price it is a wrong number.
strictVariants
? `(?!-(?!(?:v\\d|${DECORATIVE_SUFFIXES.join('|')})$)[a-z])`
: ''
].join('')
return [new RegExp(pattern + guards), value]
})
@@ -53,8 +53,10 @@ describe('resolveModelPrice', () => {
expect(
resolveModelPrice('openrouter', '~anthropic/claude-sonnet-latest', undefined)?.price.input
).toBe(3)
// …while a genuine sub-model stays unpriced.
// …while a genuine sub-model stays unpriced, including one hiding behind a
// decoration.
expect(resolveModelPrice('openai', 'gpt-5-pro', undefined)).toBeUndefined()
expect(resolveModelPrice('openai', 'gpt-5-preview-pro', undefined)).toBeUndefined()
})
it('prefers a workspace override, keeping the models own cache ratios', () => {
@@ -110,13 +110,13 @@ const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [
['o4-mini', { input: 1.1, output: 4.4, cacheRead: 0.275 }],
['o3-mini', { input: 1.1, output: 4.4, cacheRead: 0.55 }],
['o3', { input: 2, output: 8, cacheRead: 0.5 }],
// Google — a cached read is a quarter of input across the 2.5 family; the 3.x
// families are not tracked
// Google — left unpriced. Gemini's cached-input rates do not follow a single
// ratio and its Pro tier charges more above a context threshold, which a flat
// per-model rate cannot express; an estimate here would be wrong in a direction
// nobody can see. A workspace that runs Gemini sets its own rates.
['gemini-3.1', null],
['gemini-3', null],
['gemini-2.5-flash-lite', { input: 0.1, output: 0.4, cacheRead: 0.025 }],
['gemini-2.5-flash', { input: 0.3, output: 2.5, cacheRead: 0.075 }],
['gemini-2.5-pro', { input: 1.25, output: 10, cacheRead: 0.31 }]
['gemini-2.5', null]
]
const MODEL_PRICE_MATCHERS = buildModelMatchers(