mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-15 21:36:21 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e51a7bc5 | ||
|
|
68474c23f4 | ||
|
|
b224468b9a | ||
|
|
3cb1097131 | ||
|
|
5785d43167 |
@@ -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,
|
||||
|
||||
@@ -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"}')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }])
|
||||
}),
|
||||
)
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user