mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fb3d5755f | |||
| dbc7d0ee09 |
+4
-4
@@ -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"),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)] : []
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user