From 4cdec544e6f46e92e6e8b1294f7ffa98482476f2 Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Wed, 2 Sep 2026 12:00:03 -0700 Subject: [PATCH 1/2] refactor(opencode): one price mapper for catalog and config cost tiers Andrew's simplification of #34: a single price() turns models.dev and config cost shapes into {input, output, cache}, the merge no longer sets experimentalOver200K alongside a config-derived 200K tier, and the two override tests collapse into one covering gpt-5.4 and grok-4. No pricing change beyond the legacy field now being undefined whenever tiers exist. Co-Authored-By: Andrew Wang Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/provider/provider.ts | 52 ++++------- .../opencode/test/provider/provider.test.ts | 89 ++++++------------- 2 files changed, 41 insertions(+), 100 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d11734dfd33f..53e5f5325461 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1157,39 +1157,19 @@ export class Service extends Context.Service()("@opencode/Pr export const use = serviceUse(Service) -function cost(c: ModelsDev.Model["cost"]): Model["cost"] { - const result: Model["cost"] = { +function price(c: { input?: number; output?: number; cache_read?: number; cache_write?: number } | undefined) { + return { input: c?.input ?? 0, output: c?.output ?? 0, - cache: { - read: c?.cache_read ?? 0, - write: c?.cache_write ?? 0, - }, - } - if (c?.tiers) { - result.tiers = c.tiers.map((item) => ({ - input: item.input, - output: item.output, - cache: { - read: item.cache_read ?? 0, - write: item.cache_write ?? 0, - }, - tier: item.tier, - })) - } - // NOTE (Gabe, 2026-09-02): models.dev spells a model's single long-context tier as - // context_over_200k even when its tiers put the threshold elsewhere (272K for gpt-5.x), and - // Session.getUsage falls back to this field below the tier size, so tiers own the threshold. - if (c?.context_over_200k && !result.tiers?.length) { - result.experimentalOver200K = { - cache: { - read: c.context_over_200k.cache_read ?? 0, - write: c.context_over_200k.cache_write ?? 0, - }, - input: c.context_over_200k.input, - output: c.context_over_200k.output, - } + cache: { read: c?.cache_read ?? 0, write: c?.cache_write ?? 0 }, } +} + +function cost(c: ModelsDev.Model["cost"]): Model["cost"] { + const result: Model["cost"] = price(c) + if (c?.tiers) result.tiers = c.tiers.map((item) => ({ ...price(item), tier: item.tier })) + // NOTE (Gabe, 2026-09-02): models.dev keeps context_over_200k even when tiers put the threshold elsewhere (272K for gpt-5.x). + if (c?.context_over_200k && !c.tiers?.length) result.experimentalOver200K = price(c.context_over_200k) return result } @@ -1425,9 +1405,7 @@ const layer = Layer.effect( if (model.id && model.id !== modelID) return modelID return existingModel?.name ?? modelID }) - // NOTE (Gabe, 2026-09-02): a config context_over_200k prices everything above 200K, but - // Session.getUsage consults tiers first, so it rides as a 200K tier above the catalog's lower ones. - const over200k = model.cost?.context_over_200k ? cost(model.cost).experimentalOver200K : undefined + const over200K = model.cost?.context_over_200k && price(model.cost.context_over_200k) const parsedModel: Model = { id: ModelV2.ID.make(modelID), api: { @@ -1474,14 +1452,14 @@ const layer = Layer.effect( read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, }, - // NOTE (Gabe, 2026-09-02): config cannot express tiers, so an entry that only names a model keeps the catalog's. - tiers: over200k + // NOTE (Gabe, 2026-09-02): Session.getUsage reads tiers before experimentalOver200K, so a config price rides as the 200K tier. + tiers: over200K ? [ ...(existingModel?.cost?.tiers ?? []).filter((item) => item.tier.size < 200_000), - { ...over200k, tier: { type: "context", size: 200_000 } }, + { ...over200K, tier: { type: "context", size: 200_000 } }, ] : existingModel?.cost?.tiers, - experimentalOver200K: over200k ?? existingModel?.cost?.experimentalOver200K, + experimentalOver200K: over200K ? undefined : existingModel?.cost?.experimentalOver200K, }, options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), limit: { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index b9930f1b27dc..85c4d5489e1b 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -937,89 +937,52 @@ it.instance( }, ) +const price = (model: Provider.Model, inputTokens: number) => + Session.getUsage({ model, usage: new Usage({ inputTokens, outputTokens: 0, totalTokens: inputTokens }) }).cost + it.instance( - "config entry that only names a model keeps the database over-200k pricing", + "config entry that only names a model keeps the catalog's context tiers", Effect.gen(function* () { yield* set("OPENROUTER_API_KEY", "test-api-key") - const providers = yield* list - const model = providers[ProviderV2.ID.make("openrouter")].models["openai/gpt-5.4"] - expect(model.cost.input).toBe(2.5) + const model = (yield* list)[ProviderV2.ID.make("openrouter")].models["openai/gpt-5.4"] expect(model.cost.tiers).toEqual([ { input: 5, output: 22.5, cache: { read: 0.5, write: 0 }, tier: { type: "context", size: 272_000 } }, ]) - // NOTE (Gabe, 2026-09-02): OpenRouter's override for gpt-5.x starts at 272K, so the legacy - // over-200k field must not price the 200K-272K band at the tier rate. + // OpenRouter bills gpt-5.x at base until 272K, so the legacy over-200k field must not outrank the tier. expect(model.cost.experimentalOver200K).toBeUndefined() - const price = (inputTokens: number) => - Session.getUsage({ model, usage: new Usage({ inputTokens, outputTokens: 0, totalTokens: inputTokens }) }).cost - expect(price(250_000)).toBeCloseTo(0.625, 6) - expect(price(300_000)).toBeCloseTo(1.5, 6) + expect(price(model, 250_000)).toBeCloseTo(0.625, 6) + expect(price(model, 300_000)).toBeCloseTo(1.5, 6) }), - { - config: { - provider: { - openrouter: { - models: { "openai/gpt-5.4": { options: {} } }, - }, - }, - }, - }, + { config: { provider: { openrouter: { models: { "openai/gpt-5.4": { options: {} } } } } } }, ) it.instance( - "config over-200k cost overrides the database over-200k pricing", + "config over-200k cost becomes the 200K tier above the catalog's lower tiers", Effect.gen(function* () { yield* set("OPENROUTER_API_KEY", "test-api-key") - const providers = yield* list - const model = providers[ProviderV2.ID.make("openrouter")].models["openai/gpt-5.4"] - expect(model.cost.experimentalOver200K).toEqual({ - input: 7, - output: 30, - cache: { read: 0.7, write: 0 }, - }) - // NOTE (Gabe, 2026-09-02): the catalog's 272K tier would otherwise outrank the configured - // over-200k price in Session.getUsage for every context above 272K. - expect(model.cost.tiers).toEqual([ - { input: 7, output: 30, cache: { read: 0.7, write: 0 }, tier: { type: "context", size: 200_000 } }, - ]) - const priced = Session.getUsage({ - model, - usage: new Usage({ inputTokens: 300_000, outputTokens: 0, totalTokens: 300_000 }), - }) - expect(priced.cost).toBeCloseTo(2.1, 6) - }), - { - config: { - provider: { - openrouter: { - models: { "openai/gpt-5.4": { cost: { input: 3.5, output: 15, context_over_200k: { input: 7, output: 30, cache_read: 0.7 } } } }, - }, - }, - }, - }, -) - -it.instance( - "config over-200k cost keeps the database tiers below 200k", - Effect.gen(function* () { - yield* set("OPENROUTER_API_KEY", "test-api-key") - const providers = yield* list - const model = providers[ProviderV2.ID.make("openrouter")].models["x-ai/grok-4"] - expect(model.cost.tiers).toEqual([ + const models = (yield* list)[ProviderV2.ID.make("openrouter")].models + const over200K = { input: 7, output: 30, cache: { read: 0.7, write: 0 }, tier: { type: "context" as const, size: 200_000 } } + expect(models["openai/gpt-5.4"].cost.tiers).toEqual([over200K]) + expect(models["x-ai/grok-4"].cost.tiers).toEqual([ { input: 6, output: 30, cache: { read: 0, write: 0 }, tier: { type: "context", size: 128_000 } }, - { input: 9, output: 45, cache: { read: 0, write: 0 }, tier: { type: "context", size: 200_000 } }, + over200K, ]) - const price = (inputTokens: number) => - Session.getUsage({ model, usage: new Usage({ inputTokens, outputTokens: 0, totalTokens: inputTokens }) }).cost - expect(price(100_000)).toBeCloseTo(0.3, 6) - expect(price(150_000)).toBeCloseTo(0.9, 6) - expect(price(300_000)).toBeCloseTo(2.7, 6) + expect(models["x-ai/grok-4"].cost.experimentalOver200K).toBeUndefined() + expect(price(models["x-ai/grok-4"], 150_000)).toBeCloseTo(0.9, 6) + expect(price(models["x-ai/grok-4"], 300_000)).toBeCloseTo(2.1, 6) }), { config: { provider: { openrouter: { - models: { "x-ai/grok-4": { cost: { input: 3, output: 15, context_over_200k: { input: 9, output: 45 } } } }, + models: { + "openai/gpt-5.4": { + cost: { input: 3.5, output: 15, context_over_200k: { input: 7, output: 30, cache_read: 0.7 } }, + }, + "x-ai/grok-4": { + cost: { input: 3, output: 15, context_over_200k: { input: 7, output: 30, cache_read: 0.7 } }, + }, + }, }, }, }, From 54c1933a3db3f91fc74900eecf8fc6789faf3268 Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Wed, 2 Sep 2026 14:09:58 -0700 Subject: [PATCH 2/2] style(opencode): NOTE prefix on the tier test comment Co-Authored-By: Claude Fable 5.1 --- packages/opencode/test/provider/provider.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 85c4d5489e1b..1a29ffd884fe 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -948,7 +948,7 @@ it.instance( expect(model.cost.tiers).toEqual([ { input: 5, output: 22.5, cache: { read: 0.5, write: 0 }, tier: { type: "context", size: 272_000 } }, ]) - // OpenRouter bills gpt-5.x at base until 272K, so the legacy over-200k field must not outrank the tier. + // NOTE (Gabe, 2026-09-02): OpenRouter bills gpt-5.x at base until 272K, so the legacy over-200k field must not outrank the tier. expect(model.cost.experimentalOver200K).toBeUndefined() expect(price(model, 250_000)).toBeCloseTo(0.625, 6) expect(price(model, 300_000)).toBeCloseTo(1.5, 6)