Compare commits

..
7 changed files with 202 additions and 84 deletions
+6 -21
View File
@@ -59,15 +59,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
"insufficient_quota",
"usage_not_included",
"billing_error",
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
])
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -95,8 +87,7 @@ const CONTENT_POLICY_CODES = new Set([
// as a `[code]` label at the start of the rewritten message.
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
@@ -152,11 +143,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
return new ContentPolicyError(details)
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
@@ -176,12 +163,10 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
((input.status === undefined || input.status < 400) &&
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
)
return new ProviderInternalError({
...details,
+3 -3
View File
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("does not let server codes override a 4xx rejection", () =>
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
Effect.gen(function* () {
const classify = (body: string) =>
Effect.gen(function* () {
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
yield* classify('{"code":"service_unavailable"}')
}),
)
+3 -47
View File
@@ -249,54 +249,10 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(Array(5).fill("InvalidRequest"))
})
test("classifies 402 as exhausted quota", () => {
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
})
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
const substituted = (message: string) => ({
error: { type: "server_error", message: `Upstream request failed: ${message}` },
})
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
[402, substituted("Insufficient account funds")],
[402, substituted("Account invoice is overdue")],
[429, substituted("Account budget exceeded")],
]
expect(
cases.map(
([status, body]) =>
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
),
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
type: "error",
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
}
expect(
[openai, anthropic].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["InvalidRequest", "InvalidRequest"])
// Without a contradicting status the same codes still mark provider trouble.
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
"ProviderInternal",
)
expect(
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
).toBe("ProviderInternal")
).toEqual(Array(6).fill("InvalidRequest"))
})
test("classifies nested provider codes when a top-level code is also present", () => {
+30
View File
@@ -3,6 +3,7 @@ export * as OpenCodeTools from "./opencode.js"
import { SystemPart, ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Model } from "@opencode/schema/model"
import { AbsolutePath } from "@opencode/schema/schema"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
@@ -23,6 +24,14 @@ export const MoveInput = Schema.Struct({
const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })
export const ModelsInput = Schema.Struct({
provider: Schema.optionalKey(Schema.String).annotate({
description: 'Only list models from this provider, for example "anthropic".',
}),
})
const ModelsOutput = Schema.Struct({ models: Schema.Array(Model.Info) })
export const Plugin = {
id: "opencode.tools",
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
@@ -85,6 +94,27 @@ export const Plugin = {
),
),
})
draft.add({
name: "models",
description:
'List the models available to you. Reference one as "provider/model", or "provider/model#variant" using an entry from its variants. Pass the reference anywhere a model is accepted, such as the subagent tool.',
input: ModelsInput,
output: ModelsOutput,
options: { namespace: "opencode", codemode: true },
execute: (input) =>
ctx.model.list().pipe(
Effect.map((list) => {
const models = list.data.filter(
(model) => input.provider === undefined || model.providerID === input.provider,
)
return {
output: { models },
content: models.map((model) => `${model.providerID}/${model.id}: ${model.name}`).join("\n"),
}
}),
Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error })),
),
})
})
.pipe(Effect.orDie)
}),
+37 -12
View File
@@ -7,6 +7,7 @@ import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Job } from "../../job.js"
import { Model } from "../../model.js"
import { Permission } from "../../permission.js"
import { Session } from "../../session.js"
import { SessionSchema } from "../../session/schema.js"
@@ -29,6 +30,10 @@ export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
model: Schema.optionalKey(Schema.String).annotate({
description:
'Set only when the user explicitly requests a model, and add "#variant" only when they request a variant too. Format "provider/model" or "provider/model#variant". Omitted, the subagent uses the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.',
}),
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
description:
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
@@ -61,8 +66,29 @@ export const Plugin = {
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const models = yield* Model.Service
const subagents = yield* SubagentJob.make
const resolveModel = Effect.fn("SubagentTool.resolveModel")(function* (input: string) {
const ref = yield* Effect.try({
try: () => Model.Ref.parse(input),
catch: () =>
new ToolFailure({ message: `Invalid model "${input}". Use "provider/model" or "provider/model#variant".` }),
})
const model = (yield* models.available()).find(
(model) => model.providerID === ref.providerID && model.id === ref.id,
)
if (model === undefined)
return yield* new ToolFailure({
message: `Model "${ref.providerID}/${ref.id}" is not available. Use the models tool to see what is available.`,
})
if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant))
return yield* new ToolFailure({
message: `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ") || "none"}.`,
})
return ref
})
yield* ctx.tool
.transform((editor) =>
editor.add({
@@ -131,24 +157,23 @@ export const Plugin = {
return yield* new ToolFailure({
message: `Session ${existing.id} is not a child of the current session`,
})
const override = input.model === undefined ? undefined : yield* resolveModel(input.model)
// Continuing with a different agent switches the child, mirroring create semantics
// where the agent's configured model wins over the inherited one.
if (existing !== undefined && existing.agent !== agent.id) {
yield* sessions.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe(
Effect.andThen(
agent.model === undefined
? Effect.void
: sessions.switchModel({ sessionID: existing.id, model: agent.model }),
),
// where an explicit model wins over the agent's configured model, which wins over the inherited one.
if (existing !== undefined) {
const switched = existing.agent !== agent.id
const model = override ?? (switched ? agent.model : undefined)
yield* Effect.all([
switched ? sessions.switchAgent({ sessionID: existing.id, agent: agent.id }) : Effect.void,
model === undefined ? Effect.void : sessions.switchModel({ sessionID: existing.id, model }),
]).pipe(
Effect.mapError(
(error) =>
new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }),
(error) => new ToolFailure({ message: `Failed to switch subagent session: ${existing.id}`, error }),
),
)
}
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const model = override ?? agent.model ?? parent.model
const child =
existing ??
(yield* sessions
+54
View File
@@ -0,0 +1,54 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { Tool } from "@opencode/core/tool"
import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode"
import { Model } from "@opencode/schema/model"
import { Effect } from "effect"
import { testEffect } from "./lib/effect"
import { executeTool, toolIdentity } from "./lib/tool"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
it.effect("lists available models through the opencode namespace", () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
const plugins = yield* Plugin.Service
const pluginHost = yield* PluginHost.make(plugins)
yield* catalog.transform((editor) => {
editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => {
model.name = "Alpha"
model.variants = [{ id: Model.VariantID.make("fast") }]
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("beta"), (model) => {
model.name = "Beta"
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => {
model.enabled = false
})
})
yield* OpenCodeTools.Plugin.effect(pluginHost)
const registry = yield* Tool.Service
const run = (code: string) =>
executeTool(registry, {
sessionID: Session.ID.make("ses_tool_opencode"),
...toolIdentity,
call: { type: "tool-call", id: `call-${code.length}`, name: "execute", input: { code } },
})
const all = yield* run(
"const list = await tools.opencode.models({}); return list.models.map((model) => `${model.providerID}/${model.id}: ${model.name} [${model.variants.map((variant) => variant.id)}]`).sort()",
)
expect(all.content).toEqual([
{ type: "text", text: JSON.stringify(["other/beta: Beta []", "test/alpha: Alpha [fast]"], null, 2) },
])
const filtered = yield* run(
'const list = await tools.opencode.models({ provider: "other" }); return list.models.map((model) => model.id)',
)
expect(filtered.content).toEqual([{ type: "text", text: JSON.stringify(["beta"], null, 2) }])
}),
)
+69 -1
View File
@@ -45,6 +45,11 @@ const completedOutput = (sessionID: Session.ID) =>
`<subagent sessionID="${sessionID}" state="completed">\n${childText}\n</subagent>`
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
const overrideModel = Model.Ref.make({
id: Model.ID.make("override"),
providerID: Provider.ID.make("test"),
variant: Model.VariantID.make("fast"),
})
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const outputSessionID = (value: unknown) =>
@@ -107,7 +112,7 @@ const executionNode = makeGlobalNode({
const subagentPluginSupervisor = makeLocationNode({
name: "test/subagent-plugins",
layer: Layer.effectDiscard(registerToolPlugin(SubagentTool.Plugin)),
deps: [Agent.node, Config.node, Permission.node, Session.node, Job.node, Tool.node],
deps: [Agent.node, Config.node, Model.node, Permission.node, Session.node, Job.node, Tool.node],
})
const nodes = LayerNode.group([
@@ -155,6 +160,13 @@ const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(locations.get(location)))
yield* Provider.Service.use((providers) =>
providers.transform((editor) => {
editor.models.update(overrideModel.providerID, overrideModel.id, (model) => {
model.variants = [{ id: Model.VariantID.make("fast") }]
})
}),
).pipe(Effect.provide(locations.get(location)))
yield* Agent.Service.use((agents) =>
agents.transform((editor) => {
// The caller identity used by executeTool; subagent permission asserts against it.
@@ -615,6 +627,62 @@ describe("SubagentTool", () => {
),
)
it.live("runs the child on an explicitly requested model", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* Session.Service
const parent = yield* sessions.create({ location, model: parentModel })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const call = (id: string, input: Record<string, unknown>) =>
executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call" as const,
id,
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this", ...input },
},
})
// The requested model beats the agent's configured model.
const spawned = yield* call("call-override", { model: "test/override#fast" })
expect(spawned).toMatchObject({ status: "completed", metadata: { status: "completed" } })
const child = yield* sessions.get(outputSessionID(spawned.metadata))
expect(child).toMatchObject({ agent: "reviewer", model: overrideModel })
// Continuing with a model switches the existing child even when the agent is unchanged.
const continued = yield* call("call-override-continue", { sessionID: child.id, model: "test/override" })
expect(continued).toMatchObject({ status: "completed", metadata: { sessionID: child.id } })
expect((yield* sessions.get(child.id)).model).toEqual({
id: overrideModel.id,
providerID: overrideModel.providerID,
variant: Model.VariantID.make("default"),
})
const failures = [
["not-a-ref", 'Invalid model "not-a-ref". Use "provider/model" or "provider/model#variant".'],
["test/missing", 'Model "test/missing" is not available. Use the models tool to see what is available.'],
["test/override#slow", 'Variant "slow" is not available for "test/override". Available: fast.'],
] as const
for (const [model, message] of failures) {
expect(yield* call(`call-${model}`, { model })).toEqual({
status: "error",
error: { type: "tool.execution", message },
})
}
}),
),
),
)
it.live("returns child runner failures as tool errors", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),