Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
91 changes: 91 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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* () {
Expand Down
Loading