diff --git a/CHANGELOG.md b/CHANGELOG.md index a69cae22d..537a3f1d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -328,6 +328,10 @@ public 50 the old detector named an example's `samples.csv` and the input a fresh user-visible authorization instead. Credentials stored before this release are bound on their next successful refresh. Reported by Saku0512 (CWE-522). +- **A price tier now begins where it says it does.** Its start came from the + tier above ending, so a gap in the catalog charged the higher rate for + prompts too small to have reached it. A base tier written as `min: 1` is also + recognised now, instead of dropping the model from pricing altogether. - **Listing files now returns them in a stable order.** Files written at the same moment — by a checkout, a build, or a script — share a timestamp, so their order came from the filesystem and two identical searches could list diff --git a/backend/cli/src/provider/managed-pricing.ts b/backend/cli/src/provider/managed-pricing.ts index e010e1cf3..47c94431d 100644 --- a/backend/cli/src/provider/managed-pricing.ts +++ b/backend/cli/src/provider/managed-pricing.ts @@ -105,7 +105,10 @@ export namespace ManagedPricing { if (!MANAGED_OPENROUTER_MODEL_SET.has(model.id) || model.available === false) continue const pricing = metadata(model.pricing, model.hosting_provider, model.upstream_provider) if (!pricing) continue - const first = model.pricing.tiers.find((tier) => !tier.min_input_tokens) + // Token counts start at 1, so an explicit min of 1 is the base tier and + // means the same as omitting it. Looking it up by falsiness missed that + // spelling and dropped the whole model out of the catalog. + const first = model.pricing.tiers.find((tier) => !tier.min_input_tokens || tier.min_input_tokens <= 1) if (!first || (first.input === 0 && first.output === 0)) continue const cost = (tier: z.infer) => ({ input: tier.input, @@ -116,7 +119,10 @@ export namespace ManagedPricing { prices .map((tier, index) => ({ ...tier, - threshold: prices[index - 1]?.max_input_tokens ?? (tier.min_input_tokens ?? 1) - 1, + // A tier applies from its own min. The previous tier's max is the + // same number only when the catalog is contiguous, and preferring it + // charged a tier's rate from as early as the tier above ended. + threshold: (tier.min_input_tokens ?? 1) - 1, })) .filter((tier) => tier.min_input_tokens !== undefined && tier.threshold > 0) .map((tier) => ({ ...cost(tier), threshold: tier.threshold })) diff --git a/backend/cli/test/provider/managed-pricing-tiers.test.ts b/backend/cli/test/provider/managed-pricing-tiers.test.ts new file mode 100644 index 000000000..61a79322f --- /dev/null +++ b/backend/cli/test/provider/managed-pricing-tiers.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { ManagedPricing } from "../../src/provider/managed-pricing" + +const model = (tiers: unknown[]) => ({ + id: "anthropic/claude-opus-5.5", + context_length: 1_000_000, + upstream_provider: "anthropic", + hosting_provider: "anthropic", + pricing: { funding_fee_bps: 0, tiers }, +}) + +const price = (tiers: unknown[]) => ManagedPricing.parse({ models: [model(tiers)] })["anthropic/claude-opus-5.5"] + +describe("ManagedPricing tier thresholds", () => { + test("a tier starts where it declares, even when the previous one ends earlier", () => { + // The threshold came from the previous tier's max, so a gap in the catalog + // charged the 201+ rate from 101 tokens up. + const gap = price([ + { input: 2, output: 12, max_input_tokens: 100 }, + { input: 4, output: 18, min_input_tokens: 201 }, + ]) + expect(gap?.cost.tiers?.[0]?.threshold).toBe(200) + }) + + test("contiguous tiers are unaffected", () => { + const contiguous = price([ + { input: 2, output: 12, max_input_tokens: 100 }, + { input: 4, output: 18, min_input_tokens: 101 }, + ]) + expect(contiguous?.cost.tiers?.[0]?.threshold).toBe(100) + }) + + test("a selection above the gap but below the tier's min does not pick that tier", () => { + const gap = price([ + { input: 2, output: 12, max_input_tokens: 100 }, + { input: 4, output: 18, min_input_tokens: 201 }, + ]) + const threshold = gap?.cost.tiers?.[0]?.threshold ?? 0 + // Selection elsewhere is `promptTokens > threshold`, so a 150-token prompt + // must not satisfy a tier that only starts at 201. + expect(150 > threshold).toBe(false) + expect(201 > threshold).toBe(true) + }) + + test("an explicit base min of 1 still prices the model", () => { + // Token counts start at 1, so `min: 1` is the base tier, identical to an + // omitted min. The base was looked up with `!tier.min_input_tokens`, which + // is false for 1, so the model vanished from the catalog entirely. + const explicit = price([ + { input: 2, output: 12, min_input_tokens: 1 }, + { input: 4, output: 18, min_input_tokens: 200_001 }, + ]) + const omitted = price([ + { input: 2, output: 12 }, + { input: 4, output: 18, min_input_tokens: 200_001 }, + ]) + expect(explicit?.cost.input).toBe(2) + expect(explicit?.cost).toEqual(omitted?.cost) + }) +})