fix(provider): derive variants from reasoning options (#36543)
This commit is contained in:
parent
4dcfd9182c
commit
6f8e1dda15
4 changed files with 116 additions and 2 deletions
|
|
@ -51,6 +51,9 @@ export const Model = Schema.Struct({
|
||||||
release_date: Schema.String,
|
release_date: Schema.String,
|
||||||
attachment: Schema.Boolean,
|
attachment: Schema.Boolean,
|
||||||
reasoning: 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,
|
temperature: Schema.Boolean,
|
||||||
tool_call: Schema.Boolean,
|
tool_call: Schema.Boolean,
|
||||||
interleaved: Schema.optional(
|
interleaved: Schema.optional(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
|
import { describe, expect, beforeAll, beforeEach, afterAll, test } from "bun:test"
|
||||||
import { Effect, Layer, Ref } from "effect"
|
import { Effect, Layer, Ref, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||||
|
|
@ -126,6 +126,21 @@ const initialState: MockState = {
|
||||||
calls: [],
|
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", () => {
|
describe("ModelsDev Service", () => {
|
||||||
it.live("get() returns providers from disk when cache file exists", () =>
|
it.live("get() returns providers from disk when cache file exists", () =>
|
||||||
Effect.gen(function* () {
|
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({
|
const ProviderCapabilities = Schema.Struct({
|
||||||
temperature: Schema.Boolean,
|
temperature: Schema.Boolean,
|
||||||
reasoning: Schema.Boolean,
|
reasoning: Schema.Boolean,
|
||||||
|
|
@ -1039,6 +1054,7 @@ export const Model = Schema.Struct({
|
||||||
options: Schema.Record(Schema.String, Schema.Any),
|
options: Schema.Record(Schema.String, Schema.Any),
|
||||||
headers: Schema.Record(Schema.String, Schema.String),
|
headers: Schema.Record(Schema.String, Schema.String),
|
||||||
release_date: Schema.String,
|
release_date: Schema.String,
|
||||||
|
reasoning_options: optional(Schema.Array(ProviderReasoningOption)),
|
||||||
variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
|
variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
|
||||||
}).annotate({ identifier: "Model" })
|
}).annotate({ identifier: "Model" })
|
||||||
export type Model = Types.DeepMutable<Schema.Schema.Type<typeof 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
|
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 {
|
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
||||||
const base: Model = {
|
const base: Model = {
|
||||||
id: ModelV2.ID.make(model.id),
|
id: ModelV2.ID.make(model.id),
|
||||||
|
|
@ -1244,6 +1290,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
|
||||||
interleaved: model.interleaved ?? false,
|
interleaved: model.interleaved ?? false,
|
||||||
},
|
},
|
||||||
release_date: model.release_date ?? "",
|
release_date: model.release_date ?? "",
|
||||||
|
reasoning_options: reasoningOptions(model.reasoning_options),
|
||||||
variants: {},
|
variants: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1490,6 +1537,7 @@ const layer = Layer.effect(
|
||||||
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
|
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
|
||||||
family: model.family ?? existingModel?.family ?? "",
|
family: model.family ?? existingModel?.family ?? "",
|
||||||
release_date: model.release_date ?? existingModel?.release_date ?? "",
|
release_date: model.release_date ?? existingModel?.release_date ?? "",
|
||||||
|
reasoning_options: existingModel?.reasoning_options,
|
||||||
variants: {},
|
variants: {},
|
||||||
}
|
}
|
||||||
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.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("")
|
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", () => {
|
test("public provider info omits invalid models", () => {
|
||||||
const provider = Provider.fromModelsDevProvider({
|
const provider = Provider.fromModelsDevProvider({
|
||||||
id: "test",
|
id: "test",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue