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

This commit is contained in:
Aiden Cline
2026-07-12 16:12:07 -05:00
committed by GitHub
parent 4dcfd9182c
commit 6f8e1dda15
4 changed files with 116 additions and 2 deletions
+3
View File
@@ -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
View File
@@ -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* () {
@@ -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 ?? {})
@@ -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",