swayrouter 1.0.7 → 1.0.8

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/.env.example CHANGED
@@ -41,3 +41,9 @@ PORT=14045
41
41
  # Advanced performance overrides intentionally live in README.md so the
42
42
  # normal setup stays small. Automatic resource profiles detect memory/CPU and
43
43
  # bound concurrency, queues, request bodies, retries, and usage retention.
44
+ # The upstream routing guardrails below are also optional. Defaults are 3 account
45
+ # attempts per request, 15 seconds per upstream attempt, and a 45-second Sway
46
+ # deadline before a fast gateway timeout.
47
+ #SWAY_MAX_ACCOUNT_ATTEMPTS=3
48
+ #SWAY_UPSTREAM_ATTEMPT_TIMEOUT_MS=15000
49
+ #SWAY_UPSTREAM_DEADLINE_MS=45000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swayrouter",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Self-hosted AI gateway with a clean local dashboard",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -4,9 +4,10 @@ import { PROVIDERS } from "../config/providers.js";
4
4
  import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS } from "../config/appConstants.js";
5
5
  import { HTTP_STATUS } from "../config/runtimeConfig.js";
6
6
  import { resolveSessionId } from "../utils/sessionManager.js";
7
- import { proxyAwareFetch } from "../utils/proxyFetch.js";
8
- import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
9
- import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js";
7
+ import { proxyAwareFetch } from "../utils/proxyFetch.js";
8
+ import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
9
+ import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js";
10
+ import { getGeminiThoughtSignatureSync } from "../services/thoughtSignatureStore.js";
10
11
 
11
12
  function sanitizeFunctionName(name) {
12
13
  if (!name) return "_unknown";
@@ -186,17 +187,26 @@ export class AntigravityExecutor extends BaseExecutor {
186
187
  return true;
187
188
  });
188
189
 
189
- const needsBackfill = parts?.some(p => p.functionCall && !p.thoughtSignature) ?? false;
190
- if (role !== c.role || parts?.length !== c.parts?.length || needsBackfill) {
191
- return {
192
- ...c, role,
193
- parts: needsBackfill
194
- ? parts.map(p => (p.functionCall && !p.thoughtSignature)
195
- ? { ...p, thoughtSignature: DEFAULT_THINKING_AG_SIGNATURE }
196
- : p)
197
- : parts,
198
- };
199
- }
190
+ let firstFunctionCallSeen = false;
191
+ const modifiedParts = parts?.map(p => {
192
+ if (!p.functionCall) return p;
193
+ const callId = p.functionCall.id;
194
+ const cachedSig = callId
195
+ ? getGeminiThoughtSignatureSync(callId, sessionId, body.model || model)
196
+ : null;
197
+ const callSig = p.thoughtSignature
198
+ || cachedSig
199
+ || (!firstFunctionCallSeen ? DEFAULT_THINKING_AG_SIGNATURE : undefined);
200
+ firstFunctionCallSeen = true;
201
+ return callSig ? { ...p, thoughtSignature: callSig } : p;
202
+ });
203
+ const needsBackfill = modifiedParts?.some((p, index) => p !== parts[index]) ?? false;
204
+ if (role !== c.role || parts?.length !== c.parts?.length || needsBackfill) {
205
+ return {
206
+ ...c, role,
207
+ parts: modifiedParts || parts,
208
+ };
209
+ }
200
210
  return c;
201
211
  });
202
212
 
@@ -240,18 +250,18 @@ export class AntigravityExecutor extends BaseExecutor {
240
250
  ...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } })
241
251
  };
242
252
 
243
- stripBlacklisted(body);
244
-
245
- this._lastSessionId = transformedRequest.sessionId;
246
-
247
- return {
248
- ...body,
249
- project: projectId,
250
- model: body.model || model,
251
- userAgent: "antigravity",
252
- requestType: "agent",
253
- requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
254
- request: transformedRequest
253
+ stripBlacklisted(body);
254
+ delete body.requestType;
255
+
256
+ this._lastSessionId = transformedRequest.sessionId;
257
+
258
+ return {
259
+ ...body,
260
+ project: projectId,
261
+ model: body.model || model,
262
+ userAgent: "antigravity",
263
+ requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
264
+ request: transformedRequest
255
265
  };
256
266
  }
257
267
 
@@ -3,8 +3,9 @@ import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js"
3
3
  import { proxyAwareFetch } from "../utils/proxyFetch.js";
4
4
  import { dbg } from "../utils/debugLog.js";
5
5
  import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
6
- import { resolveOpenAICompatibleApiType } from "../services/provider.js";
7
- import { env } from "@/lib/env";
6
+ import { resolveOpenAICompatibleApiType } from "../services/provider.js";
7
+ import { env } from "@/lib/env";
8
+ import { createUpstreamAttemptSignal } from "../utils/upstreamTimeout.js";
8
9
 
9
10
  export function waitForRetry(delayMs, signal) {
10
11
  if (!delayMs || delayMs <= 0) {
@@ -124,7 +125,7 @@ export class BaseExecutor {
124
125
  return { status: response.status, message: bodyText || `HTTP ${response.status}` };
125
126
  }
126
127
 
127
- async execute({ model, body, stream, credentials, signal, log, proxyOptions = null, requestTag = "", attemptReason = "initial", maxTotalAttempts = env.upstreamMaxAttempts }) {
128
+ async execute({ model, body, stream, credentials, signal, log, proxyOptions = null, requestTag = "", attemptReason = "initial", maxTotalAttempts = env.upstreamMaxAttempts, attemptTimeoutMs = env.upstreamAttemptTimeoutMs, deadlineAt = null }) {
128
129
  const fallbackCount = this.getFallbackCount();
129
130
  const totalAttemptLimit = Math.max(1, Number(maxTotalAttempts) || env.upstreamMaxAttempts || 8);
130
131
  let attempt = 0;
@@ -157,27 +158,28 @@ export class BaseExecutor {
157
158
 
158
159
  if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
159
160
 
160
- const connectCtrl = new AbortController();
161
- const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
162
- const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
163
- const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
164
-
165
- try {
166
- attempt++;
161
+ const configuredTimeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
162
+ const attemptControl = createUpstreamAttemptSignal({
163
+ signal,
164
+ timeoutMs: Math.min(configuredTimeoutMs, attemptTimeoutMs || env.upstreamAttemptTimeoutMs),
165
+ deadlineAt,
166
+ });
167
+
168
+ try {
169
+ attempt++;
167
170
  const bodyStr = JSON.stringify(transformedBody);
168
171
  const fetchT0 = Date.now();
169
172
  const attemptMeta = `attempt=${attempt} · reason=${attemptReason}`;
170
173
  let upstreamHost = url;
171
174
  try { upstreamHost = new URL(url).host || url; } catch { }
172
175
  log?.line?.(requestTag, "FETCH", `${this.provider.toUpperCase()} → ${upstreamHost} · ${attemptMeta}`);
173
- dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms | ${attemptMeta}`);
174
- const response = await proxyAwareFetch(url, {
175
- method: "POST",
176
- headers,
177
- body: bodyStr,
178
- signal: mergedSignal
179
- }, proxyOptions);
180
- clearTimeout(connectTimer);
176
+ dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | attemptTimeout=${attemptControl.timeoutMs}ms | ${attemptMeta}`);
177
+ const response = await proxyAwareFetch(url, {
178
+ method: "POST",
179
+ headers,
180
+ body: bodyStr,
181
+ signal: attemptControl.signal
182
+ }, proxyOptions);
181
183
  const ct = response.headers?.get?.("content-type") || "";
182
184
  const cl = response.headers?.get?.("content-length") || "?";
183
185
  const responseLabel = response.status >= 400 ? "ERROR" : "RESPONSE";
@@ -198,15 +200,14 @@ export class BaseExecutor {
198
200
  }
199
201
 
200
202
  return { response, url, headers, transformedBody };
201
- } catch (error) {
202
- clearTimeout(connectTimer);
203
-
204
- if (signal?.aborted) throw signal.reason || error;
205
- lastError = error;
206
- const isConnectTimeout = connectCtrl.signal.aborted && error.name === "AbortError";
207
- dbg("FETCH", `${this.provider.toUpperCase()} ✖ ${error.name}: ${error.message}${isConnectTimeout ? " (connect timeout)" : ""}`);
208
-
209
- if (error.name === "AbortError" && !isConnectTimeout) throw error;
203
+ } catch (error) {
204
+ if (attemptControl.wasTimeout()) throw attemptControl.timeoutError;
205
+
206
+ if (signal?.aborted) throw signal.reason || error;
207
+ lastError = error;
208
+ dbg("FETCH", `${this.provider.toUpperCase()} ✖ ${error.name}: ${error.message}`);
209
+
210
+ if (error.name === "AbortError") throw error;
210
211
 
211
212
  if (await tryRetry(urlIndex, HTTP_STATUS.BAD_GATEWAY, `network "${error.message}"`)) { urlIndex--; continue; }
212
213
 
@@ -214,8 +215,10 @@ export class BaseExecutor {
214
215
  log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
215
216
  continue;
216
217
  }
217
- throw error;
218
- }
218
+ throw error;
219
+ } finally {
220
+ attemptControl.cleanup();
221
+ }
219
222
  }
220
223
 
221
224
  throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`);
@@ -15,6 +15,8 @@ import {
15
15
  QODER_MODEL_MAP,
16
16
  } from "../shared/qoder/constants.js";
17
17
  import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js";
18
+ import { createUpstreamAttemptSignal } from "../utils/upstreamTimeout.js";
19
+ import { env } from "@/lib/env";
18
20
 
19
21
  function normalizeMessages(messages) {
20
22
  if (!Array.isArray(messages) || messages.length === 0) {
@@ -305,7 +307,7 @@ export class QoderExecutor extends BaseExecutor {
305
307
  return QODER_CHAT_URL_ENCODED;
306
308
  }
307
309
 
308
- async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
310
+ async execute({ model, body, stream, credentials, signal, log, proxyOptions = null, attemptTimeoutMs = env.upstreamAttemptTimeoutMs, deadlineAt = null }) {
309
311
 
310
312
  const rawToken = credentials?.apiKey || credentials?.accessToken;
311
313
  if (isQoderPat(rawToken)) {
@@ -390,20 +392,25 @@ export class QoderExecutor extends BaseExecutor {
390
392
  ...cosyHeaders,
391
393
  };
392
394
 
393
- const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
394
- const connectCtrl = new AbortController();
395
- const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
396
- const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
395
+ const configuredTimeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
396
+ const attemptControl = createUpstreamAttemptSignal({
397
+ signal,
398
+ timeoutMs: Math.min(configuredTimeoutMs, attemptTimeoutMs || env.upstreamAttemptTimeoutMs),
399
+ deadlineAt,
400
+ });
397
401
 
398
402
  let response;
399
403
  try {
400
404
  response = await proxyAwareFetch(
401
405
  url,
402
- { method: "POST", headers, body: encodedBodyBuf, signal: mergedSignal },
406
+ { method: "POST", headers, body: encodedBodyBuf, signal: attemptControl.signal },
403
407
  proxyOptions,
404
408
  );
409
+ } catch (error) {
410
+ if (attemptControl.wasTimeout()) throw attemptControl.timeoutError;
411
+ throw error;
405
412
  } finally {
406
- clearTimeout(connectTimer);
413
+ attemptControl.cleanup();
407
414
  }
408
415
 
409
416
  if (!response.ok) {
@@ -89,7 +89,7 @@ export async function coerceOpenAIJsonToSSE(providerResponse, model, provider =
89
89
  });
90
90
  }
91
91
 
92
- function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey }) {
92
+ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, sessionId }) {
93
93
  const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
94
94
 
95
95
  const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
@@ -97,11 +97,11 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
97
97
 
98
98
  if (needsCodexTranslation) {
99
99
  const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
100
- return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
100
+ return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames, sessionId);
101
101
  }
102
102
 
103
103
  if (needsTranslation(targetFormat, sourceFormat)) {
104
- return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
104
+ return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames, sessionId);
105
105
  }
106
106
 
107
107
  return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
@@ -151,7 +151,8 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
151
151
  };
152
152
  }
153
153
 
154
- const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey });
154
+ const sessionId = finalBody?.request?.sessionId || translatedBody?.request?.sessionId || body?.request?.sessionId || null;
155
+ const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, sessionId });
155
156
 
156
157
  const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
157
158
  const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null;
@@ -33,6 +33,8 @@ import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
33
33
  import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
34
34
  import { resolveSessionId } from "../utils/sessionManager.js";
35
35
  import { normalizeTranslationRequest, resolveTranslationSurface, unsupportedFeatures } from "../translator/canonical.js";
36
+ import { executeWithAttemptDeadline, isUpstreamTimeoutError } from "../utils/upstreamTimeout.js";
37
+ import { env } from "@/lib/env";
36
38
 
37
39
  export function stripContinuityFields(body) {
38
40
  if (!body || !Array.isArray(body.messages)) return body;
@@ -45,7 +47,7 @@ export function stripContinuityFields(body) {
45
47
  return body;
46
48
  }
47
49
 
48
- export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking, requestSignal }) {
50
+ export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking, requestSignal, deadlineAt = null }) {
49
51
  const { provider, model, modelCapabilities } = modelInfo;
50
52
  const requestStartTime = Date.now();
51
53
  let failureRecorded = false;
@@ -315,6 +317,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
315
317
  signal: streamController.signal, log, proxyOptions,
316
318
  requestTag: reqTag,
317
319
  attemptReason: "initial",
320
+ attemptTimeoutMs: env.upstreamAttemptTimeoutMs,
321
+ deadlineAt,
318
322
  };
319
323
  trackPendingRequest(model, provider, connectionId, true);
320
324
  appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
@@ -352,7 +356,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
352
356
 
353
357
  let providerResponseFormat = targetFormat;
354
358
  try {
355
- const result = await executor.execute(executeOptions);
359
+ const result = await executeWithAttemptDeadline(
360
+ (signal) => executor.execute({ ...executeOptions, signal }),
361
+ { signal: executeOptions.signal, timeoutMs: env.upstreamAttemptTimeoutMs, deadlineAt },
362
+ );
356
363
  providerResponse = result.response;
357
364
  providerUrl = result.url;
358
365
  providerHeaders = result.headers;
@@ -361,19 +368,24 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
361
368
  reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
362
369
  } catch (error) {
363
370
  trackPendingRequest(model, provider, connectionId, false, true);
364
- const failedStatus = recordFailure(error.message || String(error), error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY);
365
- appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
371
+ const timeout = isUpstreamTimeoutError(error);
372
+ const failedStatus = recordFailure(error.message || String(error), timeout ? HTTP_STATUS.GATEWAY_TIMEOUT : error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY);
373
+ appendRequestLog({ model, provider, connectionId, status: `FAILED ${timeout ? HTTP_STATUS.GATEWAY_TIMEOUT : error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
366
374
  saveRequestDetail(buildRequestDetail({
367
375
  provider, model, connectionId,
368
376
  latency: { ttft: 0, total: Date.now() - requestStartTime },
369
377
  tokens: { prompt_tokens: 0, completion_tokens: 0 },
370
378
  request: extractRequestConfig(body, stream),
371
379
  providerRequest: translatedBody || null,
372
- response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
380
+ response: { error: error.message || String(error), status: timeout ? HTTP_STATUS.GATEWAY_TIMEOUT : error.name === "AbortError" ? 499 : 502, thinking: null },
373
381
  status: "error",
374
382
  client: { id: clientMeta.id, confidence: clientMeta.confidence, signals: clientMeta.signals, conflicts: clientMeta.conflicts }
375
383
  }), clientRawRequest).catch(() => { });
376
384
 
385
+ if (timeout) {
386
+ streamController.handleError(error);
387
+ return createErrorResult(HTTP_STATUS.GATEWAY_TIMEOUT, "Upstream request timed out");
388
+ }
377
389
  if (error.name === "AbortError") {
378
390
  streamController.handleError(error);
379
391
  return createErrorResult(499, "Request aborted");
@@ -403,7 +415,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
403
415
  try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
404
416
  }
405
417
  try {
406
- const retryResult = await executor.execute({ ...executeOptions, attemptReason: "oauth-refresh" });
418
+ const retryResult = await executeWithAttemptDeadline(
419
+ (signal) => executor.execute({ ...executeOptions, signal, attemptReason: "oauth-refresh" }),
420
+ { signal: executeOptions.signal, timeoutMs: env.upstreamAttemptTimeoutMs, deadlineAt },
421
+ );
407
422
  if (retryResult.response.ok) {
408
423
  providerResponse = retryResult.response;
409
424
  providerUrl = retryResult.url;
@@ -448,6 +463,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
448
463
  reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
449
464
  }
450
465
  } catch (e) {
466
+ if (isUpstreamTimeoutError(e)) throw e;
451
467
  if (log?.warn) log.warn("A4.4", `retry after strip "${rejectedField}" threw: ${e?.message || e}`);
452
468
  }
453
469
  } else if (apply.retryable && !apply.removed) {
@@ -517,10 +533,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
517
533
  }
518
534
  log?.line?.(reqTag, "RETRY", `A4.3 pre-content socket close · ${provider}/${model}`);
519
535
  try {
520
- const retryResp = await executor.execute({
521
- ...executeOptions,
522
- attemptReason: "stream-preview-network",
523
- });
536
+ const retryResp = await executeWithAttemptDeadline(
537
+ (signal) => executor.execute({
538
+ ...executeOptions,
539
+ signal,
540
+ attemptReason: "stream-preview-network",
541
+ }),
542
+ { signal: executeOptions.signal, timeoutMs: env.upstreamAttemptTimeoutMs, deadlineAt },
543
+ );
524
544
  if (retryResp?.response?.ok) {
525
545
  providerResponse = retryResp.response;
526
546
  providerUrl = retryResp.url;
@@ -541,6 +561,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
541
561
  );
542
562
  }
543
563
  } catch (error) {
564
+ if (isUpstreamTimeoutError(error)) {
565
+ recordFailure(error?.message || String(error), HTTP_STATUS.GATEWAY_TIMEOUT);
566
+ return createErrorResult(HTTP_STATUS.GATEWAY_TIMEOUT, "Upstream request timed out");
567
+ }
544
568
  if (requestSignal?.aborted || error?.name === "AbortError") {
545
569
  recordFailure("Request aborted", 499);
546
570
  return createErrorResult(499, "Request aborted");
@@ -578,10 +602,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
578
602
  recordFailure("Request aborted", 499);
579
603
  return createErrorResult(499, "Request aborted");
580
604
  }
581
- const retryResp = await executor.execute({
582
- ...executeOptions,
583
- attemptReason: "stream-preview",
584
- });
605
+ const retryResp = await executeWithAttemptDeadline(
606
+ (signal) => executor.execute({
607
+ ...executeOptions,
608
+ signal,
609
+ attemptReason: "stream-preview",
610
+ }),
611
+ { signal: executeOptions.signal, timeoutMs: env.upstreamAttemptTimeoutMs, deadlineAt },
612
+ );
585
613
  if (retryResp?.response?.ok) {
586
614
  providerResponse = retryResp.response;
587
615
  providerUrl = retryResp.url;
@@ -604,8 +632,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
604
632
  } catch (e) {
605
633
  log?.warn?.("A4.3", `retry threw: ${e?.message || e}`);
606
634
 
607
- recordFailure(e?.message || String(e), HTTP_STATUS.BAD_GATEWAY);
608
- return createErrorResult(HTTP_STATUS.BAD_GATEWAY, formatProviderError(e, provider, model, HTTP_STATUS.BAD_GATEWAY));
635
+ const status = isUpstreamTimeoutError(e) ? HTTP_STATUS.GATEWAY_TIMEOUT : HTTP_STATUS.BAD_GATEWAY;
636
+ recordFailure(e?.message || String(e), status);
637
+ return createErrorResult(status, isUpstreamTimeoutError(e) ? "Upstream request timed out" : formatProviderError(e, provider, model, status));
609
638
  }
610
639
  } else if (preview && (preview.kind === "committed" || preview.kind === "timeout" || preview.kind === "streamClosed")) {
611
640
 
package/src/lib/env.ts CHANGED
@@ -39,6 +39,9 @@ export interface EnvConfig {
39
39
  usageHistoryMaxRows: number;
40
40
 
41
41
  upstreamMaxAttempts: number;
42
+ upstreamAttemptTimeoutMs: number;
43
+ upstreamDeadlineMs: number;
44
+ maxAccountAttempts: number;
42
45
  }
43
46
 
44
47
  const DEFAULT_PASSWORD = "123456";
@@ -161,6 +164,21 @@ export function loadEnv(source: Record<string, string | undefined> = process.env
161
164
  nodeEnv === "production" ? resource.upstreamMaxAttempts : 8,
162
165
  100,
163
166
  ),
167
+ upstreamAttemptTimeoutMs: parsePositiveInt(
168
+ source.SWAY_UPSTREAM_ATTEMPT_TIMEOUT_MS,
169
+ 15_000,
170
+ 10 * 60 * 1000,
171
+ ),
172
+ upstreamDeadlineMs: parsePositiveInt(
173
+ source.SWAY_UPSTREAM_DEADLINE_MS,
174
+ 45_000,
175
+ 10 * 60 * 1000,
176
+ ),
177
+ maxAccountAttempts: parsePositiveInt(
178
+ source.SWAY_MAX_ACCOUNT_ATTEMPTS,
179
+ 3,
180
+ 100,
181
+ ),
164
182
  });
165
183
  }
166
184
 
@@ -38,7 +38,7 @@ export default {
38
38
  },
39
39
 
40
40
  usage: {
41
- url: "https://www.codebuddy.ai/billing/meter/get-user-resource",
41
+ url: "https://www.codebuddy.ai/v2/billing/meter/get-user-resource",
42
42
  },
43
43
  },
44
44
 
@@ -0,0 +1,49 @@
1
+ const MAX_ENTRIES = 4096;
2
+ const TTL_MS = 30 * 60 * 1000;
3
+
4
+ const signatures = new Map();
5
+
6
+ function keyFor(callId, sessionId, model) {
7
+ return `${String(sessionId)}\u0000${String(model)}\u0000${String(callId)}`;
8
+ }
9
+
10
+ function isUsable(value) {
11
+ return typeof value === "string" && value.length > 0 && value.length <= 64 * 1024;
12
+ }
13
+
14
+ function prune(now = Date.now()) {
15
+ for (const [key, entry] of signatures) {
16
+ if (entry.expiresAt <= now) signatures.delete(key);
17
+ }
18
+ while (signatures.size > MAX_ENTRIES) {
19
+ const oldest = signatures.keys().next().value;
20
+ if (oldest === undefined) break;
21
+ signatures.delete(oldest);
22
+ }
23
+ }
24
+
25
+ export function rememberGeminiThoughtSignature(callId, sessionId, model, signature) {
26
+ if (!isUsable(callId) || !isUsable(sessionId) || !isUsable(model) || !isUsable(signature)) return;
27
+ const key = keyFor(callId, sessionId, model);
28
+ signatures.delete(key);
29
+ signatures.set(key, { signature, expiresAt: Date.now() + TTL_MS });
30
+ prune();
31
+ }
32
+
33
+ export function getGeminiThoughtSignatureSync(callId, sessionId, model) {
34
+ if (!isUsable(callId) || !isUsable(sessionId) || !isUsable(model)) return null;
35
+ const key = keyFor(callId, sessionId, model);
36
+ const entry = signatures.get(key);
37
+ if (!entry) return null;
38
+ if (entry.expiresAt <= Date.now()) {
39
+ signatures.delete(key);
40
+ return null;
41
+ }
42
+ signatures.delete(key);
43
+ signatures.set(key, entry);
44
+ return entry.signature;
45
+ }
46
+
47
+ export function clearGeminiThoughtSignatures() {
48
+ signatures.clear();
49
+ }
@@ -240,10 +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 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
247
248
  ? Object.fromEntries(clientRawRequest.headers.entries?.() || [])
248
249
  : {};
249
250
  const settings = await getSettings();
@@ -311,12 +312,17 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
311
312
 
312
313
  const excludeConnectionIds = new Set();
313
314
  let lastError = null;
314
- let lastStatus = null;
315
-
316
- const cacheKey = extractCacheKey(body);
317
- let retryCount = 0;
318
-
319
- while (true) {
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
+
320
326
  const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, {
321
327
  cacheKey,
322
328
  preferredConnectionId,
@@ -348,9 +354,10 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
348
354
  credentials.retryAfter,
349
355
  credentials.retryAfterHuman || "retry shortly",
350
356
  );
351
- }
352
-
353
- const releaseAccountSlot = credentials.releaseAccountSlot;
357
+ }
358
+
359
+ accountAttempts += 1;
360
+ const releaseAccountSlot = credentials.releaseAccountSlot;
354
361
  let result;
355
362
  try {
356
363
  const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
@@ -381,8 +388,9 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
381
388
  cavemanLevel: chatSettings.cavemanLevel || "full",
382
389
  ponytailEnabled: !!chatSettings.ponytailEnabled,
383
390
  ponytailLevel: chatSettings.ponytailLevel || "full",
384
- providerThinking,
385
- requestSignal,
391
+ providerThinking,
392
+ requestSignal,
393
+ deadlineAt,
386
394
 
387
395
  sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
388
396
  onCredentialsRefreshed: async (newCreds) => {
@@ -406,21 +414,33 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
406
414
 
407
415
  releaseAccountSlot?.();
408
416
 
409
- if (isCallerCancellation({ requestSignal, status: result.status })) {
410
- return result.response;
411
- }
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
+ }
412
426
 
413
427
  const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs, { retryCount });
414
428
 
415
- if (shouldFallback) {
416
- log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`);
417
- excludeConnectionIds.add(credentials.connectionId);
418
- lastError = result.error;
419
- lastStatus = result.status;
420
- retryCount += 1;
421
- continue;
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;
422
440
  }
423
441
 
424
- return result.response;
425
- }
426
- }
442
+ return result.response;
443
+ }
444
+
445
+ return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Provider account fallback limit reached; retry shortly");
446
+ }
@@ -5,7 +5,8 @@ import { buildChunk } from "../concerns/chunk.js";
5
5
  import { toOpenAIUsage } from "../concerns/usage.js";
6
6
  import { reasoningDelta } from "../concerns/reasoning.js";
7
7
  import { encodeDataUri } from "../concerns/image.js";
8
- import { toOpenAIFinish } from "../concerns/finishReason.js";
8
+ import { toOpenAIFinish } from "../concerns/finishReason.js";
9
+ import { rememberGeminiThoughtSignature } from "../../services/thoughtSignatureStore.js";
9
10
 
10
11
  function chunkMeta(state) {
11
12
  return { id: `chatcmpl-${state.messageId}`, created: Math.floor(Date.now() / 1000), model: state.model };
@@ -47,9 +48,24 @@ export function geminiToOpenAIResponse(chunk, state) {
47
48
  }
48
49
 
49
50
  if (content?.parts) {
50
- for (const part of content.parts) {
51
- const hasThoughtSig = part.thoughtSignature || part.thought_signature;
52
- const isThought = part.thought === true;
51
+ for (const part of content.parts) {
52
+ const hasThoughtSig = part.thoughtSignature || part.thought_signature;
53
+ const isThought = part.thought === true;
54
+
55
+ if (
56
+ state.provider === "antigravity"
57
+ && state.sessionId
58
+ && (state.signatureModel || state.model)
59
+ && part.functionCall?.id
60
+ && hasThoughtSig
61
+ ) {
62
+ rememberGeminiThoughtSignature(
63
+ part.functionCall.id,
64
+ state.sessionId,
65
+ state.signatureModel || state.model,
66
+ hasThoughtSig,
67
+ );
68
+ }
53
69
 
54
70
  if (hasThoughtSig) {
55
71
  const hasTextContent = part.text !== undefined && part.text !== "";
@@ -101,9 +101,10 @@ export function createSSEStream(options = {}) {
101
101
  customToolNames = null,
102
102
  model = null,
103
103
  connectionId = null,
104
- body = null,
105
- onStreamComplete = null,
106
- apiKey = null
104
+ body = null,
105
+ onStreamComplete = null,
106
+ apiKey = null,
107
+ sessionId = null
107
108
  } = options;
108
109
 
109
110
  let buffer = "";
@@ -111,8 +112,8 @@ export function createSSEStream(options = {}) {
111
112
 
112
113
  const decoder = new TextDecoder("utf-8", { fatal: false });
113
114
 
114
- const state = mode === STREAM_MODE.TRANSLATE
115
- ? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model }
115
+ const state = mode === STREAM_MODE.TRANSLATE
116
+ ? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model, signatureModel: model, sessionId }
116
117
  : null;
117
118
 
118
119
  let totalContentLength = 0;
@@ -480,7 +481,7 @@ export function createSSEStream(options = {}) {
480
481
  });
481
482
  }
482
483
 
483
- export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null) {
484
+ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null, sessionId = null) {
484
485
  return createSSEStream({
485
486
  mode: STREAM_MODE.TRANSLATE,
486
487
  targetFormat,
@@ -493,9 +494,10 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
493
494
  connectionId,
494
495
  body,
495
496
  onStreamComplete,
496
- apiKey
497
- });
498
- }
497
+ apiKey,
498
+ sessionId
499
+ });
500
+ }
499
501
 
500
502
  export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
501
503
  return createSSEStream({
@@ -0,0 +1,67 @@
1
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 15_000;
2
+
3
+ export class UpstreamTimeoutError extends Error {
4
+ constructor(message, { kind = "attempt", timeoutMs = 0 } = {}) {
5
+ super(message);
6
+ this.name = "UpstreamTimeoutError";
7
+ this.code = "UPSTREAM_TIMEOUT";
8
+ this.kind = kind;
9
+ this.timeoutMs = timeoutMs;
10
+ }
11
+ }
12
+ export function isUpstreamTimeoutError(error) {
13
+ return error?.name === "UpstreamTimeoutError" || error?.code === "UPSTREAM_TIMEOUT";
14
+ }
15
+
16
+ function resolveTimeoutMs(value) {
17
+ const parsed = Number(value);
18
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ATTEMPT_TIMEOUT_MS;
19
+ }
20
+
21
+ export function createUpstreamAttemptSignal({ signal = null, timeoutMs, deadlineAt = null } = {}) {
22
+ const configuredTimeoutMs = resolveTimeoutMs(timeoutMs);
23
+ const remainingMs = deadlineAt == null ? Infinity : Number(deadlineAt) - Date.now();
24
+
25
+ if (remainingMs <= 0) {
26
+ throw new UpstreamTimeoutError("Upstream request deadline exceeded", {
27
+ kind: "deadline",
28
+ timeoutMs: 0,
29
+ });
30
+ }
31
+
32
+ const budgetMs = Math.max(1, Math.min(configuredTimeoutMs, remainingMs));
33
+ const kind = Number.isFinite(remainingMs) && remainingMs <= configuredTimeoutMs
34
+ ? "deadline"
35
+ : "attempt";
36
+ const timeoutError = new UpstreamTimeoutError(
37
+ kind === "deadline"
38
+ ? "Upstream request deadline exceeded"
39
+ : `Upstream attempt timed out after ${Math.ceil(budgetMs)}ms`,
40
+ { kind, timeoutMs: budgetMs },
41
+ );
42
+ const timeoutController = new AbortController();
43
+ const mergedSignal = signal
44
+ ? AbortSignal.any([signal, timeoutController.signal])
45
+ : timeoutController.signal;
46
+ const timer = setTimeout(() => timeoutController.abort(timeoutError), budgetMs);
47
+
48
+ return {
49
+ signal: mergedSignal,
50
+ timeoutError,
51
+ timeoutMs: budgetMs,
52
+ wasTimeout: () => timeoutController.signal.aborted && !signal?.aborted,
53
+ cleanup: () => clearTimeout(timer),
54
+ };
55
+ }
56
+
57
+ export async function executeWithAttemptDeadline(execute, options = {}) {
58
+ const attempt = createUpstreamAttemptSignal(options);
59
+ try {
60
+ return await execute(attempt.signal, attempt.timeoutMs);
61
+ } catch (error) {
62
+ if (attempt.wasTimeout()) throw attempt.timeoutError;
63
+ throw error;
64
+ } finally {
65
+ attempt.cleanup();
66
+ }
67
+ }