fix(opencode): close tier precedence gaps

This commit is contained in:
Aiden
2026-08-23 16:53:55 +00:00
parent 0344b3b2ba
commit c8a13619cd
8 changed files with 121 additions and 21 deletions
+19 -1
View File
@@ -91,7 +91,8 @@ export const Plugin = define({
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
const input = Array.isArray(config.cost) ? config.cost : [config.cost]
const next = input.map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
@@ -100,6 +101,23 @@ export const Plugin = define({
write: cost.cache?.write ?? 0,
},
}))
const explicit = next.filter((cost) => cost.tier?.type === "context")
const existing = model.cost.filter((cost) => cost.tier?.type === "context")
const legacy = input.find((cost) => cost.tier === undefined)?.context_over_200k
model.cost = [
...next.filter((cost) => cost.tier === undefined),
...(explicit.length > 0 ? explicit : existing),
...(legacy && explicit.length === 0 && existing.length === 0
? [
{
tier: { type: "context" as const, size: 200_000 },
input: legacy.input,
output: legacy.output,
cache: { read: legacy.cache?.read ?? 0, write: legacy.cache?.write ?? 0 },
},
]
: []),
]
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
+5
View File
@@ -22,6 +22,11 @@ class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
input: Schema.Finite,
output: Schema.Finite,
cache: Cache.pipe(Schema.optional),
context_over_200k: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache: Cache.pipe(Schema.optional),
}).pipe(Schema.optional),
}) {}
class Limit extends Schema.Class<Limit>("ConfigV2.Model.Limit")({
+3 -1
View File
@@ -51,8 +51,10 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) {
if (!override) return base
const next = cost(override)
const [baseDefault, ...baseTiers] = base
const next = cost(
baseTiers.length > 0 && !override.tiers?.length ? { ...override, context_over_200k: undefined } : override,
)
const [nextDefault, ...nextTiers] = next
const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({
@@ -2,7 +2,7 @@ import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
import type { CredentialValue, ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { EventV2 } from "../../event"
import { Credential } from "../../credential"
@@ -162,7 +162,10 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
model.time.released = Number.isFinite(released) ? released : 0
}
if (config.cost !== undefined) {
model.cost = remoteCost(config.cost)
model.cost = remoteCost(
config.cost,
model.cost.filter((cost) => cost.tier?.type === "context"),
)
}
model.status = config.status ?? "active"
model.enabled = config.status !== "deprecated"
@@ -224,7 +227,7 @@ function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined)
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
}
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>, tiers: ModelV2Info["cost"] = []) {
const legacy = input.context_over_200k
return [
{
@@ -232,7 +235,8 @@ function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cos
output: input.output,
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
},
...(legacy
...tiers,
...(legacy && tiers.length === 0
? [
{
tier: { type: "context" as const, size: 200_000 },
+9 -12
View File
@@ -194,23 +194,20 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
const lowerer = ConfigProviderOptionsV1.get(packageID)
const request = info.options && lowerer.request(info.options)
const legacy = info.cost?.context_over_200k
const costs = info.cost && [
{
input: info.cost.input,
output: info.cost.output,
cache: { read: info.cost.cache_read, write: info.cost.cache_write },
},
const costs = info.cost && {
input: info.cost.input,
output: info.cost.output,
cache: { read: info.cost.cache_read, write: info.cost.cache_write },
...(legacy
? [
{
tier: { type: "context" as const, size: 200_000 },
? {
context_over_200k: {
input: legacy.input,
output: legacy.output,
cache: { read: legacy.cache_read, write: legacy.cache_write },
},
]
: []),
]
}
: {}),
}
const capabilities =
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
@@ -55,6 +55,71 @@ function request(headers: Record<string, string>, variant?: string) {
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("prefers catalog tiers over legacy config pricing", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
models: {
"alpha-gpt-next": {
cost: [
{ input: 1, output: 2 },
{
tier: { type: "context", size: 272_000 },
input: 3,
output: 4,
cache: { read: 0.3 },
},
],
},
},
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
models: {
"alpha-gpt-next": {
cost: {
input: 5,
output: 6,
context_over_200k: { input: 100, output: 100 },
},
},
},
},
},
}),
}),
]),
})
yield* addPlugin(config)
expect(required(yield* catalog.model.get(providerID, modelID)).cost).toEqual([
{ input: 5, output: 6, cache: { read: 0, write: 0 }, tier: undefined },
{
tier: { type: "context", size: 272_000 },
input: 3,
output: 4,
cache: { read: 0.3, write: 0 },
},
])
}),
)
it.effect("keeps configured model variant bodies unchanged", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+6 -1
View File
@@ -67,7 +67,12 @@ describe("ModelsDevPlugin", () => {
experimental: {
modes: {
fast: {
cost: { input: 5, output: 30, cache_read: 0.5 },
cost: {
input: 5,
output: 30,
cache_read: 0.5,
context_over_200k: { input: 100, output: 100 },
},
provider: {
headers: { "x-mode": "fast" },
body: { service_tier: "priority" },
+6 -2
View File
@@ -1489,14 +1489,18 @@ export type Model = {
read: number
write: number
}
experimentalOver200K?: {
tiers?: Array<{
input: number
output: number
cache: {
read: number
write: number
}
}
tier: {
type: "context"
size: number
}
}>
}
limit: {
context: number