Parcourir la source

fix(provider): derive variants from reasoning options (#36543)

Aiden Cline il y a 1 mois
Parent
commit
6f8e1dda15

+ 3 - 0
packages/core/src/models-dev.ts

@@ -51,6 +51,9 @@ export const Model = Schema.Struct({
   release_date: Schema.String,
   attachment: Schema.Boolean,
   reasoning: Schema.Boolean,
+  // models.dev is external metadata and reasoning controls are expected to evolve.
+  // Provider normalization extracts the subset understood by this client.
+  reasoning_options: Schema.optional(Schema.Unknown),
   temperature: Schema.Boolean,
   tool_call: Schema.Boolean,
   interleaved: Schema.optional(

+ 17 - 2
packages/core/test/models.test.ts

@@ -1,5 +1,5 @@
-import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
-import { Effect, Layer, Ref } from "effect"
+import { describe, expect, beforeAll, beforeEach, afterAll, test } from "bun:test"
+import { Effect, Layer, Ref, Schema } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
@@ -126,6 +126,21 @@ const initialState: MockState = {
   calls: [],
 }
 
+test("models.dev model schema keeps reasoning options permissive", () => {
+  const model = Schema.decodeUnknownSync(ModelsDev.Model)({
+    id: "acme-1",
+    name: "Acme One",
+    release_date: "2026-01-01",
+    attachment: false,
+    reasoning: true,
+    reasoning_options: [{ type: "future_control", value: { nested: true } }, "future-shape"],
+    temperature: true,
+    tool_call: true,
+    limit: { context: 128000, output: 8192 },
+  })
+  expect(model.reasoning_options).toEqual([{ type: "future_control", value: { nested: true } }, "future-shape"])
+})
+
 describe("ModelsDev Service", () => {
   it.live("get() returns providers from disk when cache file exists", () =>
     Effect.gen(function* () {

+ 48 - 0
packages/opencode/src/provider/provider.ts

@@ -981,6 +981,21 @@ const ProviderInterleaved = Schema.Union([
   }),
 ])
 
+const ProviderReasoningOption = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("effort"),
+    values: Schema.Array(Schema.NullOr(Schema.String)),
+  }),
+  Schema.Struct({
+    type: Schema.Literal("toggle"),
+  }),
+  Schema.Struct({
+    type: Schema.Literal("budget_tokens"),
+    min: optional(Schema.Finite),
+    max: optional(Schema.Finite),
+  }),
+])
+
 const ProviderCapabilities = Schema.Struct({
   temperature: Schema.Boolean,
   reasoning: Schema.Boolean,
@@ -1039,6 +1054,7 @@ export const Model = Schema.Struct({
   options: Schema.Record(Schema.String, Schema.Any),
   headers: Schema.Record(Schema.String, Schema.String),
   release_date: Schema.String,
+  reasoning_options: optional(Schema.Array(ProviderReasoningOption)),
   variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
 }).annotate({ identifier: "Model" })
 export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>
@@ -1202,6 +1218,36 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
   return result
 }
 
+type ReasoningOption = NonNullable<Model["reasoning_options"]>[number]
+
+function reasoningOptions(input: unknown): Model["reasoning_options"] {
+  if (!Array.isArray(input)) return []
+  return input.flatMap((option) => {
+    const normalized = normalizeReasoningOption(option)
+    return normalized ? [normalized] : []
+  })
+}
+
+function normalizeReasoningOption(option: unknown): ReasoningOption | undefined {
+  if (!isRecord(option)) return
+  if (option.type === "effort") {
+    if (!Array.isArray(option.values)) return
+    return {
+      type: "effort",
+      values: option.values.filter((value): value is string | null => value === null || typeof value === "string"),
+    }
+  }
+  if (option.type === "toggle") return { type: "toggle" }
+  if (option.type !== "budget_tokens") return
+  const min = typeof option.min === "number" && Number.isFinite(option.min) ? option.min : undefined
+  const max = typeof option.max === "number" && Number.isFinite(option.max) ? option.max : undefined
+  return {
+    type: "budget_tokens",
+    ...(min === undefined ? {} : { min }),
+    ...(max === undefined ? {} : { max }),
+  }
+}
+
 function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
   const base: Model = {
     id: ModelV2.ID.make(model.id),
@@ -1244,6 +1290,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
       interleaved: model.interleaved ?? false,
     },
     release_date: model.release_date ?? "",
+    reasoning_options: reasoningOptions(model.reasoning_options),
     variants: {},
   }
 
@@ -1490,6 +1537,7 @@ const layer = Layer.effect(
               headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
               family: model.family ?? existingModel?.family ?? "",
               release_date: model.release_date ?? existingModel?.release_date ?? "",
+              reasoning_options: existingModel?.reasoning_options,
               variants: {},
             }
             const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})

+ 48 - 0
packages/opencode/test/provider/provider.test.ts

@@ -1426,6 +1426,54 @@ test("models.dev normalization fills required response fields", () => {
   expect(model.release_date).toBe("")
 })
 
+test("models.dev reasoning options normalize to known shapes", () => {
+  const provider = Provider.fromModelsDevProvider({
+    id: "openai",
+    name: "OpenAI",
+    env: [],
+    npm: "@ai-sdk/openai",
+    models: {
+      reasoner: {
+        id: "reasoner",
+        name: "Reasoner",
+        reasoning: true,
+        reasoning_options: [
+          { type: "future_control", value: true },
+          { type: "effort", values: ["high", null, 42] },
+          { type: "toggle" },
+          { type: "budget_tokens", min: 1024, max: "invalid" },
+        ],
+        limit: { context: 128_000, output: 16_000 },
+      },
+    },
+  } as unknown as ModelsDev.Provider)
+
+  expect(provider.models.reasoner.reasoning_options).toEqual([
+    { type: "effort", values: ["high", null] },
+    { type: "toggle" },
+    { type: "budget_tokens", min: 1024 },
+  ])
+})
+
+test("models.dev models without reasoning options normalize to an empty list", () => {
+  const provider = Provider.fromModelsDevProvider({
+    id: "openai",
+    name: "OpenAI",
+    env: [],
+    npm: "@ai-sdk/openai",
+    models: {
+      reasoner: {
+        id: "gpt-5.4",
+        name: "Reasoner",
+        reasoning: true,
+        limit: { context: 128_000, output: 16_000 },
+      },
+    },
+  } as unknown as ModelsDev.Provider)
+
+  expect(provider.models.reasoner.reasoning_options).toEqual([])
+})
+
 test("public provider info omits invalid models", () => {
   const provider = Provider.fromModelsDevProvider({
     id: "test",