diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cf6917a2c962..d11734dfd33f 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 (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, @@ -1422,6 +1425,9 @@ 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 parsedModel: Model = { id: ModelV2.ID.make(modelID), api: { @@ -1468,6 +1474,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 + ? [ + ...(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 18ec8f9fbeeb..b9930f1b27dc 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" @@ -935,6 +937,95 @@ 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.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. + 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: { + 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 }, + }) + // 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([ + { 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* () {