Преглед изворни кода

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

Aiden Cline пре 1 месец
родитељ
комит
d71454c701
2 измењених фајлова са 200 додато и 52 уклоњено
  1. 101 52
      packages/core/src/plugin/models-dev.ts
  2. 99 0
      packages/core/test/plugin/models-dev.test.ts

+ 101 - 52
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<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,
+                }),
+              )
+            }
           }
         }
       }),

+ 99 - 0
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<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(() => {