diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index edb74792f7..a148f3a599 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -129,10 +129,24 @@ function normalizeVersionSeparators(model: string): string { * Family fallbacks ending on a letter get no such guard — a version welded * straight onto the name (`llama3.1`) is exactly what they exist to catch. */ -export function buildModelMatchers(entries: [name: string, value: T][]): [RegExp, T][] { +export function buildModelMatchers( + entries: [name: string, value: T][], + { strictVariants = false }: { strictVariants?: boolean } = {} +): [RegExp, T][] { return entries.map(([name, value]) => { const pattern = normalizeVersionSeparators(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return [new RegExp(/\d$/.test(pattern) ? `${pattern}(?!\\d)` : pattern), value] + const guards = [ + // An entry ending on a version digit must not run into a longer version. + /\d$/.test(pattern) ? '(?!\\d)' : '', + // A named sub-model (`gpt-5-pro`, `gpt-5-mini`) is a different model with + // its own price, not another route to this one — so under strictVariants an + // entry does not match when a further *name* segment follows. Date suffixes + // (`-20251101`) and Bedrock's `-v1` are route decorations, not sub-models, + // and still match. 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)[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 7d77d32466..c34e125d04 100644 --- a/frontend/src/lib/components/copilot/modelPricing.test.ts +++ b/frontend/src/lib/components/copilot/modelPricing.test.ts @@ -27,15 +27,26 @@ describe('resolveModelPrice', () => { expect(resolveModelPrice('customai', 'some-in-house-model', undefined)).toBeUndefined() }) - it('does not let a newer revision inherit an older one’s price', () => { - // `gpt-5.6` normalizes to `gpt-5-6`, which the `gpt-5` entry would otherwise - // claim — pricing a model we do not track at a rate that is several times off. + it('does not let another model inherit a price through a shared prefix', () => { + // A sub-model (`-pro`) or a newer revision (`gpt-5.6` → `gpt-5-6`) is a + // different model at a different rate; inheriting `gpt-5`'s would be off by + // an order of magnitude, and silently so. expect(resolveModelPrice('openai', 'gpt-5', undefined)?.price.input).toBe(1.25) expect(resolveModelPrice('openai', 'gpt-5-mini', undefined)?.price.input).toBe(0.25) + expect(resolveModelPrice('openai', 'gpt-5-pro', undefined)).toBeUndefined() expect(resolveModelPrice('openai', 'gpt-5.6', undefined)).toBeUndefined() expect(resolveModelPrice('googleai', 'gemini-3.1', undefined)).toBeUndefined() }) + it('still resolves the route decorations that name the same model', () => { + // Dates and Bedrock's -v1 are ways of spelling one model, not sub-models. + expect(resolveModelPrice('anthropic', 'claude-opus-4-5-20251101', undefined)?.price.input).toBe(5) + expect( + resolveModelPrice('bedrock', 'anthropic.claude-sonnet-4-6-20250101-v1:0', undefined)?.price + .input + ).toBe(3) + }) + it('prefers a workspace override, keeping the model’s own cache ratios', () => { const resolved = resolveModelPrice('anthropic', 'claude-opus-5', { 'anthropic:claude-opus-5': { input: 2, output: 8 } diff --git a/frontend/src/lib/components/copilot/modelPricing.ts b/frontend/src/lib/components/copilot/modelPricing.ts index fce42915b3..69218b6e7d 100644 --- a/frontend/src/lib/components/copilot/modelPricing.ts +++ b/frontend/src/lib/components/copilot/modelPricing.ts @@ -53,10 +53,17 @@ type PriceEntry = { * Providers whose catalogue turns over too quickly to track (DeepSeek, Mistral, * Groq, TogetherAI, custom deployments) are deliberately absent. * - * `null` marks a model that is known to exist but whose rates are not: it stops a - * newer revision from falling through to an older one's family entry and being - * quietly mispriced. Unpriced is a supported state (the UI says so and points at - * the override); a confidently wrong number is not. + * `null` marks a model that is known to exist but whose rates are not. Unpriced is + * a supported state (the UI says so and points at the override); a confidently + * wrong number is not — which is also why these matchers are built with + * `strictVariants`, so an unlisted sub-model (`gpt-5-pro`) reports no rate instead + * of inheriting its family's. + * + * One known gap the per-model shape cannot express: a few vendors charge more + * above a context threshold (Gemini 2.5 Pro past 200k input, Anthropic's 1M-context + * beta). Usage is aggregated per model before pricing, so those requests are + * estimated at the standard tier and understate. An affected workspace can set the + * higher rate as its override. */ const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [ // Anthropic — Opus 4.1 and older bill at the pre-4.5 Opus rate, so the family @@ -84,8 +91,9 @@ const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [ // rate. There is no charge for writing the cache and no usage field reporting // one, so the write rate never applies. The -mini/-nano entries must precede // the family entry, which would otherwise claim them. - // Revisions past gpt-5 are priced separately by OpenAI and are not tracked here; - // without these the `gpt-5` entry below would claim them at gpt-5's rates. + // Revisions past gpt-5 are priced separately by OpenAI and are not tracked here. + // `strictVariants` does not cover these: a version bump is digits, not a name + // segment, so `gpt-5-6` would otherwise still match `gpt-5`. ['gpt-5.6', null], ['gpt-5.5', null], ['gpt-5.4', null], @@ -120,7 +128,8 @@ const MODEL_PRICE_MATCHERS = buildModelMatchers( cacheRead: entry.cacheRead ?? entry.input * CACHE_READ_RATIO, cacheWrite: entry.cacheWrite ?? entry.input * CACHE_WRITE_RATIO } - ]) + ]), + { strictVariants: true } ) export function getKnownModelPrice(model: string): ModelPrice | undefined {