diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 0424185b5f..e6d76e9903 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -69,6 +69,7 @@ "agent-usage-reminder", "non-interactive-env", "interactive-bash-session", + "model-rotation", "thinking-block-validator", "ralph-loop", "compaction-context-injector", @@ -88,7 +89,8 @@ "type": "string", "enum": [ "init-deep", - "start-work" + "start-work", + "rotation-status" ] } }, @@ -99,7 +101,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -218,6 +230,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -225,7 +264,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -344,6 +393,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -351,7 +427,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -470,6 +556,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -477,7 +590,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -596,6 +719,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -603,7 +753,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -722,6 +882,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -729,7 +916,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -848,6 +1045,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -855,7 +1079,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -974,6 +1208,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -981,7 +1242,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1100,6 +1371,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -1107,7 +1405,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1226,6 +1534,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -1233,7 +1568,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1352,6 +1697,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -1359,7 +1731,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1478,6 +1860,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -1485,7 +1894,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1604,6 +2023,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -1611,7 +2057,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1730,6 +2186,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -1737,7 +2220,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1856,6 +2349,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } }, @@ -1863,7 +2383,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1982,6 +2512,33 @@ ] } } + }, + "rotation": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "limitType": { + "default": "calls", + "type": "string", + "enum": [ + "calls", + "tokens" + ] + }, + "limitValue": { + "default": 100, + "type": "number", + "minimum": 1 + }, + "cooldownMs": { + "default": 3600000, + "type": "number", + "minimum": 0 + } + } } } } diff --git a/src/agents/sisyphus-junior.ts b/src/agents/sisyphus-junior.ts index 45b4102ddd..5acb8da5fc 100644 --- a/src/agents/sisyphus-junior.ts +++ b/src/agents/sisyphus-junior.ts @@ -5,6 +5,7 @@ import { createAgentToolRestrictions, type PermissionValue, } from "../shared/permission-compat" +import { selectAvailableModel } from "../features/model-rotation/state-manager" const SISYPHUS_JUNIOR_PROMPT = ` Sisyphus-Junior - Focused executor from OhMyOpenCode. @@ -90,7 +91,17 @@ export function createSisyphusJuniorAgentWithOverrides( override = undefined } - const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model + const rawModel = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model + const model: string = (() => { + if (typeof rawModel === "string") return rawModel + if (Array.isArray(rawModel) && rawModel.length > 0) { + if (override?.rotation?.enabled) { + return selectAvailableModel(rawModel) ?? rawModel[0] + } + return rawModel[0] + } + return SISYPHUS_JUNIOR_DEFAULTS.model + })() const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature const promptAppend = override?.prompt_append diff --git a/src/agents/types.ts b/src/agents/types.ts index d808703b16..0c23fcc7b1 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -74,9 +74,4 @@ export type OverridableAgentName = export type AgentName = BuiltinAgentName -export type AgentOverrideConfig = Partial & { - prompt_append?: string - variant?: string -} - -export type AgentOverrides = Partial> +export type { AgentOverrideConfig, AgentOverrides } from "../config/schema" diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 85df26f681..e1aece10ec 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -45,20 +45,38 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.Sisyphus.thinking).toBeUndefined() }) - test("Oracle with default model has reasoningEffort", () => { - // #given - no overrides, using systemDefaultModel for other agents - // Oracle uses its own default model (openai/gpt-5.2) from the factory singleton + test("Oracle with default model has reasoningEffort", () => { + // #given - no overrides, using systemDefaultModel for other agents + // Oracle uses its own default model (openai/gpt-5.2) from the factory singleton + + // #when + const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) + + // #then - Oracle uses systemDefaultModel since model is now required + expect(agents.oracle.model).toBe("anthropic/claude-opus-4-5") + expect(agents.oracle.thinking).toEqual({ type: "enabled", budgetTokens: 32000 }) + expect(agents.oracle.reasoningEffort).toBeUndefined() + }) + + test("does not throw when systemDefaultModel is omitted", () => { + const agents = createBuiltinAgents() + expect(agents.Sisyphus).toBeDefined() + expect(agents["orchestrator-sisyphus"]).toBeUndefined() + }) + + test("creates orchestrator-sisyphus when systemDefaultModel is omitted but override model exists", () => { + const agents = createBuiltinAgents([], { + "orchestrator-sisyphus": { + model: TEST_DEFAULT_MODEL, + }, + }) + + expect(agents["orchestrator-sisyphus"]).toBeDefined() + expect(agents["orchestrator-sisyphus"]?.model).toBe(TEST_DEFAULT_MODEL) + }) + + test("Oracle with GPT model override has reasoningEffort, no thinking", () => { - // #when - const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) - - // #then - Oracle uses systemDefaultModel since model is now required - expect(agents.oracle.model).toBe("anthropic/claude-opus-4-5") - expect(agents.oracle.thinking).toEqual({ type: "enabled", budgetTokens: 32000 }) - expect(agents.oracle.reasoningEffort).toBeUndefined() - }) - - test("Oracle with GPT model override has reasoningEffort, no thinking", () => { // #given const overrides = { oracle: { model: "openai/gpt-5.2" }, diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 4780675ae7..abea05d1e2 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -1,6 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types" -import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema" +import type { CategoriesConfig, CategoryConfig, GitMasterConfig, RotationConfig } from "../config/schema" import { createSisyphusAgent } from "./sisyphus" import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian" @@ -15,6 +15,17 @@ import type { AvailableAgent } from "./sisyphus-prompt-builder" import { deepMerge } from "../shared" import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants" import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" +import { selectAvailableModel } from "../features/model-rotation/state-manager" + +function normalizeModel(model: string | string[] | undefined, fallback: string, rotation?: RotationConfig): string { + if (!model) return fallback + if (typeof model === "string") return model + if (model.length === 0) return fallback + if (rotation?.enabled && model.length > 1) { + return selectAvailableModel(model) ?? model[0] + } + return model[0] +} type AgentSource = AgentFactory | AgentConfig @@ -118,13 +129,29 @@ function mergeAgentConfig( base: AgentConfig, override: AgentOverrideConfig ): AgentConfig { - const { prompt_append, ...rest } = override + const { prompt_append, model, rotation, ...rest } = override const merged = deepMerge(base, rest as Partial) if (prompt_append && merged.prompt) { merged.prompt = merged.prompt + "\n" + prompt_append } + if (model) { + const models = Array.isArray(model) ? model : [model] + + if (models.length > 0) { + const selectedModel = rotation?.enabled && models.length > 1 + ? (selectAvailableModel(models) ?? models[0]) + : models[0] + + merged.model = selectedModel + } + } + + if (rotation) { + merged.rotation = rotation as RotationConfig | undefined + } + return merged } @@ -136,9 +163,7 @@ export function createBuiltinAgents( categories?: CategoriesConfig, gitMasterConfig?: GitMasterConfig ): Record { - if (!systemDefaultModel) { - throw new Error("createBuiltinAgents requires systemDefaultModel") - } + const defaultModel = systemDefaultModel ?? "" const result: Record = {} const availableAgents: AvailableAgent[] = [] @@ -155,7 +180,7 @@ export function createBuiltinAgents( if (disabledAgents.includes(agentName)) continue const override = agentOverrides[agentName] - const model = override?.model ?? systemDefaultModel + const model = normalizeModel(override?.model, defaultModel, override?.rotation) let config = buildAgent(source, model, mergedCategories, gitMasterConfig) @@ -182,7 +207,7 @@ export function createBuiltinAgents( if (!disabledAgents.includes("Sisyphus")) { const sisyphusOverride = agentOverrides["Sisyphus"] - const sisyphusModel = sisyphusOverride?.model ?? systemDefaultModel + const sisyphusModel = normalizeModel(sisyphusOverride?.model, defaultModel, sisyphusOverride?.rotation) let sisyphusConfig = createSisyphusAgent(sisyphusModel, availableAgents) @@ -198,9 +223,14 @@ export function createBuiltinAgents( result["Sisyphus"] = sisyphusConfig } - if (!disabledAgents.includes("orchestrator-sisyphus")) { - const orchestratorOverride = agentOverrides["orchestrator-sisyphus"] - const orchestratorModel = orchestratorOverride?.model ?? systemDefaultModel + const orchestratorOverride = agentOverrides["orchestrator-sisyphus"] + + if (!disabledAgents.includes("orchestrator-sisyphus") && (defaultModel || orchestratorOverride?.model)) { + const orchestratorModel = normalizeModel( + orchestratorOverride?.model, + defaultModel, + orchestratorOverride?.rotation + ) let orchestratorConfig = createOrchestratorSisyphusAgent({ model: orchestratorModel, availableAgents, diff --git a/src/config/schema.ts b/src/config/schema.ts index 8be0a144c2..7b5da038ce 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -76,6 +76,7 @@ export const HookNameSchema = z.enum([ "agent-usage-reminder", "non-interactive-env", "interactive-bash-session", + "model-rotation", "thinking-block-validator", "ralph-loop", @@ -93,11 +94,27 @@ export const HookNameSchema = z.enum([ export const BuiltinCommandNameSchema = z.enum([ "init-deep", "start-work", + "rotation-status", ]) +/** + * Model rotation configuration for a single agent + * Allows round-robin rotation through multiple models with usage limits and cooldowns + */ +export const RotationConfigSchema = z.object({ + /** Enable model rotation for this agent (default: false) */ + enabled: z.boolean().default(false), + /** What to track: 'calls' (request count) or 'tokens' (token usage) */ + limitType: z.enum(["calls", "tokens"]).default("calls"), + /** Usage limit threshold */ + limitValue: z.number().min(1).default(100), + /** Cooldown period in milliseconds (default: 3600000 = 1 hour) */ + cooldownMs: z.number().min(0).default(3600000), +}) + export const AgentOverrideConfigSchema = z.object({ /** @deprecated Use `category` instead. Model is inherited from category defaults. */ - model: z.string().optional(), + model: z.union([z.string(), z.array(z.string())]).optional(), variant: z.string().optional(), /** Category name to inherit model and other settings from CategoryConfig */ category: z.string().optional(), @@ -116,6 +133,8 @@ export const AgentOverrideConfigSchema = z.object({ .regex(/^#[0-9A-Fa-f]{6}$/) .optional(), permission: AgentPermissionSchema.optional(), + /** Model rotation configuration for round-robin through multiple models */ + rotation: RotationConfigSchema.optional(), }) export const AgentOverridesSchema = z.object({ @@ -339,5 +358,6 @@ export type CategoryConfig = z.infer export type CategoriesConfig = z.infer export type BuiltinCategoryName = z.infer export type GitMasterConfig = z.infer +export type RotationConfig = z.infer export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/features/builtin-commands/commands.ts b/src/features/builtin-commands/commands.ts index f489c198ba..36c486b08d 100644 --- a/src/features/builtin-commands/commands.ts +++ b/src/features/builtin-commands/commands.ts @@ -4,6 +4,7 @@ import { INIT_DEEP_TEMPLATE } from "./templates/init-deep" import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop" import { REFACTOR_TEMPLATE } from "./templates/refactor" import { START_WORK_TEMPLATE } from "./templates/start-work" +import { ROTATION_STATUS_TEMPLATE } from "./templates/rotation-status" const BUILTIN_COMMAND_DEFINITIONS: Record> = { "init-deep": { @@ -70,6 +71,12 @@ $ARGUMENTS `, argumentHint: "[plan-name]", }, + "rotation-status": { + description: "(builtin) Display model rotation status for all agents", + template: ` +${ROTATION_STATUS_TEMPLATE} +`, + }, } export function loadBuiltinCommands( diff --git a/src/features/builtin-commands/templates/rotation-status.ts b/src/features/builtin-commands/templates/rotation-status.ts new file mode 100644 index 0000000000..9dc28acb4a --- /dev/null +++ b/src/features/builtin-commands/templates/rotation-status.ts @@ -0,0 +1,44 @@ +export const ROTATION_STATUS_TEMPLATE = `Display model rotation status for all agents with rotation enabled. + +## What to Check + +For each agent with rotation enabled, show: +1. **Agent Name**: Which agent has rotation +2. **Available Models**: List of models in rotation pool +3. **Rotation Config**: Limit type, limit value, cooldown period +4. **Current State**: Which model is currently being used (if available) + +## Format + +For each agent, display in this format: + +### {agent-name} +Models: {model1}, {model2}, {model3} +Config: {limitType}={limitValue}, cooldown={cooldownMs}ms +State: {current model or "not tracked yet"} + +## Instructions + +1. Determine OpenCode config directory: + - If $OPENCODE_CONFIG_DIR is set, use that + - Else on Linux/macOS use $XDG_CONFIG_HOME/opencode (or ~/.config/opencode) + - Else on Windows use %APPDATA%\\opencode (fallback: ~/.config/opencode) +2. Read rotation configuration from: {configDir}/oh-my-opencode.json +3. Read rotation state from: {configDir}/model-rotation-state.json (if exists) +3. Display all agents with rotation.enabled=true +4. For each agent, show their model pool and rotation config +5. If state file exists, show current tracked model for each agent + +## Example Output + +### Sisyphus +Models: github-copilot/claude-opus-4-5, github-copilot/claude-sonnet-4.5, zai-coding-plan/glm-4.7 +Config: calls=100, cooldown=180000ms +State: github-copilot/claude-opus-4-5 (2 calls used) + +### oracle +Models: github-copilot/claude-opus-4-5, zai-coding-plan/glm-4.7 +Config: calls=50, cooldown=120000ms +State: zai-coding-plan/glm-4.7 (12 calls used) + +Display status for all configured rotation agents.` diff --git a/src/features/builtin-commands/types.ts b/src/features/builtin-commands/types.ts index c626092cf7..a833ea0e84 100644 --- a/src/features/builtin-commands/types.ts +++ b/src/features/builtin-commands/types.ts @@ -1,6 +1,6 @@ import type { CommandDefinition } from "../claude-code-command-loader" -export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work" +export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work" | "rotation-status" export interface BuiltinCommandConfig { disabled_commands?: BuiltinCommandName[] diff --git a/src/features/model-rotation/CLAUDE.md b/src/features/model-rotation/CLAUDE.md new file mode 100644 index 0000000000..adfdcb1150 --- /dev/null +++ b/src/features/model-rotation/CLAUDE.md @@ -0,0 +1,7 @@ + +# Recent Activity + + + +*No recent activity* + \ No newline at end of file diff --git a/src/features/model-rotation/constants.ts b/src/features/model-rotation/constants.ts new file mode 100644 index 0000000000..d654075cdb --- /dev/null +++ b/src/features/model-rotation/constants.ts @@ -0,0 +1,60 @@ +/** + * Constants for model rotation feature + */ + +/** Default cooldown period: 1 hour in milliseconds */ +export const DEFAULT_COOLDOWN_MS = 3600000 + +/** Default usage limit: 100 API calls */ +export const DEFAULT_LIMIT_VALUE = 100 + +/** Default limit type: track API calls */ +export const DEFAULT_LIMIT_TYPE: "calls" | "tokens" = "calls" + +/** State file location relative to config directory */ +export const STATE_FILENAME = "model-rotation-state.json" + +/** Maximum age before pruning unused model state (30 days) */ +export const STATE_MAX_AGE_DAYS = 30 + +/** Error keywords that trigger rotation */ +export const ROTATION_ERROR_KEYWORDS = [ + "rate limit", + "quota exceeded", + "too many requests", + "429", + "rate_limited", + "quota_exceeded", + "resource_exhausted", + "service_unavailable", + "insufficient_quota", + "overloaded", + "rate_limit_error", + "permission_denied", + "maximum quota", + "model not found", + "modelfound", + "ProviderModelNotFoundError", + "not found", + "invalid model", + "does not exist", +] as const + +/** Provider-specific error codes */ +export const ANTHROPIC_ERROR_CODES = { + RATE_LIMIT: 429, + OVERLOADED: 529, + QUOTA_EXCEEDED: 529, +} as const + +export const OPENAI_ERROR_CODES = { + RATE_LIMIT: 429, + INSUFFICIENT_QUOTA: 429, +} as const + +export const GOOGLE_ERROR_CODES = { + QUOTA_EXCEEDED: 429, + RESOURCE_EXHAUSTED: 429, + UNAVAILABLE: 503, + PERMISSION_DENIED: 403, +} as const diff --git a/src/features/model-rotation/error-parser.test.ts b/src/features/model-rotation/error-parser.test.ts new file mode 100644 index 0000000000..9c74fa0e4b --- /dev/null +++ b/src/features/model-rotation/error-parser.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from "bun:test" +import { ErrorParser } from "./error-parser" + +describe("ErrorParser", () => { + it("should detect Anthropic rate limit error from error object", () => { + const parser = new ErrorParser() + const error = { + status: 429, + error: { + type: "rate_limit_error", + message: "Rate limit exceeded", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("rate_limit") + expect(result.provider).toBe("anthropic") + expect(result.message).toBe("Rate limit exceeded") + }) + + it("should detect Anthropic overloaded error", () => { + const parser = new ErrorParser() + const error = { + status: 529, + error: { + type: "overloaded_error", + message: "Overloaded", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("quota") + expect(result.provider).toBe("anthropic") + }) + + it("should trigger rotation for overloaded_error even without keywords in message", () => { + const parser = new ErrorParser() + const error = { + status: 529, + error: { + type: "overloaded_error", + message: "Server temporarily busy", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("quota") + expect(result.provider).toBe("anthropic") + }) + + it("should detect OpenAI rate limit error", () => { + const parser = new ErrorParser() + const error = { + error: { + message: "Rate limit exceeded", + code: "rate_limit_exceeded", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("rate_limit") + expect(result.provider).toBe("openai") + }) + + it("should not misclassify OpenAI rate_limit_error as Anthropic", () => { + const parser = new ErrorParser() + const error = { + error: { + type: "rate_limit_error", + message: "Rate limit exceeded", + code: "rate_limit_exceeded", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("rate_limit") + expect(result.provider).toBe("openai") + }) + + it("should detect OpenAI quota exceeded error", () => { + const parser = new ErrorParser() + const error = { + error: { + message: "Insufficient quota", + code: "insufficient_quota", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("quota") + expect(result.provider).toBe("openai") + }) + + it("should detect OpenAI insufficient_quota error with 429 status as quota", () => { + const parser = new ErrorParser() + const error = { + status: 429, + error: { + message: "Insufficient quota", + code: "insufficient_quota", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("quota") + expect(result.provider).toBe("openai") + }) + + it("should detect Google resource exhausted error", () => { + const parser = new ErrorParser() + const error = { + status: 429, + error: { + status: "RESOURCE_EXHAUSTED", + message: "Resource exhausted", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("quota") + expect(result.provider).toBe("google") + }) + + it("should not trigger rotation for non-quota errors", () => { + const parser = new ErrorParser() + const error = { + error: { + message: "Internal server error", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(false) + expect(result.errorType).toBe("other") + }) + + it("should extract error message from nested error object", () => { + const parser = new ErrorParser() + const error = { + error: { + data: { + error: { + message: "Rate limit exceeded", + }, + }, + }, + } + + const result = parser.parseError(error) + + expect(result.message).toBe("Rate limit exceeded") + }) + + it("should detect quota keywords in error message", () => { + const parser = new ErrorParser() + const error = { + error: { + message: "Maximum quota reached for this account", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("quota") + }) + + it("should detect rate limit keywords in error message", () => { + const parser = new ErrorParser() + const error = { + error: { + message: "Too many requests, please rate limit", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("rate_limit") + }) + + it("should fallback to pattern matching for unknown error format", () => { + const parser = new ErrorParser() + const error = { + error: { + type: "custom_error", + details: "Rate limit exceeded", + }, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("rate_limit") + }) + + it("should detect 429 status code", () => { + const parser = new ErrorParser() + const error = { + status: 429, + } + + const result = parser.parseError(error) + + expect(result.isRotationTriggering).toBe(true) + expect(result.errorType).toBe("rate_limit") + }) + + it("should identify Anthropic provider from model string", () => { + const parser = new ErrorParser() + const error = { + model: "anthropic/claude-opus-4-5", + } + + const result = parser.parseError(error) + + expect(result.provider).toBe("anthropic") + }) + + it("should identify OpenAI provider from model string", () => { + const parser = new ErrorParser() + const error = { + model: "openai/gpt-5.2", + } + + const result = parser.parseError(error) + + expect(result.provider).toBe("openai") + }) + + it("should identify Google provider from model string", () => { + const parser = new ErrorParser() + const error = { + model: "google/gemini-3-flash", + } + + const result = parser.parseError(error) + + expect(result.provider).toBe("google") + }) + + it("should default to unknown provider for unrecognized model", () => { + const parser = new ErrorParser() + const error = { + model: "custom/model", + } + + const result = parser.parseError(error) + + expect(result.provider).toBe("unknown") + }) +}) diff --git a/src/features/model-rotation/error-parser.ts b/src/features/model-rotation/error-parser.ts new file mode 100644 index 0000000000..19122bfb42 --- /dev/null +++ b/src/features/model-rotation/error-parser.ts @@ -0,0 +1,328 @@ +import { ROTATION_ERROR_KEYWORDS } from "./constants" + +export class ErrorParser { + parseError(error: unknown): { + isRotationTriggering: boolean + errorType: "rate_limit" | "quota" | "model_not_found" | "other" + provider: "anthropic" | "openai" | "google" | "unknown" + message: string + } { + // Handle string errors + if (typeof error === "string") { + return this.parseFromString(error, "unknown") + } + + if (!error || typeof error !== "object") { + return this.defaultResult(String(error ?? "")) + } + + const err = error as Record + + // Extract provider from model field if present + const provider = this.detectProvider(err) + + // Check status code first (429/529 are rate limit/quota errors) + if (err.status === 429 || err.status === 529) { + const message = this.extractMessage(err) + const nestedError = err.error as Record | undefined + const nestedStatus = String(nestedError?.status ?? "").toLowerCase() + const nestedCode = String(nestedError?.code ?? "").toLowerCase() + const nestedMessage = String(nestedError?.message ?? "").toLowerCase() + const isQuotaError = + nestedStatus === "resource_exhausted" || + message.toLowerCase().includes("exhausted") || + nestedCode.includes("insufficient") || + nestedMessage.includes("insufficient") + + const errorType = err.status === 529 || isQuotaError ? "quota" : "rate_limit" + return { + isRotationTriggering: true, + errorType, + provider: provider !== "unknown" ? provider : this.detectProviderFromErrorType(err), + message, + } + } + + // Check nested error object + const nestedError = err.error as Record | undefined + if (nestedError && typeof nestedError === "object") { + const result = this.parseNestedError(nestedError, provider) + if (result.isRotationTriggering) { + return result + } + } + + // Check message field directly + const message = this.extractMessage(err) + if (this.containsRotationKeywords(message)) { + return { + isRotationTriggering: true, + errorType: this.determineErrorType(message, err), + provider, + message, + } + } + + return { + isRotationTriggering: false, + errorType: "other", + provider, + message, + } + } + + private parseNestedError( + nestedError: Record, + parentProvider: "anthropic" | "openai" | "google" | "unknown" + ): { + isRotationTriggering: boolean + errorType: "rate_limit" | "quota" | "model_not_found" | "other" + provider: "anthropic" | "openai" | "google" | "unknown" + message: string + } { + const errorType = String(nestedError.type ?? "").toLowerCase() + const errorCode = String(nestedError.code ?? "").toLowerCase() + const errorStatus = String(nestedError.status ?? "").toLowerCase() + const message = this.extractMessage(nestedError) + + // Detect provider from error type patterns + let provider = parentProvider + if (provider === "unknown") { + if (errorCode.includes("rate_limit") || errorCode.includes("insufficient_quota")) { + provider = "openai" + } else if (errorStatus === "resource_exhausted") { + provider = "google" + } else if ( + errorType.includes("rate_limit") || + errorType === "quota_exceeded" || + errorType === "overloaded_error" + ) { + provider = "anthropic" + } + } + + // Check for rotation-triggering patterns + if ( + errorType.includes("rate_limit") || + errorType.includes("quota") || + errorType === "overloaded_error" || + errorCode.includes("rate_limit") || + errorCode.includes("quota") || + errorCode.includes("insufficient") || + errorStatus === "resource_exhausted" || + this.containsRotationKeywords(message) + ) { + return { + isRotationTriggering: true, + errorType: this.determineErrorType(message, nestedError), + provider, + message, + } + } + + // Check for deeply nested errors (e.g., error.data.error.message) + const deepError = nestedError.data as Record | undefined + if (deepError?.error && typeof deepError.error === "object") { + const deepResult = this.parseNestedError( + deepError.error as Record, + provider + ) + if (deepResult.isRotationTriggering) { + return deepResult + } + } + + return this.defaultResult(message) + } + + private parseFromString( + error: string, + provider: "anthropic" | "openai" | "google" | "unknown" + ): { + isRotationTriggering: boolean + errorType: "rate_limit" | "quota" | "model_not_found" | "other" + provider: "anthropic" | "openai" | "google" | "unknown" + message: string + } { + if (this.containsRotationKeywords(error)) { + return { + isRotationTriggering: true, + errorType: this.determineErrorTypeFromString(error), + provider: provider !== "unknown" ? provider : this.detectProviderFromString(error), + message: error, + } + } + return this.defaultResult(error) + } + + private containsRotationKeywords(text: string): boolean { + const lowerText = text.toLowerCase() + return ROTATION_ERROR_KEYWORDS.some((keyword) => + lowerText.includes(keyword.toLowerCase()) + ) + } + + private determineErrorType( + message: string, + error: Record + ): "rate_limit" | "quota" | "model_not_found" | "other" { + const lowerMessage = message.toLowerCase() + const errorType = String(error.type ?? "").toLowerCase() + const errorCode = String(error.code ?? "").toLowerCase() + const errorStatus = String(error.status ?? "").toLowerCase() + const errorName = String(error.name ?? "").toLowerCase() + + if ( + lowerMessage.includes("not found") || + lowerMessage.includes("does not exist") || + lowerMessage.includes("invalid model") || + errorName.includes("modelnotfound") || + errorType.includes("not_found") + ) { + return "model_not_found" + } + + if ( + lowerMessage.includes("quota") || + lowerMessage.includes("insufficient") || + lowerMessage.includes("exhausted") || + errorType.includes("quota") || + errorCode.includes("quota") || + errorCode.includes("insufficient") || + errorStatus === "resource_exhausted" + ) { + return "quota" + } + + return "rate_limit" + } + + private determineErrorTypeFromString(text: string): "rate_limit" | "quota" | "model_not_found" | "other" { + const lowerText = text.toLowerCase() + if ( + lowerText.includes("not found") || + lowerText.includes("does not exist") || + lowerText.includes("invalid model") || + lowerText.includes("modelnotfound") + ) { + return "model_not_found" + } + if ( + lowerText.includes("quota") || + lowerText.includes("insufficient") || + lowerText.includes("exhausted") + ) { + return "quota" + } + return "rate_limit" + } + + private extractMessage(error: Record): string { + // Try common message fields + if (typeof error.message === "string") { + return error.message + } + + // Try nested error.message + const nestedError = error.error as Record | undefined + if (nestedError && typeof nestedError.message === "string") { + return nestedError.message + } + + // Try deeper nesting + const deepError = nestedError?.data as Record | undefined + if (deepError?.error && typeof deepError.error === "object") { + const deepestError = deepError.error as Record + if (typeof deepestError.message === "string") { + return deepestError.message + } + } + + // Try details field + if (typeof error.details === "string") { + return error.details + } + if (typeof nestedError?.details === "string") { + return nestedError.details + } + + return String(error ?? "") + } + + private detectProvider( + error: Record + ): "anthropic" | "openai" | "google" | "unknown" { + // Check model field first (most reliable) + const model = String(error.model ?? "").toLowerCase() + if (model.includes("anthropic") || model.includes("claude")) { + return "anthropic" + } + if (model.includes("openai") || model.includes("gpt")) { + return "openai" + } + if (model.includes("google") || model.includes("gemini")) { + return "google" + } + + return "unknown" + } + + private detectProviderFromErrorType( + error: Record + ): "anthropic" | "openai" | "google" | "unknown" { + const nestedError = error.error as Record | undefined + if (!nestedError) return "unknown" + + const errorType = String(nestedError.type ?? "").toLowerCase() + const errorCode = String(nestedError.code ?? "").toLowerCase() + const errorStatus = String(nestedError.status ?? "").toLowerCase() + + // OpenAI uses code: "rate_limit_exceeded" or "insufficient_quota" + if (errorCode.includes("rate_limit") || errorCode.includes("insufficient")) { + return "openai" + } + + if ( + errorType.includes("rate_limit") || + errorType === "quota_exceeded" || + errorType === "overloaded_error" + ) { + return "anthropic" + } + + // Google uses status: "RESOURCE_EXHAUSTED" + if (errorStatus === "resource_exhausted") { + return "google" + } + + return "unknown" + } + + private detectProviderFromString(text: string): "anthropic" | "openai" | "google" | "unknown" { + const lowerText = text.toLowerCase() + if (lowerText.includes("anthropic") || lowerText.includes("claude")) { + return "anthropic" + } + if (lowerText.includes("openai") || lowerText.includes("gpt")) { + return "openai" + } + if (lowerText.includes("google") || lowerText.includes("gemini")) { + return "google" + } + return "unknown" + } + + private defaultResult(message: string): { + isRotationTriggering: boolean + errorType: "rate_limit" | "quota" | "model_not_found" | "other" + provider: "anthropic" | "openai" | "google" | "unknown" + message: string + } { + return { + isRotationTriggering: false, + errorType: "other", + provider: "unknown", + message, + } + } +} diff --git a/src/features/model-rotation/hooks.test.ts b/src/features/model-rotation/hooks.test.ts new file mode 100644 index 0000000000..9d592e4c02 --- /dev/null +++ b/src/features/model-rotation/hooks.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, spyOn } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import { createRotationHooks } from "./hooks" + +function createPluginCtx(impl: { + messages: () => Promise<{ data: unknown[] }> +}): PluginInput { + return { + client: { + tui: undefined, + session: { + messages: () => impl.messages(), + }, + }, + } as unknown as PluginInput +} + +describe("createRotationHooks usage recording", () => { + it("should record usage only once for repeated message.updated events", async () => { + const pluginCtx = createPluginCtx({ + messages: async () => ({ + data: [ + { info: { role: "assistant", tokens: { input: 1, output: 2, cache: { read: 0 } } } }, + ], + }), + }) + + const config = { + agents: { + "test-agent": { + model: ["m1", "m2"], + rotation: { enabled: true, limitType: "calls", limitValue: 10, cooldownMs: 1000 }, + }, + }, + } as unknown as OhMyOpenCodeConfig + + const hooks = createRotationHooks({ pluginCtx, config }) + expect(hooks).not.toBeNull() + + const hookEvent = hooks?.event + if (!hookEvent) throw new Error("Expected rotation hook event") + + const testEngine = ((pluginCtx as any).__rotationTest?.map as Map | undefined) + ?.get("test-agent") + ?.engine + if (!testEngine) throw new Error("Expected test engine handle") + + const recordUsageSpy = spyOn(testEngine as any, "recordUsage") + + const baseEvent = { + event: { + type: "message.updated", + properties: { + sessionID: "ses_1", + info: { + agent: "test-agent", + modelID: "m1", + role: "assistant", + }, + }, + }, + } + await hookEvent(baseEvent as any) + await hookEvent(baseEvent as any) + expect(recordUsageSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/model-rotation/hooks.ts b/src/features/model-rotation/hooks.ts new file mode 100644 index 0000000000..a0f099de4b --- /dev/null +++ b/src/features/model-rotation/hooks.ts @@ -0,0 +1,184 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import type { RotationConfig } from "./types" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import { RotationEngine } from "./rotation-engine" +import { getSharedStateManager } from "./state-manager" +import { ErrorParser } from "./error-parser" +import { log } from "../../shared" + +const AGENTS_WITH_ROTATION = new Map() + +const SESSION_LAST_RECORDED_TOKENS = new Map() + +function normalizeModels(model: string | string[] | undefined): string[] { + if (!model) return [] + if (Array.isArray(model)) return model + return [model] +} + +interface RotationHooksContext { + pluginCtx: PluginInput + config: OhMyOpenCodeConfig +} + +export function createRotationHooks(ctx: RotationHooksContext): Hooks | null { + if (!ctx.config.agents) return null + + const sharedStateManager = getSharedStateManager() + const errorParser = new ErrorParser() + + const rotationEnabledAgents: string[] = [] + + for (const [agentName, agentConfig] of Object.entries(ctx.config.agents)) { + if (!agentConfig.rotation?.enabled || !agentConfig.model) continue + + const models = normalizeModels(agentConfig.model) + if (models.length === 0) continue + + const engine = new RotationEngine(agentName, agentConfig.rotation, sharedStateManager) + AGENTS_WITH_ROTATION.set(agentName, { engine, config: agentConfig.rotation, availableModels: models }) + rotationEnabledAgents.push(agentName) + } + + if (rotationEnabledAgents.length === 0) { + return null + } + + ;(ctx.pluginCtx as any).__rotationTest = { agents: rotationEnabledAgents, map: AGENTS_WITH_ROTATION } + + + const showToast = (title: string, message: string, variant: "info" | "success" | "warning" | "error") => { + ctx.pluginCtx.client.tui + ?.showToast({ + body: { + title, + message, + variant, + duration: 5000, + }, + }) + .catch(() => {}) + } + + const hooks: Hooks = { + event: async (eventInput) => { + + if (eventInput.event.type !== "message.updated") return + + const info = eventInput.event.properties as { info?: unknown } | undefined + + if (!info) return + + const infoObj = info.info as { agent?: unknown; modelID?: unknown; error?: unknown; role?: unknown } | undefined + + if (!infoObj) { + log("[model-rotation] No infoObj in event") + return + } + + log("[model-rotation] Event infoObj", { + role: infoObj.role, + agent: infoObj.agent, + modelID: infoObj.modelID, + hasError: !!infoObj.error, + errorPreview: infoObj.error ? JSON.stringify(infoObj.error).slice(0, 200) : null, + }) + + if (infoObj.role !== "assistant") return + + const agentName = typeof infoObj.agent === "string" ? infoObj.agent : undefined + if (!agentName) return + + const rotationData = AGENTS_WITH_ROTATION.get(agentName) + if (!rotationData) return + + const modelID = typeof infoObj.modelID === "string" ? infoObj.modelID : undefined + const currentModel = modelID ?? "" + + if (!currentModel) return + + const tokensUsed = await (async () => { + try { + const sessionID = (eventInput.event.properties as { sessionID?: unknown } | undefined)?.sessionID + if (typeof sessionID !== "string") return undefined + + const response = await ctx.pluginCtx.client.session.messages({ + path: { id: sessionID }, + }) + + const messages = (response.data ?? response) as { info: { role?: unknown; tokens?: unknown } }[] + + const assistantMessages = messages.filter((m) => m.info.role === "assistant") + if (assistantMessages.length === 0) return undefined + + const lastAssistant = assistantMessages[assistantMessages.length - 1] + const tokens = lastAssistant.info.tokens as + | { input?: number; output?: number; cache?: { read?: number } } + | undefined + + return (tokens?.input ?? 0) + (tokens?.cache?.read ?? 0) + (tokens?.output ?? 0) + } catch { + return undefined + } + })() + + const sessionID = (eventInput.event.properties as { sessionID?: unknown } | undefined)?.sessionID + if (typeof sessionID === "string") { + const key = `${sessionID}:${agentName}:${currentModel}` + const lastRecorded = SESSION_LAST_RECORDED_TOKENS.get(key) + + if (typeof tokensUsed === "number") { + if (lastRecorded === tokensUsed) return + SESSION_LAST_RECORDED_TOKENS.set(key, tokensUsed) + } else { + if (lastRecorded !== undefined) return + SESSION_LAST_RECORDED_TOKENS.set(key, -1) + } + } + + rotationData.engine.recordUsage(currentModel, tokensUsed) + + if (!infoObj.error) return + + + const error = infoObj.error + const parsedError = errorParser.parseError(error) + + log("[model-rotation] Parsed error", { + errorType: parsedError.errorType, + isRotationTriggering: parsedError.isRotationTriggering, + }) + + if (!parsedError.isRotationTriggering) return + + const result = rotationData.engine.rotateOnError(currentModel, rotationData.availableModels) + + + if (result.allDepleted) { + showToast( + "Model Rotation", + `⚠️ ${agentName}: All models depleted (${parsedError.errorType}). Please check your API quotas.`, + "error" + ) + return + } + + if (result.rotated && result.nextModel) { + showToast( + "Model Rotation", + `🔄 ${agentName}: Rotating ${currentModel} → ${result.nextModel} (${result.reason})`, + "info" + ) + } + }, + } + + ;(hooks as any).__rotationTest = { engineByAgent: AGENTS_WITH_ROTATION } + + return hooks + } + diff --git a/src/features/model-rotation/rotation-engine.test.ts b/src/features/model-rotation/rotation-engine.test.ts new file mode 100644 index 0000000000..d00c0b7302 --- /dev/null +++ b/src/features/model-rotation/rotation-engine.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { RotationConfig } from "./types" +import { RotationEngine } from "./rotation-engine" +import { RotationStateManager } from "./state-manager" + +function createStateManager(): RotationStateManager { + const configDir = mkdtempSync(join(tmpdir(), "omo-model-rotation-")) + return new RotationStateManager(configDir) +} + +describe("RotationEngine", () => { + it("should not rotate to current model", () => { + const stateManager = createStateManager() + const config: RotationConfig = { + enabled: true, + limitType: "calls", + limitValue: 1, + cooldownMs: 60_000, + } + + const engine = new RotationEngine("test-agent", config, stateManager) + + const result = engine.rotateOnError("a", ["a", "b"]) + + expect(result.rotated).toBe(true) + expect(result.allDepleted).toBe(false) + expect(result.nextModel).toBe("b") + }) + + it("should select next model in round-robin order (not restart from first)", () => { + // #given a rotation engine with models ["a", "b", "c"] where all are available + const stateManager = createStateManager() + const config: RotationConfig = { + enabled: true, + limitType: "calls", + limitValue: 10, + cooldownMs: 60_000, + } + const engine = new RotationEngine("test-agent", config, stateManager) + + // #when we rotate away from "b" + const result = engine.rotateOnError("b", ["a", "b", "c"]) + + // #then it should select "c" (next after "b"), not "a" (first in array) + expect(result.rotated).toBe(true) + expect(result.nextModel).toBe("c") + }) + + it("should wrap around to first model when current is last", () => { + // #given a rotation engine with models ["a", "b", "c"] + const stateManager = createStateManager() + const config: RotationConfig = { + enabled: true, + limitType: "calls", + limitValue: 10, + cooldownMs: 60_000, + } + const engine = new RotationEngine("test-agent", config, stateManager) + + // #when we rotate away from "c" (last model) + const result = engine.rotateOnError("c", ["a", "b", "c"]) + + // #then it should wrap around to "a" + expect(result.rotated).toBe(true) + expect(result.nextModel).toBe("a") + }) + + it("should skip depleted models in round-robin order", () => { + // #given model "c" is depleted and in cooldown + const stateManager = createStateManager() + const config: RotationConfig = { + enabled: true, + limitType: "calls", + limitValue: 10, + cooldownMs: 60_000, + } + const engine = new RotationEngine("test-agent", config, stateManager) + + // Mark "c" as depleted with active cooldown (future expiry) + stateManager.updateModelState("c", (current) => ({ + ...current, + usage: { + ...current.usage, + inCooldown: true, + cooldownUntil: new Date(Date.now() + 60_000).toISOString(), + }, + depleted: true, + })) + + // #when we rotate away from "b" + const result = engine.rotateOnError("b", ["a", "b", "c"]) + + // #then it should skip "c" (depleted) and wrap to "a" + expect(result.rotated).toBe(true) + expect(result.nextModel).toBe("a") + }) + + it("should re-activate model when cooldown expired and reset usage counters", () => { + // #given a model that was depleted due to hitting limits + const stateManager = createStateManager() + const config: RotationConfig = { + enabled: true, + limitType: "calls", + limitValue: 10, + cooldownMs: 60_000, + } + + const engine = new RotationEngine("test-agent", config, stateManager) + + stateManager.updateModelState("b", (current) => ({ + ...current, + usage: { + ...current.usage, + callCount: 10, + tokenCount: 50000, + inCooldown: true, + cooldownUntil: new Date(Date.now() - 1000).toISOString(), + }, + depleted: true, + })) + + // #when cooldown expires and model is re-selected + const next = engine.getNextModel(["a", "b"], "a") + + // #then model should be available with reset counters + expect(next).toBe("b") + const state = stateManager.getModelState("b") + expect(state?.depleted).toBe(false) + expect(state?.usage.inCooldown).toBe(false) + expect(state?.usage.callCount).toBe(0) + expect(state?.usage.tokenCount).toBeUndefined() + }) +}) diff --git a/src/features/model-rotation/rotation-engine.ts b/src/features/model-rotation/rotation-engine.ts new file mode 100644 index 0000000000..c5ca2599f1 --- /dev/null +++ b/src/features/model-rotation/rotation-engine.ts @@ -0,0 +1,151 @@ +import type { RotationConfig, RotationResult, ModelRotationState } from "./types" +import { RotationStateManager } from "./state-manager" +import { DEFAULT_COOLDOWN_MS, DEFAULT_LIMIT_VALUE, DEFAULT_LIMIT_TYPE } from "./constants" + +/** + * Core rotation engine for round-robin model selection + * Handles proactive and reactive rotation paths + */ +export class RotationEngine { + private stateManager: RotationStateManager + private agentName: string + private config: RotationConfig + + constructor(agentName: string, config: RotationConfig, stateManager: RotationStateManager) { + this.agentName = agentName + this.config = config + this.stateManager = stateManager + } + + /** + * Check if current model should rotate based on usage limits (proactive path) + */ + shouldRotate(currentModel: string, availableModels: string[]): RotationResult { + if (!this.config.enabled || availableModels.length <= 1) { + return { rotated: false, nextModel: null, reason: null, allDepleted: false } + } + + const currentStats = this.stateManager.getUsageStats(currentModel) + if (!currentStats) { + return { rotated: false, nextModel: null, reason: null, allDepleted: false } + } + + if (this.config.limitType === "calls" && currentStats.callCount >= this.config.limitValue) { + return this.rotate( + currentModel, + availableModels, + `Usage limit reached (${currentStats.callCount}/${this.config.limitValue} calls)` + ) + } + + if ( + this.config.limitType === "tokens" && + typeof currentStats.tokenCount === "number" && + currentStats.tokenCount >= this.config.limitValue + ) { + return this.rotate( + currentModel, + availableModels, + `Usage limit reached (${currentStats.tokenCount}/${this.config.limitValue} tokens)` + ) + } + + return { rotated: false, nextModel: null, reason: null, allDepleted: false } + } + + /** + * Handle rotation triggered by API error (reactive path) + */ + rotateOnError(currentModel: string, availableModels: string[]): RotationResult { + if (!this.config.enabled || availableModels.length <= 1) { + return { rotated: false, nextModel: null, reason: null, allDepleted: false } + } + + return this.rotate(currentModel, availableModels, "API quota/rate limit error") + } + + private rotate(currentModel: string, availableModels: string[], reason: string): RotationResult { + const nextModel = this.findNextAvailableModel(availableModels, currentModel) + + if (!nextModel) { + this.markAllDepleted(availableModels) + return { rotated: true, nextModel: null, reason, allDepleted: true } + } + + this.stateManager.markDepleted(currentModel, this.config.cooldownMs) + + return { rotated: true, nextModel, reason, allDepleted: false } + } + + private findNextAvailableModel(availableModels: string[], currentModel: string): string | null { + this.stateManager.pruneOldModels(30) + + const currentIndex = availableModels.indexOf(currentModel) + const modelCount = availableModels.length + + for (let i = 1; i <= modelCount; i++) { + const nextIndex = (currentIndex + i) % modelCount + const model = availableModels[nextIndex] + + if (model === currentModel) continue + + const state = this.stateManager.getModelState(model) + + if (!state) { + return model + } + + const cooldownUntil = state.usage.cooldownUntil + if (state.usage.inCooldown && typeof cooldownUntil === "string") { + if (new Date() >= new Date(cooldownUntil)) { + this.stateManager.markAvailable(model) + return model + } + } + + if (!state.depleted && !this.stateManager.isInCooldown(model)) { + return model + } + } + + return null + } + + recordUsage(model: string, tokensUsed?: number): void { + if (this.config.enabled) { + this.stateManager.incrementUsage(model, tokensUsed) + } + } + + resetCooldowns(): void { + for (const model of this.stateManager.getAllModels()) { + const state = this.stateManager.getModelState(model) + if (state?.usage.inCooldown) { + this.stateManager.markAvailable(model) + } + } + } + + private markAllDepleted(availableModels: string[]): void { + for (const model of availableModels) { + const state = this.stateManager.getModelState(model) + if (state && !state.depleted) { + this.stateManager.markDepleted(model, this.config.cooldownMs) + } + } + } + + isFullyDepleted(availableModels: string[]): boolean { + for (const model of availableModels) { + const state = this.stateManager.getModelState(model) + if (!state || !state.depleted) { + return false + } + } + return true + } + + getNextModel(availableModels: string[], currentModel: string): string | null { + return this.findNextAvailableModel(availableModels, currentModel) + } +} diff --git a/src/features/model-rotation/state-manager.ts b/src/features/model-rotation/state-manager.ts new file mode 100644 index 0000000000..bba58d9669 --- /dev/null +++ b/src/features/model-rotation/state-manager.ts @@ -0,0 +1,242 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs" +import { dirname, join } from "node:path" +import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" +import type { RotationState, ModelRotationState, ModelUsageStats } from "./types" +import { STATE_FILENAME } from "./constants" + +/** + * Manages rotation state with atomic persistence + * Follows existing pattern from ralph-loop and other hooks + */ +export class RotationStateManager { + private stateFilePath: string + private inMemoryState: RotationState = {} + private stateLoaded: boolean = false + + constructor(configDir: string) { + this.stateFilePath = join(configDir, STATE_FILENAME) + this.ensureDirectoryExists(configDir) + this.loadState() + } + + private ensureDirectoryExists(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + loadState(): RotationState { + if (this.stateLoaded) return { ...this.inMemoryState } + + try { + if (existsSync(this.stateFilePath)) { + const content = readFileSync(this.stateFilePath, "utf-8") + this.inMemoryState = JSON.parse(content) + } else { + this.inMemoryState = {} + } + } catch (error) { + console.error(`[model-rotation] Failed to load state: ${error}`) + this.inMemoryState = {} + } + + this.stateLoaded = true + return { ...this.inMemoryState } + } + + saveState(): void { + try { + const tempPath = `${this.stateFilePath}.tmp` + writeFileSync(tempPath, JSON.stringify(this.inMemoryState, null, 2) + "\n", "utf-8") + // Use atomic rename for safe file writes + renameSync(tempPath, this.stateFilePath) + } catch (error) { + console.error(`[model-rotation] Failed to save state: ${error}`) + // Try to clean up temp file if rename failed + try { + if (existsSync(`${this.stateFilePath}.tmp`)) { + unlinkSync(`${this.stateFilePath}.tmp`) + } + } catch { + // Ignore cleanup errors + } + } + } + + getModelState(model: string): ModelRotationState | undefined { + this.loadState() + return this.inMemoryState[model] + } + + updateModelState(model: string, updater: (current: ModelRotationState) => ModelRotationState): void { + this.loadState() + const current = this.getModelState(model) || this.createEmptyModelState() + const updated = updater(current) + this.inMemoryState[model] = updated + this.saveState() + } + + incrementUsage(model: string, tokensUsed?: number): void { + this.updateModelState(model, (current) => ({ + usage: { + ...current.usage, + callCount: current.usage.callCount + 1, + tokenCount: + typeof tokensUsed === "number" + ? (current.usage.tokenCount ?? 0) + tokensUsed + : current.usage.tokenCount, + lastUsedAt: new Date().toISOString(), + }, + depleted: current.depleted, + })) + } + + markDepleted(model: string, cooldownMs: number): void { + const cooldownUntil = new Date(Date.now() + cooldownMs).toISOString() + this.updateModelState(model, (current) => ({ + usage: { + ...current.usage, + lastUsedAt: new Date().toISOString(), + inCooldown: true, + cooldownUntil, + }, + depleted: true, + })) + } + + markAvailable(model: string): void { + this.updateModelState(model, (current) => ({ + usage: { + ...current.usage, + callCount: 0, + tokenCount: undefined, + inCooldown: false, + cooldownUntil: null, + }, + depleted: false, + })) + } + + isInCooldown(model: string): boolean { + const state = this.getModelState(model) + if (!state?.usage.inCooldown) return false + + const cooldownUntil = new Date(state.usage.cooldownUntil!) + const now = new Date() + return now < cooldownUntil + } + + getUsageStats(model: string): ModelUsageStats | undefined { + return this.getModelState(model)?.usage + } + + resetUsage(model: string): void { + this.updateModelState(model, (current) => ({ + usage: { + ...current.usage, + callCount: 0, + tokenCount: undefined, + }, + depleted: false, + })) + } + + getAllModels(): string[] { + this.loadState() + return Object.keys(this.inMemoryState) + } + + pruneOldModels(maxAgeDays: number = 30): void { + this.loadState() + const cutoffDate = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000) + let pruned = 0 + + for (const [model, state] of Object.entries(this.inMemoryState)) { + if (state.usage.lastUsedAt) { + const lastUsed = new Date(state.usage.lastUsedAt) + if (lastUsed < cutoffDate && !state.usage.inCooldown && state.usage.callCount === 0) { + delete this.inMemoryState[model] + pruned++ + } + } + } + + if (pruned > 0) { + this.saveState() + } + } + + private createEmptyModelState(): ModelRotationState { + return { + usage: { + callCount: 0, + lastUsedAt: new Date().toISOString(), + inCooldown: false, + cooldownUntil: null, + }, + depleted: false, + } + } + + /** + * Select the first available model from a list, respecting cooldown and depletion state + */ + selectAvailableModel(models: string[]): string | null { + if (models.length === 0) return null + if (models.length === 1) return models[0] + + this.loadState() + this.pruneOldModels(30) + + for (const model of models) { + const state = this.getModelState(model) + + // Model never used or not tracked - it's available + if (!state) { + return model + } + + // Check if cooldown expired + if (state.usage.inCooldown && state.usage.cooldownUntil) { + const cooldownUntil = new Date(state.usage.cooldownUntil) + if (new Date() >= cooldownUntil) { + // Cooldown expired, mark as available + this.markAvailable(model) + return model + } + } + + // Model is available if not depleted and not in active cooldown + if (!state.depleted && !this.isInCooldown(model)) { + return model + } + } + + // All models depleted/in cooldown - fall back to first model + return models[0] + } +} + +// Singleton instance for shared state across the plugin +let sharedStateManager: RotationStateManager | null = null + +/** + * Get or create the shared rotation state manager + */ +export function getSharedStateManager(): RotationStateManager { + if (!sharedStateManager) { + const configDir = getOpenCodeConfigDir({ + binary: "opencode", + version: null, + }) + sharedStateManager = new RotationStateManager(configDir) + } + return sharedStateManager +} + +/** + * Select the first available model from a list, using shared state + */ +export function selectAvailableModel(models: string[]): string | null { + return getSharedStateManager().selectAvailableModel(models) +} diff --git a/src/features/model-rotation/types.ts b/src/features/model-rotation/types.ts new file mode 100644 index 0000000000..3c5fc9ee44 --- /dev/null +++ b/src/features/model-rotation/types.ts @@ -0,0 +1,85 @@ +/** + * Type definitions for model rotation feature + */ + +/** + * Rotation configuration for a single agent + */ +export interface RotationConfig { + /** Enable model rotation for this agent (default: false) */ + enabled: boolean + /** What to track: 'calls' (request count) or 'tokens' (token usage) */ + limitType: "calls" | "tokens" + /** Usage limit threshold */ + limitValue: number + /** Cooldown period in milliseconds (default: 3600000 = 1 hour) */ + cooldownMs: number +} + +/** + * Usage statistics for a single model + */ +export interface ModelUsageStats { + /** Number of API calls made */ + callCount: number + /** Token count (optional, requires response parsing) */ + tokenCount?: number + /** Last used timestamp (ISO string) */ + lastUsedAt: string + /** Whether model is currently in cooldown */ + inCooldown: boolean + /** When cooldown expires (ISO string, null if not in cooldown) */ + cooldownUntil: string | null +} + +/** + * Per-model rotation state + */ +export interface ModelRotationState { + /** Usage statistics */ + usage: ModelUsageStats + /** Whether model is permanently depleted (all models used) */ + depleted: boolean +} + +/** + * Complete rotation state for all models + * Shared across all agents (API quota is global) + */ +export interface RotationState { + /** Per-model state keyed by model identifier */ + [model: string]: ModelRotationState +} + +/** + * Rotation result from engine + */ +export interface RotationResult { + /** Whether rotation occurred */ + rotated: boolean + /** Next model to use (if rotated) */ + nextModel: string | null + /** Reason for rotation (if occurred) */ + reason: string | null + /** Whether all models are now depleted */ + allDepleted: boolean +} + +/** + * Parsed error information + */ +export interface ParsedError { + /** Whether error triggers rotation */ + isRotationTriggering: boolean + /** Error type: 'quota', 'rate_limit', 'model_not_found', or 'other' */ + errorType: "quota" | "rate_limit" | "model_not_found" | "other" + /** Provider that generated the error */ + provider: "anthropic" | "openai" | "google" | "unknown" + /** Human-readable message */ + message: string +} + +/** + * Provider identification + */ +export type Provider = "anthropic" | "openai" | "google" | "unknown" diff --git a/src/hooks/model-rotation/CLAUDE.md b/src/hooks/model-rotation/CLAUDE.md new file mode 100644 index 0000000000..adfdcb1150 --- /dev/null +++ b/src/hooks/model-rotation/CLAUDE.md @@ -0,0 +1,7 @@ + +# Recent Activity + + + +*No recent activity* + \ No newline at end of file diff --git a/src/hooks/model-rotation/index.ts b/src/hooks/model-rotation/index.ts new file mode 100644 index 0000000000..56bbd6d6ef --- /dev/null +++ b/src/hooks/model-rotation/index.ts @@ -0,0 +1,13 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import { createRotationHooks as createRotationHooksInternal } from "../../features/model-rotation/hooks" + +interface RotationHooksContext { + pluginCtx: PluginInput + config: OhMyOpenCodeConfig +} + +export function createRotationHooks(ctx: RotationHooksContext): Hooks | null { + if (!ctx.config.agents) return null + return createRotationHooksInternal(ctx) +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 755edf33bd..6a3d268aee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import { createSisyphusOrchestratorHook, createPrometheusMdOnlyHook, } from "./hooks"; +import { createRotationHooks } from "./features/model-rotation/hooks"; import { contextCollector, createContextInjectorMessagesTransformHook, @@ -287,6 +288,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createAutoSlashCommandHook({ skills: mergedSkills }) : null; + const modelRotationHooks = isHookEnabled("model-rotation") + ? createRotationHooks({ pluginCtx: ctx, config: pluginConfig }) + : null; + const configHandler = createConfigHandler({ ctx, pluginConfig, @@ -412,6 +417,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await interactiveBashSession?.event(input); await ralphLoop?.event(input); await sisyphusOrchestrator?.handler(input); + await modelRotationHooks?.event?.(input); const { event } = input; const props = event.properties as Record | undefined;