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
52 changes: 15 additions & 37 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1157,39 +1157,19 @@ export class Service extends Context.Service<Service, Interface>()("@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
}

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
89 changes: 26 additions & 63 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// 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()
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])
Comment thread
goleary marked this conversation as resolved.
Resolved
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 } },
},
},
},
},
},
Expand Down
Loading