diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index dba9cbece552..2f6be0e5f723 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1094,6 +1094,21 @@ export const ConfigProvidersResult = Schema.Struct({ export type ConfigProvidersResult = Types.DeepMutable> 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( { @@ -1202,8 +1217,8 @@ 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: { @@ -1211,18 +1226,19 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { 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 } @@ -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 = {} as Record const languages = new Map() @@ -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: { @@ -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: { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..2782fc6086de 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -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: diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 32d4e3a39b10..8e4eb9beef83 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -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* () { @@ -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: { @@ -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", () => { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c76dd98b8614..e56812b2d361 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -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, @@ -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({ @@ -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, @@ -1889,11 +1884,6 @@ 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({ @@ -1901,7 +1891,7 @@ describe("SessionNs.getUsage", () => { 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"])(