From 36c5f3614b185680df77b0e7bfae04179454bbf0 Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 18:58:57 +0100 Subject: [PATCH 01/12] feat: add model rotation for automatic failover on quota/rate limits Model rotation feature allows agents to automatically switch between multiple configured models when rate limits or quota errors are detected. Features: - Configurable rotation per agent with model pools - Error detection for rate limits, quota exceeded, overloaded errors - Usage tracking (calls or tokens) with configurable limits - Cooldown periods before model reuse - State persistence across sessions - rotation-status command to view current rotation state Schema changes: - AgentOverrideConfig.model now accepts string | string[] - Added RotationConfig schema for rotation settings - Added 'rotation-status' to BuiltinCommandNameSchema - Added 'model-rotation' to HookNameSchema --- assets/oh-my-opencode.schema.json | 589 +++++++++++++++++- src/agents/sisyphus-junior.ts | 13 +- src/agents/types.ts | 7 +- src/agents/utils.ts | 42 +- src/config/schema.ts | 22 +- src/features/builtin-commands/commands.ts | 7 + .../templates/rotation-status.ts | 40 ++ src/features/builtin-commands/types.ts | 2 +- src/features/model-rotation/CLAUDE.md | 7 + src/features/model-rotation/constants.ts | 60 ++ .../model-rotation/error-parser.test.ts | 218 +++++++ src/features/model-rotation/error-parser.ts | 315 ++++++++++ src/features/model-rotation/hooks.ts | 128 ++++ .../model-rotation/rotation-engine.ts | 119 ++++ src/features/model-rotation/state-manager.ts | 233 +++++++ src/features/model-rotation/types.ts | 85 +++ src/hooks/model-rotation/CLAUDE.md | 7 + src/hooks/model-rotation/index.ts | 13 + src/index.ts | 6 + 19 files changed, 1883 insertions(+), 30 deletions(-) create mode 100644 src/features/builtin-commands/templates/rotation-status.ts create mode 100644 src/features/model-rotation/CLAUDE.md create mode 100644 src/features/model-rotation/constants.ts create mode 100644 src/features/model-rotation/error-parser.test.ts create mode 100644 src/features/model-rotation/error-parser.ts create mode 100644 src/features/model-rotation/hooks.ts create mode 100644 src/features/model-rotation/rotation-engine.ts create mode 100644 src/features/model-rotation/state-manager.ts create mode 100644 src/features/model-rotation/types.ts create mode 100644 src/hooks/model-rotation/CLAUDE.md create mode 100644 src/hooks/model-rotation/index.ts 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.ts b/src/agents/utils.ts index 4780675ae7..c5b375da90 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,30 @@ 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] + let selectedModel: string | undefined + + if (rotation?.enabled && models.length > 1) { + selectedModel = selectAvailableModel(models) ?? models[0] + } else { + selectedModel = models.length > 0 ? models[0] : undefined + } + + merged.model = selectedModel + } + + if (rotation) { + merged.rotation = rotation as RotationConfig | undefined + } + return merged } @@ -155,7 +183,7 @@ export function createBuiltinAgents( if (disabledAgents.includes(agentName)) continue const override = agentOverrides[agentName] - const model = override?.model ?? systemDefaultModel + const model = normalizeModel(override?.model, systemDefaultModel, override?.rotation) let config = buildAgent(source, model, mergedCategories, gitMasterConfig) @@ -182,7 +210,7 @@ export function createBuiltinAgents( if (!disabledAgents.includes("Sisyphus")) { const sisyphusOverride = agentOverrides["Sisyphus"] - const sisyphusModel = sisyphusOverride?.model ?? systemDefaultModel + const sisyphusModel = normalizeModel(sisyphusOverride?.model, systemDefaultModel, sisyphusOverride?.rotation) let sisyphusConfig = createSisyphusAgent(sisyphusModel, availableAgents) @@ -200,7 +228,11 @@ export function createBuiltinAgents( if (!disabledAgents.includes("orchestrator-sisyphus")) { const orchestratorOverride = agentOverrides["orchestrator-sisyphus"] - const orchestratorModel = orchestratorOverride?.model ?? systemDefaultModel + const orchestratorModel = normalizeModel( + orchestratorOverride?.model, + systemDefaultModel, + 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..2dbd0c9787 --- /dev/null +++ b/src/features/builtin-commands/templates/rotation-status.ts @@ -0,0 +1,40 @@ +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. Read rotation configuration from: ~/.config/opencode/oh-my-opencode.json +2. Read rotation state from: ~/.config/opencode/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..ed8bbabb6d --- /dev/null +++ b/src/features/model-rotation/error-parser.test.ts @@ -0,0 +1,218 @@ +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 quota exceeded error", () => { + const parser = new ErrorParser() + const error = { + status: 529, + error: { + type: "quota_exceeded", + message: "Quota exceeded", + }, + } + + 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 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 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..51a234a7a9 --- /dev/null +++ b/src/features/model-rotation/error-parser.ts @@ -0,0 +1,315 @@ +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) + // Check for Google RESOURCE_EXHAUSTED which is quota, not rate limit + const nestedError = err.error as Record | undefined + const nestedStatus = String(nestedError?.status ?? "").toLowerCase() + const isResourceExhausted = nestedStatus === "resource_exhausted" || message.toLowerCase().includes("exhausted") + + const errorType = (err.status === 529 || isResourceExhausted) ? "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 (errorType.includes("rate_limit") || errorType === "quota_exceeded") { + provider = "anthropic" + } else if (errorCode.includes("rate_limit") || errorCode.includes("insufficient_quota")) { + provider = "openai" + } else if (errorStatus === "resource_exhausted") { + provider = "google" + } + } + + // Check for rotation-triggering patterns + if ( + errorType.includes("rate_limit") || + errorType.includes("quota") || + 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() + + // Anthropic uses type: "rate_limit_error" or "quota_exceeded" + if (errorType.includes("rate_limit") || errorType === "quota_exceeded") { + return "anthropic" + } + + // OpenAI uses code: "rate_limit_exceeded" or "insufficient_quota" + if (errorCode.includes("rate_limit") || errorCode.includes("insufficient")) { + return "openai" + } + + // 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.ts b/src/features/model-rotation/hooks.ts new file mode 100644 index 0000000000..c81cc60fc8 --- /dev/null +++ b/src/features/model-rotation/hooks.ts @@ -0,0 +1,128 @@ +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() + +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 + } + + const showToast = (title: string, message: string, variant: "info" | "success" | "warning" | "error") => { + ctx.pluginCtx.client.tui + ?.showToast({ + body: { + title, + message, + variant, + duration: 5000, + }, + }) + .catch(() => {}) + } + + return { + 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" || !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 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 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" + ) + } + }, + } +} diff --git a/src/features/model-rotation/rotation-engine.ts b/src/features/model-rotation/rotation-engine.ts new file mode 100644 index 0000000000..2fbadd7e7b --- /dev/null +++ b/src/features/model-rotation/rotation-engine.ts @@ -0,0 +1,119 @@ +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)`) + } + + 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) + + 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[]): string | null { + this.stateManager.pruneOldModels(30) + + for (const model of availableModels) { + const state = this.stateManager.getModelState(model) + + if (!state) { + return model + } + + if (!state.depleted && !this.stateManager.isInCooldown(model)) { + return model + } + } + + return null + } + + recordUsage(model: string): void { + if (this.config.enabled) { + this.stateManager.incrementUsage(model) + } + } + + resetCooldowns(): void { + for (const model of this.stateManager.getAllModels()) { + const state = this.stateManager.getModelState(model) + if (state?.usage.inCooldown && !state.depleted) { + 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[]): string | null { + return this.findNextAvailableModel(availableModels) + } +} diff --git a/src/features/model-rotation/state-manager.ts b/src/features/model-rotation/state-manager.ts new file mode 100644 index 0000000000..7d06b93234 --- /dev/null +++ b/src/features/model-rotation/state-manager.ts @@ -0,0 +1,233 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs" +import { dirname, join } from "node:path" +import { homedir } from "node:os" +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): void { + this.updateModelState(model, (current) => ({ + usage: { + ...current.usage, + callCount: current.usage.callCount + 1, + 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, + 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 = join(homedir(), ".config", "opencode") + 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; From 25ca679bfb8b4e3e4a95ebb4db699f7bf9c787f4 Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 20:24:25 +0100 Subject: [PATCH 02/12] fix(agents): handle empty model overrides and optional default model Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/agents/utils.test.ts | 7 +++++++ src/agents/utils.ts | 25 +++++++++++-------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 85df26f681..52072915d7 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -58,6 +58,13 @@ describe("createBuiltinAgents with model overrides", () => { 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("Oracle with GPT model override has reasoningEffort, no thinking", () => { // #given const overrides = { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index c5b375da90..359ecb5cee 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -138,15 +138,14 @@ function mergeAgentConfig( if (model) { const models = Array.isArray(model) ? model : [model] - let selectedModel: string | undefined - if (rotation?.enabled && models.length > 1) { - selectedModel = selectAvailableModel(models) ?? models[0] - } else { - selectedModel = models.length > 0 ? models[0] : undefined - } + if (models.length > 0) { + const selectedModel = rotation?.enabled && models.length > 1 + ? (selectAvailableModel(models) ?? models[0]) + : models[0] - merged.model = selectedModel + merged.model = selectedModel + } } if (rotation) { @@ -164,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[] = [] @@ -183,7 +180,7 @@ export function createBuiltinAgents( if (disabledAgents.includes(agentName)) continue const override = agentOverrides[agentName] - const model = normalizeModel(override?.model, systemDefaultModel, override?.rotation) + const model = normalizeModel(override?.model, defaultModel, override?.rotation) let config = buildAgent(source, model, mergedCategories, gitMasterConfig) @@ -210,7 +207,7 @@ export function createBuiltinAgents( if (!disabledAgents.includes("Sisyphus")) { const sisyphusOverride = agentOverrides["Sisyphus"] - const sisyphusModel = normalizeModel(sisyphusOverride?.model, systemDefaultModel, sisyphusOverride?.rotation) + const sisyphusModel = normalizeModel(sisyphusOverride?.model, defaultModel, sisyphusOverride?.rotation) let sisyphusConfig = createSisyphusAgent(sisyphusModel, availableAgents) @@ -226,11 +223,11 @@ export function createBuiltinAgents( result["Sisyphus"] = sisyphusConfig } - if (!disabledAgents.includes("orchestrator-sisyphus")) { + if (!disabledAgents.includes("orchestrator-sisyphus") && defaultModel) { const orchestratorOverride = agentOverrides["orchestrator-sisyphus"] const orchestratorModel = normalizeModel( orchestratorOverride?.model, - systemDefaultModel, + defaultModel, orchestratorOverride?.rotation ) let orchestratorConfig = createOrchestratorSisyphusAgent({ From edb5bab87a6f9bd2c800177e9278644ce736ac89 Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 20:24:45 +0100 Subject: [PATCH 03/12] fix(model-rotation): correct error parsing for overload and OpenAI rate limits Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../model-rotation/error-parser.test.ts | 23 +++++++++++++--- src/features/model-rotation/error-parser.ts | 27 ++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/features/model-rotation/error-parser.test.ts b/src/features/model-rotation/error-parser.test.ts index ed8bbabb6d..9110be18de 100644 --- a/src/features/model-rotation/error-parser.test.ts +++ b/src/features/model-rotation/error-parser.test.ts @@ -20,13 +20,13 @@ describe("ErrorParser", () => { expect(result.message).toBe("Rate limit exceeded") }) - it("should detect Anthropic quota exceeded error", () => { + it("should detect Anthropic overloaded error", () => { const parser = new ErrorParser() const error = { status: 529, error: { - type: "quota_exceeded", - message: "Quota exceeded", + type: "overloaded_error", + message: "Overloaded", }, } @@ -53,6 +53,23 @@ describe("ErrorParser", () => { 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 = { diff --git a/src/features/model-rotation/error-parser.ts b/src/features/model-rotation/error-parser.ts index 51a234a7a9..a65f208bc0 100644 --- a/src/features/model-rotation/error-parser.ts +++ b/src/features/model-rotation/error-parser.ts @@ -24,10 +24,10 @@ export class ErrorParser { // Check status code first (429/529 are rate limit/quota errors) if (err.status === 429 || err.status === 529) { const message = this.extractMessage(err) - // Check for Google RESOURCE_EXHAUSTED which is quota, not rate limit const nestedError = err.error as Record | undefined const nestedStatus = String(nestedError?.status ?? "").toLowerCase() - const isResourceExhausted = nestedStatus === "resource_exhausted" || message.toLowerCase().includes("exhausted") + const isResourceExhausted = + nestedStatus === "resource_exhausted" || message.toLowerCase().includes("exhausted") const errorType = (err.status === 529 || isResourceExhausted) ? "quota" : "rate_limit" return { @@ -83,12 +83,16 @@ export class ErrorParser { // Detect provider from error type patterns let provider = parentProvider if (provider === "unknown") { - if (errorType.includes("rate_limit") || errorType === "quota_exceeded") { - provider = "anthropic" - } else if (errorCode.includes("rate_limit") || errorCode.includes("insufficient_quota")) { + 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" } } @@ -267,16 +271,19 @@ export class ErrorParser { const errorCode = String(nestedError.code ?? "").toLowerCase() const errorStatus = String(nestedError.status ?? "").toLowerCase() - // Anthropic uses type: "rate_limit_error" or "quota_exceeded" - if (errorType.includes("rate_limit") || errorType === "quota_exceeded") { - return "anthropic" - } - // 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" From 20ffced86fdf5dcb297ececcadda515c6c0d5114 Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 20:25:10 +0100 Subject: [PATCH 04/12] fix(model-rotation): record token usage and resolve config dir for state Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../templates/rotation-status.ts | 8 ++- src/features/model-rotation/hooks.ts | 57 ++++++++++++++----- .../model-rotation/rotation-engine.ts | 22 ++++++- src/features/model-rotation/state-manager.ts | 13 ++++- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/features/builtin-commands/templates/rotation-status.ts b/src/features/builtin-commands/templates/rotation-status.ts index 2dbd0c9787..9dc28acb4a 100644 --- a/src/features/builtin-commands/templates/rotation-status.ts +++ b/src/features/builtin-commands/templates/rotation-status.ts @@ -19,8 +19,12 @@ State: {current model or "not tracked yet"} ## Instructions -1. Read rotation configuration from: ~/.config/opencode/oh-my-opencode.json -2. Read rotation state from: ~/.config/opencode/model-rotation-state.json (if exists) +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 diff --git a/src/features/model-rotation/hooks.ts b/src/features/model-rotation/hooks.ts index c81cc60fc8..8f06360f89 100644 --- a/src/features/model-rotation/hooks.ts +++ b/src/features/model-rotation/hooks.ts @@ -82,7 +82,48 @@ export function createRotationHooks(ctx: RotationHooksContext): Hooks | null { errorPreview: infoObj.error ? JSON.stringify(infoObj.error).slice(0, 200) : null, }) - if (infoObj.role !== "assistant" || !infoObj.error) return + 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 + } + })() + + rotationData.engine.recordUsage(currentModel, tokensUsed) + + if (!infoObj.error) return + const error = infoObj.error const parsedError = errorParser.parseError(error) @@ -92,20 +133,10 @@ export function createRotationHooks(ctx: RotationHooksContext): Hooks | null { isRotationTriggering: parsedError.isRotationTriggering, }) - if (!parsedError.isRotationTriggering) 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 (!parsedError.isRotationTriggering) return - if (!currentModel) return + const result = rotationData.engine.rotateOnError(currentModel, rotationData.availableModels) - const result = rotationData.engine.rotateOnError(currentModel, rotationData.availableModels) if (result.allDepleted) { showToast( diff --git a/src/features/model-rotation/rotation-engine.ts b/src/features/model-rotation/rotation-engine.ts index 2fbadd7e7b..c9dfbe972e 100644 --- a/src/features/model-rotation/rotation-engine.ts +++ b/src/features/model-rotation/rotation-engine.ts @@ -31,7 +31,23 @@ export class RotationEngine { } if (this.config.limitType === "calls" && currentStats.callCount >= this.config.limitValue) { - return this.rotate(currentModel, availableModels, `Usage limit reached (${currentStats.callCount}/${this.config.limitValue} calls)`) + 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 } @@ -79,9 +95,9 @@ export class RotationEngine { return null } - recordUsage(model: string): void { + recordUsage(model: string, tokensUsed?: number): void { if (this.config.enabled) { - this.stateManager.incrementUsage(model) + this.stateManager.incrementUsage(model, tokensUsed) } } diff --git a/src/features/model-rotation/state-manager.ts b/src/features/model-rotation/state-manager.ts index 7d06b93234..e105e5f8fd 100644 --- a/src/features/model-rotation/state-manager.ts +++ b/src/features/model-rotation/state-manager.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs" import { dirname, join } from "node:path" -import { homedir } from "node:os" +import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import type { RotationState, ModelRotationState, ModelUsageStats } from "./types" import { STATE_FILENAME } from "./constants" @@ -76,11 +76,15 @@ export class RotationStateManager { this.saveState() } - incrementUsage(model: string): void { + 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, @@ -219,7 +223,10 @@ let sharedStateManager: RotationStateManager | null = null */ export function getSharedStateManager(): RotationStateManager { if (!sharedStateManager) { - const configDir = join(homedir(), ".config", "opencode") + const configDir = getOpenCodeConfigDir({ + binary: "opencode", + version: null, + }) sharedStateManager = new RotationStateManager(configDir) } return sharedStateManager From dd9f844181760935fc8102019b01cf7857dfad6a Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 21:23:21 +0100 Subject: [PATCH 05/12] fix(model-rotation): avoid rotating to current model and reset cooldowns Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../model-rotation/rotation-engine.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/features/model-rotation/rotation-engine.ts b/src/features/model-rotation/rotation-engine.ts index c9dfbe972e..1c38d786d1 100644 --- a/src/features/model-rotation/rotation-engine.ts +++ b/src/features/model-rotation/rotation-engine.ts @@ -65,7 +65,7 @@ export class RotationEngine { } private rotate(currentModel: string, availableModels: string[], reason: string): RotationResult { - const nextModel = this.findNextAvailableModel(availableModels) + const nextModel = this.findNextAvailableModel(availableModels, currentModel) if (!nextModel) { this.markAllDepleted(availableModels) @@ -77,16 +77,26 @@ export class RotationEngine { return { rotated: true, nextModel, reason, allDepleted: false } } - private findNextAvailableModel(availableModels: string[]): string | null { + private findNextAvailableModel(availableModels: string[], currentModel: string): string | null { this.stateManager.pruneOldModels(30) for (const model of availableModels) { + 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 } @@ -104,7 +114,7 @@ export class RotationEngine { resetCooldowns(): void { for (const model of this.stateManager.getAllModels()) { const state = this.stateManager.getModelState(model) - if (state?.usage.inCooldown && !state.depleted) { + if (state?.usage.inCooldown) { this.stateManager.markAvailable(model) } } @@ -129,7 +139,7 @@ export class RotationEngine { return true } - getNextModel(availableModels: string[]): string | null { - return this.findNextAvailableModel(availableModels) + getNextModel(availableModels: string[], currentModel: string): string | null { + return this.findNextAvailableModel(availableModels, currentModel) } } From 52e4280920bcc05cf0d4c33d1b7fb3f81110a678 Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 21:23:31 +0100 Subject: [PATCH 06/12] fix(model-rotation): classify OpenAI insufficient_quota as quota Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../model-rotation/error-parser.test.ts | 17 +++++++++++++++++ src/features/model-rotation/error-parser.ts | 13 +++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/features/model-rotation/error-parser.test.ts b/src/features/model-rotation/error-parser.test.ts index 9110be18de..8bb357f052 100644 --- a/src/features/model-rotation/error-parser.test.ts +++ b/src/features/model-rotation/error-parser.test.ts @@ -86,6 +86,23 @@ describe("ErrorParser", () => { 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 = { diff --git a/src/features/model-rotation/error-parser.ts b/src/features/model-rotation/error-parser.ts index a65f208bc0..54d9151cf1 100644 --- a/src/features/model-rotation/error-parser.ts +++ b/src/features/model-rotation/error-parser.ts @@ -26,10 +26,15 @@ export class ErrorParser { const message = this.extractMessage(err) const nestedError = err.error as Record | undefined const nestedStatus = String(nestedError?.status ?? "").toLowerCase() - const isResourceExhausted = - nestedStatus === "resource_exhausted" || message.toLowerCase().includes("exhausted") - - const errorType = (err.status === 529 || isResourceExhausted) ? "quota" : "rate_limit" + 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, From 34acb4dbf8f4275bf2b558052ece137d4dcef4f6 Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 21:26:01 +0100 Subject: [PATCH 07/12] test(model-rotation): cover cooldown expiration and self-rotation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../model-rotation/rotation-engine.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/features/model-rotation/rotation-engine.test.ts 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..38bed28d90 --- /dev/null +++ b/src/features/model-rotation/rotation-engine.test.ts @@ -0,0 +1,61 @@ +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 re-activate model when cooldown expired", () => { + const stateManager = createStateManager() + const config: RotationConfig = { + enabled: true, + limitType: "calls", + limitValue: 1, + cooldownMs: 60_000, + } + + const engine = new RotationEngine("test-agent", config, stateManager) + + stateManager.updateModelState("b", (current) => ({ + ...current, + usage: { + ...current.usage, + inCooldown: true, + cooldownUntil: new Date(Date.now() - 1000).toISOString(), + }, + depleted: true, + })) + + const next = engine.getNextModel(["a", "b"], "a") + + expect(next).toBe("b") + const state = stateManager.getModelState("b") + expect(state?.depleted).toBe(false) + expect(state?.usage.inCooldown).toBe(false) + }) +}) From 9884df29b67ed0d00e9e6a255942f11d5263f56e Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 21:57:31 +0100 Subject: [PATCH 08/12] fix(model-rotation): trigger rotation on anthropic overloaded_error --- .../model-rotation/error-parser.test.ts | 17 +++++++++++++++++ src/features/model-rotation/error-parser.ts | 1 + 2 files changed, 18 insertions(+) diff --git a/src/features/model-rotation/error-parser.test.ts b/src/features/model-rotation/error-parser.test.ts index 8bb357f052..9c74fa0e4b 100644 --- a/src/features/model-rotation/error-parser.test.ts +++ b/src/features/model-rotation/error-parser.test.ts @@ -37,6 +37,23 @@ describe("ErrorParser", () => { 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 = { diff --git a/src/features/model-rotation/error-parser.ts b/src/features/model-rotation/error-parser.ts index 54d9151cf1..19122bfb42 100644 --- a/src/features/model-rotation/error-parser.ts +++ b/src/features/model-rotation/error-parser.ts @@ -105,6 +105,7 @@ export class ErrorParser { if ( errorType.includes("rate_limit") || errorType.includes("quota") || + errorType === "overloaded_error" || errorCode.includes("rate_limit") || errorCode.includes("quota") || errorCode.includes("insufficient") || From 8da05814c11dfd9057424466cb04548b74142f2a Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 21:57:35 +0100 Subject: [PATCH 09/12] fix(agents): allow orchestrator-sisyphus when only override model exists --- src/agents/utils.test.ts | 33 ++++++++++++++++++++++----------- src/agents/utils.ts | 5 +++-- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 52072915d7..e1aece10ec 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -45,18 +45,18 @@ 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) + // #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() - }) + // #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() @@ -64,8 +64,19 @@ describe("createBuiltinAgents with model overrides", () => { 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", () => { - 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 359ecb5cee..abea05d1e2 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -223,8 +223,9 @@ export function createBuiltinAgents( result["Sisyphus"] = sisyphusConfig } - if (!disabledAgents.includes("orchestrator-sisyphus") && defaultModel) { - const orchestratorOverride = agentOverrides["orchestrator-sisyphus"] + const orchestratorOverride = agentOverrides["orchestrator-sisyphus"] + + if (!disabledAgents.includes("orchestrator-sisyphus") && (defaultModel || orchestratorOverride?.model)) { const orchestratorModel = normalizeModel( orchestratorOverride?.model, defaultModel, From 63bbdef54934f81682fb7ffa7cda0be174b9838a Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 21:57:39 +0100 Subject: [PATCH 10/12] fix(model-rotation): dedupe usage recording for streaming message.updated --- src/features/model-rotation/hooks.test.ts | 68 +++++++++++++++++++++++ src/features/model-rotation/hooks.ts | 41 +++++++++++--- 2 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 src/features/model-rotation/hooks.test.ts 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 index 8f06360f89..a0f099de4b 100644 --- a/src/features/model-rotation/hooks.ts +++ b/src/features/model-rotation/hooks.ts @@ -12,6 +12,8 @@ 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 @@ -42,9 +44,12 @@ export function createRotationHooks(ctx: RotationHooksContext): Hooks | null { rotationEnabledAgents.push(agentName) } - if (rotationEnabledAgents.length === 0) { - return null - } + 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 @@ -59,8 +64,9 @@ export function createRotationHooks(ctx: RotationHooksContext): Hooks | null { .catch(() => {}) } - return { - event: async (eventInput) => { + const hooks: Hooks = { + event: async (eventInput) => { + if (eventInput.event.type !== "message.updated") return const info = eventInput.event.properties as { info?: unknown } | undefined @@ -120,6 +126,20 @@ export function createRotationHooks(ctx: RotationHooksContext): Hooks | null { } })() + 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 @@ -154,6 +174,11 @@ export function createRotationHooks(ctx: RotationHooksContext): Hooks | null { "info" ) } - }, - } -} + }, + } + + ;(hooks as any).__rotationTest = { engineByAgent: AGENTS_WITH_ROTATION } + + return hooks + } + From 82ab993467432b3302e908f3fd2b15e951008115 Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 23:00:40 +0100 Subject: [PATCH 11/12] fix(model-rotation): implement true round-robin model selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, findNextAvailableModel always iterated from the beginning of the availableModels array, biasing earlier entries. Now it starts from the model after the current one and wraps around, ensuring fair round-robin distribution (a → b → c → a → ...). --- .../model-rotation/rotation-engine.test.ts | 68 +++++++++++++++++++ .../model-rotation/rotation-engine.ts | 8 ++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/features/model-rotation/rotation-engine.test.ts b/src/features/model-rotation/rotation-engine.test.ts index 38bed28d90..4a9f6de78d 100644 --- a/src/features/model-rotation/rotation-engine.test.ts +++ b/src/features/model-rotation/rotation-engine.test.ts @@ -30,6 +30,74 @@ describe("RotationEngine", () => { 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", () => { const stateManager = createStateManager() const config: RotationConfig = { diff --git a/src/features/model-rotation/rotation-engine.ts b/src/features/model-rotation/rotation-engine.ts index 1c38d786d1..c5ca2599f1 100644 --- a/src/features/model-rotation/rotation-engine.ts +++ b/src/features/model-rotation/rotation-engine.ts @@ -80,7 +80,13 @@ export class RotationEngine { private findNextAvailableModel(availableModels: string[], currentModel: string): string | null { this.stateManager.pruneOldModels(30) - for (const model of availableModels) { + 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) From 9ba54ccb2fbd7f265a4ff4fc3b453190306eff99 Mon Sep 17 00:00:00 2001 From: TheSmuks Date: Mon, 19 Jan 2026 23:46:52 +0100 Subject: [PATCH 12/12] fix(model-rotation): reset usage counters when cooldown expires When a model's cooldown period expired, only the cooldown flags were being cleared but usage counters (callCount, tokenCount) remained at their previous values. This caused models to immediately re-deplete after cooldown since they were still over their limits. Now markAvailable() also resets callCount to 0 and tokenCount to undefined, allowing the model to start fresh after cooldown. --- src/features/model-rotation/rotation-engine.test.ts | 11 +++++++++-- src/features/model-rotation/state-manager.ts | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/features/model-rotation/rotation-engine.test.ts b/src/features/model-rotation/rotation-engine.test.ts index 4a9f6de78d..d00c0b7302 100644 --- a/src/features/model-rotation/rotation-engine.test.ts +++ b/src/features/model-rotation/rotation-engine.test.ts @@ -98,12 +98,13 @@ describe("RotationEngine", () => { expect(result.nextModel).toBe("a") }) - it("should re-activate model when cooldown expired", () => { + 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: 1, + limitValue: 10, cooldownMs: 60_000, } @@ -113,17 +114,23 @@ describe("RotationEngine", () => { ...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/state-manager.ts b/src/features/model-rotation/state-manager.ts index e105e5f8fd..bba58d9669 100644 --- a/src/features/model-rotation/state-manager.ts +++ b/src/features/model-rotation/state-manager.ts @@ -108,6 +108,8 @@ export class RotationStateManager { this.updateModelState(model, (current) => ({ usage: { ...current.usage, + callCount: 0, + tokenCount: undefined, inCooldown: false, cooldownUntil: null, },