From 61083d499da6155453015348bd573b0a42e74c03 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 6 Apr 2026 17:38:37 +0900 Subject: [PATCH] fix(oauth+errors): OAuth silent refresh, quota STOP patterns, compaction loop cap Bug fixes: 1. OAuth token refresh (#3149): buildHttpRequestInit() now attempts silent refresh via refresh_token before triggering full browser re-auth. Added refresh() method to McpOAuthProvider. Includes test isolation fix for discovery mock. 2. Quota error STOP (#3126): Added STOP_MESSAGE_PATTERNS in model-error-classifier that take precedence over RETRYABLE_MESSAGE_PATTERNS. Message-only quota errors now non-retryable. Runtime-fallback: quota_exceeded with 'retrying in' signal still triggers fallback (provider-managed auto-retry). Restored removed patterns. 3. Compaction loop (#3127): MAX_RECOVERY_ATTEMPTS=3 cap + additional suppression guard from opencode session in degradation monitor. Also: refactored extractAutoRetrySignal to auto-retry-signal.ts, new regression tests for quota classifier and compaction degradation monitor. --- assets/oh-my-opencode.schema.json | 437 ++++-------------- src/features/mcp-oauth/provider.test.ts | 86 +++- src/features/mcp-oauth/provider.ts | 100 ++-- .../skill-mcp-manager/manager.test.ts | 73 ++- .../skill-mcp-manager/oauth-handler.ts | 16 +- src/features/skill-mcp-manager/types.ts | 2 +- ...ion-degradation-monitor.regression.test.ts | 127 +++++ ...eemptive-compaction-degradation-monitor.ts | 11 + .../runtime-fallback/auto-retry-signal.ts | 32 ++ src/hooks/runtime-fallback/constants.ts | 17 +- .../runtime-fallback/error-classifier.test.ts | 108 +---- .../runtime-fallback/error-classifier.ts | 45 +- .../quota-error-classifier.regression.test.ts | 56 +++ src/shared/model-error-classifier.test.ts | 22 + src/shared/model-error-classifier.ts | 10 +- 15 files changed, 611 insertions(+), 531 deletions(-) create mode 100644 src/hooks/preemptive-compaction-degradation-monitor.regression.test.ts create mode 100644 src/hooks/runtime-fallback/auto-retry-signal.ts create mode 100644 src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 607988931..2a1b49ccc 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -263,9 +263,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -311,9 +308,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -403,9 +397,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -613,9 +604,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -661,9 +649,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -753,9 +738,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -963,9 +945,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -1011,9 +990,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -1103,9 +1079,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -1313,9 +1286,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -1361,9 +1331,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -1453,9 +1420,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -1666,9 +1630,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -1714,9 +1675,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -1806,9 +1764,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -2016,9 +1971,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -2064,9 +2016,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -2156,9 +2105,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -2366,9 +2312,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -2414,9 +2357,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -2506,9 +2446,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -2716,9 +2653,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -2764,9 +2698,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -2856,9 +2787,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -3066,9 +2994,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -3114,9 +3039,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -3206,9 +3128,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -3416,9 +3335,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -3464,9 +3380,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -3556,9 +3469,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -3766,9 +3676,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -3814,9 +3721,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -3906,9 +3810,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -4116,9 +4017,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -4164,9 +4062,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -4256,9 +4151,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -4466,9 +4358,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -4514,9 +4403,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -4606,9 +4492,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -4816,9 +4699,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -4864,9 +4744,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string", "enum": [ @@ -4956,9 +4833,6 @@ }, "providerOptions": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "ultrawork": { @@ -4993,9 +4867,6 @@ }, "categories": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -5203,9 +5074,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -5215,8 +5083,7 @@ }, "max_prompt_tokens": { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "is_unstable_agent": { "type": "boolean" @@ -5251,9 +5118,6 @@ }, "plugins_override": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -5277,8 +5141,8 @@ "type": "boolean" }, "tdd": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true } }, "additionalProperties": false @@ -5311,39 +5175,39 @@ "type": "object", "properties": { "enabled": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false }, "notification": { - "default": "detailed", "type": "string", "enum": [ "off", "minimal", "detailed" - ] + ], + "default": "detailed" }, "turn_protection": { "type": "object", "properties": { "enabled": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true }, "turns": { - "default": 3, "type": "number", "minimum": 1, - "maximum": 10 + "maximum": 10, + "default": 3 } }, - "required": [ - "enabled", - "turns" - ], "additionalProperties": false }, "protected_tools": { + "type": "array", + "items": { + "type": "string" + }, "default": [ "task", "todowrite", @@ -5352,11 +5216,7 @@ "session_read", "session_write", "session_search" - ], - "type": "array", - "items": { - "type": "string" - } + ] }, "strategies": { "type": "object", @@ -5365,62 +5225,46 @@ "type": "object", "properties": { "enabled": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true } }, - "required": [ - "enabled" - ], "additionalProperties": false }, "supersede_writes": { "type": "object", "properties": { "enabled": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true }, "aggressive": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false } }, - "required": [ - "enabled", - "aggressive" - ], "additionalProperties": false }, "purge_errors": { "type": "object", "properties": { "enabled": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true }, "turns": { - "default": 5, "type": "number", "minimum": 1, - "maximum": 20 + "maximum": 20, + "default": 5 } }, - "required": [ - "enabled", - "turns" - ], "additionalProperties": false } }, "additionalProperties": false } }, - "required": [ - "enabled", - "notification", - "protected_tools" - ], "additionalProperties": false }, "task_system": { @@ -5444,8 +5288,7 @@ }, "max_tools": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "minimum": 1 } }, "additionalProperties": false @@ -5542,9 +5385,6 @@ }, "metadata": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": {} }, "allowed-tools": { @@ -5568,32 +5408,27 @@ "type": "object", "properties": { "enabled": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false }, "default_max_iterations": { - "default": 100, "type": "number", "minimum": 1, - "maximum": 1000 + "maximum": 1000, + "default": 100 }, "state_dir": { "type": "string" }, "default_strategy": { - "default": "continue", "type": "string", "enum": [ "reset", "continue" - ] + ], + "default": "continue" } }, - "required": [ - "enabled", - "default_max_iterations", - "default_strategy" - ], "additionalProperties": false }, "runtime_fallback": { @@ -5643,9 +5478,6 @@ }, "providerConcurrency": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "number", "minimum": 0 @@ -5653,9 +5485,6 @@ }, "modelConcurrency": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "number", "minimum": 0 @@ -5663,13 +5492,11 @@ }, "maxDepth": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "minimum": 1 }, "maxDescendants": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "minimum": 1 }, "staleTimeoutMs": { "type": "number", @@ -5693,8 +5520,7 @@ }, "maxToolCalls": { "type": "integer", - "minimum": 10, - "maximum": 9007199254740991 + "minimum": 10 }, "circuitBreaker": { "type": "object", @@ -5704,13 +5530,11 @@ }, "maxToolCalls": { "type": "integer", - "minimum": 10, - "maximum": 9007199254740991 + "minimum": 10 }, "consecutiveThreshold": { "type": "integer", - "minimum": 5, - "maximum": 9007199254740991 + "minimum": 5 } }, "additionalProperties": false @@ -5738,8 +5562,7 @@ }, "refresh_timeout_ms": { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "source_url": { "type": "string", @@ -5752,38 +5575,31 @@ "type": "object", "properties": { "enabled": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false }, "gateways": { - "default": {}, "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { "type": { - "default": "http", "type": "string", "enum": [ "http", "command" - ] + ], + "default": "http" }, "url": { "type": "string" }, "method": { - "default": "POST", - "type": "string" + "type": "string", + "default": "POST" }, "headers": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -5795,25 +5611,18 @@ "type": "number" } }, - "required": [ - "type", - "method" - ], "additionalProperties": false - } + }, + "default": {} }, "hooks": { - "default": {}, "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { "enabled": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true }, "gateway": { "type": "string" @@ -5823,12 +5632,12 @@ } }, "required": [ - "enabled", "gateway", "instruction" ], "additionalProperties": false - } + }, + "default": {} }, "replyListener": { "type": "object", @@ -5843,11 +5652,11 @@ "type": "string" }, "authorizedDiscordUserIds": { - "default": [], "type": "array", "items": { "type": "string" - } + }, + "default": [] }, "telegramBotToken": { "type": "string" @@ -5856,104 +5665,77 @@ "type": "string" }, "pollIntervalMs": { - "default": 3000, - "type": "number" + "type": "number", + "default": 3000 }, "rateLimitPerMinute": { - "default": 10, - "type": "number" + "type": "number", + "default": 10 }, "maxMessageLength": { - "default": 500, - "type": "number" + "type": "number", + "default": 500 }, "includePrefix": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true } }, - "required": [ - "authorizedDiscordUserIds", - "pollIntervalMs", - "rateLimitPerMinute", - "maxMessageLength", - "includePrefix" - ], "additionalProperties": false } }, - "required": [ - "enabled", - "gateways", - "hooks" - ], "additionalProperties": false }, "babysitting": { "type": "object", "properties": { "timeout_ms": { - "default": 120000, - "type": "number" + "type": "number", + "default": 120000 } }, - "required": [ - "timeout_ms" - ], "additionalProperties": false }, "git_master": { + "type": "object", + "properties": { + "commit_footer": { + "type": [ + "boolean", + "string" + ], + "default": true + }, + "include_co_authored_by": { + "type": "boolean", + "default": true + }, + "git_env_prefix": { + "type": "string", + "default": "GIT_MASTER=1" + } + }, + "additionalProperties": false, "default": { "commit_footer": true, "include_co_authored_by": true, "git_env_prefix": "GIT_MASTER=1" - }, - "type": "object", - "properties": { - "commit_footer": { - "default": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string" - } - ] - }, - "include_co_authored_by": { - "default": true, - "type": "boolean" - }, - "git_env_prefix": { - "default": "GIT_MASTER=1", - "type": "string" - } - }, - "required": [ - "commit_footer", - "include_co_authored_by", - "git_env_prefix" - ], - "additionalProperties": false + } }, "browser_automation_engine": { "type": "object", "properties": { "provider": { - "default": "playwright", "type": "string", "enum": [ "playwright", "agent-browser", "dev-browser", "playwright-cli" - ] + ], + "default": "playwright" } }, - "required": [ - "provider" - ], "additionalProperties": false }, "websearch": { @@ -5973,11 +5755,10 @@ "type": "object", "properties": { "enabled": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false }, "layout": { - "default": "main-vertical", "type": "string", "enum": [ "main-horizontal", @@ -5985,42 +5766,35 @@ "tiled", "even-horizontal", "even-vertical" - ] + ], + "default": "main-vertical" }, "main_pane_size": { - "default": 60, "type": "number", "minimum": 20, - "maximum": 80 + "maximum": 80, + "default": 60 }, "main_pane_min_width": { - "default": 120, "type": "number", - "minimum": 40 + "minimum": 40, + "default": 120 }, "agent_pane_min_width": { - "default": 40, "type": "number", - "minimum": 20 + "minimum": 20, + "default": 40 }, "isolation": { - "default": "inline", "type": "string", "enum": [ "inline", "window", "session" - ] + ], + "default": "inline" } }, - "required": [ - "enabled", - "layout", - "main_pane_size", - "main_pane_min_width", - "agent_pane_min_width", - "isolation" - ], "additionalProperties": false }, "sisyphus": { @@ -6036,13 +5810,10 @@ "type": "string" }, "claude_code_compat": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false } }, - "required": [ - "claude_code_compat" - ], "additionalProperties": false } }, @@ -6052,13 +5823,10 @@ "type": "object", "properties": { "auto_commit": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true } }, - "required": [ - "auto_commit" - ], "additionalProperties": false }, "_migrations": { @@ -6068,8 +5836,5 @@ } } }, - "required": [ - "git_master" - ], "additionalProperties": false } \ No newline at end of file diff --git a/src/features/mcp-oauth/provider.test.ts b/src/features/mcp-oauth/provider.test.ts index bc4feec09..96b32216e 100644 --- a/src/features/mcp-oauth/provider.test.ts +++ b/src/features/mcp-oauth/provider.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, afterEach, mock } from "bun:test" +import { describe, expect, it, beforeEach, afterEach, mock, afterAll } from "bun:test" import { createHash, randomBytes } from "node:crypto" import type { OAuthTokenData } from "./storage" @@ -226,6 +226,90 @@ describe("McpOAuthProvider", () => { }) }) + describe("refresh", () => { + let originalFetch: typeof globalThis.fetch + let originalEnv: string | undefined + + beforeEach(() => { + originalFetch = globalThis.fetch + originalEnv = process.env.OPENCODE_CONFIG_DIR + const { mkdirSync } = require("node:fs") + const { tmpdir } = require("node:os") + const { join } = require("node:path") + const testDir = join(tmpdir(), `mcp-oauth-provider-refresh-test-${Date.now()}`) + mkdirSync(testDir, { recursive: true }) + process.env.OPENCODE_CONFIG_DIR = testDir + }) + + afterEach(() => { + globalThis.fetch = originalFetch + if (originalEnv === undefined) { + delete process.env.OPENCODE_CONFIG_DIR + } else { + process.env.OPENCODE_CONFIG_DIR = originalEnv + } + }) + + it("exchanges refresh token and preserves it when the response omits a new one", async () => { + // Stub fetch to handle both discovery (well-known) and token exchange + const fetchStub = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString() + if (url.includes("oauth-protected-resource")) { + // PRM: return authorization_servers pointing to auth server + return new Response( + JSON.stringify({ authorization_servers: ["https://auth.example.com"] }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + if (url.includes(".well-known")) { + // AS metadata + return new Response( + JSON.stringify({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + // Token exchange + const body = init?.body?.toString() ?? "" + expect(body).toContain("grant_type=refresh_token") + expect(body).toContain("refresh_token=refresh-token-456") + expect(body).toContain("client_id=my-client") + return new Response( + JSON.stringify({ access_token: "refreshed-access-token", expires_in: 3600 }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) + const fetchMock = Object.assign( + async (...args: Parameters): ReturnType => fetchStub(...args), + { preconnect: originalFetch.preconnect.bind(originalFetch) }, + ) satisfies typeof fetch + globalThis.fetch = fetchMock + + // given + const providerModule = await importFreshProviderModule() + const provider = new providerModule.McpOAuthProvider({ + serverUrl: "https://mcp.example.com", + clientId: "my-client", + }) + provider.saveTokens({ + accessToken: "old-access-token", + refreshToken: "refresh-token-456", + expiresAt: Math.floor(Date.now() / 1000) - 60, + clientInfo: { clientId: "my-client" }, + }) + + // when + const result = await provider.refresh("refresh-token-456") + + // then + expect(result.accessToken).toBe("refreshed-access-token") + expect(result.refreshToken).toBe("refresh-token-456") // preserved from input when absent in response + }) + }) + describe("redirectUrl", () => { it("returns localhost callback URL with default port", () => { // given diff --git a/src/features/mcp-oauth/provider.ts b/src/features/mcp-oauth/provider.ts index bf098fdd4..06fa7b393 100644 --- a/src/features/mcp-oauth/provider.ts +++ b/src/features/mcp-oauth/provider.ts @@ -19,6 +19,48 @@ export type McpOAuthProviderOptions = { scopes?: string[] } +async function parseTokenResponse(tokenResponse: Response): Promise> { + if (!tokenResponse.ok) { + let errorDetail = `${tokenResponse.status}` + try { + const body = (await tokenResponse.json()) as Record + if (body.error) { + errorDetail = `${tokenResponse.status} ${body.error}` + if (body.error_description) { + errorDetail += `: ${body.error_description}` + } + } + } catch { + // Response body not JSON + } + throw new Error(`Token exchange failed: ${errorDetail}`) + } + + return (await tokenResponse.json()) as Record +} + +function buildOAuthTokenData( + tokenData: Record, + clientInfo: ClientCredentials, + fallbackRefreshToken?: string, +): OAuthTokenData { + const accessToken = tokenData.access_token + if (typeof accessToken !== "string") { + throw new Error("Token response missing access_token") + } + + return { + accessToken, + refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : fallbackRefreshToken, + expiresAt: + typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined, + clientInfo: { + clientId: clientInfo.clientId, + ...(clientInfo.clientSecret ? { clientSecret: clientInfo.clientSecret } : {}), + }, + } +} + export class McpOAuthProvider { private readonly serverUrl: string private readonly configClientId: string | undefined @@ -131,38 +173,38 @@ export class McpOAuthProvider { }).toString(), }) - if (!tokenResponse.ok) { - let errorDetail = `${tokenResponse.status}` - try { - const body = (await tokenResponse.json()) as Record - if (body.error) { - errorDetail = `${tokenResponse.status} ${body.error}` - if (body.error_description) { - errorDetail += `: ${body.error_description}` - } - } - } catch { - // Response body not JSON - } - throw new Error(`Token exchange failed: ${errorDetail}`) + const tokenData = await parseTokenResponse(tokenResponse) + const oauthTokenData = buildOAuthTokenData(tokenData, clientInfo) + + this.saveTokens(oauthTokenData) + return oauthTokenData + } + + async refresh(refreshToken: string): Promise { + const metadata = await discoverOAuthServerMetadata(this.serverUrl) + const clientInfo = this.clientInformation() + const clientId = clientInfo?.clientId ?? this.configClientId + if (!clientId) { + throw new Error("No client information available. Run login() or register a client first.") } - const tokenData = (await tokenResponse.json()) as Record - const accessToken = tokenData.access_token - if (typeof accessToken !== "string") { - throw new Error("Token response missing access_token") - } + const tokenResponse = await fetch(metadata.tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + ...(clientInfo?.clientSecret ? { client_secret: clientInfo.clientSecret } : {}), + ...(metadata.resource ? { resource: metadata.resource } : {}), + }).toString(), + }) - const oauthTokenData: OAuthTokenData = { - accessToken, - refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : undefined, - expiresAt: - typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined, - clientInfo: { - clientId: clientInfo.clientId, - clientSecret: clientInfo.clientSecret, - }, - } + const tokenData = await parseTokenResponse(tokenResponse) + const oauthTokenData = buildOAuthTokenData(tokenData, { + clientId, + ...(clientInfo?.clientSecret ? { clientSecret: clientInfo.clientSecret } : {}), + }, refreshToken) this.saveTokens(oauthTokenData) return oauthTokenData diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index ae648222f..66c36b3ba 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -1,14 +1,16 @@ import { describe, it, expect, beforeEach, afterEach, afterAll, mock, spyOn } from "bun:test" import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import type { OAuthTokenData } from "../mcp-oauth/storage" // Mock the MCP SDK transports to avoid network calls const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure"))) const mockHttpClose = mock(() => Promise.resolve()) let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {} -const mockTokens = mock(() => null as { accessToken: string } | null) -const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token" }) as Promise<{ accessToken: string } | null>) +const mockTokens = mock(() => null as OAuthTokenData | null) +const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token" } satisfies OAuthTokenData)) +const mockRefresh = mock((_: string) => Promise.resolve({ accessToken: "refreshed-token" } satisfies OAuthTokenData)) async function importFreshManagerModule(): Promise { mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ @@ -41,12 +43,14 @@ describe("SkillMcpManager", () => { createOAuthProvider: () => ({ tokens: () => mockTokens(), login: () => mockLogin(), + refresh: (refreshToken: string) => mockRefresh(refreshToken), }), }) mockHttpConnect.mockClear() mockHttpClose.mockClear() mockTokens.mockClear() mockLogin.mockClear() + mockRefresh.mockClear() }) afterEach(async () => { @@ -724,6 +728,71 @@ describe("SkillMcpManager", () => { expect(headers?.Authorization).toBe("Bearer oauth-token") }) + it("attempts silent refresh for expired stored tokens before login", async () => { + // given + const info: SkillMcpClientInfo = { + serverName: "oauth-refresh", + skillName: "oauth-skill", + sessionID: "session-oauth-refresh", + } + const config: ClaudeCodeMcpServer = { + url: "https://mcp.example.com/mcp", + oauth: { + clientId: "my-client", + }, + } + mockTokens.mockReturnValue({ + accessToken: "expired-token", + refreshToken: "refresh-token", + expiresAt: Math.floor(Date.now() / 1000) - 60, + }) + mockRefresh.mockResolvedValue({ accessToken: "refreshed-token" }) + + // when + try { + await manager.getOrCreateClient(info, config) + } catch { /* connection fails in test */ } + + // then + const headers = lastTransportInstance.options?.requestInit?.headers as Record | undefined + expect(headers?.Authorization).toBe("Bearer refreshed-token") + expect(mockRefresh).toHaveBeenCalledWith("refresh-token") + expect(mockLogin).not.toHaveBeenCalled() + }) + + it("falls back to login when silent refresh fails", async () => { + // given + const info: SkillMcpClientInfo = { + serverName: "oauth-refresh-fallback", + skillName: "oauth-skill", + sessionID: "session-oauth-refresh-fallback", + } + const config: ClaudeCodeMcpServer = { + url: "https://mcp.example.com/mcp", + oauth: { + clientId: "my-client", + }, + } + mockTokens.mockReturnValue({ + accessToken: "expired-token", + refreshToken: "refresh-token", + expiresAt: Math.floor(Date.now() / 1000) - 60, + }) + mockRefresh.mockRejectedValue(new Error("Refresh failed")) + mockLogin.mockResolvedValue({ accessToken: "login-token" }) + + // when + try { + await manager.getOrCreateClient(info, config) + } catch { /* connection fails in test */ } + + // then + const headers = lastTransportInstance.options?.requestInit?.headers as Record | undefined + expect(headers?.Authorization).toBe("Bearer login-token") + expect(mockRefresh).toHaveBeenCalledWith("refresh-token") + expect(mockLogin).toHaveBeenCalled() + }) + it("does not create auth provider when oauth config is absent", async () => { // given const info: SkillMcpClientInfo = { diff --git a/src/features/skill-mcp-manager/oauth-handler.ts b/src/features/skill-mcp-manager/oauth-handler.ts index c09c845f2..5e76a2f81 100644 --- a/src/features/skill-mcp-manager/oauth-handler.ts +++ b/src/features/skill-mcp-manager/oauth-handler.ts @@ -44,7 +44,7 @@ export async function buildHttpRequestInit( const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth, createOAuthProvider) let tokenData = provider.tokens() - if (!tokenData || isTokenExpired(tokenData)) { + if (!tokenData) { try { tokenData = await provider.login() } catch { @@ -52,6 +52,20 @@ export async function buildHttpRequestInit( } } + if (tokenData && isTokenExpired(tokenData)) { + try { + tokenData = tokenData.refreshToken + ? await provider.refresh(tokenData.refreshToken) + : await provider.login() + } catch { + try { + tokenData = await provider.login() + } catch { + tokenData = null + } + } + } + if (tokenData) { headers.Authorization = `Bearer ${tokenData.accessToken}` } diff --git a/src/features/skill-mcp-manager/types.ts b/src/features/skill-mcp-manager/types.ts index cbaa29204..d2e77e3ae 100644 --- a/src/features/skill-mcp-manager/types.ts +++ b/src/features/skill-mcp-manager/types.ts @@ -50,7 +50,7 @@ export interface ProcessCleanupHandler { export type OAuthProviderLike = Pick< McpOAuthProvider, - "tokens" | "login" + "tokens" | "login" | "refresh" > export type OAuthProviderFactory = (options: { diff --git a/src/hooks/preemptive-compaction-degradation-monitor.regression.test.ts b/src/hooks/preemptive-compaction-degradation-monitor.regression.test.ts new file mode 100644 index 000000000..d1f628930 --- /dev/null +++ b/src/hooks/preemptive-compaction-degradation-monitor.regression.test.ts @@ -0,0 +1,127 @@ +/// + +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" + +const logMock = mock(() => {}) + +mock.module("../shared/logger", () => ({ + log: logMock, +})) + +afterAll(() => { mock.restore() }) + +const { createPreemptiveCompactionHook } = await import("./preemptive-compaction") + +type AssistantHistoryMessage = { + info: { + id: string + role: "assistant" + } + parts: Array<{ type: string; text?: string }> +} + +function createMockCtx(sessionHistory: AssistantHistoryMessage[]) { + return { + client: { + session: { + messages: mock(() => Promise.resolve({ data: sessionHistory })), + summarize: mock(() => Promise.resolve({})), + }, + tui: { + showToast: mock(() => Promise.resolve({})), + }, + }, + directory: "/tmp/test", + } +} + +function appendAssistantHistory( + sessionHistory: AssistantHistoryMessage[], + input: { + id: string + parts: AssistantHistoryMessage["parts"] + }, +): void { + sessionHistory.push({ + info: { + id: input.id, + role: "assistant", + }, + parts: input.parts, + }) +} + +function buildAssistantUpdate(input: { + sessionID: string + id: string + parts: unknown[] +}) { + return { + event: { + type: "message.updated", + properties: { + info: { + id: input.id, + role: "assistant", + sessionID: input.sessionID, + providerID: "opencode", + modelID: "kimi-k2.5-free", + finish: true, + tokens: { input: 1000, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + parts: input.parts, + }, + }, + }, + } +} + +describe("preemptive-compaction degradation monitor regressions", () => { + beforeEach(() => { + logMock.mockClear() + }) + + it("does not re-arm monitoring after recovery-triggered compaction", async () => { + // given + const sessionHistory: AssistantHistoryMessage[] = [] + const ctx = createMockCtx(sessionHistory) + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_recovery_compaction_guard" + const stepOnlyParts = [{ type: "step-start" }, { type: "step-finish" }] + + await hook.event({ + event: { + type: "session.compacted", + properties: { sessionID }, + }, + }) + + // when + appendAssistantHistory(sessionHistory, { id: "msg_1", parts: stepOnlyParts }) + await hook.event(buildAssistantUpdate({ sessionID, id: "msg_1", parts: stepOnlyParts })) + + appendAssistantHistory(sessionHistory, { id: "msg_2", parts: stepOnlyParts }) + await hook.event(buildAssistantUpdate({ sessionID, id: "msg_2", parts: stepOnlyParts })) + + appendAssistantHistory(sessionHistory, { id: "msg_3", parts: stepOnlyParts }) + await hook.event(buildAssistantUpdate({ sessionID, id: "msg_3", parts: stepOnlyParts })) + + await hook.event({ + event: { + type: "session.compacted", + properties: { sessionID }, + }, + }) + + appendAssistantHistory(sessionHistory, { id: "msg_4", parts: stepOnlyParts }) + await hook.event(buildAssistantUpdate({ sessionID, id: "msg_4", parts: stepOnlyParts })) + + appendAssistantHistory(sessionHistory, { id: "msg_5", parts: stepOnlyParts }) + await hook.event(buildAssistantUpdate({ sessionID, id: "msg_5", parts: stepOnlyParts })) + + appendAssistantHistory(sessionHistory, { id: "msg_6", parts: stepOnlyParts }) + await hook.event(buildAssistantUpdate({ sessionID, id: "msg_6", parts: stepOnlyParts })) + + // then + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/preemptive-compaction-degradation-monitor.ts b/src/hooks/preemptive-compaction-degradation-monitor.ts index 64a29eb03..2da8ce27f 100644 --- a/src/hooks/preemptive-compaction-degradation-monitor.ts +++ b/src/hooks/preemptive-compaction-degradation-monitor.ts @@ -6,6 +6,7 @@ import { resolveCompactionModel } from "./shared/compaction-model-resolver" const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 const POST_COMPACTION_MONITOR_COUNT = 5 const POST_COMPACTION_NO_TEXT_THRESHOLD = 3 +const RECOVERY_COMPACTION_SUPPRESSION_MS = 5_000 declare function setTimeout(handler: () => void, timeout?: number): unknown declare function clearTimeout(timeoutID: unknown): void @@ -74,6 +75,7 @@ export function createPostCompactionDegradationMonitor(args: { const postCompactionNoTextStreak = new Map() const postCompactionRecoveryTriggered = new Set() const postCompactionEpoch = new Map() + const suppressRecoveryCompactionUntil = new Map() const postCompactionRecoveryCount = new Map() const MAX_RECOVERY_ATTEMPTS = 3 @@ -87,6 +89,13 @@ export function createPostCompactionDegradationMonitor(args: { } const onSessionCompacted = (sessionID: string): void => { + const suppressedUntil = suppressRecoveryCompactionUntil.get(sessionID) + if (suppressedUntil && suppressedUntil > Date.now()) { + suppressRecoveryCompactionUntil.delete(sessionID) + return + } + suppressRecoveryCompactionUntil.delete(sessionID) + const nextEpoch = (postCompactionEpoch.get(sessionID) ?? 0) + 1 postCompactionEpoch.set(sessionID, nextEpoch) postCompactionRemaining.set(sessionID, POST_COMPACTION_MONITOR_COUNT) @@ -116,6 +125,7 @@ export function createPostCompactionDegradationMonitor(args: { postCompactionRecoveryTriggered.add(sessionID) compactionInProgress.add(sessionID) const recoveryEpoch = postCompactionEpoch.get(sessionID) ?? 0 + suppressRecoveryCompactionUntil.set(sessionID, Date.now() + RECOVERY_COMPACTION_SUPPRESSION_MS) try { const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel( @@ -148,6 +158,7 @@ export function createPostCompactionDegradationMonitor(args: { log("[preemptive-compaction] Triggered recovery after post-compaction no-text tail", { sessionID }) } catch (error) { + suppressRecoveryCompactionUntil.delete(sessionID) log("[preemptive-compaction] Failed to recover post-compaction no-text tail", { sessionID, error: String(error), diff --git a/src/hooks/runtime-fallback/auto-retry-signal.ts b/src/hooks/runtime-fallback/auto-retry-signal.ts new file mode 100644 index 000000000..1d33edbee --- /dev/null +++ b/src/hooks/runtime-fallback/auto-retry-signal.ts @@ -0,0 +1,32 @@ +export interface AutoRetrySignal { + signal: string +} + +const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [ + (combined) => /retrying\s+in/i.test(combined), + (combined) => + /(?:too\s+many\s+requests|quota\s+will\s+reset\s+after|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined), +] + +export function extractAutoRetrySignal(info: Record | undefined): AutoRetrySignal | undefined { + if (!info) return undefined + + const candidates: string[] = [] + + const directStatus = info.status + if (typeof directStatus === "string") candidates.push(directStatus) + + const summary = info.summary + if (typeof summary === "string") candidates.push(summary) + + const message = info.message + if (typeof message === "string") candidates.push(message) + + const details = info.details + if (typeof details === "string") candidates.push(details) + + const combined = candidates.join("\n") + if (!combined) return undefined + + return AUTO_RETRY_PATTERNS.some((test) => test(combined)) ? { signal: combined } : undefined +} diff --git a/src/hooks/runtime-fallback/constants.ts b/src/hooks/runtime-fallback/constants.ts index 0e78fdbc7..a42b10923 100644 --- a/src/hooks/runtime-fallback/constants.ts +++ b/src/hooks/runtime-fallback/constants.ts @@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config" */ export const DEFAULT_CONFIG: Required = { enabled: false, - retry_on_errors: [402, 429, 500, 502, 503, 504], + retry_on_errors: [429, 500, 502, 503, 504], max_fallback_attempts: 3, cooldown_seconds: 60, timeout_seconds: 30, @@ -25,26 +25,21 @@ export const DEFAULT_CONFIG: Required = { export const RETRYABLE_ERROR_PATTERNS = [ /rate.?limit/i, /too.?many.?requests/i, - /quota.?exceeded/i, /quota\s+will\s+reset\s+after/i, + /quota.?exceeded/i, /(?:you(?:'ve|\s+have)\s+)?reached\s+your\s+usage\s+limit/i, - /all\s+credentials\s+for\s+model/i, - /cool(?:ing)?\s+down/i, /exhausted\s+your\s+capacity/i, /usage\s+limit\s+has\s+been\s+reached/i, + /all\s+credentials\s+for\s+model/i, + /cool(?:ing)?\s+down/i, /model.{0,20}?not.{0,10}?supported/i, /model_not_supported/i, + /insufficient.?(?:credits?|funds?|balance)/i, + /credit.*balance.*too.*low/i, /service.?unavailable/i, /overloaded/i, /temporarily.?unavailable/i, /try.?again/i, - /credit.*balance.*too.*low/i, - /insufficient.?(?:credits?|funds?|balance)/i, - /subscription.*quota/i, - /billing.?(?:hard.?)?limit/i, - /payment.?required/i, - /out\s+of\s+credits?/i, - /(?:^|\s)402(?:\s|$)/, /(?:^|\s)429(?:\s|$)/, /(?:^|\s)503(?:\s|$)/, /(?:^|\s)529(?:\s|$)/, diff --git a/src/hooks/runtime-fallback/error-classifier.test.ts b/src/hooks/runtime-fallback/error-classifier.test.ts index 962c62ab0..958babe5f 100644 --- a/src/hooks/runtime-fallback/error-classifier.test.ts +++ b/src/hooks/runtime-fallback/error-classifier.test.ts @@ -181,113 +181,7 @@ describe("extractStatusCode", () => { }) }) -describe("quota error detection (fixes #2747)", () => { - test("classifies prettified subscription quota error as quota_exceeded", () => { - //#given - const error = { - name: "AI_APICallError", - message: "Subscription quota exceeded. You can continue using free models.", - } - - //#when - const errorType = classifyErrorType(error) - const retryable = isRetryableError(error, [402, 429, 500, 502, 503, 504]) - - //#then - expect(errorType).toBe("quota_exceeded") - expect(retryable).toBe(true) - }) - - test("classifies billing hard limit error as quota_exceeded", () => { - //#given - const error = { message: "You have reached your billing hard limit." } - - //#when - const errorType = classifyErrorType(error) - - //#then - expect(errorType).toBe("quota_exceeded") - }) - - test("classifies exhausted capacity error as quota_exceeded", () => { - //#given - const error = { message: "You have exhausted your capacity on this model." } - - //#when - const errorType = classifyErrorType(error) - - //#then - expect(errorType).toBe("quota_exceeded") - }) - - test("classifies out of credits error as quota_exceeded", () => { - //#given - const error = { message: "Out of credits. Please add more credits to continue." } - - //#when - const errorType = classifyErrorType(error) - - //#then - expect(errorType).toBe("quota_exceeded") - }) - - test("treats HTTP 402 Payment Required as retryable", () => { - //#given - const error = { statusCode: 402, message: "Payment Required" } - - //#when - const retryable = isRetryableError(error, [402, 429, 500, 502, 503, 504]) - - //#then - expect(retryable).toBe(true) - }) - - test("matches subscription quota pattern in RETRYABLE_ERROR_PATTERNS", () => { - //#given - const error = { message: "Subscription quota exceeded. You can continue using free models." } - - //#when - const retryable = isRetryableError(error, [429, 503]) - - //#then - expect(retryable).toBe(true) - }) - - test("treats hard usage-limit wording as retryable", () => { - //#given - const error = { message: "You've reached your usage limit for this month. Please upgrade to continue." } - - //#when - const retryable = isRetryableError(error, [429, 503]) - - //#then - expect(retryable).toBe(true) - }) - - test("classifies QuotaExceededError by errorName even without quota keywords in message", () => { - //#given - const error = { name: "QuotaExceededError", message: "Request failed." } - - //#when - const errorType = classifyErrorType(error) - - //#then - expect(errorType).toBe("quota_exceeded") - }) - - test("detects payment required errors as retryable", () => { - //#given - const error = { message: "Error 402: payment required for this request" } - - //#when - const errorType = classifyErrorType(error) - const retryable = isRetryableError(error, [429, 503]) - - //#then - expect(errorType).toBe("quota_exceeded") - expect(retryable).toBe(true) - }) - +describe("model support fallback", () => { test("detects model_not_supported errors as retryable for fallback chain", () => { //#given const error1 = { message: "model_not_supported" } diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index e625ded0a..962e831b1 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -1,5 +1,7 @@ import { DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS } from "./constants" +export { extractAutoRetrySignal } from "./auto-retry-signal" + export function getErrorMessage(error: unknown): string { if (!error) return "" if (typeof error === "string") return error.toLowerCase() @@ -137,44 +139,6 @@ export function classifyErrorType(error: unknown): string | undefined { return undefined } -export interface AutoRetrySignal { - signal: string -} - -export const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [ - (combined) => /retrying\s+in/i.test(combined), - (combined) => - /(?:too\s+many\s+requests|quota\s*exceeded|quota\s+will\s+reset\s+after|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined), -] - -export function extractAutoRetrySignal(info: Record | undefined): AutoRetrySignal | undefined { - if (!info) return undefined - - const candidates: string[] = [] - - const directStatus = info.status - if (typeof directStatus === "string") candidates.push(directStatus) - - const summary = info.summary - if (typeof summary === "string") candidates.push(summary) - - const message = info.message - if (typeof message === "string") candidates.push(message) - - const details = info.details - if (typeof details === "string") candidates.push(details) - - const combined = candidates.join("\n") - if (!combined) return undefined - - const isAutoRetry = AUTO_RETRY_PATTERNS.some((test) => test(combined)) - if (isAutoRetry) { - return { signal: combined } - } - - return undefined -} - export function containsErrorContent( parts: Array<{ type?: string; text?: string }> | undefined ): { hasError: boolean; errorMessage?: string } { @@ -204,7 +168,10 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole } if (errorType === "quota_exceeded") { - return true + // When a provider signals an auto-retry (e.g. "retrying in ~2 weeks"), + // we should still trigger fallback to another model rather than STOP. + const hasAutoRetrySignal = /retrying\s+in/i.test(message) + return hasAutoRetrySignal } if (statusCode && retryOnErrors.includes(statusCode)) { diff --git a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts new file mode 100644 index 000000000..0caa816a3 --- /dev/null +++ b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" + +import { classifyErrorType, isRetryableError } from "./error-classifier" + +describe("runtime-fallback quota error regressions", () => { + test("classifies subscription quota errors as quota_exceeded and stops retry", () => { + //#given + const error = { + name: "AI_APICallError", + message: "Subscription quota exceeded. You can continue using free models.", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("treats HTTP 402 payment required as non-retryable", () => { + //#given + const error = { statusCode: 402, message: "Payment Required" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(false) + }) + + test("keeps HTTP 429 rate limit retryable", () => { + //#given + const error = { statusCode: 429, message: "Too Many Requests: rate limit reached" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + + test("classifies quota error names as quota_exceeded without retry", () => { + //#given + const error = { name: "QuotaExceededError", message: "Request failed." } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) +}) diff --git a/src/shared/model-error-classifier.test.ts b/src/shared/model-error-classifier.test.ts index fdaf14ba2..a1f7c5265 100644 --- a/src/shared/model-error-classifier.test.ts +++ b/src/shared/model-error-classifier.test.ts @@ -215,6 +215,28 @@ describe("model-error-classifier", () => { //#then expect(result).toBe(true) }) + + test("treats subscription quota message as non-retryable", () => { + //#given + const error = { message: "Subscription quota exceeded. You can continue using free models." } + + //#when + const result = shouldRetryError(error) + + //#then + expect(result).toBe(false) + }) + + test("treats HTTP 429 rate limit message as retryable", () => { + //#given + const error = { message: "429 Too Many Requests: rate limit reached" } + + //#when + const result = shouldRetryError(error) + + //#then + expect(result).toBe(true) + }) }) export {} diff --git a/src/shared/model-error-classifier.ts b/src/shared/model-error-classifier.ts index cbd0b8c12..b20918d18 100644 --- a/src/shared/model-error-classifier.ts +++ b/src/shared/model-error-classifier.ts @@ -84,23 +84,25 @@ const STOP_MESSAGE_PATTERNS = [ "usage limit has been reached", "free usage limit", "billing limit", + "billing hard limit", "monthly limit", "plan limit", + "subscription quota", "subscription limit", + "payment required", "out of credits", "credits exhausted", "insufficient credits", "insufficient balance", + "credit balance", + "usage limit for this month", + "exhausted your capacity", ] const AUTO_RETRY_GATE_PATTERNS = [ "rate limit", - "quota", - "usage limit", - "limit reached", "cooling down", "credentials for model", - "exhausted your capacity", ] function hasProviderAutoRetrySignal(message: string): boolean {