mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c71ebe46df |
@@ -0,0 +1,90 @@
|
|||||||
|
export * as ModelRouting from "./model-routing.js"
|
||||||
|
|
||||||
|
import { Model } from "./model.js"
|
||||||
|
import { Provider } from "./provider.js"
|
||||||
|
|
||||||
|
export const roles = ["fast", "smart", "vision", "long-context"] as const
|
||||||
|
export type Role = (typeof roles)[number]
|
||||||
|
|
||||||
|
export function resolve(selection: string, available: readonly Model.Info[]) {
|
||||||
|
if (!isRole(selection)) return exact(selection, available)
|
||||||
|
return select(selection, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function select(role: Role, available: readonly Model.Info[]) {
|
||||||
|
const candidates = available.filter(
|
||||||
|
(model) =>
|
||||||
|
model.status === "active" &&
|
||||||
|
model.capabilities.tools &&
|
||||||
|
model.capabilities.input.includes("text") &&
|
||||||
|
model.capabilities.output.includes("text"),
|
||||||
|
)
|
||||||
|
const eligible =
|
||||||
|
role === "vision"
|
||||||
|
? candidates.filter((model) => model.capabilities.input.includes("image"))
|
||||||
|
: role === "fast"
|
||||||
|
? candidates.filter((model) => !SLOW_MODEL_RE.test(identity(model)))
|
||||||
|
: candidates
|
||||||
|
if (eligible.length === 0) return
|
||||||
|
|
||||||
|
const sorted = eligible.toSorted((a, b) => {
|
||||||
|
if (role === "fast") {
|
||||||
|
const tagged = Number(fast(b)) - Number(fast(a))
|
||||||
|
if (tagged !== 0) return tagged
|
||||||
|
const price = cost(a) - cost(b)
|
||||||
|
if (price !== 0) return price
|
||||||
|
}
|
||||||
|
if (role === "smart" || role === "vision") {
|
||||||
|
const tagged = Number(smart(b)) - Number(smart(a))
|
||||||
|
if (tagged !== 0) return tagged
|
||||||
|
}
|
||||||
|
if (role === "long-context") {
|
||||||
|
const context = b.limit.context - a.limit.context
|
||||||
|
if (context !== 0) return context
|
||||||
|
}
|
||||||
|
const released = b.time.released - a.time.released
|
||||||
|
if (released !== 0) return released
|
||||||
|
return `${a.providerID}/${a.id}`.localeCompare(`${b.providerID}/${b.id}`)
|
||||||
|
})
|
||||||
|
const selected = sorted[0]
|
||||||
|
return Model.Ref.make({ providerID: selected.providerID, id: selected.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRole(selection: string): selection is Role {
|
||||||
|
return roles.includes(selection as Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(selection: string, available: readonly Model.Info[]) {
|
||||||
|
const providerEnd = selection.indexOf("/")
|
||||||
|
if (providerEnd <= 0) return
|
||||||
|
const variantStart = selection.indexOf("#", providerEnd + 1)
|
||||||
|
const providerID = Provider.ID.make(selection.slice(0, providerEnd))
|
||||||
|
const id = Model.ID.make(selection.slice(providerEnd + 1, variantStart === -1 ? undefined : variantStart))
|
||||||
|
const variant = variantStart === -1 ? undefined : Model.VariantID.make(selection.slice(variantStart + 1))
|
||||||
|
if (!id || !providerID || (variantStart !== -1 && !variant)) return
|
||||||
|
const model = available.find((item) => item.providerID === providerID && item.id === id)
|
||||||
|
if (!model) return
|
||||||
|
if (variant && !model.variants.some((item) => item.id === variant)) return
|
||||||
|
return Model.Ref.make({ providerID, id, variant })
|
||||||
|
}
|
||||||
|
|
||||||
|
function cost(model: Model.Info) {
|
||||||
|
const price = model.cost[0]
|
||||||
|
return price ? price.input + price.output : Number.MAX_SAFE_INTEGER
|
||||||
|
}
|
||||||
|
|
||||||
|
function fast(model: Model.Info) {
|
||||||
|
return FAST_MODEL_RE.test(identity(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
function smart(model: Model.Info) {
|
||||||
|
return SMART_MODEL_RE.test(identity(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
function identity(model: Model.Info) {
|
||||||
|
return `${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAST_MODEL_RE = /\b(nano|flash|lite|mini|small|fast)\b/
|
||||||
|
const SLOW_MODEL_RE = /\b(haiku)\b/
|
||||||
|
const SMART_MODEL_RE = /\b(opus|pro|max|ultra|reasoner|reasoning)\b|\b(gpt-5|grok-4|deepseek-v4|kimi-k2)\b/
|
||||||
@@ -4,7 +4,9 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Effect, Schema, Scope } from "effect"
|
import { Effect, Schema, Scope } from "effect"
|
||||||
import { Agent } from "../../agent.js"
|
import { Agent } from "../../agent.js"
|
||||||
|
import { Catalog } from "../../catalog.js"
|
||||||
import { Config } from "../../config.js"
|
import { Config } from "../../config.js"
|
||||||
|
import { ModelRouting } from "../../model-routing.js"
|
||||||
import { PluginRuntime } from "../../plugin/runtime.js"
|
import { PluginRuntime } from "../../plugin/runtime.js"
|
||||||
import { Permission } from "../../permission.js"
|
import { Permission } from "../../permission.js"
|
||||||
import { SessionSchema } from "../../session/schema.js"
|
import { SessionSchema } from "../../session/schema.js"
|
||||||
@@ -23,6 +25,10 @@ export const Input = Schema.Struct({
|
|||||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
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" }),
|
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" }),
|
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||||
|
model: Schema.optionalKey(Schema.String).annotate({
|
||||||
|
description:
|
||||||
|
'Optional model route. Use "fast" for cheap bounded work, "smart" for difficult reasoning, "vision" for image input, or "long-context" for very large inputs. Pass an exact provider/model ID only when the user requests one. Omit this field to use the agent model or inherit the parent model.',
|
||||||
|
}),
|
||||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||||
description:
|
description:
|
||||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
|
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
|
||||||
@@ -47,6 +53,7 @@ export const Plugin = {
|
|||||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const runtime = yield* PluginRuntime.Service
|
const runtime = yield* PluginRuntime.Service
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
@@ -163,8 +170,14 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
||||||
|
|
||||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
const routed = input.model
|
||||||
const model = agent.model ?? parent.model
|
? ModelRouting.resolve(input.model, yield* catalog.model.available())
|
||||||
|
: undefined
|
||||||
|
if (input.model && !routed)
|
||||||
|
return yield* new ToolFailure({
|
||||||
|
message: `No available model matches route: ${input.model}`,
|
||||||
|
})
|
||||||
|
const model = routed ?? agent.model ?? parent.model
|
||||||
const child = yield* runtime.session
|
const child = yield* runtime.session
|
||||||
.create({
|
.create({
|
||||||
parentID: context.sessionID,
|
parentID: context.sessionID,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
|
import { ModelRouting } from "@opencode-ai/core/model-routing"
|
||||||
|
import { Model } from "@opencode-ai/core/model"
|
||||||
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
|
|
||||||
|
const model = (
|
||||||
|
providerID: string,
|
||||||
|
id: string,
|
||||||
|
input: readonly string[],
|
||||||
|
options: { cost?: number; context?: number; released?: number } = {},
|
||||||
|
) =>
|
||||||
|
Model.Info.make({
|
||||||
|
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make(id)),
|
||||||
|
name: id,
|
||||||
|
capabilities: { tools: true, input: [...input], output: ["text"] },
|
||||||
|
time: { released: options.released ?? 1 },
|
||||||
|
cost: [
|
||||||
|
{
|
||||||
|
input: Money.USDPerMillionTokens.make(options.cost ?? 1),
|
||||||
|
output: Money.USDPerMillionTokens.make(options.cost ?? 1),
|
||||||
|
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
limit: { context: options.context ?? 100_000, output: 10_000 },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("ModelRouting.select", () => {
|
||||||
|
test("routes fast work to an inexpensive fast family without selecting haiku", () => {
|
||||||
|
const selected = ModelRouting.select("fast", [
|
||||||
|
model("anthropic", "claude-haiku-4", ["text"], { cost: 0.1, released: 4 }),
|
||||||
|
model("google", "gemini-flash", ["text"], { cost: 0.2, released: 3 }),
|
||||||
|
model("openai", "gpt-5", ["text"], { cost: 2, released: 5 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(selected).toEqual(
|
||||||
|
Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-flash") }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes smart work to a high-capability family", () => {
|
||||||
|
const selected = ModelRouting.select("smart", [
|
||||||
|
model("google", "gemini-flash", ["text"], { released: 5 }),
|
||||||
|
model("anthropic", "claude-opus-4", ["text"], { released: 3 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(selected).toEqual(
|
||||||
|
Model.Ref.make({ providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-opus-4") }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("enforces role capabilities and provider availability through the candidate set", () => {
|
||||||
|
expect(
|
||||||
|
ModelRouting.select("vision", [
|
||||||
|
model("openai", "gpt-5", ["text"], { released: 5 }),
|
||||||
|
model("google", "gemini-pro-vision", ["text", "image"], { released: 3 }),
|
||||||
|
]),
|
||||||
|
).toEqual(Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-pro-vision") }))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ModelRouting.select("long-context", [
|
||||||
|
model("openai", "gpt-5", ["text"], { context: 200_000 }),
|
||||||
|
model("google", "gemini-pro", ["text"], { context: 1_000_000 }),
|
||||||
|
]),
|
||||||
|
).toEqual(Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-pro") }))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves only exact models present in the available catalog", () => {
|
||||||
|
const available = [model("xai", "grok-4", ["text"])]
|
||||||
|
expect(ModelRouting.resolve("xai/grok-4", available)).toEqual(
|
||||||
|
Model.Ref.make({ providerID: Provider.ID.make("xai"), id: Model.ID.make("grok-4") }),
|
||||||
|
)
|
||||||
|
expect(ModelRouting.resolve("xai/grok-5", available)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import { Global } from "@opencode-ai/util/global"
|
|||||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { Bus } from "@opencode-ai/core/bus"
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
@@ -36,6 +37,7 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
|||||||
const childText = "child final response"
|
const childText = "child final response"
|
||||||
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
|
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 parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||||
|
const fastModel = Model.Ref.make({ id: Model.ID.make("gemini-flash"), providerID: Provider.ID.make("route") })
|
||||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
|
|
||||||
const outputSessionID = (value: unknown) =>
|
const outputSessionID = (value: unknown) =>
|
||||||
@@ -100,7 +102,7 @@ const subagentPluginSupervisor = makeLocationNode({
|
|||||||
PluginSupervisor.Service,
|
PluginSupervisor.Service,
|
||||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||||
),
|
),
|
||||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
deps: [Agent.node, Catalog.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodes = LayerNode.group([
|
const nodes = LayerNode.group([
|
||||||
@@ -142,6 +144,27 @@ const withSubagent = (location: Location.Ref) =>
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.provide(locations.get(location)))
|
).pipe(Effect.provide(locations.get(location)))
|
||||||
|
yield* Catalog.Service.use((catalog) =>
|
||||||
|
catalog.transform((draft) => {
|
||||||
|
draft.provider.update(fastModel.providerID, (provider) => {
|
||||||
|
provider.activation = "enabled"
|
||||||
|
})
|
||||||
|
draft.model.update(fastModel.providerID, fastModel.id, (model) => {
|
||||||
|
Object.assign(model, Model.Info.default(fastModel.providerID, fastModel.id), {
|
||||||
|
name: "Gemini Flash",
|
||||||
|
family: Model.Family.make("gemini-flash"),
|
||||||
|
time: { released: Date.now() },
|
||||||
|
cost: [
|
||||||
|
{
|
||||||
|
input: Money.USDPerMillionTokens.make(0.1),
|
||||||
|
output: Money.USDPerMillionTokens.make(0.2),
|
||||||
|
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(locations.get(location)))
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("SubagentTool", () => {
|
describe("SubagentTool", () => {
|
||||||
@@ -320,6 +343,37 @@ describe("SubagentTool", () => {
|
|||||||
})
|
})
|
||||||
const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
|
const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
|
||||||
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
|
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
|
||||||
|
|
||||||
|
const routed = yield* executeTool(registry, {
|
||||||
|
sessionID: parent.id,
|
||||||
|
...toolIdentity,
|
||||||
|
call: {
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-subagent-routed",
|
||||||
|
name: SubagentTool.name,
|
||||||
|
input: { agent: "reviewer", description: "fast", prompt: "quick check", model: "fast" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(yield* sessions.get(outputSessionID(routed.metadata))).toMatchObject({
|
||||||
|
parentID: parent.id,
|
||||||
|
model: fastModel,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* executeTool(registry, {
|
||||||
|
sessionID: parent.id,
|
||||||
|
...toolIdentity,
|
||||||
|
call: {
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-subagent-missing-model",
|
||||||
|
name: SubagentTool.name,
|
||||||
|
input: { agent: "reviewer", description: "missing", prompt: "check", model: "missing/model" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
status: "error",
|
||||||
|
error: { type: "tool.execution", message: "No available model matches route: missing/model" },
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user