diff --git a/CHANGELOG.md b/CHANGELOG.md index 0466e695ac..8c78f3bb33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ _In development β€” bullets added per PR; finalized at release._ ### πŸ”§ Bug Fixes -- **fix(dashboard):** free proxy pool no longer mis-reports state β€” "Add to pool" stops optimistically showing "In Pool" when the connectivity probe fails (route returns 422 and the UI now gates on the parsed `success` flag), "Sync All" persists and surfaces a real `lastSyncAt` even when a sync returns zero new proxies, and `REDIS_URL` is now opt-in in `.env.example` (with a state-change-gated `[REDIS] Error:` log throttle) so a non-running localhost no longer floods the logs (#4878). - **fix(dashboard):** show custom provider given-name instead of internal id across dashboard pages β€” cache, combo health, compression analytics, cost overview, health/autopilot, provider stats, route explainability, provider utilization, runtime. Adds shared `resolveProviderName` resolver and `useProviderNodeMap` hook. (#4603) +- **fix(sse):** fail over on 400 responses carrying rate-limit text β€” providers like MiMoCode signal throttling with a non-standard 400 whose body reads `"Detected high-frequency non-compliant requests from you."`. These are now classified as fallback-worthy (`RATE_LIMIT_EXCEEDED`, connection-cooldown scope) so combo routing fails over to another free target instead of surfacing `[502]: fetch failed`. Malformed-400 detection still wins, preserving the #2101 infinite-loop guard. (#4976) --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index a0c0e69abb..50af955e4c 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_25_4976_ratelimit_400_failover": "Bug fix #4976: a 400 carrying rate-limit text (e.g. MiMoCode 'Detected high-frequency non-compliant requests') was classified as a non-fallbackable generic 400, so MiMo-auto never failed over -> 502 in Cline. open-sse/services/accountFallback.ts 1762->1773 (+11 = a bounded ReDoS-safe RATE_LIMIT_TEXT_PATTERNS array + a single check in the 400 branch returning buildRetryableFallback(RATE_LIMIT_EXCEEDED) at connection-cooldown scope, placed AFTER malformed/overflow detection so a malformed 400 keeps its #2101 MODEL_CAPACITY guard). Irreducible classification wiring at the existing 400 branch; the pattern array is the minimal addition. Covered by tests/unit/accountfallback-ratelimit-400-4976.test.ts (EN+zh rate-limit text -> fallback; generic 400 stays non-fallback; malformed 400 stays MODEL_CAPACITY).", "_rebaseline_2026_06_24_4945_task_aware_router_precedence": "Base-red fix: the #4945 task-aware reordering (reorderByTaskWeight) runs for strategy \"auto\" and was applied AFTER the auto router (selectWithStrategy: lkgp/cost/etc) had already pinned orderedTargets[0], silently defeating the operator's explicit LKGP/cost choice (proven: tests/unit/combo-routing-engine.test.ts 'honors LKGP after filtering to tool-capable models' + 'falls back to the full pool when tool filtering empties candidates' selected gpt-oss-120b β€” the 1st target β€” instead of the LKGP/cost winner; instrumentation showed orderedTargets[0]=claude post-filter then gpt-oss post-task-reorder). open-sse/services/combo.ts 3306->3321 (+15 = a one-line `autoUsedExplicitRouter` flag set when the explicit router succeeds, and at the task-aware step: when that flag is set, pin orderedTargets[0] and let task-aware refine only the fallback tail β€” reorderByTaskWeight returns the same objects so identity filtering is safe). gpt-oss-120b correctly REMAINS tool-capable (model-capabilities-registry.test.ts asserts true), so this is NOT a tool-capability change. Irreducible chokepoint wiring at the existing auto-route boundary; not extractable. Covered by the two combo-routing-engine.test.ts cases above + combo-task-aware.test.ts (task-aware still active for non-router strategies).", "_rebaseline_2026_06_24_per_connection_max_concurrent": "Feature (per-connection max_concurrent enforcement, issue #9 follow-up) own growth: open-sse/services/combo.ts 3225->3238 (+13 net). Provider connections already carry a maxConcurrent ceiling (provider_connections.max_concurrent, migration 029, set in the UI/API/DB) but routing ignored it, so low-concurrency subscription accounts (GLM/MiniMax ~1) got flooded -> 429 + cooldown, worse under quota-share where keys share one account. The new wiring lives at the two combo dispatch chokepoints: (1) the strategy===\"quota-share\" branch resolves each target connection's cap and passes it into selectQuotaShareTarget so an at-cap connection is deprioritized (fail-open, never hard-blocked); (2) handleRoundRobinCombo resolves the target connection's cap and uses it as the semaphore maxConcurrency (fallback = combo-level concurrency). ALL extractable logic was moved into the new pure leaf open-sse/services/combo/concurrencyCaps.ts (resolveMaxConcurrentByConnection + makeConnectionConcurrencyResolver, DB reads deduped per connectionId, fail-open) and the pure effectiveMaxConcurrency resolver into combo/comboPredicates.ts; combo.ts keeps only the irreducible chokepoint wiring (a 5-line import + the two call-site bindings). Cohesive at the existing dispatch boundaries, not further extractable. Structural shrink of combo.ts tracked in #3501. Covered by tests/unit/quota-share-strategy.test.ts (per-connection maxConcurrent gating cases) + tests/unit/combo/effective-max-concurrency.test.ts.", "_rebaseline_2026_06_24_combo_cooldown_wait_quota_share": "Feature quota-share combo cooldown-aware retry (Variante A) own growth: open-sse/services/combo.ts 3225->3293 (+68 = the cooldown-wait wrap inside handleComboChat's quota-share path. The existing setTry loop body is LEFT at its original indentation: instead of an outer `while (true)` (which would re-indent ~1600 lines and bloat the review), the setTry loop is hoisted into a small recursive closure `dispatchWithCooldownRetry`, and a wait+redispatch is a tail `return dispatchWithCooldownRetry()` β€” re-running ONLY the set loop (exactly the prior continue-to-top-of-set-loop semantics) while selection/shadow-routing/setup above stay untouched. At the 429 crystallization point the lock reason is resolved via getModelLockoutInfo, the decision via the new pure resolveComboCooldownWaitDecision, then await waitForCooldownAwareRetry (499 on abort), decrement the budget, recurse. Gated to strategy==='quota-share' && comboCooldownWait.enabled. `git diff` == `git diff -w` for combo.ts (zero re-indentation noise). The gating policy + reason resolution are extracted to the new pure leaf open-sse/services/combo/comboCooldownRetry.ts (1098 (+115 = the ComboCooldownWaitCard exposing enabled/maxWaitMs/maxAttempts/budgetMs in Settings > Resilience, mirroring WaitForCooldownCard; wired through GET/PATCH in src/app/api/resilience/route.ts + comboCooldownWaitSettingsSchema in src/shared/validation/schemas/settings.ts; new UI labels use the t(key)||English-fallback pattern, en-only). Structural shrink of combo.ts + ResilienceTab tracked in #3501.", @@ -139,7 +140,7 @@ "open-sse/mcp-server/schemas/tools.ts": 1497, "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1118, - "open-sse/services/accountFallback.ts": 1762, + "open-sse/services/accountFallback.ts": 1773, "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index e62698f904..696fdab687 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -251,6 +251,20 @@ const MALFORMED_REQUEST_PATTERNS = [ /tool_call.*name.*(?:blank|empty|missing)/i, ]; +// Rate-limit text on a 400 β€” some providers (e.g. MiMoCode) signal throttling with a +// non-standard 400 status whose body carries rate-limit semantics instead of a 429 +// (#4976). When detected, the request is fallback-worthy at connection-cooldown scope +// (NOT a whole-provider breaker) so combo routing can fail over to another free target. +// Bounded, non-overlapping patterns only (ReDoS-safe β€” no nested quantifiers). +const RATE_LIMIT_TEXT_PATTERNS = [ + /high.?frequency/i, + /non-compliant/i, + /too many requests/i, + /rate.?limit/i, + /钑繁/, // "frequent" (zh) β€” high-frequency request throttling + /ι’‘ηŽ‡/, // "frequency" (zh) β€” request-frequency throttling +]; + // Parameter validation errors β€” model-specific constraints (different models = different limits) const PARAM_VALIDATION_PATTERNS = [ /max_tokens.*illegal/i, @@ -1582,6 +1596,16 @@ export function checkFallbackError( }; } + // Some providers (e.g. MiMoCode) signal throttling with a non-standard 400 whose + // body carries rate-limit semantics ("Detected high-frequency non-compliant + // requests from you.") instead of a 429. Detected here (AFTER malformed/overflow + // detection above, so a genuinely malformed 400 still wins and keeps its #2101 + // zero-cooldown MODEL_CAPACITY classification), it is fallback-worthy at + // connection-cooldown scope so combo can fail over to another target (#4976). + if (RATE_LIMIT_TEXT_PATTERNS.some((p) => p.test(errorStr))) { + return buildRetryableFallback(RateLimitReason.RATE_LIMIT_EXCEEDED); + } + // Generic 400 is not account-fallback-worthy. Combo routing may still try a // different provider/model because combo fallback is target-level orchestration. return { shouldFallback: false, cooldownMs: 0, reason: RateLimitReason.UNKNOWN }; diff --git a/tests/unit/accountfallback-ratelimit-400-4976.test.ts b/tests/unit/accountfallback-ratelimit-400-4976.test.ts new file mode 100644 index 0000000000..e1de7b6f8c --- /dev/null +++ b/tests/unit/accountfallback-ratelimit-400-4976.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4976 β€” A 400 response whose body carries rate-limit semantics (e.g. MiMoCode's +// "Detected high-frequency non-compliant requests from you.") was misclassified as a +// non-fallbackable generic 400, so MiMo-auto combo never failed over and the raw +// failure surfaced to Cline as `[502]: fetch failed`. checkFallbackError must now +// detect rate-limit text on a 400 and treat it as fallback-worthy +// (RATE_LIMIT_EXCEEDED, connection-cooldown scope) WITHOUT regressing the #2101 +// malformed-400 infinite-loop guard. + +const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts"); +const { RateLimitReason } = await import("../../open-sse/config/constants.ts"); + +test("#4976 400 with rate-limit text (MiMoCode) β†’ fallback with RATE_LIMIT_EXCEEDED", () => { + const res = checkFallbackError( + 400, + "Detected high-frequency non-compliant requests from you.", + 0, + null, + "mimocode" + ); + assert.equal(res.shouldFallback, true); + assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); +}); + +test("#4976 400 with Chinese rate-limit text β†’ fallback with RATE_LIMIT_EXCEEDED", () => { + const res = checkFallbackError(400, "ζ£€ζ΅‹εˆ°ζ‚¨ηš„θ―·ζ±‚ι’‘ηŽ‡θΏ‡ι«˜οΌŒθ―·η¨εŽε†θ―•", 0, null, "mimocode"); + assert.equal(res.shouldFallback, true); + assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); +}); + +test("#4976 regression: a generic non-rate-limit 400 still does NOT fall over (#2101 guard)", () => { + const res = checkFallbackError(400, "Invalid JSON: unexpected token at position 12"); + assert.equal(res.shouldFallback, false); +}); + +test("#4976 regression: a malformed 400 stays MODEL_CAPACITY, not reclassified as rate-limit", () => { + // Malformed-request detection must win over the new rate-limit text check so the + // #2101 infinite-loop guard (zero-cooldown MODEL_CAPACITY) is preserved. + const res = checkFallbackError(400, "messages must alternate between user and assistant"); + assert.equal(res.shouldFallback, true); + assert.equal(res.reason, RateLimitReason.MODEL_CAPACITY); +});