From c427784e605485bed09714bd301167a5ac0b6af2 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 13 Aug 2026 20:57:50 +0200 Subject: [PATCH] fix: count OpenRouter cache writes and drop unverifiable rates --- backend/windmill-ai/src/ai_types.rs | 6 ++++++ .../src/lib/components/copilot/chat/tokenUsage.ts | 9 +++++++-- frontend/src/lib/components/copilot/modelConfig.ts | 13 ++++++++----- .../src/lib/components/copilot/modelPricing.test.ts | 4 +++- frontend/src/lib/components/copilot/modelPricing.ts | 10 +++++----- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/backend/windmill-ai/src/ai_types.rs b/backend/windmill-ai/src/ai_types.rs index 3f2d96aeba..69c8ac4538 100644 --- a/backend/windmill-ai/src/ai_types.rs +++ b/backend/windmill-ai/src/ai_types.rs @@ -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 diff --git a/frontend/src/lib/components/copilot/chat/tokenUsage.ts b/frontend/src/lib/components/copilot/chat/tokenUsage.ts index cb38d6adbd..d02231ef4c 100644 --- a/frontend/src/lib/components/copilot/chat/tokenUsage.ts +++ b/frontend/src/lib/components/copilot/chat/tokenUsage.ts @@ -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 } : {}) } } diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index 42e1f8df37..c957e1a06e 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -145,11 +145,14 @@ export function buildModelMatchers( // 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] }) diff --git a/frontend/src/lib/components/copilot/modelPricing.test.ts b/frontend/src/lib/components/copilot/modelPricing.test.ts index 4d87a81360..6c256e6bec 100644 --- a/frontend/src/lib/components/copilot/modelPricing.test.ts +++ b/frontend/src/lib/components/copilot/modelPricing.test.ts @@ -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 model’s own cache ratios', () => { diff --git a/frontend/src/lib/components/copilot/modelPricing.ts b/frontend/src/lib/components/copilot/modelPricing.ts index 69218b6e7d..9d5459406c 100644 --- a/frontend/src/lib/components/copilot/modelPricing.ts +++ b/frontend/src/lib/components/copilot/modelPricing.ts @@ -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(