swayrouter 1.0.8 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swayrouter",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Self-hosted AI gateway with a clean local dashboard",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,8 +59,22 @@ export function isNonRetryableRequestStatus(status) {
59
59
  export function isCancellationStatus(status) {
60
60
  return Number(status) === 499;
61
61
  }
62
-
63
- const T1_BACKOFF_BASE = 30 * 1000;
62
+
63
+ const QUOTA_EXHAUSTION_PATTERNS = [
64
+ /\binsufficient[_\s-]+quota\b/i,
65
+ /\b(?:quota|resource)[\s_-]+(?:is[\s_-]+)?(?:exhausted|depleted)\b/i,
66
+ /\b(?:quota|resource)[\s_-]+(?:has[\s_-]+been[\s_-]+)?(?:exceeded|reached)\b/i,
67
+ /\b(?:exceeded|exhausted|depleted)\s+(?:(?:your|the)\s+)?(?:current\s+)?(?:quota|resource)\b/i,
68
+ /\b(?:daily|weekly|monthly)\s+(?:usage\s+)?(?:quota|limit)\s+(?:is\s+)?(?:exceeded|exhausted|reached)\b/i,
69
+ /\bno\s+(?:remaining\s+)?quota\b/i,
70
+ ];
71
+
72
+ export function isQuotaExhaustionError(errorText) {
73
+ const text = typeof errorText === "string" ? errorText : JSON.stringify(errorText ?? "");
74
+ return QUOTA_EXHAUSTION_PATTERNS.some((pattern) => pattern.test(text));
75
+ }
76
+
77
+ const T1_BACKOFF_BASE = 30 * 1000;
64
78
  const T1_BACKOFF_CAP = 5 * 60 * 1000;
65
79
 
66
80
  const T3_DEPRIORITIZE_MS = 10 * 60 * 1000;
@@ -93,22 +107,30 @@ export const COOLDOWN_MS = {
93
107
  requestNotAllowed: COOLDOWN.short,
94
108
  };
95
109
 
96
- export function classifyError(status, errorText, retryCount = 0, opts = {}) {
97
- const lowerError = errorText
98
- ? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase()
99
- : "";
100
- const rng = typeof opts.rng === "function" ? opts.rng : null;
101
-
110
+ export function classifyError(status, errorText, retryCount = 0, opts = {}) {
111
+ const lowerError = errorText
112
+ ? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase()
113
+ : "";
114
+ const rng = typeof opts.rng === "function" ? opts.rng : null;
115
+
116
+ const quotaExhausted = isQuotaExhaustionError(lowerError);
117
+ if (quotaExhausted || Number(status) === 429) {
118
+ const exp = T1_BACKOFF_BASE * Math.pow(2, retryCount);
119
+ const base = Math.min(exp, T1_BACKOFF_CAP);
120
+ const jitter = rng ? Math.floor(rng() * base * JITTER_FRACTION) : 0;
121
+ return { tier: ERROR_TIERS.T1, action: "cooldown", cooldownMs: base + jitter };
122
+ }
123
+
102
124
  if (isNonRetryableRequestStatus(status) || isCancellationStatus(status)) {
103
- return {
104
- tier: ERROR_TIERS.T3,
105
- action: "final",
106
- cooldownMs: 0,
125
+ return {
126
+ tier: ERROR_TIERS.T3,
127
+ action: "final",
128
+ cooldownMs: 0,
107
129
  };
108
130
  }
109
131
 
110
132
  const t1Texts = ["rate limit", "too many requests", "quota exceeded", "capacity", "overloaded"];
111
- if (t1Texts.some((t) => lowerError.includes(t)) || Number(status) === 429) {
133
+ if (t1Texts.some((t) => lowerError.includes(t))) {
112
134
  const exp = T1_BACKOFF_BASE * Math.pow(2, retryCount);
113
135
  const base = Math.min(exp, T1_BACKOFF_CAP);
114
136
  const jitter = rng ? Math.floor(rng() * base * JITTER_FRACTION) : 0;
@@ -1,10 +1,11 @@
1
1
  import { NextResponse } from "next/server";
2
- import {
3
- getProviderConnectionById,
4
- getProxyPoolById,
5
- updateProviderConnection,
6
- deleteProviderConnection,
7
- } from "@/models";
2
+ import {
3
+ getProviderConnectionById,
4
+ getProxyPoolById,
5
+ updateProviderConnection,
6
+ deleteProviderConnection,
7
+ } from "@/models";
8
+ import { clearAllLocksForConnection } from "@/lib/db/repos/accountModelLocksRepo.js";
8
9
 
9
10
  function normalizeProxyConfig(body = {}) {
10
11
  const hasAnyProxyField =
@@ -118,7 +119,12 @@ export async function PUT(request, { params }) {
118
119
  if (priority !== undefined) updateData.priority = priority;
119
120
  if (globalPriority !== undefined) updateData.globalPriority = globalPriority;
120
121
  if (defaultModel !== undefined) updateData.defaultModel = defaultModel;
121
- if (isActive !== undefined) updateData.isActive = isActive;
122
+ if (isActive !== undefined) {
123
+ updateData.isActive = isActive;
124
+ if (isActive === true && existing.isActive === false) {
125
+ await clearAllLocksForConnection(id);
126
+ }
127
+ }
122
128
  if (apiKey && existing.authType === "apikey") {
123
129
  updateData.apiKey = typeof apiKey === "string" ? apiKey.trim() : apiKey;
124
130
  }
@@ -7,6 +7,7 @@ import {
7
7
  isDeprioitized,
8
8
  isCancellationStatus,
9
9
  isNonRetryableRequestStatus,
10
+ isQuotaExhaustionError,
10
11
  } from "../config/errorConfig.js";
11
12
 
12
13
  import { fnv1a } from "@/lib/security/payloadCapture.js";
@@ -18,6 +19,11 @@ export function getQuotaCooldown(backoffLevel = 0) {
18
19
  }
19
20
 
20
21
  export function checkFallbackError(status, errorText, backoffLevel = 0) {
22
+ if (isQuotaExhaustionError(errorText)) {
23
+ const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
24
+ return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
25
+ }
26
+
21
27
  if (isNonRetryableRequestStatus(status) || isCancellationStatus(status)) {
22
28
  return { shouldFallback: false, cooldownMs: 0, action: "final", newBackoffLevel: backoffLevel };
23
29
  }
@@ -1,13 +1,13 @@
1
1
  import { getProviderConnectionById, getProviderConnectionsForRouting, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb";
2
2
  import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
3
3
  import {
4
- formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate,
5
- getEarliestModelLockUntil,
6
-
7
- isDeprioritized, sortWithDeprioritization, pickCacheAffine,
8
- DEPRIORITIZE_FIELD,
9
- } from "../../services/accountFallback.js";
10
- import { MAX_RATE_LIMIT_COOLDOWN_MS, classifyError, ERROR_TIERS } from "../../config/errorConfig.js";
4
+ formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate,
5
+ getEarliestModelLockUntil,
6
+
7
+ isDeprioritized, sortWithDeprioritization, pickCacheAffine,
8
+ DEPRIORITIZE_FIELD,
9
+ } from "../../services/accountFallback.js";
10
+ import { MAX_RATE_LIMIT_COOLDOWN_MS, classifyError, ERROR_TIERS, isQuotaExhaustionError } from "../../config/errorConfig.js";
11
11
  import { HTTP_STATUS } from "../../config/runtimeConfig.js";
12
12
  import { recordLock, clearLock, clearAllLocksForConnection } from "@/lib/db/repos/accountModelLocksRepo.js";
13
13
  import { accountFallbackDecisionsTotal, cacheAffineSelectionsTotal } from "@/observability/metrics.js";
@@ -397,15 +397,20 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
397
397
  }
398
398
  if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
399
399
 
400
- const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
401
- const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs);
400
+ const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
401
+ const providerId = resolveProviderId(provider);
402
+ const codeBuddyQuotaExhausted = ["codebuddy-cn", "codebuddy-intl"].includes(providerId)
403
+ && isQuotaExhaustionError(errorText);
404
+ const lockModel = githubResetAtMs || codeBuddyQuotaExhausted ? null : model;
405
+ const lockUpdate = buildModelLockUpdate(lockModel, cooldownMs);
402
406
 
403
407
  const deprioUpdate = deprioitizeUntilMs ? { [DEPRIORITIZE_FIELD]: deprioitizeUntilMs } : {};
404
408
 
405
- await updateProviderConnection(connectionId, {
406
- ...lockUpdate,
407
- ...deprioUpdate,
408
- testStatus: "unavailable",
409
+ await updateProviderConnection(connectionId, {
410
+ ...lockUpdate,
411
+ ...deprioUpdate,
412
+ ...(codeBuddyQuotaExhausted ? { isActive: false } : {}),
413
+ testStatus: "unavailable",
409
414
  lastError: reason,
410
415
  errorCode: status,
411
416
  lastErrorAt: new Date().toISOString(),
@@ -413,7 +418,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
413
418
  });
414
419
  invalidateProviderAccountPool(provider);
415
420
 
416
- await recordLock(connectionId, githubResetAtMs ? null : model, {
421
+ await recordLock(connectionId, lockModel, {
417
422
  tier, reason, expiresAt: new Date(Date.now() + cooldownMs).toISOString(),
418
423
  }).catch(() => {});
419
424
 
@@ -45,7 +45,7 @@ export async function parseUpstreamError(response, executor = null) {
45
45
  if (parsed && typeof parsed === "object") {
46
46
  return {
47
47
  statusCode: parsed.status || response.status,
48
- message: executor?.provider === "codebuddy-intl" && parsed.message
48
+ message: ["codebuddy-cn", "codebuddy-intl"].includes(executor?.provider) && parsed.message
49
49
  ? parsed.message
50
50
  : DEFAULT_ERROR_MESSAGES[response.status] || "Upstream provider request failed",
51
51
  resetsAtMs: parsed.resetsAtMs,