From 80728e7d75dcc4db1e3143bba947141026d1ca6f Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Sat, 19 Sep 2026 13:12:03 +0200 Subject: [PATCH] feat: better config --- README.md | 8 +- examples/custom.opencode.jsonc | 16 ++- src/config.ts | 51 +++++++-- src/index.ts | 50 ++++++-- src/provider.ts | 13 ++- tests/plugin.test.ts | 204 ++++++++++++++++++++++++++++----- 6 files changed, 277 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 9879f4a..85e6af1 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,10 @@ Both installers use `~/.config/opencode`, respect `XDG_CONFIG_HOME`, and accept See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc). -For an installed plugin, use `./plugins/stanp-opencode` as the plugin package path. Set `lunaPriority` to `false` to avoid requesting priority pricing. -Custom configurations require the six worker assignments. The legacy optional `coordinate` -assignment is accepted for compatibility but deprecated and ignored; orchestration inherits -`deep-lo`, or `good-lo` when the configured `deep-lo` model is unavailable. +For an installed plugin, use `./plugins/stanp-opencode` as the plugin package path. In GPT mode with the `openai` provider, set top-level `lunaPriority` to `false` to avoid requesting priority pricing. Other providers do not accept this option. Custom top-level and per-agent `provider` and `lunaPriority` fields are invalid. +Custom configurations require the six worker assignments and use `provider/model` model names. +The legacy optional `coordinate` assignment is accepted for compatibility but ignored. Orchestration +inherits `deep-lo`, or `good-lo` when the configured `deep-lo` model is unavailable. ## Multi-work diff --git a/examples/custom.opencode.jsonc b/examples/custom.opencode.jsonc index cddb266..7dd47fd 100644 --- a/examples/custom.opencode.jsonc +++ b/examples/custom.opencode.jsonc @@ -3,22 +3,20 @@ // Required for multi-work -> coordinate -> workers. Apply in worktrees too. // Multi-work and coordinate use deep-lo, falling back to good-lo when unavailable. "experimental": { "subagent_depth": 2 }, - // All six worker assignments are required. Model IDs are local to the provider. + // All six worker assignments are required. Models use provider/model names. // An unavailable deep-lo omits deep workers and uses good-lo for orchestration. "plugins": [ { "package": "stanp-opencode", "options": { "mode": "custom", - "provider": "openai", - "lunaPriority": false, "agents": { - "fast-lo": { "model": "gpt-5.6-luna", "effort": "low" }, - "fast-hi": { "model": "gpt-5.6-luna", "effort": "high" }, - "good-lo": { "model": "gpt-5.6-sol", "effort": "low" }, - "good-hi": { "model": "gpt-5.6-sol", "effort": "high" }, - "deep-lo": { "model": "gpt-6-astra", "effort": "low" }, - "deep-hi": { "model": "gpt-6-astra", "effort": "high" }, + "fast-lo": { "model": "openai/gpt-5.6-luna", "effort": "low" }, + "fast-hi": { "model": "openai/gpt-5.6-luna", "effort": "high" }, + "good-lo": { "model": "openai/gpt-5.6-sol", "effort": "low" }, + "good-hi": { "model": "openai/gpt-5.6-sol", "effort": "high" }, + "deep-lo": { "model": "openai/gpt-6-astra", "effort": "low" }, + "deep-hi": { "model": "openai/gpt-6-astra", "effort": "high" }, }, }, }, diff --git a/src/config.ts b/src/config.ts index ee7f379..c3fb01b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,31 +9,58 @@ export const workerNames = [ "deep-hi", ] as const; -const assignment = z.strictObject({ +const gptAssignment = z.strictObject({ model: z.string().trim().min(1), effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]), }); +const customAssignment = gptAssignment.extend({ + model: z + .string() + .trim() + .refine((model) => { + const slash = model.indexOf("/"); + return slash > 0 && slash < model.length - 1; + }, "model must use the provider/model convention"), +}); + const common = { - provider: z.string().trim().min(1).default("openai"), - lunaPriority: z.boolean().default(true), playwright: z.boolean().default(false), }; export const settings = z.discriminatedUnion("mode", [ - z.strictObject({ ...common, mode: z.literal("gpt") }), + z + .strictObject({ + ...common, + mode: z.literal("gpt"), + provider: z.string().trim().min(1).default("openai"), + lunaPriority: z.boolean().optional(), + }) + .transform((value, context) => { + if (value.provider !== "openai" && value.lunaPriority !== undefined) { + context.addIssue({ + code: "custom", + path: ["lunaPriority"], + message: "lunaPriority is only supported by the openai provider", + }); + return z.NEVER; + } + return value.provider === "openai" + ? { ...value, lunaPriority: value.lunaPriority ?? true } + : value; + }), z.strictObject({ ...common, mode: z.literal("custom"), agents: z.strictObject({ - "fast-lo": assignment, - "fast-hi": assignment, - "good-lo": assignment, - "good-hi": assignment, - "deep-lo": assignment, - "deep-hi": assignment, + "fast-lo": customAssignment, + "fast-hi": customAssignment, + "good-lo": customAssignment, + "good-hi": customAssignment, + "deep-lo": customAssignment, + "deep-hi": customAssignment, // Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo. - coordinate: assignment.optional(), + coordinate: customAssignment.optional(), }), }), ]); @@ -45,4 +72,4 @@ export const gptAgents = { "good-hi": { model: "gpt-5.6-sol", effort: "high" }, "deep-lo": { model: "gpt-6-astra", effort: "low" }, "deep-hi": { model: "gpt-6-astra", effort: "high" }, -} satisfies Record<(typeof workerNames)[number], z.infer>; +} satisfies Record<(typeof workerNames)[number], z.infer>; diff --git a/src/index.ts b/src/index.ts index 5c98146..7cf4b15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import type { RpcRegistration } from "@opencode/plugin/promise/rpc"; import type { ModelInfo } from "@opencode/client"; import { z } from "zod"; import { gptAgents, settings, workerNames } from "./config.js"; -import { discover } from "./provider.js"; +import { discover, modelKey } from "./provider.js"; import { descriptions, routingGuide } from "./routing.js"; import { QuotaTracker } from "./quota.js"; import { QuotaRpc } from "./quota-rpc.js"; @@ -50,6 +50,10 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission id: "stanp-opencode", async setup(context) { const selected = settings.parse({ mode: "gpt", ...context.options }); + const lunaPriority = + selected.mode === "gpt" && + selected.provider === "openai" && + selected.lunaPriority === true; let models = new Map(); let inherited: Agent.Info["permissions"] = Agent.Info.default( Agent.ID.make("build"), @@ -83,7 +87,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission continue; } if (event.type === "model.updated") { - models = await discover(context, selected.provider); + models = await discover(context, lunaPriority); await context.agent.reload(); } else if (event.type === "agent.updated") { // The configured build transform may run after ours. Read the final @@ -118,13 +122,24 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission if (!controller.signal.aborted) console.error("stanp-opencode model update failed", error); }); - models = await discover(context, selected.provider); + models = await discover(context, lunaPriority); const { data: initialAgents } = await context.agent.list(); inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited; permissionsChanged(inherited); permissionKey = JSON.stringify(inherited); - const assignments = selected.mode === "gpt" ? gptAgents : selected.agents; + const assignments = + selected.mode === "gpt" + ? Object.fromEntries( + workerNames.map((name) => [ + name, + { + ...gptAgents[name], + provider: selected.provider, + }, + ]), + ) + : selected.agents; let workers: (typeof workerNames)[number][] = []; const active = new Set(); const playwrightCommand = selected.playwright @@ -163,11 +178,26 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission }); await context.agent.transform((editor) => { - const deep = models.has(assignments["deep-lo"].model); + const coordinates = (assignment: { model: string }) => { + if (selected.mode === "gpt") + return { provider: selected.provider, model: assignment.model }; + const slash = assignment.model.indexOf("/"); + return { + provider: assignment.model.slice(0, slash), + model: assignment.model.slice(slash + 1), + }; + }; + const availableFor = ( + assignment: (typeof assignments)[(typeof workerNames)[number]], + ) => { + const { provider, model } = coordinates(assignment); + return models.get(modelKey(provider, model)); + }; + const deep = availableFor(assignments["deep-lo"]) !== undefined; const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-")); const coordinate = assignments[deep ? "deep-lo" : "good-lo"]; workers = [coordinate, ...candidates.map((name) => assignments[name])].every( - ({ model }) => models.has(model), + (assignment) => availableFor(assignment) !== undefined, ) ? candidates : []; @@ -176,7 +206,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission if (workers.length === 0) return; for (const name of workers) { const assignment = assignments[name]; - const available = models.get(assignment.model)!; + const available = availableFor(assignment)!; editor.update(name, (agent) => { const hasVariant = available.model.variants.some( ({ id }) => id === assignment.effort, @@ -196,7 +226,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission headers: {}, body: {}, }; - if (selected.lunaPriority && available.serviceTier) + if (lunaPriority && available.serviceTier) agent.request.settings.serviceTier = available.serviceTier; agent.permissions = [ ...structuredClone(inherited), @@ -205,7 +235,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission }); } editor.update("coordinate", (agent) => { - const available = models.get(coordinate.model)!; + const available = availableFor(coordinate)!; const hasVariant = available.model.variants.some( ({ id }) => id === coordinate.effort, ); @@ -224,6 +254,8 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission headers: {}, body: {}, }; + if (lunaPriority && available.serviceTier) + agent.request.settings.serviceTier = available.serviceTier; agent.system = routingGuide(deep); agent.permissions = [ { action: "*", resource: "*", effect: "deny" }, diff --git a/src/provider.ts b/src/provider.ts index 30bb865..f3b6cec 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -20,13 +20,20 @@ function priority(model: ModelInfo) { : undefined; } -export async function discover(context: Plugin.Context, providerID: string) { +export function modelKey(providerID: string, modelID: string) { + return `${providerID}\0${modelID}`; +} + +export async function discover(context: Plugin.Context, detectPriority: boolean) { // Native lists already apply activation, integrations, configuration, and model transforms. const { data } = await context.model.list(); const models = new Map(); for (const model of data) { - if (model.providerID === providerID && model.enabled) - models.set(model.id, { model, serviceTier: priority(model) }); + if (model.enabled) + models.set(modelKey(model.providerID, model.id), { + model, + serviceTier: detectPriority ? priority(model) : undefined, + }); } return models; } diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 39979f2..2f0037d 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -9,7 +9,7 @@ import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/too import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client"; import type { SessionContext } from "@opencode/plugin/promise/session"; import { z } from "zod"; -import { gptAgents } from "../src/config.js"; +import { gptAgents, settings, workerNames } from "../src/config.js"; import { basePlugin as plugin } from "../src/index.js"; import { playwrightMcpCommand } from "../src/playwright.js"; import { bindingKey } from "../src/multi-work.js"; @@ -27,7 +27,31 @@ function reasoningModel(id: string): ModelInfo { })), }; } +function providerModel(provider: string, id: string): ModelInfo { + return { + ...reasoningModel(id), + providerID: Provider.ID.make(provider), + }; +} const allModels = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map(reasoningModel); +type CustomAssignment = { + model: string; + effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; +}; +const customAssignments = (provider = "test") => { + const assignment = (name: (typeof workerNames)[number]): CustomAssignment => ({ + ...gptAgents[name], + model: `${provider}/${gptAgents[name].model}`, + }); + return { + "fast-lo": assignment("fast-lo"), + "fast-hi": assignment("fast-hi"), + "good-lo": assignment("good-lo"), + "good-hi": assignment("good-hi"), + "deep-lo": assignment("deep-lo"), + "deep-hi": assignment("deep-hi"), + }; +}; const cleanups: Plugin.Cleanup[] = []; afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); @@ -352,15 +376,20 @@ test("orchestration inherits deep-lo and falls back to good-lo with available wo expect((await host(allModels.slice(1))).agents.has("coordinate")).toBe(false); const customAgents = { - ...gptAgents, - "good-lo": { model: "custom-good", effort: "high" as const }, - "deep-lo": { model: "custom-deep", effort: "medium" as const }, + ...customAssignments(), + "good-lo": { + model: "test/custom-good", + effort: "high" as const, + }, + "deep-lo": { + model: "test/custom-deep", + effort: "medium" as const, + }, }; const custom = await host( [...allModels, reasoningModel("custom-good"), reasoningModel("custom-deep")], { mode: "custom", - provider: "test", agents: customAgents, }, ); @@ -370,7 +399,6 @@ test("orchestration inherits deep-lo and falls back to good-lo with available wo }); const customFallback = await host([...allModels, reasoningModel("custom-good")], { mode: "custom", - provider: "test", agents: customAgents, }); expect(customFallback.agents.get("coordinate")?.model).toMatchObject({ @@ -382,13 +410,106 @@ test("orchestration inherits deep-lo and falls back to good-lo with available wo ( await host(allModels, { mode: "custom", - provider: "test", agents: customAgents, }) ).agents.has("coordinate"), ).toBe(false); }); +test("custom assignments resolve duplicate model IDs by provider and refresh provider-aware fallback", async () => { + const agents = customAssignments("alpha"); + agents["fast-lo"] = { ...agents["fast-lo"], model: "beta/shared" }; + agents["fast-hi"] = { ...agents["fast-hi"], model: "alpha/shared" }; + agents["deep-lo"] = { ...agents["deep-lo"], model: "beta/deep" }; + agents["deep-hi"] = { ...agents["deep-hi"], model: "beta/deep" }; + const base = [ + providerModel("alpha", "shared"), + providerModel("beta", "shared"), + providerModel("alpha", "gpt-5.6-sol"), + providerModel("alpha", "gpt-6-astra"), + ]; + const result = await host(base, { mode: "custom", agents }); + expect(String(result.agents.get("fast-lo")?.model?.providerID)).toBe("beta"); + expect(String(result.agents.get("fast-hi")?.model?.providerID)).toBe("alpha"); + expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol"); + expect(result.agents.has("deep-lo")).toBe(false); + + await result.refresh([...base, providerModel("beta", "deep")]); + expect(result.agents.get("coordinate")?.model).toMatchObject({ + providerID: "beta", + id: "deep", + }); + expect(result.agents.has("deep-lo")).toBe(true); + + const nested = customAssignments("alpha"); + nested["fast-lo"] = { ...nested["fast-lo"], model: "alpha/group/shared" }; + const nestedResult = await host( + [...base, providerModel("alpha", "gpt-5.6-luna"), providerModel("alpha", "group/shared")], + { mode: "custom", agents: nested }, + ); + expect(String(nestedResult.agents.get("fast-lo")?.model?.id)).toBe("group/shared"); +}); + +test("custom configuration requires qualified models and rejects obsolete fields", () => { + expect(() => settings.parse({ mode: "custom", agents: customAssignments() })).not.toThrow(); + for (const model of ["bare", "/model", "provider/"]) + expect(() => + settings.parse({ + mode: "custom", + agents: { + ...customAssignments(), + "fast-lo": { model, effort: "low" }, + }, + }), + ).toThrow(); + const nested = customAssignments(); + nested["fast-lo"] = { model: "provider/group/model", effort: "low" }; + expect(() => settings.parse({ mode: "custom", agents: nested })).not.toThrow(); + expect(() => + settings.parse({ mode: "custom", provider: "test", agents: customAssignments() }), + ).toThrow(); + expect(() => + settings.parse({ mode: "custom", lunaPriority: false, agents: customAssignments() }), + ).toThrow(); + expect(() => + settings.parse({ + mode: "custom", + agents: { + ...customAssignments(), + "fast-lo": { ...customAssignments()["fast-lo"], provider: "test" }, + }, + }), + ).toThrow(); + expect(() => + settings.parse({ + mode: "custom", + agents: { + ...customAssignments(), + "fast-lo": { ...customAssignments()["fast-lo"], lunaPriority: true }, + }, + }), + ).toThrow(); + expect(() => + settings.parse({ + mode: "custom", + agents: { ...customAssignments(), coordinate: customAssignments()["deep-lo"] }, + }), + ).not.toThrow(); + for (const obsolete of [{ provider: "test" }, { lunaPriority: true }]) + expect(() => + settings.parse({ + mode: "custom", + agents: { + ...customAssignments(), + coordinate: { + ...customAssignments()["deep-lo"], + ...obsolete, + }, + }, + }), + ).toThrow(); +}); + test("worker inheritance and coordinator isolation preserve build and default", async () => { const result = await host(); expect(result.defaultAgent).toBe("build"); @@ -689,33 +810,58 @@ test("coordinate prompt admission requires owned ready placement; local and move test("reasoning settings and Luna priority use native advertised or official support", async () => { const luna = { - ...model("gpt-5.6-luna"), + ...providerModel("openai", "gpt-5.6-luna"), package: "@opencode/ai/providers/openai", settings: { baseURL: "https://api.openai.com/v1" }, }; - const ordinary = await host(); + const openaiModels = [ + luna, + providerModel("openai", "gpt-5.6-sol"), + providerModel("openai", "gpt-6-astra"), + ]; + const ordinary = await host( + [providerModel("openai", "gpt-5.6-luna"), ...openaiModels.slice(1)], + { provider: "openai" }, + ); expect(ordinary.agents.get("fast-lo")?.request.settings).toEqual({}); expect(String(ordinary.agents.get("fast-lo")?.model?.variant)).toBe("low"); - const official = await host([luna, ...allModels.slice(1)]); - expect(official.agents.get("fast-hi")?.request.settings).toEqual({ - reasoningEffort: "high", - serviceTier: "priority", - }); - const proxy = await host([ - { ...luna, settings: { baseURL: "https://proxy.example/v1" } }, - ...allModels.slice(1), - ]); - expect(proxy.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" }); - const advertised = await host([ - { ...luna, package: "custom", settings: { serviceTier: "fast" } }, - ...allModels.slice(1), - ]); + const official = await host(openaiModels, { provider: "openai" }); + expect(official.agents.get("fast-hi")?.request.settings).toEqual({ serviceTier: "priority" }); + const proxy = await host( + [{ ...luna, settings: { baseURL: "https://proxy.example/v1" } }, ...openaiModels.slice(1)], + { provider: "openai" }, + ); + expect(proxy.agents.get("fast-lo")?.request.settings).toEqual({}); + const advertised = await host( + [ + { ...luna, package: "custom", settings: { serviceTier: "fast" } }, + ...openaiModels.slice(1), + ], + { provider: "openai" }, + ); expect(advertised.agents.get("fast-lo")?.request.settings.serviceTier).toBe("fast"); - const disabled = await host([luna, ...allModels.slice(1)], { - provider: "test", + const sdkAlias = await host( + [{ ...luna, package: "@ai-sdk/openai" }, ...openaiModels.slice(1)], + { provider: "openai" }, + ); + expect(sdkAlias.agents.get("fast-lo")?.request.settings.serviceTier).toBe("priority"); + const disabled = await host(openaiModels, { + provider: "openai", lunaPriority: false, }); - expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" }); + expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({}); + + const custom = customAssignments("openai"); + const customResult = await host(openaiModels, { + mode: "custom", + agents: custom, + }); + expect(customResult.agents.get("fast-lo")?.request.settings.serviceTier).toBeUndefined(); + expect(customResult.agents.get("fast-hi")?.request.settings.serviceTier).toBeUndefined(); + + const other = await host(allModels, { provider: "test" }); + expect(other.agents.get("fast-lo")?.request.settings.serviceTier).toBeUndefined(); + expect(() => settings.parse({ mode: "gpt", provider: "test", lunaPriority: true })).toThrow(); }); test("coordinator uses inherited effort and ignores the legacy custom assignment", async () => { @@ -737,8 +883,10 @@ test("coordinator uses inherited effort and ignores the legacy custom assignment const custom = await host([...allModels, model("local")], { mode: "custom", - provider: "test", - agents: { ...gptAgents, coordinate: { model: "local", effort: "xhigh" } }, + agents: { + ...customAssignments(), + coordinate: { model: "test/local", effort: "xhigh" }, + }, }); expect(custom.agents.get("coordinate")?.model).toMatchObject({ id: "gpt-6-astra",