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
153 changes: 101 additions & 52 deletions packages/core/src/plugin/models-dev.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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,
}),
)
}
}
}
}),
Expand Down
99 changes: 99 additions & 0 deletions packages/core/test/plugin/models-dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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(() => {
Expand Down
Loading