diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 1adc979d256f..075a6ed093c6 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -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["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, + }), + ) + } } } }), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 0858bd433b45..6c0f7e070296 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -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), + 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(() => {