swayrouter 1.0.8 → 1.3.0

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.id.md +7 -3
  3. package/README.md +7 -3
  4. package/dashboard/dist/assets/{CliTools-aPWuAaGl.js → CliTools-BA3qQ4Y-.js} +1 -1
  5. package/dashboard/dist/assets/{Combo-BLv2ftiC.js → Combo-CoYmIAt0.js} +1 -1
  6. package/dashboard/dist/assets/{ConsoleLog-XQc0AJjY.js → ConsoleLog-DEJBcRXY.js} +1 -1
  7. package/dashboard/dist/assets/{Dashboard-DTZuAXn6.js → Dashboard-0H0FmS_z.js} +1 -1
  8. package/dashboard/dist/assets/{Header-BZEBRuoI.js → Header-DjeUJuR7.js} +1 -1
  9. package/dashboard/dist/assets/{Keys-DoBcUcd1.js → Keys-D5i0CJPw.js} +1 -1
  10. package/dashboard/dist/assets/{Login-CLA9YPSW.js → Login-Bmyb9mgb.js} +1 -1
  11. package/dashboard/dist/assets/{ManageApiKey-C6AcZdXV.js → ManageApiKey--COl62Nm.js} +1 -1
  12. package/dashboard/dist/assets/{OAuthCallback-CngALmEQ.js → OAuthCallback-DH2M63oY.js} +1 -1
  13. package/dashboard/dist/assets/{Provider-gBLoiXWc.js → Provider-DubAFZl3.js} +2 -2
  14. package/dashboard/dist/assets/{ProviderModelAccordion-BiCN7K3L.js → ProviderModelAccordion-Cuk2_DWf.js} +1 -1
  15. package/dashboard/dist/assets/{Proxy-WdkDbR59.js → Proxy-C2UzXL2r.js} +1 -1
  16. package/dashboard/dist/assets/{QuotaMonitor-DJnvSQmA.js → QuotaMonitor-gk5otjdB.js} +1 -1
  17. package/dashboard/dist/assets/{Settings-BqVAj_Z6.js → Settings-DlOhYL96.js} +1 -1
  18. package/dashboard/dist/assets/{Setup-YEZfhKZl.js → Setup-3-yf1tvY.js} +1 -1
  19. package/dashboard/dist/assets/{StatusBadge-CbCgRp1u.js → StatusBadge-iBeqU7Qd.js} +1 -1
  20. package/dashboard/dist/assets/{SwayChat-BNkpBa01.js → SwayChat-BlbMT8HI.js} +1 -1
  21. package/dashboard/dist/assets/{Usage-BtG4T3yX.js → Usage-C1uKYlTO.js} +1 -1
  22. package/dashboard/dist/assets/{accordion-I9hJ3zKn.js → accordion-DovdZQJu.js} +1 -1
  23. package/dashboard/dist/assets/{admin-extras-api-CZx9UMQG.js → admin-extras-api-5xk7DmbO.js} +1 -1
  24. package/dashboard/dist/assets/{api-keys-api-CoNfm8AZ.js → api-keys-api-DVdIV17Y.js} +1 -1
  25. package/dashboard/dist/assets/{card-3Ad838s5.js → card-DvGOwuMw.js} +1 -1
  26. package/dashboard/dist/assets/{checkbox-MVYpQird.js → checkbox-ClXwjb6M.js} +1 -1
  27. package/dashboard/dist/assets/{connections-api-BReJMHgi.js → connections-api-DlH7CVMr.js} +1 -1
  28. package/dashboard/dist/assets/{copy-button-D9SzZokt.js → copy-button-CCsXIvkJ.js} +1 -1
  29. package/dashboard/dist/assets/{currency-api-B0hvChGg.js → currency-api-CHfn9Y-I.js} +1 -1
  30. package/dashboard/dist/assets/{flip-button-yX_RgEC4.js → flip-button-CDurxfqc.js} +1 -1
  31. package/dashboard/dist/assets/{frame-R55qLb-9.js → frame-CY26iHQE.js} +1 -1
  32. package/dashboard/dist/assets/{index-Dne9pkEF.js → index-CkR38iPY.js} +2 -2
  33. package/dashboard/dist/assets/{ripple-button-BNF8KOIB.js → ripple-button-BrLlmKvT.js} +1 -1
  34. package/dashboard/dist/assets/{segmented-W2XJE85e.js → segmented-CxobJihW.js} +1 -1
  35. package/dashboard/dist/assets/{select-BnilqBos.js → select-DfWoWPVg.js} +1 -1
  36. package/dashboard/dist/assets/{skeleton-CuG5ZRYw.js → skeleton-81lPHspy.js} +1 -1
  37. package/dashboard/dist/assets/{table-CmYFkpYA.js → table-CEyzPvr7.js} +1 -1
  38. package/dashboard/dist/assets/{tabs-j6meB_LR.js → tabs-DkTPKASE.js} +1 -1
  39. package/dashboard/dist/assets/{tunnel-api-6Weym8SK.js → tunnel-api-m02RsElG.js} +1 -1
  40. package/dashboard/dist/index.html +1 -1
  41. package/package.json +1 -1
  42. package/src/config/errorConfig.js +35 -13
  43. package/src/dashboardGuard.js +29 -13
  44. package/src/executors/azure.js +12 -12
  45. package/src/executors/default.js +38 -13
  46. package/src/lib/db/repos/connectionsRepo.js +4 -0
  47. package/src/lib/db/repos/settingsRepo.js +54 -24
  48. package/src/providers/registry/azure.js +20 -19
  49. package/src/providers/registry/openai.js +15 -12
  50. package/src/routes/api/providers/[id]/route.js +13 -7
  51. package/src/routes/api/settings/route.js +43 -34
  52. package/src/services/accountFallback.js +78 -17
  53. package/src/sse/handlers/chat.js +63 -57
  54. package/src/sse/services/auth.js +15 -3
  55. package/src/translator/formats/responsesApi.js +8 -6
  56. package/src/translator/request/openai-responses.js +18 -15
  57. package/src/utils/error.js +1 -1
  58. package/src/utils/sessionManager.js +10 -2
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
- import { getSettings, updateSettings } from "@/lib/localDb";
3
- import { stripRemovedSettings } from "@/lib/db/repos/settingsRepo.js";
2
+ import { getSettings, updateSettings } from "@/lib/localDb";
3
+ import { stripRemovedSettings, validateFallbackStrategies } from "@/lib/db/repos/settingsRepo.js";
4
4
  import { resetComboRotation } from "open-sse/services/combo.js";
5
5
  import bcrypt from "bcryptjs";
6
6
  import { cookies } from "next/headers";
@@ -21,13 +21,13 @@ const PROTECTED_SETTING_KEYS = ["password", "dbPath", "tunnelEnabled", "tunnelUr
21
21
 
22
22
  const CLI_MODEL_MAPPING_KEYS = new Set(["enabled", "entries"]);
23
23
 
24
- function sanitizeSettingsForResponse(settings) {
25
- const {
26
- password: _password,
27
- dbPath: _dbPath,
28
- ...safeSettings
29
- } = stripRemovedSettings(settings);
30
- return safeSettings;
24
+ function sanitizeSettingsForResponse(settings) {
25
+ const {
26
+ password: _password,
27
+ dbPath: _dbPath,
28
+ ...safeSettings
29
+ } = stripRemovedSettings(settings);
30
+ return safeSettings;
31
31
  }
32
32
 
33
33
  export const __test__ = { sanitizeSettingsForResponse };
@@ -86,6 +86,15 @@ export async function PATCH(request) {
86
86
 
87
87
  for (const key of PROTECTED_SETTING_KEYS) delete body[key];
88
88
 
89
+ try {
90
+ validateFallbackStrategies(body);
91
+ } catch (error) {
92
+ if (error?.code === "INVALID_FALLBACK_STRATEGY") {
93
+ return NextResponse.json({ error: error.message }, { status: 400 });
94
+ }
95
+ throw error;
96
+ }
97
+
89
98
  const currentSettings = await getSettings();
90
99
  if (
91
100
  currentSettings.tunnelEnabled === true &&
@@ -126,31 +135,31 @@ export async function PATCH(request) {
126
135
  body.cliModelMappings = sanitizeCliModelMappings(body.cliModelMappings);
127
136
  }
128
137
 
129
- if (Object.prototype.hasOwnProperty.call(body, "profileName")) {
130
- body.profileName = typeof body.profileName === "string"
131
- ? body.profileName.trim().replace(/\s+/g, " ").slice(0, 40)
132
- : "";
133
- }
134
-
135
- if (Object.prototype.hasOwnProperty.call(body, "currency")) {
136
- body.currency = body.currency === "IDR" ? "IDR" : "USD";
137
- }
138
-
139
- if (Object.prototype.hasOwnProperty.call(body, "profileAvatar")) {
140
- const avatar = typeof body.profileAvatar === "string" ? body.profileAvatar.trim() : "";
141
- const isValidAvatar = avatar === ""
142
- || (avatar.length <= 700_000
143
- && /^data:image\/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/=\r\n]+$/.test(avatar));
144
- if (!isValidAvatar) {
145
- return NextResponse.json(
146
- { error: "Profile photo must be a PNG, JPG, WEBP, or GIF up to 512 KB" },
147
- { status: 400 },
148
- );
149
- }
150
- body.profileAvatar = avatar;
151
- }
152
-
153
- const settings = await updateSettings(body);
138
+ if (Object.prototype.hasOwnProperty.call(body, "profileName")) {
139
+ body.profileName = typeof body.profileName === "string"
140
+ ? body.profileName.trim().replace(/\s+/g, " ").slice(0, 40)
141
+ : "";
142
+ }
143
+
144
+ if (Object.prototype.hasOwnProperty.call(body, "currency")) {
145
+ body.currency = body.currency === "IDR" ? "IDR" : "USD";
146
+ }
147
+
148
+ if (Object.prototype.hasOwnProperty.call(body, "profileAvatar")) {
149
+ const avatar = typeof body.profileAvatar === "string" ? body.profileAvatar.trim() : "";
150
+ const isValidAvatar = avatar === ""
151
+ || (avatar.length <= 700_000
152
+ && /^data:image\/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/=\r\n]+$/.test(avatar));
153
+ if (!isValidAvatar) {
154
+ return NextResponse.json(
155
+ { error: "Profile photo must be a PNG, JPG, WEBP, or GIF up to 512 KB" },
156
+ { status: 400 },
157
+ );
158
+ }
159
+ body.profileAvatar = avatar;
160
+ }
161
+
162
+ const settings = await updateSettings(body);
154
163
 
155
164
  if (forcedPasswordChange) {
156
165
 
@@ -3,13 +3,15 @@ import {
3
3
  BACKOFF_CONFIG,
4
4
  TRANSIENT_COOLDOWN_MS,
5
5
  classifyError,
6
- ERROR_TIERS,
7
- isDeprioitized,
8
- isCancellationStatus,
9
- isNonRetryableRequestStatus,
10
- } from "../config/errorConfig.js";
6
+ ERROR_TIERS,
7
+ isDeprioitized,
8
+ isCancellationStatus,
9
+ isNonRetryableRequestStatus,
10
+ isQuotaExhaustionError,
11
+ } from "../config/errorConfig.js";
11
12
 
12
13
  import { fnv1a } from "@/lib/security/payloadCapture.js";
14
+ import { extractClientSessionId } from "../utils/sessionManager.js";
13
15
 
14
16
  export function getQuotaCooldown(backoffLevel = 0) {
15
17
  const level = Math.max(0, backoffLevel - 1);
@@ -18,7 +20,12 @@ export function getQuotaCooldown(backoffLevel = 0) {
18
20
  }
19
21
 
20
22
  export function checkFallbackError(status, errorText, backoffLevel = 0) {
21
- if (isNonRetryableRequestStatus(status) || isCancellationStatus(status)) {
23
+ if (isQuotaExhaustionError(errorText)) {
24
+ const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
25
+ return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
26
+ }
27
+
28
+ if (isNonRetryableRequestStatus(status) || isCancellationStatus(status)) {
22
29
  return { shouldFallback: false, cooldownMs: 0, action: "final", newBackoffLevel: backoffLevel };
23
30
  }
24
31
 
@@ -193,25 +200,52 @@ export function pickCacheAffine(connections, { cacheKey, excludeIds } = {}) {
193
200
  const all = connections || [];
194
201
  if (all.length === 0) return { connection: null, affinity: "miss" };
195
202
  const excluded = excludeIds instanceof Set ? excludeIds : (excludeIds ? new Set(excludeIds) : null);
203
+ const isExcluded = (c) => !!(excluded && c && excluded.has(c.id));
204
+ const prio = (c) => (c && c.priority != null ? c.priority : 999);
205
+
206
+ // Stable copy ordered by ascending priority (lower number = higher priority).
207
+ const ordered = all.slice().sort((a, b) => prio(a) - prio(b));
196
208
 
197
209
  if (!cacheKey) {
198
210
 
199
- const firstAvail = excluded ? all.find((c) => !excluded.has(c.id)) : all[0];
211
+ const firstAvail = ordered.find((c) => !isExcluded(c));
200
212
  return { connection: firstAvail || null, affinity: "miss" };
201
213
  }
202
214
 
203
- const n = all.length;
204
- const bucket = fnv1a(cacheKey) % n;
205
- const primary = all[bucket] || null;
206
-
207
- if (primary && (!excluded || !excluded.has(primary.id))) {
208
- return { connection: primary, affinity: "hit" };
215
+ // Group into priority tiers, preserving stable order within each tier.
216
+ const tiers = [];
217
+ for (const c of ordered) {
218
+ const p = prio(c);
219
+ const last = tiers[tiers.length - 1];
220
+ if (!last || last.priority !== p) tiers.push({ priority: p, members: [c] });
221
+ else last.members.push(c);
209
222
  }
210
223
 
211
- for (let step = 1; step <= n; step++) {
212
- const cand = all[(bucket + step) % n];
213
- if (!cand || (excluded && excluded.has(cand.id))) continue;
214
- return { connection: cand, affinity: "rotate" };
224
+ // Highest-priority tier with capacity wins; affinity is "hit" only when the
225
+ // top tier's hashed primary is itself available, otherwise "rotate".
226
+ let rotated = false;
227
+ for (const tier of tiers) {
228
+ const members = tier.members;
229
+ if (members.length === 0) continue;
230
+ if (members.every((c) => isExcluded(c))) {
231
+ // Whole tier excluded → fall through to the next tier.
232
+ rotated = true;
233
+ continue;
234
+ }
235
+
236
+ const bucket = fnv1a(cacheKey) % members.length;
237
+ const primary = members[bucket];
238
+ if (!isExcluded(primary)) {
239
+ return { connection: primary, affinity: rotated ? "rotate" : "hit" };
240
+ }
241
+
242
+ for (let step = 1; step < members.length; step++) {
243
+ const cand = members[(bucket + step) % members.length];
244
+ if (!isExcluded(cand)) return { connection: cand, affinity: "rotate" };
245
+ }
246
+
247
+ // Defensive: tier had a non-excluded member but the walk missed it.
248
+ rotated = true;
215
249
  }
216
250
 
217
251
  return { connection: null, affinity: "rotate" };
@@ -252,3 +286,30 @@ export function extractCacheKey(body) {
252
286
  if (typeof body.input === "string" && body.input) return body.input.slice(0, 256);
253
287
  return null;
254
288
  }
289
+
290
+ // Resolve a stable cache-affine key for a request.
291
+ // Priority:
292
+ // (a) an EXPLICIT client-provided session/conversation identity (headers, or
293
+ // body prompt_cache_key / session_id / conversation_id / metadata.user_id).
294
+ // An explicit id means "one conversation" → pin it to one account so every
295
+ // multi-turn request reuses that provider account's prompt cache.
296
+ // We deliberately ignore the random/derived session fallback (generateBinaryStyleId
297
+ // / deriveSessionId) here — it is unstable and would defeat affinity.
298
+ // (b) otherwise fall back to the prompt-prefix text (extractCacheKey), which is
299
+ // the best available proxy and also shares a common system prompt across clients.
300
+ // Bound to <=256 chars. Format-agnostic (OpenAI messages, Gemini contents, plain prompt/input).
301
+ /**
302
+ * @param {{ headers?: Record<string, string>|null, body?: unknown, connectionId?: string|null, scope?: string }} [opts]
303
+ * @returns {string|null}
304
+ */
305
+ export function deriveCacheKey({ headers, body, connectionId, scope = "" } = {}) {
306
+ try {
307
+ // includeRequestId:false — x-client-request-id is PER-REQUEST, not per-conversation;
308
+ // letting it through would make the key change every call and defeat affinity.
309
+ const explicit = extractClientSessionId(headers, body, scope, { includeRequestId: false });
310
+ if (explicit) return `session:${explicit}`.slice(0, 256);
311
+ } catch {
312
+
313
+ }
314
+ return extractCacheKey(body);
315
+ }
@@ -7,7 +7,7 @@ import {
7
7
  extractApiKey,
8
8
  isValidApiKey,
9
9
  } from "../services/auth.js";
10
- import { extractCacheKey } from "../../services/accountFallback.js";
10
+ import { deriveCacheKey } from "../../services/accountFallback.js";
11
11
  import { getSettings, getProviderConnectionById } from "@/lib/localDb";
12
12
  import { getModelInfo, getComboModels } from "../services/model.js";
13
13
  import { handleChatCore } from "../../handlers/chatCore.js";
@@ -22,13 +22,13 @@ import { updateProviderCredentials, checkAndRefreshToken } from "../services/tok
22
22
  import { getProjectIdForConnection } from "../../services/projectId.js";
23
23
  import { getConsistentMachineId } from "@/shared/utils/machineId";
24
24
  import { isCallerCancellation } from "../services/requestCancellation.js";
25
- import { env } from "@/lib/env";
26
-
27
- const INTERNAL_CLI_TOKEN_HEADER = "x-swayrouter-cli-token";
28
- const INTERNAL_CONNECTION_HEADER = "x-swayrouter-connection-id";
29
- const INTERNAL_CLI_TOKEN_SALT = "swayrouter-cli-auth";
30
-
31
- function attachAccountLease(response, release) {
25
+ import { env } from "@/lib/env";
26
+
27
+ const INTERNAL_CLI_TOKEN_HEADER = "x-swayrouter-cli-token";
28
+ const INTERNAL_CONNECTION_HEADER = "x-swayrouter-connection-id";
29
+ const INTERNAL_CLI_TOKEN_SALT = "swayrouter-cli-auth";
30
+
31
+ function attachAccountLease(response, release) {
32
32
  if (!response || typeof release !== "function") return response;
33
33
 
34
34
  let released = false;
@@ -240,11 +240,11 @@ export async function handleChat(request, clientRawRequest = null) {
240
240
  return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey, preferredConnectionId, request?.signal);
241
241
  }
242
242
 
243
- async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, preferredConnectionId = null, requestSignal = null) {
244
- requestSignal ||= request?.signal || null;
245
- if (requestSignal?.aborted) return errorResponse(499, "Request aborted");
246
- const deadlineAt = Date.now() + env.upstreamDeadlineMs;
247
- const incomingClient = clientRawRequest?.headers
243
+ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, preferredConnectionId = null, requestSignal = null) {
244
+ requestSignal ||= request?.signal || null;
245
+ if (requestSignal?.aborted) return errorResponse(499, "Request aborted");
246
+ const deadlineAt = Date.now() + env.upstreamDeadlineMs;
247
+ const incomingClient = clientRawRequest?.headers
248
248
  ? Object.fromEntries(clientRawRequest.headers.entries?.() || [])
249
249
  : {};
250
250
  const settings = await getSettings();
@@ -312,17 +312,23 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
312
312
 
313
313
  const excludeConnectionIds = new Set();
314
314
  let lastError = null;
315
- let lastStatus = null;
316
-
317
- const cacheKey = extractCacheKey(body);
318
- let retryCount = 0;
319
- let accountAttempts = 0;
320
-
321
- while (accountAttempts < env.maxAccountAttempts) {
322
- if (Date.now() >= deadlineAt) {
323
- return errorResponse(HTTP_STATUS.GATEWAY_TIMEOUT, "Sway Router upstream deadline exceeded");
324
- }
325
-
315
+ let lastStatus = null;
316
+
317
+ const requestHeaders = clientRawRequest?.headers || Object.fromEntries(request.headers.entries());
318
+ const cacheKey = deriveCacheKey({
319
+ headers: requestHeaders,
320
+ body,
321
+ connectionId: preferredConnectionId || null,
322
+ scope: provider || "",
323
+ });
324
+ let retryCount = 0;
325
+ let accountAttempts = 0;
326
+
327
+ while (accountAttempts < env.maxAccountAttempts) {
328
+ if (Date.now() >= deadlineAt) {
329
+ return errorResponse(HTTP_STATUS.GATEWAY_TIMEOUT, "Sway Router upstream deadline exceeded");
330
+ }
331
+
326
332
  const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, {
327
333
  cacheKey,
328
334
  preferredConnectionId,
@@ -354,10 +360,10 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
354
360
  credentials.retryAfter,
355
361
  credentials.retryAfterHuman || "retry shortly",
356
362
  );
357
- }
358
-
359
- accountAttempts += 1;
360
- const releaseAccountSlot = credentials.releaseAccountSlot;
363
+ }
364
+
365
+ accountAttempts += 1;
366
+ const releaseAccountSlot = credentials.releaseAccountSlot;
361
367
  let result;
362
368
  try {
363
369
  const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
@@ -375,7 +381,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
375
381
  const providerThinking = (chatSettings.providerThinking || {})[provider] || null;
376
382
  result = await handleChatCore({
377
383
  body: { ...body, model: `${provider}/${model}` },
378
- modelInfo: { provider, model, modelCapabilities: modelInfo.modelCapabilities },
384
+ modelInfo: { provider, model, modelCapabilities: modelInfo.modelCapabilities },
379
385
  credentials: refreshedCredentials,
380
386
  log,
381
387
  clientRawRequest,
@@ -388,9 +394,9 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
388
394
  cavemanLevel: chatSettings.cavemanLevel || "full",
389
395
  ponytailEnabled: !!chatSettings.ponytailEnabled,
390
396
  ponytailLevel: chatSettings.ponytailLevel || "full",
391
- providerThinking,
392
- requestSignal,
393
- deadlineAt,
397
+ providerThinking,
398
+ requestSignal,
399
+ deadlineAt,
394
400
 
395
401
  sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
396
402
  onCredentialsRefreshed: async (newCreds) => {
@@ -414,33 +420,33 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
414
420
 
415
421
  releaseAccountSlot?.();
416
422
 
417
- if (isCallerCancellation({ requestSignal, status: result.status })) {
418
- return result.response;
419
- }
420
-
421
- if (Date.now() >= deadlineAt) {
422
- return result.response.status === HTTP_STATUS.GATEWAY_TIMEOUT
423
- ? result.response
424
- : errorResponse(HTTP_STATUS.GATEWAY_TIMEOUT, "Sway Router upstream deadline exceeded");
425
- }
423
+ if (isCallerCancellation({ requestSignal, status: result.status })) {
424
+ return result.response;
425
+ }
426
+
427
+ if (Date.now() >= deadlineAt) {
428
+ return result.response.status === HTTP_STATUS.GATEWAY_TIMEOUT
429
+ ? result.response
430
+ : errorResponse(HTTP_STATUS.GATEWAY_TIMEOUT, "Sway Router upstream deadline exceeded");
431
+ }
426
432
 
427
433
  const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs, { retryCount });
428
434
 
429
- if (shouldFallback) {
430
- log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`);
431
- excludeConnectionIds.add(credentials.connectionId);
432
- lastError = result.error;
433
- lastStatus = result.status;
434
- retryCount += 1;
435
- if (accountAttempts >= env.maxAccountAttempts) {
436
- log.warn("FALLBACK", `[${provider}/${model}] account attempt limit reached (${env.maxAccountAttempts})`);
437
- return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Provider account fallback limit reached; retry shortly");
438
- }
439
- continue;
435
+ if (shouldFallback) {
436
+ log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`);
437
+ excludeConnectionIds.add(credentials.connectionId);
438
+ lastError = result.error;
439
+ lastStatus = result.status;
440
+ retryCount += 1;
441
+ if (accountAttempts >= env.maxAccountAttempts) {
442
+ log.warn("FALLBACK", `[${provider}/${model}] account attempt limit reached (${env.maxAccountAttempts})`);
443
+ return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Provider account fallback limit reached; retry shortly");
444
+ }
445
+ continue;
440
446
  }
441
447
 
442
- return result.response;
443
- }
444
-
445
- return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Provider account fallback limit reached; retry shortly");
446
- }
448
+ return result.response;
449
+ }
450
+
451
+ return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Provider account fallback limit reached; retry shortly");
452
+ }
@@ -7,7 +7,7 @@ import {
7
7
  isDeprioritized, sortWithDeprioritization, pickCacheAffine,
8
8
  DEPRIORITIZE_FIELD,
9
9
  } from "../../services/accountFallback.js";
10
- import { MAX_RATE_LIMIT_COOLDOWN_MS, classifyError, ERROR_TIERS } from "../../config/errorConfig.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";
@@ -40,6 +40,13 @@ function githubMonthlyResetMs(status, errorText, provider) {
40
40
  return Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
41
41
  }
42
42
 
43
+ /**
44
+ * @param {string} provider
45
+ * @param {Set<string>|string|null} [excludeConnectionIds]
46
+ * @param {string|null} [model]
47
+ * @param {{ cacheKey?: string|null, promptPrefix?: string|null, preferredConnectionId?: string|null, signal?: AbortSignal|null, [key: string]: unknown }} [options]
48
+ * @returns {Promise<Record<string, unknown>|null>}
49
+ */
43
50
  export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null, options = {}) {
44
51
 
45
52
  const excludeSet = excludeConnectionIds instanceof Set
@@ -398,13 +405,18 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
398
405
  if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
399
406
 
400
407
  const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
401
- const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs);
408
+ const providerId = resolveProviderId(provider);
409
+ const codeBuddyQuotaExhausted = ["codebuddy-cn", "codebuddy-intl"].includes(providerId)
410
+ && isQuotaExhaustionError(errorText);
411
+ const lockModel = githubResetAtMs || codeBuddyQuotaExhausted ? null : model;
412
+ const lockUpdate = buildModelLockUpdate(lockModel, cooldownMs);
402
413
 
403
414
  const deprioUpdate = deprioitizeUntilMs ? { [DEPRIORITIZE_FIELD]: deprioitizeUntilMs } : {};
404
415
 
405
416
  await updateProviderConnection(connectionId, {
406
417
  ...lockUpdate,
407
418
  ...deprioUpdate,
419
+ ...(codeBuddyQuotaExhausted ? { isActive: false } : {}),
408
420
  testStatus: "unavailable",
409
421
  lastError: reason,
410
422
  errorCode: status,
@@ -413,7 +425,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
413
425
  });
414
426
  invalidateProviderAccountPool(provider);
415
427
 
416
- await recordLock(connectionId, githubResetAtMs ? null : model, {
428
+ await recordLock(connectionId, lockModel, {
417
429
  tier, reason, expiresAt: new Date(Date.now() + cooldownMs).toISOString(),
418
430
  }).catch(() => {});
419
431
 
@@ -1,11 +1,11 @@
1
1
  import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js";
2
2
 
3
3
  export function normalizeResponsesInput(input) {
4
- if (typeof input === "string") {
5
- return [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: input }] }];
6
- }
7
- if (Array.isArray(input)) {
8
- return input;
4
+ if (typeof input === "string") {
5
+ return [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: input }] }];
6
+ }
7
+ if (Array.isArray(input)) {
8
+ return input;
9
9
  }
10
10
  return null;
11
11
  }
@@ -109,7 +109,9 @@ export function convertResponsesApiFormat(body) {
109
109
  delete result.input;
110
110
  delete result.instructions;
111
111
  delete result.include;
112
- delete result.prompt_cache_key;
112
+ // Preserve a client-supplied prompt_cache_key (explicit conversation identity for
113
+ // upstream prompt caching); drop only when absent/empty.
114
+ if (!result.prompt_cache_key) delete result.prompt_cache_key;
113
115
  delete result.store;
114
116
  delete result.reasoning;
115
117
 
@@ -209,7 +209,10 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
209
209
  delete result.input;
210
210
  delete result.instructions;
211
211
  delete result.include;
212
- delete result.prompt_cache_key;
212
+ // Preserve a client-supplied prompt_cache_key: it is the explicit conversation
213
+ // identity upstream caches on, so dropping it would guarantee cache misses.
214
+ // (An empty/absent value is dropped — never send an empty param upstream.)
215
+ if (!result.prompt_cache_key) delete result.prompt_cache_key;
213
216
  delete result.store;
214
217
  if (typeof result.reasoning?.effort === "string") {
215
218
  result.reasoning_effort = result.reasoning.effort;
@@ -269,18 +272,18 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
269
272
  store: false
270
273
  };
271
274
 
272
- const systemParts = [];
275
+ const systemParts = [];
273
276
  const messages = body.messages || [];
274
277
 
275
278
  for (const msg of messages) {
276
- if (msg.role === ROLE.SYSTEM || msg.role === ROLE.DEVELOPER) {
277
- const content = typeof msg.content === "string"
278
- ? msg.content
279
- : Array.isArray(msg.content)
280
- ? msg.content.map((part) => part?.text || "").filter(Boolean).join("\n")
281
- : "";
282
- if (content) systemParts.push(content);
283
- continue;
279
+ if (msg.role === ROLE.SYSTEM || msg.role === ROLE.DEVELOPER) {
280
+ const content = typeof msg.content === "string"
281
+ ? msg.content
282
+ : Array.isArray(msg.content)
283
+ ? msg.content.map((part) => part?.text || "").filter(Boolean).join("\n")
284
+ : "";
285
+ if (content) systemParts.push(content);
286
+ continue;
284
287
  }
285
288
 
286
289
  if (msg.role === ROLE.USER || msg.role === ROLE.ASSISTANT) {
@@ -342,7 +345,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
342
345
  }
343
346
  }
344
347
 
345
- if (systemParts.length > 0) result.instructions = systemParts.join("\n\n");
348
+ if (systemParts.length > 0) result.instructions = systemParts.join("\n\n");
346
349
 
347
350
  if (body.tools && Array.isArray(body.tools)) {
348
351
  result.tools = body.tools.map(tool => {
@@ -360,10 +363,10 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
360
363
  }
361
364
 
362
365
  if (body.temperature !== undefined) result.temperature = body.temperature;
363
- // The Responses API uses `max_output_tokens`; sending the Chat Completions
364
- // field `max_tokens` makes OpenCode reject Muse Spark requests with 400.
365
- if (body.max_output_tokens !== undefined) result.max_output_tokens = body.max_output_tokens;
366
- else if (body.max_tokens !== undefined) result.max_output_tokens = body.max_tokens;
366
+ // The Responses API uses `max_output_tokens`; sending the Chat Completions
367
+ // field `max_tokens` makes OpenCode reject Muse Spark requests with 400.
368
+ if (body.max_output_tokens !== undefined) result.max_output_tokens = body.max_output_tokens;
369
+ else if (body.max_tokens !== undefined) result.max_output_tokens = body.max_tokens;
367
370
  if (body.top_p !== undefined) result.top_p = body.top_p;
368
371
  if (body.reasoning !== undefined) result.reasoning = body.reasoning;
369
372
  if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" };
@@ -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,
@@ -90,7 +90,15 @@ function extractAntigravitySession(body) {
90
90
  return m ? normalizeSessionId(m[1]) : null;
91
91
  }
92
92
 
93
- function extractClientSessionId(headers, body, scope = "") {
93
+ /**
94
+ * @param {Record<string, string>|null|undefined} headers
95
+ * @param {unknown} body
96
+ * @param {string} [scope]
97
+ * @param {{ includeRequestId?: boolean }} [opts]
98
+ * @returns {string|null}
99
+ */
100
+ export function extractClientSessionId(headers, body, scope = "", opts = {}) {
101
+ const { includeRequestId = true } = opts;
94
102
  const claude = extractClaudeCodeSession(body?.metadata?.user_id);
95
103
  if (claude) return `claude:${claude}`;
96
104
  const antigravity = extractAntigravitySession(body);
@@ -99,7 +107,7 @@ function extractClientSessionId(headers, body, scope = "") {
99
107
  const v = headerValue(headers, key);
100
108
  if (v) return v;
101
109
  }
102
- const requestId = scope === "kiro" ? null : headerValue(headers, "x-client-request-id");
110
+ const requestId = (scope === "kiro" || !includeRequestId) ? null : headerValue(headers, "x-client-request-id");
103
111
  if (requestId) return requestId;
104
112
  const fromBody =
105
113
  normalizeSessionId(body?.prompt_cache_key) ||