Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 2fb3d5755f fix: settle foreign typed tool failures instead of dropping them
A tool implementation declares `Effect<Result, Tool.Error>` but a plugin can
fail with any typed error at runtime. Such a failure slipped past every
`catchTag("Tool.Error")" downstream and past classifyToolExits (which drops
Fail reasons on the assumption they are all declines), so the call was never
settled: the part stayed "running" forever, the step continued anyway, and
the execution reported success.

Enforce the declared contract at the untrusted boundary (tool runtime maps
foreign typed failures to Tool.Error) and harden classifyToolExits to surface
any surviving non-decline typed failure as a defect instead of silently
dropping it.
2026-08-20 00:22:29 -04:00
opencode-agent[bot] dbc7d0ee09 chore: update nix node_modules hashes 2026-08-20 04:01:43 +00:00
10 changed files with 59 additions and 575 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-9IJxoe/MdL6nmoeKvxZop+77HJ/b3HbmpytNUvLqYkc=",
"aarch64-linux": "sha256-KR1J102RDTRDZIkAD3jQfXeP1G2DN9Es7KkdKo+daec=",
"aarch64-darwin": "sha256-HCgSoq1W6XU6m73Ck3wV8gjB687caUTM3w/EO+V7wsE=",
"x86_64-darwin": "sha256-GrKTDvjg0XeDIWbOvayWQjj0SdJd8WPm2+q3IVS17Jg="
"x86_64-linux": "sha256-SBRJwBuBi++5+x8ECufVqMKZMiw9OzF1gr6r/94KpPo=",
"aarch64-linux": "sha256-A/FpmriQw8HCwLffkYQ2FEycUE9dWW5PbSzCaiAvaLU=",
"aarch64-darwin": "sha256-9YXDArPsKlBPZoFuvjXCFoGd0pmOEDAG6qN6VhdSi64=",
"x86_64-darwin": "sha256-m9I3g+UZnwmKflXaZReu9y+4S853wDkq9/zZ2YTEO3I="
}
}
@@ -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 }
}
+9 -1
View File
@@ -89,7 +89,15 @@ const classifyToolExits = (
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> => (Cause.isFailReason(reason) ? [] : [reason]),
(reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason)
? isDecline(reason.error)
? []
: // A typed failure here broke the ExecuteError contract (the per-fiber
// `catchTag("Tool.Error")` consumes honest ones). Surfacing it as a defect
// keeps it from being dropped, which would leave its call unsettled forever.
[Cause.makeDieReason(reason.error)]
: [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
+14 -1
View File
@@ -13,7 +13,20 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool.input, input)
const result = yield* tool.execute(decoded, context)
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
// downstream and leave its call permanently unsettled, so the declared contract is
// enforced here at the untrusted boundary. Declines tunnel through as defects and
// interrupts are not errors; neither is touched.
const result = yield* tool.execute(decoded, context).pipe(
Effect.mapError((error: unknown) =>
error instanceof Tool.Error
? error
: new Tool.Error({
message: error instanceof globalThis.Error ? error.message : String(error),
}),
),
)
if (tool.output === undefined) {
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
return {
-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([])
})
})
+18
View File
@@ -94,6 +94,24 @@ test("declared outputs cannot bypass validation and raw outputs stay JSON-compat
)
})
test("foreign typed failures settle as Tool.Error at the untrusted boundary", async () => {
class ForeignFailure extends Schema.TaggedError<ForeignFailure>()("Plugin.ForeignFailure", {
message: Schema.String,
}) {}
const lying: Info = {
name: "lying",
description: "Fails with a non-Tool.Error typed failure",
input: Schema.Struct({}),
execute: () => new ForeignFailure({ message: "transport died" }) as never,
}
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
expect(exit._tag).toBe("Failure")
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
expect(error).toBeInstanceOf(Tool.Error)
expect((error as Tool.Error).message).toBe("transport died")
})
test("execute supports callable namespace tools", async () => {
const callable: Info = {
name: "admin",