From 209738561889914f6dbbec6be4251643039847bb Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Wed, 2 Sep 2026 09:45:39 -0700 Subject: [PATCH 1/6] fix(opencode): keep over-200k cost tiers through the config model merge A config entry that names a catalog model rebuilt cost from input, output and cache only, dropping the catalog's tiers and experimentalOver200K, so every step above 200K context was priced at the base rate. Carry both through, and map a config context_over_200k when one is given. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/provider/provider.ts | 13 +++++ .../opencode/test/provider/provider.test.ts | 48 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cf6917a2c962..af0fcce94c70 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1468,6 +1468,19 @@ const layer = Layer.effect( read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, }, + // Config cannot express tiers, and a config entry that only names a model + // (the common case) must not price its long-context steps at the base rate. + tiers: existingModel?.cost?.tiers, + experimentalOver200K: model?.cost?.context_over_200k + ? { + input: model.cost.context_over_200k.input, + output: model.cost.context_over_200k.output, + cache: { + read: model.cost.context_over_200k.cache_read ?? 0, + write: model.cost.context_over_200k.cache_write ?? 0, + }, + } + : 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 18ec8f9fbeeb..3be63e1472c4 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -935,6 +935,54 @@ it.instance( }, ) +it.instance( + "config entry that only names a model keeps the database over-200k pricing", + 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) + expect(model.cost.experimentalOver200K).toEqual({ + input: 5, + output: 22.5, + cache: { read: 0.5, write: 0 }, + }) + expect(model.cost.tiers?.[0]?.tier).toEqual({ type: "context", size: 272_000 }) + }), + { + config: { + provider: { + openrouter: { + models: { "openai/gpt-5.4": { options: {} } }, + }, + }, + }, + }, +) + +it.instance( + "config over-200k cost overrides the database over-200k pricing", + 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 }, + }) + }), + { + 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( "completely new provider not in database can be configured", Effect.gen(function* () { From 5634a091e6f11f5067e5e0b744cae1ca4128027d Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Wed, 2 Sep 2026 10:43:38 -0700 Subject: [PATCH 2/6] fix(opencode): drop catalog context tiers when config prices over-200k itself Session.getUsage consults cost.tiers before experimentalOver200K, so a config entry that supplied its own context_over_200k would still be priced at the catalog tier above that tier's size. When config prices the long-context range itself, the merged model now carries no catalog context tiers, and the override test asserts the price through Session.getUsage at 300K tokens. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/provider/provider.ts | 5 ++++- packages/opencode/test/provider/provider.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index af0fcce94c70..416305589184 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1470,7 +1470,10 @@ const layer = Layer.effect( }, // Config cannot express tiers, and a config entry that only names a model // (the common case) must not price its long-context steps at the base rate. - tiers: existingModel?.cost?.tiers, + // A config `context_over_200k` governs every context above 200K, so the + // catalog's context tiers (which session.ts consults first) are dropped + // rather than letting them shadow the configured price above their size. + tiers: model?.cost?.context_over_200k ? undefined : existingModel?.cost?.tiers, experimentalOver200K: model?.cost?.context_over_200k ? { input: model.cost.context_over_200k.input, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 3be63e1472c4..5fc73d2aed8d 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -15,6 +15,8 @@ import { Config } from "@/config/config" import { Env } from "../../src/env" import { Plugin } from "../../src/plugin/index" import { Provider } from "@/provider/provider" +import { Session } from "@/session/session" +import { Usage } from "@opencode-ai/llm" import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" @@ -971,6 +973,14 @@ it.instance( output: 30, cache: { read: 0.7, write: 0 }, }) + // The catalog's 272K context tier would otherwise outrank the configured + // over-200k price in Session.getUsage for every context above 272K. + expect(model.cost.tiers).toBeUndefined() + const priced = Session.getUsage({ + model, + usage: new Usage({ inputTokens: 300_000, outputTokens: 0, totalTokens: 300_000 }), + }) + expect(priced.cost).toBeCloseTo(2.1, 6) }), { config: { From f2ae4e742c693f664e85e41b286f4b8f240a483c Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Wed, 2 Sep 2026 10:46:25 -0700 Subject: [PATCH 3/6] refactor(opencode): map the config over-200k price through the catalog cost converter The config cost shape is a structural subset of the models.dev cost, so the existing cost() helper builds experimentalOver200K instead of a second hand-copied mapping. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/provider/provider.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 416305589184..dc293ca925b1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1468,21 +1468,12 @@ const layer = Layer.effect( read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, }, - // Config cannot express tiers, and a config entry that only names a model - // (the common case) must not price its long-context steps at the base rate. - // A config `context_over_200k` governs every context above 200K, so the - // catalog's context tiers (which session.ts consults first) are dropped - // rather than letting them shadow the configured price above their size. + // Config cannot express tiers, so a config entry that only names a model keeps the + // catalog's. A config context_over_200k prices everything above 200K itself, and + // Session.getUsage consults tiers first, so it must not inherit tiers that shadow it. tiers: model?.cost?.context_over_200k ? undefined : existingModel?.cost?.tiers, experimentalOver200K: model?.cost?.context_over_200k - ? { - input: model.cost.context_over_200k.input, - output: model.cost.context_over_200k.output, - cache: { - read: model.cost.context_over_200k.cache_read ?? 0, - write: model.cost.context_over_200k.cache_write ?? 0, - }, - } + ? cost(model.cost).experimentalOver200K : existingModel?.cost?.experimentalOver200K, }, options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), From b9d86d66991e98529459282ce6596d50bbb3a85d Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Wed, 2 Sep 2026 10:51:09 -0700 Subject: [PATCH 4/6] fix(opencode): keep catalog tiers below 200k under a config over-200k price Dropping every catalog tier lost sub-200K tiers such as grok-4's 128K tier, while keeping them would let them outrank the configured price above 200K because Session.getUsage consults tiers first. The config context_over_200k is now carried as a 200K context tier above the catalog tiers below it, mirroring the v1 config migration. Covered at 100K, 150K and 300K tokens against the fixture's x-ai/grok-4. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/provider/provider.ts | 18 ++++++----- .../opencode/test/provider/provider.test.ts | 31 ++++++++++++++++++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index dc293ca925b1..e6ed0110a9c3 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1422,6 +1422,9 @@ const layer = Layer.effect( if (model.id && model.id !== modelID) return modelID return existingModel?.name ?? modelID }) + // NOTE: a config context_over_200k prices everything above 200K itself. Session.getUsage + // consults tiers first, so it is carried as a 200K tier above any catalog tiers below it. + const over200k = model.cost?.context_over_200k ? cost(model.cost).experimentalOver200K : undefined const parsedModel: Model = { id: ModelV2.ID.make(modelID), api: { @@ -1468,13 +1471,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, }, - // Config cannot express tiers, so a config entry that only names a model keeps the - // catalog's. A config context_over_200k prices everything above 200K itself, and - // Session.getUsage consults tiers first, so it must not inherit tiers that shadow it. - tiers: model?.cost?.context_over_200k ? undefined : existingModel?.cost?.tiers, - experimentalOver200K: model?.cost?.context_over_200k - ? cost(model.cost).experimentalOver200K - : existingModel?.cost?.experimentalOver200K, + // Config cannot express tiers, so a config entry that only names a model keeps the catalog's. + tiers: over200k + ? [ + ...(existingModel?.cost?.tiers ?? []).filter((item) => item.tier.size < 200_000), + { ...over200k, tier: { type: "context", size: 200_000 } }, + ] + : existingModel?.cost?.tiers, + experimentalOver200K: over200k ?? 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 5fc73d2aed8d..952948360a66 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -975,7 +975,9 @@ it.instance( }) // The catalog's 272K context tier would otherwise outrank the configured // over-200k price in Session.getUsage for every context above 272K. - expect(model.cost.tiers).toBeUndefined() + 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 }), @@ -993,6 +995,33 @@ it.instance( }, ) +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([ + { 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 } }, + ]) + 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) + }), + { + config: { + provider: { + openrouter: { + models: { "x-ai/grok-4": { cost: { input: 3, output: 15, context_over_200k: { input: 9, output: 45 } } } }, + }, + }, + }, + }, +) + it.instance( "completely new provider not in database can be configured", Effect.gen(function* () { From 709983cb428e549a5b38d1115a64d89eaec8728e Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Wed, 2 Sep 2026 10:55:24 -0700 Subject: [PATCH 5/6] fix(opencode): let catalog tiers own the threshold over the legacy over-200k field models.dev spells a model's single long-context tier as context_over_200k even when its tiers place the threshold elsewhere: OpenRouter's override for openai/gpt-5.4 and gpt-5.6-terra starts at 272K, Gemini 3.1 Pro at 200K. Session.getUsage consults tiers first but falls back to experimentalOver200K below the tier size, so a 250K-token step on gpt-5.x was priced at the tier rate while OpenRouter bills it at base. The catalog converter now sets experimentalOver200K only when the model has no tiers, which makes catalog-only and config-named models agree. Tracks the same defect as anomalyco/opencode#44224. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/provider/provider.ts | 5 ++++- packages/opencode/test/provider/provider.test.ts | 16 ++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e6ed0110a9c3..317232ff3c6a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1177,7 +1177,10 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { tier: item.tier, })) } - if (c?.context_over_200k) { + // NOTE: 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). Session.getUsage falls back to + // this field below the tier size, so the tiers own the threshold whenever they exist. + if (c?.context_over_200k && !result.tiers?.length) { result.experimentalOver200K = { cache: { read: c.context_over_200k.cache_read ?? 0, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 952948360a66..26177d118190 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -944,12 +944,16 @@ it.instance( const providers = yield* list const model = providers[ProviderV2.ID.make("openrouter")].models["openai/gpt-5.4"] expect(model.cost.input).toBe(2.5) - expect(model.cost.experimentalOver200K).toEqual({ - input: 5, - output: 22.5, - cache: { read: 0.5, write: 0 }, - }) - expect(model.cost.tiers?.[0]?.tier).toEqual({ type: "context", size: 272_000 }) + expect(model.cost.tiers).toEqual([ + { input: 5, output: 22.5, cache: { read: 0.5, write: 0 }, tier: { type: "context", size: 272_000 } }, + ]) + // 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. + 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) }), { config: { From 34663afd40b29ffa6e40066c6b0d281142c333e4 Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Wed, 2 Sep 2026 11:06:04 -0700 Subject: [PATCH 6/6] style(opencode): andytown NOTE format for the tier comments Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/provider/provider.ts | 12 ++++++------ packages/opencode/test/provider/provider.test.ts | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 317232ff3c6a..d11734dfd33f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1177,9 +1177,9 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { tier: item.tier, })) } - // NOTE: 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). Session.getUsage falls back to - // this field below the tier size, so the tiers own the threshold whenever they exist. + // 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: { @@ -1425,8 +1425,8 @@ const layer = Layer.effect( if (model.id && model.id !== modelID) return modelID return existingModel?.name ?? modelID }) - // NOTE: a config context_over_200k prices everything above 200K itself. Session.getUsage - // consults tiers first, so it is carried as a 200K tier above any catalog tiers below it. + // 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 parsedModel: Model = { id: ModelV2.ID.make(modelID), @@ -1474,7 +1474,7 @@ const layer = Layer.effect( read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, }, - // Config cannot express tiers, so a config entry that only names a model keeps the catalog's. + // NOTE (Gabe, 2026-09-02): config cannot express tiers, so an entry that only names a model keeps the catalog's. tiers: over200k ? [ ...(existingModel?.cost?.tiers ?? []).filter((item) => item.tier.size < 200_000), diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 26177d118190..b9930f1b27dc 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -947,8 +947,8 @@ 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'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. + // 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. expect(model.cost.experimentalOver200K).toBeUndefined() const price = (inputTokens: number) => Session.getUsage({ model, usage: new Usage({ inputTokens, outputTokens: 0, totalTokens: inputTokens }) }).cost @@ -977,7 +977,7 @@ it.instance( output: 30, cache: { read: 0.7, write: 0 }, }) - // The catalog's 272K context tier would otherwise outrank the configured + // 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 } },