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.
- package/CHANGELOG.md +9 -0
- package/README.id.md +7 -3
- package/README.md +7 -3
- package/dashboard/dist/assets/{CliTools-aPWuAaGl.js → CliTools-BA3qQ4Y-.js} +1 -1
- package/dashboard/dist/assets/{Combo-BLv2ftiC.js → Combo-CoYmIAt0.js} +1 -1
- package/dashboard/dist/assets/{ConsoleLog-XQc0AJjY.js → ConsoleLog-DEJBcRXY.js} +1 -1
- package/dashboard/dist/assets/{Dashboard-DTZuAXn6.js → Dashboard-0H0FmS_z.js} +1 -1
- package/dashboard/dist/assets/{Header-BZEBRuoI.js → Header-DjeUJuR7.js} +1 -1
- package/dashboard/dist/assets/{Keys-DoBcUcd1.js → Keys-D5i0CJPw.js} +1 -1
- package/dashboard/dist/assets/{Login-CLA9YPSW.js → Login-Bmyb9mgb.js} +1 -1
- package/dashboard/dist/assets/{ManageApiKey-C6AcZdXV.js → ManageApiKey--COl62Nm.js} +1 -1
- package/dashboard/dist/assets/{OAuthCallback-CngALmEQ.js → OAuthCallback-DH2M63oY.js} +1 -1
- package/dashboard/dist/assets/{Provider-gBLoiXWc.js → Provider-DubAFZl3.js} +2 -2
- package/dashboard/dist/assets/{ProviderModelAccordion-BiCN7K3L.js → ProviderModelAccordion-Cuk2_DWf.js} +1 -1
- package/dashboard/dist/assets/{Proxy-WdkDbR59.js → Proxy-C2UzXL2r.js} +1 -1
- package/dashboard/dist/assets/{QuotaMonitor-DJnvSQmA.js → QuotaMonitor-gk5otjdB.js} +1 -1
- package/dashboard/dist/assets/{Settings-BqVAj_Z6.js → Settings-DlOhYL96.js} +1 -1
- package/dashboard/dist/assets/{Setup-YEZfhKZl.js → Setup-3-yf1tvY.js} +1 -1
- package/dashboard/dist/assets/{StatusBadge-CbCgRp1u.js → StatusBadge-iBeqU7Qd.js} +1 -1
- package/dashboard/dist/assets/{SwayChat-BNkpBa01.js → SwayChat-BlbMT8HI.js} +1 -1
- package/dashboard/dist/assets/{Usage-BtG4T3yX.js → Usage-C1uKYlTO.js} +1 -1
- package/dashboard/dist/assets/{accordion-I9hJ3zKn.js → accordion-DovdZQJu.js} +1 -1
- package/dashboard/dist/assets/{admin-extras-api-CZx9UMQG.js → admin-extras-api-5xk7DmbO.js} +1 -1
- package/dashboard/dist/assets/{api-keys-api-CoNfm8AZ.js → api-keys-api-DVdIV17Y.js} +1 -1
- package/dashboard/dist/assets/{card-3Ad838s5.js → card-DvGOwuMw.js} +1 -1
- package/dashboard/dist/assets/{checkbox-MVYpQird.js → checkbox-ClXwjb6M.js} +1 -1
- package/dashboard/dist/assets/{connections-api-BReJMHgi.js → connections-api-DlH7CVMr.js} +1 -1
- package/dashboard/dist/assets/{copy-button-D9SzZokt.js → copy-button-CCsXIvkJ.js} +1 -1
- package/dashboard/dist/assets/{currency-api-B0hvChGg.js → currency-api-CHfn9Y-I.js} +1 -1
- package/dashboard/dist/assets/{flip-button-yX_RgEC4.js → flip-button-CDurxfqc.js} +1 -1
- package/dashboard/dist/assets/{frame-R55qLb-9.js → frame-CY26iHQE.js} +1 -1
- package/dashboard/dist/assets/{index-Dne9pkEF.js → index-CkR38iPY.js} +2 -2
- package/dashboard/dist/assets/{ripple-button-BNF8KOIB.js → ripple-button-BrLlmKvT.js} +1 -1
- package/dashboard/dist/assets/{segmented-W2XJE85e.js → segmented-CxobJihW.js} +1 -1
- package/dashboard/dist/assets/{select-BnilqBos.js → select-DfWoWPVg.js} +1 -1
- package/dashboard/dist/assets/{skeleton-CuG5ZRYw.js → skeleton-81lPHspy.js} +1 -1
- package/dashboard/dist/assets/{table-CmYFkpYA.js → table-CEyzPvr7.js} +1 -1
- package/dashboard/dist/assets/{tabs-j6meB_LR.js → tabs-DkTPKASE.js} +1 -1
- package/dashboard/dist/assets/{tunnel-api-6Weym8SK.js → tunnel-api-m02RsElG.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/config/errorConfig.js +35 -13
- package/src/dashboardGuard.js +29 -13
- package/src/executors/azure.js +12 -12
- package/src/executors/default.js +38 -13
- package/src/lib/db/repos/connectionsRepo.js +4 -0
- package/src/lib/db/repos/settingsRepo.js +54 -24
- package/src/providers/registry/azure.js +20 -19
- package/src/providers/registry/openai.js +15 -12
- package/src/routes/api/providers/[id]/route.js +13 -7
- package/src/routes/api/settings/route.js +43 -34
- package/src/services/accountFallback.js +78 -17
- package/src/sse/handlers/chat.js +63 -57
- package/src/sse/services/auth.js +15 -3
- package/src/translator/formats/responsesApi.js +8 -6
- package/src/translator/request/openai-responses.js +18 -15
- package/src/utils/error.js +1 -1
- 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
|
-
|
|
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 (
|
|
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 =
|
|
211
|
+
const firstAvail = ordered.find((c) => !isExcluded(c));
|
|
200
212
|
return { connection: firstAvail || null, affinity: "miss" };
|
|
201
213
|
}
|
|
202
214
|
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
}
|
package/src/sse/handlers/chat.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
extractApiKey,
|
|
8
8
|
isValidApiKey,
|
|
9
9
|
} from "../services/auth.js";
|
|
10
|
-
import {
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
+
}
|
package/src/sse/services/auth.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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" };
|
package/src/utils/error.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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) ||
|