Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 6933e2e262 refactor(core): use Latch for shell output gate 2026-08-19 23:49:34 -04:00
11 changed files with 19 additions and 622 deletions
@@ -159,14 +159,6 @@ export const coreFields = {
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safety_identifier: Schema.optional(Schema.String),
stream_options: Schema.optional(
Schema.Struct({
include_obfuscation: Schema.optional(Schema.Boolean),
}),
),
top_logprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String),
@@ -187,8 +179,6 @@ export const coreFields = {
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
}
const OpenResponsesBody = Schema.Struct({
@@ -588,12 +578,6 @@ const lowerOptions = (request: LLMRequest) => {
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
...(options.streamOptions?.includeObfuscation !== undefined
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
@@ -643,8 +627,6 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request),
}
})
@@ -47,17 +47,9 @@ export const AllowedTools = Schema.Struct({
})
export type AllowedTools = typeof AllowedTools.Type
export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
@@ -126,10 +126,6 @@ describe("Open Responses-compatible route", () => {
providerOptions: {
reasoningEffort: "low",
store: true,
metadata: { environment: "test" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
@@ -140,7 +136,6 @@ describe("Open Responses-compatible route", () => {
LLM.request({
model,
prompt: "Think.",
generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 },
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
@@ -148,12 +143,6 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
metadata: { environment: "test" },
safety_identifier: "user_123",
stream_options: { include_obfuscation: false },
top_logprobs: 3,
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
@@ -1284,7 +1284,6 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
promptCacheKey: "session_123",
generation: { presencePenalty: 0.25, frequencyPenalty: -0.25 },
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
@@ -1294,10 +1293,6 @@ describe("OpenAI Responses route", () => {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
metadata: { environment: "test", tenant: "acme" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 5,
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
@@ -1311,12 +1306,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
expect(prepared.body.safety_identifier).toBe("user_123")
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
expect(prepared.body.top_logprobs).toBe(5)
expect(prepared.body.presence_penalty).toBe(0.25)
expect(prepared.body.frequency_penalty).toBe(-0.25)
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
@@ -6,7 +6,6 @@ import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Provider } from "../../provider.js"
import { VariantPlugin } from "../../plugin/variant.js"
export const Plugin = define({
id: "opencode.config.provider",
@@ -35,8 +34,6 @@ export const Plugin = define({
})
yield* ctx.catalog.transform((catalog) => {
const fallback = new Map<string, { providerID: string; modelID: string }>()
const explicit = new Map<string, { providerID: string; modelID: string }>()
const configuredDefault = Config.latest(loaded.entries, "model")
if (configuredDefault !== undefined)
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
@@ -51,13 +48,6 @@ export const Plugin = define({
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
const key = `${providerID}\0${id}`
if (!catalog.model.get(providerID, id) && config.variants === undefined)
fallback.set(key, { providerID, modelID: id })
if (config.variants !== undefined) {
fallback.delete(key)
explicit.set(key, { providerID, modelID: id })
}
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
@@ -77,7 +67,6 @@ export const Plugin = define({
}
if (config.variants !== undefined) {
model.variants ??= []
if (config.variants.length === 0) model.variants = []
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
@@ -107,15 +96,6 @@ export const Plugin = define({
})
}
}
for (const item of fallback.values()) {
const model = catalog.model.get(item.providerID, item.modelID)
if (!model || model.variants.length > 0) continue
VariantPlugin.markFallback(model)
}
for (const item of explicit.values()) {
const model = catalog.model.get(item.providerID, item.modelID)
if (model) VariantPlugin.suppressFallback(model)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
+1 -166
View File
@@ -12,8 +12,7 @@ export const Plugin = define({
for (const record of catalog.provider.list()) {
for (const model of record.models.values()) {
catalog.model.update(model.providerID, model.id, (draft) => {
if (suppressed.has(draft)) return
const generated = fallbacks.has(draft) ? fallback(draft, record.provider) : generate(draft, record.provider)
const generated = generate(draft, record.provider)
if (generated.length === 0) return
const variants = draft.variants ?? []
@@ -43,167 +42,3 @@ export function generate(
settings: { reasoningEffort: id },
}))
}
const OPENAI_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"]
const COMMON_EFFORTS = ["low", "medium", "high"]
const ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
const CLAUDE_MANUAL_THINKING_MAX = { haiku: [4, 5], sonnet: [4, 5], opus: [4, 5] } as const
// Config runs immediately before this plugin over the same materialized model objects.
// Weak markers retain omitted versus explicit empty variants without exposing provenance publicly.
const fallbacks = new WeakSet<object>()
const suppressed = new WeakSet<object>()
export function markFallback(model: object) {
suppressed.delete(model)
fallbacks.add(model)
}
export function suppressFallback(model: object) {
fallbacks.delete(model)
suppressed.add(model)
}
export function fallback(
model: {
readonly modelID: string
readonly package?: string
readonly settings?: Readonly<Record<string, unknown>>
readonly limit: { readonly output: number }
},
provider?: { readonly package: string },
): NonNullable<Model.Info["variants"]> {
const packageName = model.package ?? provider?.package
if (openAIResponses(packageName, model.settings))
return OPENAI_EFFORTS.map((id) => ({
id: Model.VariantID.make(id),
settings: settings(packageName, {
reasoningEffort: id,
reasoningSummary: "auto",
include: ENCRYPTED_REASONING,
}),
}))
if (openAIChat(packageName, model.settings)) return efforts(packageName, COMMON_EFFORTS)
if (google(packageName)) return googleVariants(packageName, model.modelID, model.limit.output)
if (anthropic(packageName)) return anthropicVariants(packageName, model.modelID, model.limit.output)
return []
}
function openAIResponses(packageName: string | undefined, settings: Readonly<Record<string, unknown>> | undefined) {
if (Provider.isAISDK(packageName))
return (
Provider.packageName(packageName) === "@ai-sdk/openai" ||
(Provider.packageName(packageName) === "@ai-sdk/azure" && settings?.useCompletionUrls !== true)
)
return [
"@opencode-ai/ai/providers/openai",
"@opencode-ai/ai/providers/openai/responses",
"@opencode-ai/ai/providers/azure",
"@opencode-ai/ai/providers/azure/responses",
"@opencode-ai/ai/providers/google-vertex/responses",
].includes(packageName ?? "")
}
function openAIChat(packageName: string | undefined, settings: Readonly<Record<string, unknown>> | undefined) {
if (Provider.isAISDK(packageName))
return (
Provider.packageName(packageName) === "@ai-sdk/openai-compatible" ||
(Provider.packageName(packageName) === "@ai-sdk/azure" && settings?.useCompletionUrls === true)
)
return [
"@opencode-ai/ai/providers/openai/chat",
"@opencode-ai/ai/providers/openai-compatible",
"@opencode-ai/ai/providers/azure/chat",
"@opencode-ai/ai/providers/google-vertex/chat",
].includes(packageName ?? "")
}
function google(packageName: string | undefined) {
if (Provider.isAISDK(packageName))
return ["@ai-sdk/google", "@ai-sdk/google-vertex"].includes(Provider.packageName(packageName))
return [
"@opencode-ai/ai/providers/google",
"@opencode-ai/ai/providers/google-vertex",
"@opencode-ai/ai/providers/google-vertex/gemini",
].includes(packageName ?? "")
}
function anthropic(packageName: string | undefined) {
if (Provider.isAISDK(packageName))
return ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(Provider.packageName(packageName))
return [
"@opencode-ai/ai/providers/anthropic",
"@opencode-ai/ai/providers/anthropic-compatible",
"@opencode-ai/ai/providers/google-vertex/messages",
].includes(packageName ?? "")
}
function settings(packageName: string | undefined, value: Readonly<Record<string, unknown>>) {
return Provider.isAISDK(packageName) ? value : { providerOptions: value }
}
function efforts(packageName: string | undefined, ids: readonly string[]) {
return ids.map((id) => ({ id: Model.VariantID.make(id), settings: settings(packageName, { reasoningEffort: id }) }))
}
function googleVariants(
packageName: string | undefined,
modelID: string,
output: number,
): NonNullable<Model.Info["variants"]> {
if (!/(?:^|[/.:_-])gemini-2[.-]5(?:[/.:_-]|$)/i.test(modelID))
return COMMON_EFFORTS.map((effort) => ({
id: Model.VariantID.make(effort),
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }),
}))
const variants = [
{ id: "high", budget: 16_000 },
{ id: "max", budget: /(?:^|[/.:_-])pro(?:[/.:_-]|$)/i.test(modelID) ? 32_768 : 24_576 },
]
const maximum = output - 1
if (maximum <= 0) return []
return variants.map((item) => ({
id: Model.VariantID.make(item.id),
settings: settings(packageName, {
thinkingConfig: { includeThoughts: true, thinkingBudget: Math.min(item.budget, maximum) },
}),
}))
}
function anthropicVariants(
packageName: string | undefined,
modelID: string,
output: number,
): NonNullable<Model.Info["variants"]> {
const model = claudeModel(modelID)
const version = model && CLAUDE_MANUAL_THINKING_MAX[model.family]
const manual = version && (model.major < version[0] || (model.major === version[0] && model.minor <= version[1]))
if (!manual) {
const ids =
!model || model.major > 4 || model.minor >= 7 ? [...COMMON_EFFORTS, "xhigh", "max"] : [...COMMON_EFFORTS, "max"]
return ids.map((id) => ({
id: Model.VariantID.make(id),
settings: settings(packageName, { thinking: { type: "adaptive", display: "summarized" }, effort: id }),
}))
}
const maximum = Math.min(31_999, output - 1)
if (maximum <= 0) return []
return [
{ id: "high", budget: Math.min(16_000, maximum) },
{ id: "max", budget: maximum },
].map((item) => ({
id: Model.VariantID.make(item.id),
settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: item.budget } }),
}))
}
function claudeModel(modelID: string) {
const familyFirst = /(?:^|[/.:_-])(opus|sonnet|haiku)-([1-9]\d*)(?:[.-](\d{1,2}))?(?:[/.:_-]|$)/i.exec(modelID)
const versionFirst = /(?:^|[/.:_-])claude-([1-9]\d*)(?:[.-](\d{1,2}))?-(opus|sonnet|haiku)(?:[/.:_-]|$)/i.exec(
modelID,
)
const family = (["haiku", "sonnet", "opus"] as const).find((item) => item === (familyFirst?.[1] ?? versionFirst?.[3]))
const major = Number(familyFirst?.[2] ?? versionFirst?.[1])
const minor = Number(familyFirst?.[3] ?? versionFirst?.[2] ?? 0)
if (!family || !Number.isFinite(major) || !Number.isFinite(minor)) return
return { family, major, minor }
}
+5 -5
View File
@@ -1,7 +1,7 @@
export * as Shell from "./shell.js"
import path from "path"
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Schedule, Stream } from "effect"
import { Context, Deferred, Duration, Effect, Fiber, Latch, Layer, Schema, Schedule, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
@@ -286,7 +286,7 @@ const layer = () =>
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const outputDone = Latch.makeUnsafe()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
@@ -304,8 +304,8 @@ const layer = () =>
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
yield* outputDone.open
}).pipe(Effect.catch(() => outputDone.open)),
)
yield* Effect.promise(
() =>
@@ -324,7 +324,7 @@ const layer = () =>
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
yield* outputDone.await
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
-29
View File
@@ -399,38 +399,10 @@ describe("PluginSupervisor config", () => {
}),
)
it.live("lets an explicit empty config array clear generated variants", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts")],
providers: {
configured: {
models: {
"glm-5.2": { variants: [] },
},
},
},
},
Effect.gen(function* () {
yield* ready()
const catalog = yield* Catalog.Service
expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual(
[],
)
}),
),
)
it.live("allows variant generation to be disabled", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { reasoner: {} },
},
},
},
Effect.gen(function* () {
yield* ready()
@@ -441,7 +413,6 @@ describe("PluginSupervisor config", () => {
expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
])
expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("reasoner")))?.variants).toEqual([])
}),
),
)
+1 -172
View File
@@ -9,18 +9,16 @@ import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (entries: Entry[], variants = false) {
const addPlugin = Effect.fn(function* (entries: Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
if (variants) yield* VariantPlugin.Plugin.effect(host)
})
function required<T>(value: T | undefined): T {
@@ -106,175 +104,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("adds fallback variants to new configured models when variants are omitted", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("gpt-next")
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { "gpt-next": {} },
},
},
}),
}),
]
yield* addPlugin(entries, true)
const variants = required(yield* catalog.model.get(providerID, modelID)).variants
expect(variants.map((variant) => String(variant.id))).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
expect(variants[3]?.settings).toEqual({
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
}),
)
it.effect("keeps explicit empty and custom configured variants", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: {
disabled: { variants: [] },
explicit: { variants: [{ id: "deep", settings: { reasoningEffort: "max" } }] },
},
},
},
}),
}),
]
yield* addPlugin(entries, true)
expect((yield* catalog.model.get(providerID, Model.ID.make("disabled")))?.variants).toEqual([])
expect(
(yield* catalog.model.get(providerID, Model.ID.make("explicit")))?.variants.map((variant) => ({
...variant,
id: String(variant.id),
})),
).toEqual([{ id: "deep", settings: { reasoningEffort: "max" } }])
}),
)
it.effect("does not add config fallbacks to existing catalog models", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("known")
yield* catalog.transform((draft) => {
draft.model.update(providerID, modelID, () => {})
})
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { known: { name: "Known" } },
},
},
}),
}),
]
yield* addPlugin(entries, true)
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
}),
)
it.effect("lets an explicit empty array clear inherited variants", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("known")
yield* catalog.transform((draft) => {
draft.model.update(providerID, modelID, (model) => {
model.variants = [{ id: Model.VariantID.make("high"), settings: { reasoningEffort: "high" } }]
})
})
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { known: { variants: [] } },
},
},
}),
}),
]
yield* addPlugin(entries, true)
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
}),
)
it.effect("respects layered variant intent and the final package flavor", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
models: {
cleared: {},
disabled: { variants: [] },
flavor: {},
},
},
},
}),
}),
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: {
cleared: { variants: [] },
disabled: { name: "Disabled" },
flavor: { name: "OpenAI" },
},
},
},
}),
}),
]
yield* addPlugin(entries, true)
expect((yield* catalog.model.get(providerID, Model.ID.make("cleared")))?.variants).toEqual([])
expect((yield* catalog.model.get(providerID, Model.ID.make("disabled")))?.variants).toEqual([])
expect(
(yield* catalog.model.get(providerID, Model.ID.make("flavor")))?.variants.map((variant) => String(variant.id)),
).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
}),
)
it.effect("preserves catalog capabilities unless config overrides them", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+11 -31
View File
@@ -9,7 +9,6 @@ import { Integration } from "@opencode-ai/core/integration"
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
import { Catalog } from "@opencode-ai/core/catalog"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Npm } from "@opencode-ai/util/npm"
@@ -510,34 +509,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("applies native OpenAI fallback settings to Responses requests", () =>
Effect.gen(function* () {
const packageName = "@opencode-ai/ai/providers/openai"
const base = model(packageName, { modelID: "gpt-next", limit: { context: 100, output: 32_000 } })
const catalog = model(packageName, {
modelID: "gpt-next",
limit: { context: 100, output: 32_000 },
variants: VariantPlugin.fallback(base),
})
const resolved = yield* ModelResolver.resolveModel(
catalog,
VariantID.make("high"),
Credential.Key.make({ type: "key", key: "secret" }),
)
expect(resolved.route.defaults.providerOptions).toMatchObject({
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
include: ["reasoning.encrypted_content"],
reasoning: { effort: "high", summary: "auto" },
})
}),
)
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
@@ -914,7 +885,12 @@ describe("ModelResolver", () => {
{ reasoning: { effort: "high" } },
{ reasoning: { effort: "high" } },
],
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", { reasoningEffort: "high" }, { reasoningEffort: "high" }],
[
"@ai-sdk/xai",
"@opencode-ai/ai/providers/xai",
{ reasoningEffort: "high" },
{ reasoningEffort: "high" },
],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
@@ -964,7 +940,11 @@ describe("ModelResolver", () => {
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
[
"@ai-sdk/google-vertex/anthropic",
"@opencode-ai/ai/providers/google-vertex/messages",
"claude-sonnet-4-6",
],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
+1 -151
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -59,153 +59,3 @@ describe("VariantPlugin", () => {
}),
)
})
describe("VariantPlugin.fallback", () => {
const model = (
modelID: string,
packageName: string,
output = 32_000,
settings?: Readonly<Record<string, unknown>>,
) => ({
modelID,
package: packageName,
settings,
limit: { output },
})
const plain = (variants: Model.Info["variants"]) =>
variants.map((variant) => ({ ...variant, id: String(variant.id) }))
const settings = (packageName: string, value: Readonly<Record<string, unknown>>) =>
Provider.isAISDK(packageName) ? value : { providerOptions: value }
test.each([
Provider.aisdk("@ai-sdk/openai"),
Provider.aisdk("@ai-sdk/azure"),
"@opencode-ai/ai/providers/openai",
"@opencode-ai/ai/providers/openai/responses",
"@opencode-ai/ai/providers/azure",
"@opencode-ai/ai/providers/azure/responses",
"@opencode-ai/ai/providers/google-vertex/responses",
])("adds OpenAI Responses variants for %s", (packageName) => {
const variants = VariantPlugin.fallback(model("gpt-next", packageName))
expect(variants.map((variant) => String(variant.id))).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
expect(variants[0]?.settings).toEqual(
settings(packageName, {
reasoningEffort: "none",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
}),
)
})
test.each([
Provider.aisdk("@ai-sdk/openai-compatible"),
"@opencode-ai/ai/providers/openai/chat",
"@opencode-ai/ai/providers/openai-compatible",
"@opencode-ai/ai/providers/azure/chat",
"@opencode-ai/ai/providers/google-vertex/chat",
])("adds conservative chat variants for %s", (packageName) => {
expect(plain(VariantPlugin.fallback(model("reasoner", packageName)))).toEqual([
{ id: "low", settings: settings(packageName, { reasoningEffort: "low" }) },
{ id: "medium", settings: settings(packageName, { reasoningEffort: "medium" }) },
{ id: "high", settings: settings(packageName, { reasoningEffort: "high" }) },
])
})
test("uses chat fallbacks for AI SDK Azure completion URLs", () => {
const variants = VariantPlugin.fallback(
model("deployment", Provider.aisdk("@ai-sdk/azure"), 32_000, { useCompletionUrls: true }),
)
expect(plain(variants)).toEqual([
{ id: "low", settings: { reasoningEffort: "low" } },
{ id: "medium", settings: { reasoningEffort: "medium" } },
{ id: "high", settings: { reasoningEffort: "high" } },
])
})
test.each([
Provider.aisdk("@ai-sdk/google"),
Provider.aisdk("@ai-sdk/google-vertex"),
"@opencode-ai/ai/providers/google",
"@opencode-ai/ai/providers/google-vertex",
"@opencode-ai/ai/providers/google-vertex/gemini",
])("adds Google level and legacy budget variants for %s", (packageName) => {
expect(plain(VariantPlugin.fallback(model("gemini-next", packageName)))).toEqual([
{
id: "low",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } }),
},
{
id: "medium",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" } }),
},
{
id: "high",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }),
},
])
expect(plain(VariantPlugin.fallback(model("gemini-2.5-pro", packageName, 64_000)))).toEqual([
{
id: "high",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }),
},
{
id: "max",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }),
},
])
expect(VariantPlugin.fallback(model("gemini-12.5-pro", packageName)).map((variant) => String(variant.id))).toEqual([
"low",
"medium",
"high",
])
})
test.each([
Provider.aisdk("@ai-sdk/anthropic"),
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
"@opencode-ai/ai/providers/anthropic",
"@opencode-ai/ai/providers/anthropic-compatible",
"@opencode-ai/ai/providers/google-vertex/messages",
])("adds Anthropic adaptive and legacy budget variants for %s", (packageName) => {
expect(VariantPlugin.fallback(model("claude-opus-4-7", packageName)).map((variant) => String(variant.id))).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
])
expect(VariantPlugin.fallback(model("claude-opus-4-7", packageName))[0]?.settings).toEqual(
settings(packageName, {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
}),
)
expect(plain(VariantPlugin.fallback(model("claude-haiku-4-5", packageName, 20_000)))).toEqual([
{ id: "high", settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: 16_000 } }) },
{ id: "max", settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: 19_999 } }) },
])
for (const family of ["haiku", "sonnet", "opus"]) {
expect(VariantPlugin.fallback(model(`claude-${family}-4-5`, packageName))[0]?.settings).toEqual(
settings(packageName, { thinking: { type: "enabled", budgetTokens: 16_000 } }),
)
expect(VariantPlugin.fallback(model(`claude-${family}-4-6`, packageName))[0]?.settings).toEqual(
settings(packageName, {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
}),
)
}
expect(VariantPlugin.fallback(model("claude-mythos-4-5", packageName))[0]?.settings).toEqual(
settings(packageName, {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
}),
)
})
test("does not add fallbacks for unknown packages", () => {
expect(VariantPlugin.fallback(model("reasoner", "custom"))).toEqual([])
})
})