fix(core): expose models.dev modes as models (#34521)

This commit is contained in:
Aiden Cline 2026-06-29 17:51:06 -05:00 committed by GitHub
parent 884c256033
commit d71454c701
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 200 additions and 52 deletions

View file

@ -1,7 +1,7 @@
import { define } from "./internal"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models-dev"
import { ProviderV2 } from "../provider"
@ -10,7 +10,7 @@ function released(date: string) {
return Number.isFinite(time) ? time : 0
}
function cost(input: ModelsDev.Model["cost"]) {
function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
const base = {
input: input?.input ?? 0,
output: input?.output ?? 0,
@ -19,30 +19,101 @@ function cost(input: ModelsDev.Model["cost"]) {
write: input?.cache_write ?? 0,
},
}
if (!input?.context_over_200k) return [base]
return [
base,
{
tier: {
type: "context" as const,
size: 200_000,
},
input: input.context_over_200k.input,
output: input.context_over_200k.output,
...(input?.tiers?.map((item) => ({
tier: item.tier,
input: item.input,
output: item.output,
cache: {
read: input.context_over_200k.cache_read ?? 0,
write: input.context_over_200k.cache_write ?? 0,
read: item.cache_read ?? 0,
write: item.cache_write ?? 0,
},
},
})) ?? []),
...(input?.context_over_200k
? [
{
tier: {
type: "context" as const,
size: 200_000,
},
input: input.context_over_200k.input,
output: input.context_over_200k.output,
cache: {
read: input.context_over_200k.cache_read ?? 0,
write: input.context_over_200k.cache_write ?? 0,
},
},
]
: []),
]
}
function variants(model: ModelsDev.Model) {
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({
id: ModelV2.VariantID.make(id),
headers: { ...(item.provider?.headers ?? {}) },
body: { ...(item.provider?.body ?? {}) },
}))
function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) {
if (!override) return base
const next = cost(override)
const [baseDefault, ...baseTiers] = base
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]) => ({
...left,
...right,
tier: right.tier ?? left.tier,
cache: { ...left.cache, ...right.cache },
})
const tiers = new Map(baseTiers.map((item) => [tierKey(item), item]))
for (const item of nextTiers) {
const current = tiers.get(tierKey(item))
tiers.set(tierKey(item), current ? merge(current, item) : item)
}
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
}
function modeName(model: ModelsDev.Model, mode: string) {
return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
}
function applyModel(
draft: ModelV2Info,
model: ModelsDev.Model,
input: {
readonly name?: string
readonly cost?: ModelV2Info["cost"]
readonly request?: NonNullable<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
} = {},
) {
draft.name = input.name ?? model.name
draft.family = model.family
draft.api = model.provider?.npm
? {
id: model.id,
type: "aisdk",
package: model.provider.npm,
url: model.provider.api,
}
: {
id: model.id,
type: "native",
url: model.provider?.api,
settings: {},
}
draft.capabilities = {
tools: model.tool_call,
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
draft.variants = []
draft.time.released = released(model.release_date)
draft.cost = input.cost ?? cost(model.cost)
draft.status = model.status ?? "active"
draft.enabled = true
draft.limit = {
context: model.limit.context,
input: model.limit.input,
output: model.limit.output,
}
Object.assign(draft.request.headers, input.request?.headers ?? {})
Object.assign(draft.request.body, input.request?.body ?? {})
}
export const ModelsDevPlugin = define({
@ -89,39 +160,17 @@ export const ModelsDevPlugin = define({
})
for (const model of Object.values(item.models)) {
const modelID = ModelV2.ID.make(model.id)
catalog.model.update(providerID, modelID, (draft) => {
draft.name = model.name
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
draft.api = model.provider?.npm
? {
id: draft.api.id,
type: "aisdk",
package: model.provider?.npm,
url: model.provider.api,
}
: {
id: draft.api.id,
type: "native",
url: model.provider?.api,
settings: {},
}
draft.capabilities = {
tools: model.tool_call,
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
draft.variants = variants(model)
draft.time.released = released(model.release_date)
draft.cost = cost(model.cost)
draft.status = model.status ?? "active"
draft.enabled = true
draft.limit = {
context: model.limit.context,
input: model.limit.input,
output: model.limit.output,
}
})
const baseCost = cost(model.cost)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
applyModel(draft, model, {
name: modeName(model, mode),
cost: mergeCost(baseCost, options.cost),
request: options.provider,
}),
)
}
}
}
}),

View file

@ -8,8 +8,10 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@ -25,6 +27,103 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
const it = testEffect(layer)
describe("ModelsDevPlugin", () => {
it.effect("projects models.dev modes as separate models instead of variants", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const models = ModelsDev.Service.of({
get: () =>
Effect.succeed({
acme: {
id: "acme",
name: "Acme",
env: [],
npm: "@ai-sdk/openai-compatible",
api: "https://api.acme.test/v1",
models: {
"gpt-5.4": {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
release_date: "2026-01-01",
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
cost: {
input: 2.5,
output: 15,
tiers: [
{
tier: { type: "context", size: 272_000 },
input: 3,
output: 18,
cache_read: 0.25,
},
],
context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 },
},
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
experimental: {
modes: {
fast: {
cost: { input: 5, output: 30, cache_read: 0.5 },
provider: {
headers: { "x-mode": "fast" },
body: { service_tier: "priority" },
},
},
},
},
},
},
},
} satisfies Record<string, ModelsDev.Provider>),
refresh: () => Effect.void,
})
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(Effect.provideService(ModelsDev.Service, models))
const providerID = ProviderV2.ID.make("acme")
const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
expect(base?.variants).toEqual([])
expect(base?.request.body).toEqual({})
expect(fast).toMatchObject({
id: "gpt-5.4-fast",
providerID: "acme",
name: "GPT-5.4 Fast",
api: { id: "gpt-5.4" },
request: {
headers: { "x-mode": "fast" },
body: { service_tier: "priority" },
},
variants: [],
})
expect(fast?.cost).toEqual([
{ input: 5, output: 30, cache: { read: 0.5, write: 0 } },
{
tier: { type: "context", size: 272_000 },
input: 3,
output: 18,
cache: { read: 0.25, write: 0 },
},
{
tier: { type: "context", size: 200_000 },
input: 5,
output: 22.5,
cache: { read: 0.5, write: 0 },
},
])
}),
)
it.effect("registers key methods for providers with environment variables", () =>
Effect.acquireUseRelease(
Effect.sync(() => {