mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 02:06:00 +00:00
fix(provider): derive variants from reasoning options (#36543)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user