Skip to content
Open
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
57 changes: 44 additions & 13 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,21 @@ export const ConfigProvidersResult = Schema.Struct({
export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>

export function toPublicInfo(provider: Info): Info {
const result = cloneInfo(provider)
for (const model of Object.values(result.models)) {
if (model.cost.experimentalOver200K) continue
if (model.cost.tiers?.length !== 1) continue
const [tier] = model.cost.tiers
model.cost.experimentalOver200K = {
input: tier.input,
output: tier.output,
cache: { ...tier.cache },
}
}
return result
}

function cloneInfo(provider: Info): Info {
return JSON.parse(
JSON.stringify(
{
Expand Down Expand Up @@ -1202,27 +1217,28 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
write: c?.cache_write ?? 0,
},
}
if (c?.tiers) {
result.tiers = c.tiers.map((item) => ({
const 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,
}))
}
if (c?.context_over_200k) {
result.experimentalOver200K = {
})) ?? []
const legacy = c?.context_over_200k
if (legacy && tiers.length === 0)
tiers.push({
input: legacy.input,
output: legacy.output,
cache: {
read: c.context_over_200k.cache_read ?? 0,
write: c.context_over_200k.cache_write ?? 0,
read: legacy.cache_read ?? 0,
write: legacy.cache_write ?? 0,
},
input: c.context_over_200k.input,
output: c.context_over_200k.output,
}
}
tier: { type: "context", size: 200_000 },
})
if (tiers.length > 0) result.tiers = tiers
return result
}

Expand Down Expand Up @@ -1378,7 +1394,7 @@ const layer = Layer.effect(
const cfg = yield* config.get()
const modelsDev = yield* modelsDevSvc.get()
const catalog = mapValues(modelsDev, fromModelsDevProvider)
const database = mapValues(catalog, toPublicInfo)
const database = mapValues(catalog, cloneInfo)

const providers: Record<ProviderV2.ID, Info> = {} as Record<ProviderV2.ID, Info>
const languages = new Map<string, LanguageModelV3>()
Expand Down Expand Up @@ -1482,6 +1498,20 @@ const layer = Layer.effect(
if (model.id && model.id !== modelID) return modelID
return existingModel?.name ?? modelID
})
const legacy = existingModel?.cost.tiers?.length ? undefined : model.cost?.context_over_200k
const tiers = legacy
? [
...(existingModel?.cost.tiers?.filter(
(item) => item.tier.type !== "context" || item.tier.size !== 200_000,
) ?? []),
{
input: legacy.input,
output: legacy.output,
cache: { read: legacy.cache_read ?? 0, write: legacy.cache_write ?? 0 },
tier: { type: "context" as const, size: 200_000 },
},
]
: existingModel?.cost.tiers
const parsedModel: Model = {
id: ModelV2.ID.make(modelID),
api: {
Expand Down Expand Up @@ -1528,6 +1558,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,
},
tiers,
},
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
limit: {
Expand Down
5 changes: 1 addition & 4 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,10 +380,7 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?
const costInfo =
input.model.cost?.tiers
?.filter((item) => item.tier.type === "context" && contextTokens > item.tier.size)
.sort((a, b) => b.tier.size - a.tier.size)[0] ??
(input.model.cost?.experimentalOver200K && contextTokens > 200_000
? input.model.cost.experimentalOver200K
: input.model.cost)
.sort((a, b) => b.tier.size - a.tier.size)[0] ?? input.model.cost
const totalNanoAiu = input.metadata?.["copilot"]?.["totalNanoAiu"]
return {
cost:
Expand Down
137 changes: 129 additions & 8 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,81 @@ it.instance(
},
)

it.instance(
"model config preserves catalog cost tiers",
Effect.gen(function* () {
yield* set("REQUESTY_API_KEY", "test-api-key")
const providers = yield* list
const provider = providers[ProviderV2.ID.make("requesty")]
const model = provider.models["google/gemini-2.5-pro"]
expect(model.cost.tiers).toEqual([
{
input: 2.5,
output: 15,
cache: { read: 0.25, write: 0 },
tier: { type: "context", size: 200_000 },
},
])
expect(model.cost.experimentalOver200K).toBeUndefined()
expect(Provider.toPublicInfo(provider).models["google/gemini-2.5-pro"].cost.experimentalOver200K).toEqual({
input: 2.5,
output: 15,
cache: { read: 0.25, write: 0 },
})
}),
{
config: {
provider: {
requesty: {
models: {
"google/gemini-2.5-pro": {
cost: {
input: 1.25,
output: 10,
context_over_200k: { input: 4, output: 16, cache_read: 0.4 },
},
},
},
},
},
},
},
)

it.instance(
"legacy model config pricing is normalized into a context tier",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderV2.ID.make("legacy")].models.legacy.cost.tiers).toEqual([
{
input: 4,
output: 16,
cache: { read: 0.4, write: 0 },
tier: { type: "context", size: 200_000 },
},
])
}),
{
config: {
provider: {
legacy: {
npm: "@ai-sdk/openai-compatible",
options: { apiKey: "test-api-key" },
models: {
legacy: {
cost: {
input: 1.25,
output: 10,
context_over_200k: { input: 4, output: 16, cache_read: 0.4 },
},
},
},
},
},
},
},
)

it.instance(
"model config preserves explicitly empty models.dev variants",
Effect.gen(function* () {
Expand Down Expand Up @@ -1401,10 +1476,18 @@ test("mode options and cost are derived from the base model", () => {
input: 2.5,
output: 15,
cache_read: 0.25,
tiers: [
{
input: 5,
output: 22.5,
cache_read: 0.5,
tier: { type: "context", size: 272_000 },
},
],
context_over_200k: {
input: 5,
output: 22.5,
cache_read: 0.5,
input: 4,
output: 20,
cache_read: 0.4,
},
},
limit: {
Expand Down Expand Up @@ -1449,11 +1532,49 @@ test("mode options and cost are derived from the base model", () => {
const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"]
expect(pro.api.id).toEqual("gpt-5.6-sol")
expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" })
expect(model.cost.experimentalOver200K).toEqual({
input: 5,
output: 22.5,
cache: { read: 0.5, write: 0 },
})
expect(model.cost.tiers).toEqual([
{
input: 5,
output: 22.5,
cache: { read: 0.5, write: 0 },
tier: { type: "context", size: 272_000 },
},
])
})

test("legacy models.dev pricing is normalized into a context tier", () => {
const provider = {
id: "legacy",
name: "Legacy",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
legacy: {
id: "legacy",
name: "Legacy",
release_date: "2026-01-01",
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
cost: {
input: 1,
output: 2,
context_over_200k: { input: 3, output: 4, cache_read: 0.3 },
},
limit: { context: 1_000_000, output: 32_000 },
},
},
} as unknown as ModelsDev.Provider

expect(Provider.fromModelsDevProvider(provider).models.legacy.cost.tiers).toEqual([
{
input: 3,
output: 4,
cache: { read: 0.3, write: 0 },
tier: { type: "context", size: 200_000 },
},
])
})

test("models.dev normalization fills required response fields", () => {
Expand Down
16 changes: 3 additions & 13 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1830,7 +1830,7 @@ describe("SessionNs.getUsage", () => {
expect(result.cost).toBe(0.04473525)
})

test("uses matching context cost tier before over-200k fallback", () => {
test("uses the matching context cost tier", () => {
const model = createModel({
context: 1_000_000,
output: 32_000,
Expand All @@ -1852,11 +1852,6 @@ describe("SessionNs.getUsage", () => {
tier: { type: "context", size: 500_000 },
},
],
experimentalOver200K: {
input: 100,
output: 100,
cache: { read: 100, write: 100 },
},
},
})
const result = SessionNs.getUsage({
Expand All @@ -1873,7 +1868,7 @@ describe("SessionNs.getUsage", () => {
expect(result.cost).toBe(2.75 + 0.6 + 0.05)
})

test("falls back to over-200k pricing when no cost tier matches", () => {
test("uses base pricing when no context cost tier matches", () => {
const model = createModel({
context: 1_000_000,
output: 32_000,
Expand All @@ -1889,19 +1884,14 @@ describe("SessionNs.getUsage", () => {
tier: { type: "context", size: 500_000 },
},
],
experimentalOver200K: {
input: 3,
output: 4,
cache: { read: 0.3, write: 1.5 },
},
},
})
const result = SessionNs.getUsage({
model,
usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }),
})

expect(result.cost).toBe(0.9 + 0.4)
expect(result.cost).toBe(0.3 + 0.2)
})

test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])(
Expand Down
Loading