mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-18 04:33:30 +00:00
fix(provider): derive variants from reasoning metadata (#36624)
This commit is contained in:
parent
f47684787a
commit
a8062ea314
8 changed files with 534 additions and 175 deletions
|
|
@ -44,6 +44,21 @@ const Cost = Schema.Struct({
|
|||
),
|
||||
})
|
||||
|
||||
const ReasoningOption = 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: Schema.optional(Schema.Finite),
|
||||
max: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
])
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
|
|
@ -51,11 +66,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,
|
||||
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal(true),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, beforeAll, beforeEach, afterAll, test } from "bun:test"
|
||||
import { Effect, Layer, Ref, Schema } from "effect"
|
||||
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
|
||||
import { Effect, Layer, Ref } 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,21 +126,6 @@ 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,21 +981,6 @@ 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,
|
||||
|
|
@ -1054,7 +1039,6 @@ 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>>
|
||||
|
|
@ -1218,36 +1202,6 @@ 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),
|
||||
|
|
@ -1290,13 +1244,14 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
|
|||
interleaved: model.interleaved ?? false,
|
||||
},
|
||||
release_date: model.release_date ?? "",
|
||||
reasoning_options: reasoningOptions(model.reasoning_options),
|
||||
variants: {},
|
||||
}
|
||||
|
||||
const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base)
|
||||
|
||||
return {
|
||||
...base,
|
||||
variants: mapValues(ProviderTransform.variants(base), (v) => v),
|
||||
variants: mapValues(variants, (v) => v),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1537,10 +1492,13 @@ 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 ?? {})
|
||||
const variants =
|
||||
existingModel?.api.npm === parsedModel.api.npm
|
||||
? (existingModel.variants ?? ProviderTransform.variants(parsedModel))
|
||||
: ProviderTransform.variants(parsedModel)
|
||||
const merged = mergeDeep(variants, model.variants ?? {})
|
||||
parsedModel.variants = mapValues(
|
||||
pickBy(merged, (v) => !v.disabled),
|
||||
(v) => omit(v, ["disabled"]),
|
||||
|
|
@ -1668,7 +1626,7 @@ const layer = Layer.effect(
|
|||
)
|
||||
delete provider.models[modelID]
|
||||
|
||||
if (!model.variants || Object.keys(model.variants).length === 0) {
|
||||
if (model.variants === undefined) {
|
||||
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1578,4 +1578,189 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
|
|||
return schema
|
||||
}
|
||||
|
||||
export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] {
|
||||
const options = model.reasoning_options
|
||||
if (options === undefined) return
|
||||
if (options.length === 0) return {}
|
||||
|
||||
const effort = options.find((option) => option.type === "effort")
|
||||
if (effort) return nonEmptyVariants(effortVariants(target, effort.values))
|
||||
|
||||
const toggle = options.some((option) => option.type === "toggle")
|
||||
const budget = options.find((option) => option.type === "budget_tokens")
|
||||
if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined
|
||||
|
||||
return nonEmptyVariants({
|
||||
...(toggle ? reasoningToggle(target) : {}),
|
||||
...budgetVariants(target, budget.min, budget.max),
|
||||
})
|
||||
}
|
||||
|
||||
function effortVariants(model: Provider.Model, values: readonly unknown[]) {
|
||||
return Object.fromEntries(
|
||||
values.flatMap((value) => {
|
||||
const id = (() => {
|
||||
if (value === null) return "none"
|
||||
if (typeof value === "string") return value
|
||||
})()
|
||||
if (id === undefined) return []
|
||||
const settings = reasoningEffort(model, id)
|
||||
return settings ? [[id, settings]] : []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function budgetVariants(model: Provider.Model, min?: number, max?: number) {
|
||||
const limit = model.limit.output - 1
|
||||
if (limit <= 0) return {}
|
||||
const high = Math.min(
|
||||
max === undefined ? Math.max(min ?? 0, 16_000) : Math.min(Math.max(min ?? 0, 16_000), max),
|
||||
limit,
|
||||
)
|
||||
const maximum = max === undefined ? undefined : Math.min(max, limit)
|
||||
return Object.fromEntries(
|
||||
[
|
||||
{ id: "high", budget: high },
|
||||
...(maximum === undefined || maximum === high ? [] : [{ id: "max", budget: maximum }]),
|
||||
].flatMap((item) => {
|
||||
const settings = reasoningBudget(model, item.budget)
|
||||
return settings ? [[item.id, settings]] : []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function nonEmptyVariants(variants: NonNullable<Provider.Model["variants"]>): Provider.Model["variants"] {
|
||||
return Object.keys(variants).length > 0 ? variants : undefined
|
||||
}
|
||||
|
||||
function reasoningToggle(model: Provider.Model): NonNullable<Provider.Model["variants"]> {
|
||||
if (model.api.npm === "@ai-sdk/alibaba")
|
||||
return {
|
||||
none: { enableThinking: false },
|
||||
high: { enableThinking: true },
|
||||
}
|
||||
if (model.api.npm === "@ai-sdk/cohere")
|
||||
return {
|
||||
none: { thinking: { type: "disabled" } },
|
||||
high: { thinking: { type: "enabled" } },
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function reasoningEffort(model: Provider.Model, effort: string) {
|
||||
switch (model.api.npm) {
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return { reasoning: { effort } }
|
||||
case "@ai-sdk/anthropic":
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return anthropicEffort(model, effort)
|
||||
case "@ai-sdk/google":
|
||||
case "@ai-sdk/google-vertex":
|
||||
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
if (anthropicAdaptiveEfforts(model.api.id))
|
||||
return {
|
||||
reasoningConfig: {
|
||||
type: "adaptive",
|
||||
maxReasoningEffort: effort,
|
||||
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
|
||||
},
|
||||
}
|
||||
if (model.api.id.includes("anthropic")) return
|
||||
return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
|
||||
case "@ai-sdk/gateway":
|
||||
if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort }
|
||||
if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
|
||||
return { reasoningEffort: effort }
|
||||
case "@ai-sdk/github-copilot":
|
||||
// OAuth discovery replaces these with variants from Copilot's /models capabilities.
|
||||
if (model.id.includes("gemini")) return
|
||||
if (model.id.includes("claude")) return { reasoningEffort: effort }
|
||||
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
|
||||
case "@ai-sdk/openai":
|
||||
case "@ai-sdk/amazon-bedrock/mantle":
|
||||
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
|
||||
case "@ai-sdk/azure":
|
||||
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
|
||||
case "@jerome-benoit/sap-ai-provider-v2":
|
||||
if (model.id.includes("anthropic"))
|
||||
return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } }
|
||||
return { modelParams: { reasoning_effort: effort } }
|
||||
case "@ai-sdk/openai-compatible":
|
||||
case "@ai-sdk/xai":
|
||||
case "@ai-sdk/mistral":
|
||||
case "@ai-sdk/groq":
|
||||
case "@ai-sdk/cerebras":
|
||||
case "@ai-sdk/deepinfra":
|
||||
case "@ai-sdk/togetherai":
|
||||
case "venice-ai-sdk-provider":
|
||||
case "ai-gateway-provider":
|
||||
return { reasoningEffort: effort }
|
||||
case "@ai-sdk/cohere":
|
||||
case "@ai-sdk/perplexity":
|
||||
case "@ai-sdk/vercel":
|
||||
case "@ai-sdk/alibaba":
|
||||
case "gitlab-ai-provider":
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function anthropicEffort(model: Provider.Model, effort: string) {
|
||||
if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort }
|
||||
if (!anthropicAdaptiveEfforts(model.api.id)) return
|
||||
return {
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
|
||||
},
|
||||
effort,
|
||||
}
|
||||
}
|
||||
|
||||
function reasoningBudget(model: Provider.Model, budget: number) {
|
||||
switch (model.api.npm) {
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return { reasoning: { max_tokens: budget } }
|
||||
case "@ai-sdk/anthropic":
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return { thinking: { type: "enabled", budgetTokens: budget } }
|
||||
case "@ai-sdk/google":
|
||||
case "@ai-sdk/google-vertex":
|
||||
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
return { reasoningConfig: { type: "enabled", budgetTokens: budget } }
|
||||
case "@ai-sdk/gateway":
|
||||
if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } }
|
||||
if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
|
||||
return
|
||||
case "@ai-sdk/cohere":
|
||||
return { thinking: { type: "enabled", tokenBudget: budget } }
|
||||
case "@ai-sdk/alibaba":
|
||||
return { enableThinking: true, thinkingBudget: budget }
|
||||
case "@jerome-benoit/sap-ai-provider-v2":
|
||||
if (model.id.includes("anthropic"))
|
||||
return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } }
|
||||
if (model.id.includes("gemini"))
|
||||
return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }
|
||||
return
|
||||
case "@ai-sdk/amazon-bedrock/mantle":
|
||||
case "@ai-sdk/azure":
|
||||
case "@ai-sdk/cerebras":
|
||||
case "@ai-sdk/deepinfra":
|
||||
case "@ai-sdk/github-copilot":
|
||||
case "@ai-sdk/groq":
|
||||
case "@ai-sdk/mistral":
|
||||
case "@ai-sdk/openai":
|
||||
case "@ai-sdk/openai-compatible":
|
||||
case "@ai-sdk/perplexity":
|
||||
case "@ai-sdk/togetherai":
|
||||
case "@ai-sdk/vercel":
|
||||
case "@ai-sdk/xai":
|
||||
case "ai-gateway-provider":
|
||||
case "gitlab-ai-provider":
|
||||
case "venice-ai-sdk-provider":
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export * as ProviderTransform from "./transform"
|
||||
|
|
|
|||
|
|
@ -562,6 +562,45 @@ it.instance(
|
|||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"model config preserves explicitly empty models.dev variants",
|
||||
Effect.gen(function* () {
|
||||
yield* set("OPENAI_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.openai].models["custom-gpt-chat"]
|
||||
expect(model.name).toBe("Custom GPT Chat")
|
||||
expect(model.variants).toEqual({})
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
openai: { models: { "custom-gpt-chat": { id: "gpt-5-chat-latest", name: "Custom GPT Chat" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"model config regenerates variants when overriding the provider package",
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants?.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(model.variants?.max).toBeUndefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: { "claude-sonnet-4-6": { name: "Claude via OpenAI" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"disabled_providers prevents loading even with env var",
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -1426,52 +1465,60 @@ 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",
|
||||
test("models.dev reasoning options replace generated variants and unsupported options fall back", () => {
|
||||
const provider = {
|
||||
id: "reasoning",
|
||||
name: "Reasoning",
|
||||
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: {
|
||||
explicit: {
|
||||
id: "gpt-5.4",
|
||||
name: "Reasoner",
|
||||
name: "Explicit",
|
||||
reasoning: true,
|
||||
limit: { context: 128_000, output: 16_000 },
|
||||
reasoning_options: [{ type: "effort", values: ["low"] }],
|
||||
limit: { context: 128_000, output: 64_000 },
|
||||
},
|
||||
empty: {
|
||||
id: "gpt-5.4",
|
||||
name: "Empty",
|
||||
reasoning: true,
|
||||
reasoning_options: [],
|
||||
limit: { context: 128_000, output: 64_000 },
|
||||
},
|
||||
fallback: {
|
||||
id: "gpt-5.4",
|
||||
name: "Fallback",
|
||||
reasoning: true,
|
||||
reasoning_options: [{ type: "toggle" }],
|
||||
limit: { context: 128_000, output: 64_000 },
|
||||
},
|
||||
override: {
|
||||
id: "gemini-3-pro",
|
||||
name: "Override",
|
||||
reasoning: true,
|
||||
reasoning_options: [{ type: "effort", values: ["high"] }],
|
||||
provider: { npm: "@ai-sdk/google" },
|
||||
limit: { context: 128_000, output: 64_000 },
|
||||
experimental: { modes: { fast: {} } },
|
||||
},
|
||||
},
|
||||
} as unknown as ModelsDev.Provider)
|
||||
} as unknown as ModelsDev.Provider
|
||||
|
||||
expect(provider.models.reasoner.reasoning_options).toEqual([])
|
||||
const models = Provider.fromModelsDevProvider(provider).models
|
||||
expect(models.explicit.variants).toEqual({
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
})
|
||||
expect(models.empty.variants).toEqual({})
|
||||
expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh"])
|
||||
expect(models.override.variants).toEqual({
|
||||
high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } },
|
||||
})
|
||||
expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants)
|
||||
})
|
||||
|
||||
test("public provider info omits invalid models", () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ProviderTransform } from "@/provider/transform"
|
|||
import { LLMRequestPrep } from "@/session/llm/request"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { jsonSchema } from "ai"
|
||||
|
||||
describe("ProviderTransform.options - setCacheKey", () => {
|
||||
|
|
@ -2988,6 +2989,242 @@ describe("ProviderTransform.temperature - Cohere North", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.reasoningVariants", () => {
|
||||
const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model
|
||||
const target = (npm: string, id = "test-model") =>
|
||||
({ id, api: { id, npm, url: "" }, capabilities: { reasoning: true }, limit: { output: 64_000 } }) as any
|
||||
|
||||
test("respects explicitly empty reasoning options", () => {
|
||||
expect(ProviderTransform.reasoningVariants(model([]), target("@ai-sdk/openai"))).toEqual({})
|
||||
})
|
||||
|
||||
test.each([
|
||||
["@openrouter/ai-sdk-provider", { reasoning: { effort: "high" } }],
|
||||
["@ai-sdk/anthropic", { thinking: { type: "adaptive" }, effort: "high" }, "claude-opus-4-6"],
|
||||
[
|
||||
"@ai-sdk/google-vertex/anthropic",
|
||||
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
"claude-opus-4-7",
|
||||
],
|
||||
["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }],
|
||||
["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }],
|
||||
[
|
||||
"@ai-sdk/azure",
|
||||
{
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ai-sdk/openai",
|
||||
{
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ai-sdk/amazon-bedrock/mantle",
|
||||
{
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ai-sdk/github-copilot",
|
||||
{
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
],
|
||||
["@ai-sdk/openai-compatible", { reasoningEffort: "high" }],
|
||||
["@ai-sdk/xai", { reasoningEffort: "high" }],
|
||||
["@ai-sdk/mistral", { reasoningEffort: "high" }],
|
||||
["@ai-sdk/groq", { reasoningEffort: "high" }],
|
||||
["@ai-sdk/cerebras", { reasoningEffort: "high" }],
|
||||
["@ai-sdk/deepinfra", { reasoningEffort: "high" }],
|
||||
["@ai-sdk/togetherai", { reasoningEffort: "high" }],
|
||||
["venice-ai-sdk-provider", { reasoningEffort: "high" }],
|
||||
["ai-gateway-provider", { reasoningEffort: "high" }],
|
||||
["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }],
|
||||
])("converts effort for %s", (npm, expected, ...args) => {
|
||||
const id = args[0] as string | undefined
|
||||
expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm, id))).toEqual(
|
||||
{ high: expected },
|
||||
)
|
||||
})
|
||||
|
||||
test("uses bare effort for Claude Opus 4.5", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(
|
||||
model([{ type: "effort", values: ["high"] }]),
|
||||
target("@ai-sdk/anthropic", "claude-opus-4-5"),
|
||||
),
|
||||
).toEqual({ high: { effort: "high" } })
|
||||
})
|
||||
|
||||
test("leaves legacy Anthropic effort options to budget fallback", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(
|
||||
model([{ type: "effort", values: ["high"] }]),
|
||||
target("@ai-sdk/anthropic", "claude-sonnet-4"),
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses adaptive reasoning config for Anthropic models on Bedrock", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(
|
||||
model([{ type: "effort", values: ["high"] }]),
|
||||
target("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-4-7-v1:0"),
|
||||
),
|
||||
).toEqual({
|
||||
high: {
|
||||
reasoningConfig: {
|
||||
type: "adaptive",
|
||||
maxReasoningEffort: "high",
|
||||
display: "summarized",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves legacy Anthropic Bedrock effort options to budget fallback", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(
|
||||
model([{ type: "effort", values: ["high"] }]),
|
||||
target("@ai-sdk/amazon-bedrock", "anthropic.claude-sonnet-4-v1:0"),
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each([
|
||||
["@openrouter/ai-sdk-provider", { reasoning: { max_tokens: 16_000 } }],
|
||||
["@ai-sdk/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }],
|
||||
["@ai-sdk/google-vertex/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }],
|
||||
["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }],
|
||||
["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }],
|
||||
["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", budgetTokens: 16_000 } }],
|
||||
["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }],
|
||||
["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }],
|
||||
])("converts token budgets for %s", (npm, high) => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 16_000 }]), target(npm)),
|
||||
).toEqual({ high })
|
||||
})
|
||||
|
||||
test("maps null effort to none", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(model([{ type: "effort", values: [null] }]), target("@ai-sdk/openai")),
|
||||
).toEqual({
|
||||
none: {
|
||||
reasoningEffort: "none",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
["@ai-sdk/alibaba", { none: { enableThinking: false }, high: { enableThinking: true } }],
|
||||
[
|
||||
"@ai-sdk/cohere",
|
||||
{
|
||||
none: { thinking: { type: "disabled" } },
|
||||
high: { thinking: { type: "enabled" } },
|
||||
},
|
||||
],
|
||||
])("converts toggle options for %s", (npm, expected) => {
|
||||
expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target(npm))).toEqual(expected)
|
||||
})
|
||||
|
||||
test("combines Cohere toggle and budget options", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(
|
||||
model([{ type: "toggle" }, { type: "budget_tokens", min: 1 }]),
|
||||
target("@ai-sdk/cohere"),
|
||||
),
|
||||
).toEqual({
|
||||
none: { thinking: { type: "disabled" } },
|
||||
high: { thinking: { type: "enabled", tokenBudget: 16_000 } },
|
||||
})
|
||||
})
|
||||
|
||||
test("generates bounded high and max token budgets", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(
|
||||
model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]),
|
||||
target("@ai-sdk/anthropic"),
|
||||
),
|
||||
).toEqual({
|
||||
high: { thinking: { type: "enabled", budgetTokens: 16_000 } },
|
||||
max: { thinking: { type: "enabled", budgetTokens: 63_999 } },
|
||||
})
|
||||
})
|
||||
|
||||
test("caps token budgets below the model output limit", () => {
|
||||
const anthropic = target("@ai-sdk/anthropic")
|
||||
anthropic.limit.output = 5_000
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic),
|
||||
).toEqual({
|
||||
high: { thinking: { type: "enabled", budgetTokens: 4_999 } },
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers effort options over token budgets", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(
|
||||
model([
|
||||
{ type: "budget_tokens", min: 1_024, max: 64_000 },
|
||||
{ type: "effort", values: ["low"] },
|
||||
]),
|
||||
target("@ai-sdk/openai"),
|
||||
),
|
||||
).toEqual({
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves unsupported options for heuristic fallback", () => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/perplexity")),
|
||||
).toBeUndefined()
|
||||
expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target("@ai-sdk/openai"))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses model-family options for gateway and GitHub Copilot", () => {
|
||||
const effort = model([{ type: "effort", values: ["high"] }])
|
||||
expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "anthropic/claude-sonnet-4"))).toEqual(
|
||||
{
|
||||
high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
},
|
||||
)
|
||||
expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "google/gemini-3-pro"))).toEqual({
|
||||
high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } },
|
||||
})
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(effort, target("@ai-sdk/github-copilot", "gemini-3-pro")),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each(["@ai-sdk/cohere", "@ai-sdk/perplexity", "@ai-sdk/vercel", "@ai-sdk/alibaba", "gitlab-ai-provider"])(
|
||||
"does not invent effort controls for %s",
|
||||
(npm) => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm)),
|
||||
).toBeUndefined()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("ProviderTransform.variants", () => {
|
||||
const createMockModel = (overrides: Partial<any> = {}): any => ({
|
||||
id: "test/test-model",
|
||||
|
|
|
|||
|
|
@ -2100,20 +2100,6 @@ export type Model = {
|
|||
[key: string]: string
|
||||
}
|
||||
release_date: string
|
||||
reasoning_options?: Array<
|
||||
| {
|
||||
type: "effort"
|
||||
values: Array<string>
|
||||
}
|
||||
| {
|
||||
type: "toggle"
|
||||
}
|
||||
| {
|
||||
type: "budget_tokens"
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
>
|
||||
variants?: {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
|
|
|
|||
|
|
@ -21777,58 +21777,6 @@
|
|||
"release_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"reasoning_options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["effort"]
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "values"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["toggle"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["budget_tokens"]
|
||||
},
|
||||
"min": {
|
||||
"type": "number"
|
||||
},
|
||||
"max": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue