Compare commits

...

10 Commits

Author SHA1 Message Date
diegosouzapw
f18ad405cd test(mutation): register search-432-plan-limit-cooldown in tap.testFiles
The test (merged with the DuckDuckGo cooldown fix) covers accountFallback.ts and
auth.ts but was not listed, so check:mutation-test-coverage --strict reds any PR
whose merge ref includes it. Base also merged in.
2026-08-30 20:37:10 -03:00
diegosouzapw
761ab1b3f0 Merge remote-tracking branch 'origin/release/v3.8.51' into chore/12146-batch4-shared-components 2026-08-30 20:36:37 -03:00
Diego Rodrigues de Sa e Souza
7f49b342b5 chore(lint): batch 0 of #12146 — type the call-log-cap sqlite rows instead of 45 as-any casts (#12157)
Typed CallLogRow / PayloadEnvelope views over the raw rows and payload envelopes;
(assert as any).equal back to assert.equal. Suppression entry for the file removed —
the gate now watches it for real. eslint (CI command) clean, suite 15/15.

Refs #12146
2026-08-30 20:35:33 -03:00
diegosouzapw
9101b86f5c chore(lint): batch 4 of #12146 — resolve the react-hooks compiler violations in shared/components
Real refactors (no suppressions, no eslint-disable) for the 21 react-hooks/*
violations across the 11 src/shared/components files of this batch:

- set-state-in-effect (prop/state mirror or modal open/close reset):
  replaced with guarded render-time adjustments (react.dev "You Might Not
  Need an Effect" prev-tracking pattern) — KiroAuthModal,
  ModelSelectModal, ProxyConfigModal, OAuthModal (provider-change, close
  and open resets; ref invalidation split into ref-only effects),
  RequestLoggerDetail.sections (liveDetail mirror),
  ComboCompressionModeSelect (initialCompressionMode mirror).
- set-state-in-effect (fetch+set effects calling component-scope
  functions): moved the async loader inside the effect (ModelSelectModal
  fetchCombos/fetchProviderNodes/fetchCustomModels, PricingModal
  loadPricing, useProviderDailyUsage fetchRows — now with a cancelled
  guard) or wrapped the call in an effect-local async runner
  (ReasoningRoutingRules load, UsageStats fetchStats, OAuthModal
  startOAuthFlow) with every setState on the async path.
- OAuthModal device-code countdown: deviceCodeSecondsRemaining state
  deleted and derived from deviceCodeExpiresAt plus a `now` tick state
  updated by the interval (re-anchored when polling starts).
- Sidebar localStorage hydration: reads moved into useSyncExternalStore
  snapshots (server snapshot null) applied via render-time adjustment;
  skipInitialActiveExpansion ref converted to state; the active-section
  expansion effect became a render-time adjustment keyed on the old
  effect deps; persistence consolidated into one saveToStorage effect
  (removes the saves that ran inside setState updaters and drops a
  pre-existing eslint-disable for exhaustive-deps).
- immutability (use-before-declare): PricingModal loadPricing inlined
  into its effect; ProxyConfigModal resetFields hoisted above the load
  effect as a dependency-free useCallback.
- exhaustive-deps (ProxyConfigModal): effect now depends on the stable
  resetFields and on hoisted translated strings (socks5HiddenError,
  levelGlobalLabel) instead of the `t` identity.
- preserve-manual-memoization (UsageStats sortedAccounts): optional
  chains destructured into locals so the memo deps match the usage.

config/quality/eslint-suppressions.json: removed every react-hooks/*
entry for the 11 files (other-rule entries preserved).

Validation: eslint gate (--suppressions-location, --max-warnings 0) green
on all 11 files; typecheck:core clean; node unit sweep 373/373; vitest
sweep 547/550 with the 3 fails being 5s-timeout flakes under parallel
load (all pass isolated 8/8, one in an untouched file).

Refs #12146
2026-08-30 20:19:23 -03:00
小妍儿 ✨
897c3f8c9d fix(cli): register alias resolver hooks in-thread on modern runtimes (#12073) (#12083)
Validado em worktree combinada com typecheck limpo, testes focados verdes, gates de file-size/complexity/cognitive-complexity/cycles OK. Obrigado!
2026-08-30 19:40:14 -03:00
Wahid Sadik
4e4522c285 fix(sse): strip type:'custom' from Claude tools on agentrouter dispatch (#12126)
Validado em worktree combinada com typecheck limpo, testes focados verdes, gates de file-size/complexity/cognitive-complexity/cycles OK. Obrigado!
2026-08-30 19:39:50 -03:00
quiterunner-commits
9aa7c2459a fix(sse): stop advertising video providers the dispatcher cannot run (#12131)
Validado em worktree combinada com typecheck limpo, testes focados verdes, gates de file-size/complexity/cognitive-complexity/cycles OK. Obrigado!
2026-08-30 19:39:46 -03:00
Abhishek Divekar
8a1d9bf910 feat(resilience): default the credential health check sweep to 60 minutes (#12138)
Validado em worktree combinada com typecheck limpo, testes focados verdes, gates de file-size/complexity/cognitive-complexity/cycles OK. Obrigado!
2026-08-30 19:39:42 -03:00
Bob.Hou
ececf91e9e fix(search): treat HTTP 432 and plan limit errors as transient cooldown (#12139)
Validado em worktree combinada com typecheck limpo, testes focados verdes, gates de file-size/complexity/cognitive-complexity/cycles OK. Obrigado!
2026-08-30 19:39:36 -03:00
quiterunner-commits
43f2b2c288 fix(sse): refuse an AI Horde queue that cannot fit the request budget (#12143)
Validado em worktree combinada com typecheck limpo, testes focados verdes, gates de file-size/complexity/cognitive-complexity/cycles OK. Obrigado!
2026-08-30 19:39:33 -03:00
34 changed files with 1527 additions and 367 deletions

View File

@@ -176,13 +176,14 @@ function isWithinRoot(ancestor, candidate) {
* Register the ESM resolve hook for the current process. Safe to call multiple
* times — subsequent calls are no-ops once the hook is installed.
*
* Uses Node's stable `module.register()` API (available since Node 20.6,
* required Node 22+ here). The hook runs in a worker thread but only reads the
* captured `root`, so no shared-state hazards.
* Modern runtimes import the hook module in-thread, initialize its root with a
* plain function call, and register its synchronous resolver through
* `module.registerHooks()`. Runtimes without that API (notably Bun) retain the
* `module.register()` worker-thread loader lifecycle path.
*
* @param {string} root Absolute path to the package root.
* @returns {Promise<boolean>} Resolves `true` once registered (or if already
* registered), `false` on environments where `module.register` is unavailable.
* registered), `false` when neither registration API is usable.
*/
let _registered = false;
export async function registerAliasResolver(root) {
@@ -201,7 +202,7 @@ export async function registerAliasResolver(root) {
}
try {
const { register } = await import("node:module");
const mod = await import("node:module");
// #7808: load the hook from a real file on disk via pathToFileURL() instead
// of building a `data:text/javascript,...` URL dynamically. CodeQL's
// `js/incomplete-url-substring-sanitization` flagged the interpolated
@@ -211,14 +212,21 @@ export async function registerAliasResolver(root) {
// package.json "files": ["bin/"].
const hookPath = join(__dirname, "aliasResolverHook.mjs");
const hookUrl = pathToFileURL(hookPath);
register(hookUrl, { data: { root } });
if (typeof mod.registerHooks === "function") {
const hook = await import(hookUrl.href);
hook.initialize({ root });
mod.registerHooks({ resolve: hook.resolve });
_registered = true;
return true;
}
mod.register(hookUrl, { data: { root } });
_registered = true;
return true;
} catch {
// Older Node or sandboxed env without module.register — fall back to the
// default resolver. The bug will resurface only in the exact global-install
// scenario, which is what we explicitly patched; other entry points still
// work because they import via relative paths.
// Runtime or sandboxed env without a usable module hook API — fall back to
// the default resolver. The bug will resurface only in the exact
// global-install scenario, which is what we explicitly patched; other entry
// points still work because they import via relative paths.
return false;
}
}

View File

@@ -0,0 +1 @@
- **fix(cli):** use in-thread alias resolver hooks on modern runtimes to avoid deprecation noise and improve Node.js forward compatibility ([#12073](https://github.com/diegosouzapw/OmniRoute/issues/12073)).

View File

@@ -2595,9 +2595,6 @@
"src/shared/components/KiroAuthModal.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/LanguageSelector.tsx": {
@@ -2605,11 +2602,6 @@
"count": 1
}
},
"src/shared/components/ModelSelectModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/shared/components/NotificationToast.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -2618,28 +2610,11 @@
"src/shared/components/OAuthModal.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/shared/components/PricingModal.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
},
"src/shared/components/ProxyConfigModal.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/ProxyLogDetail.tsx": {
@@ -2647,16 +2622,6 @@
"count": 1
}
},
"src/shared/components/ReasoningRoutingRules.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/RequestLoggerDetail.sections.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/RequestLoggerV2.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
@@ -2676,17 +2641,6 @@
},
"@typescript-eslint/no-unused-vars": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/shared/components/UsageStats.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/analytics/charts.tsx": {
@@ -2699,16 +2653,6 @@
"count": 1
}
},
"src/shared/components/analytics/useProviderDailyUsage.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/compression/ComboCompressionModeSelect.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/docs/CodeBlock.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -3552,11 +3496,6 @@
"count": 1
}
},
"tests/unit/call-log-cap.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 45
}
},
"tests/unit/call-log-startup.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1

View File

@@ -175,6 +175,7 @@ export const HTTP_STATUS = {
REQUEST_TIMEOUT: 408,
GONE: 410,
RATE_LIMITED: 429,
PLAN_LIMIT_EXCEEDED: 432,
SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,

View File

@@ -190,6 +190,13 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "pollinations-video",
// Живая проверка 2026-08-30: диспетчер videoGeneration.ts не разбирает
// "pollinations-video" и отвечает 400 Unsupported video format — модель
// висела в выдаче каталога, но не исполнялась ни при каких ключах.
unsupported: true,
unsupportedReason:
"Pollinations video has no submit/poll transport in the dispatcher yet. " +
"Use an image model or another video provider until one is added.",
models: [{ id: "default", name: "Pollinations Video (Free)" }],
},
@@ -200,6 +207,12 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "minimax-video",
// Живая проверка 2026-08-30: 400 Unsupported video format на всех трёх
// моделях Hailuo. Свой submit → query API, не покрытый job-пресетами.
unsupported: true,
unsupportedReason:
"MiniMax video uses its own submit/query transport that the dispatcher " +
"does not implement yet. Generate video via another provider for now.",
models: [
{ id: "MiniMax-Hailuo-2.3", name: "Hailuo 2.3" },
{ id: "MiniMax-Hailuo-02", name: "Hailuo 02" },
@@ -214,6 +227,12 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "together-video",
// Не рекламируется по той же причине, что pollinations/minimax: формат
// объявлен, ветки в диспетчере нет (проверено разбором 2026-08-30).
unsupported: true,
unsupportedReason:
"Together video has no transport in the dispatcher yet. " +
"Use another video provider until one is added.",
models: [
{ id: "wan-ai/wan2.1-t2v-480p", name: "Wan 2.1 T2V 480p" },
{ id: "wan-ai/wan2.7-t2v", name: "Wan 2.7 T2V" },
@@ -227,6 +246,12 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "replicate-video",
// Не рекламируется: формат объявлен, ветки в диспетчере нет
// (проверено разбором 2026-08-30).
unsupported: true,
unsupportedReason:
"Replicate video has no prediction submit/poll transport in the " +
"dispatcher yet. Use another video provider until one is added.",
models: [
{ id: "minimax/video-01", name: "MiniMax Video 01" },
{ id: "wan-ai/wan2.1-t2v-480p", name: "Wan 2.1 T2V" },
@@ -394,7 +419,20 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
baseUrl: "https://nano-gpt.com/api/v1/video/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai",
// Диспетчер знает формат под именем "openai-video" — под "openai" ветки нет,
// и провайдер отдавал 400 Unsupported video format (живая проверка
// 2026-08-30). Тот же обработчик обслуживает кастомные OpenAI-совместимые
// ноды, а baseUrl выше — ровно их путь.
format: "openai-video",
// Живая проверка 2026-08-30: адрес выше отдаёт 404 (HTML-страница), как и
// вариант во множественном числе /api/v1/videos/generations. Контроль на том
// же ключе: /api/v1/images/generations отвечает 401 JSON — то есть 404 здесь
// значит «маршрута нет», а не «ключ не тот». Формат исправлен на рабочее имя
// заранее, чтобы провайдер ожил правкой одного адреса, когда он появится.
unsupported: true,
unsupportedReason:
"NanoGPT video endpoint returns 404 — no video route is published under " +
"/api/v1/video(s)/generations. Use another video provider.",
models: [{ id: "default", name: "NanoGPT Video" }],
},
};

View File

@@ -131,7 +131,7 @@ import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
import { resolveOmniGlyphTransport } from "../services/compression/imageTransportPolicy.ts";
import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts";
import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
import { normalizeClaudeToolsForDispatch } from "./chatCore/claudeToolDefaults.ts";
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -2615,9 +2615,13 @@ export async function handleChatCore({
// definitions that omit the required `type` discriminator with HTTP 400. Default
// a missing `type` to "custom" before dispatch, mirroring Anthropic's own
// inference, so legacy Claude-format tool payloads survive strict gateways (#2195).
// AgentRouter is the opposite quirk: its Rust deserializer only accepts versioned
// tool types and 400s on `type: "custom"` — there the discriminator is stripped
// instead (see claudeToolDefaults.ts).
if (targetFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
translatedBody.tools = defaultClaudeToolType(
translatedBody.tools
translatedBody.tools = normalizeClaudeToolsForDispatch(
translatedBody.tools,
provider
) as typeof translatedBody.tools;
}

View File

@@ -25,3 +25,41 @@ export function defaultClaudeToolType(tools: unknown): unknown {
return tool;
});
}
/**
* Strip the `type: "custom"` discriminator from Claude-format tools, leaving every
* other field (name, description, input_schema, …) untouched. AgentRouter's upstream
* (New-API, Rust serde) only accepts versioned tool types (`web_search_20250305`,
* `web_search_20260209`); plain tools must omit `type` entirely, so `type: "custom"` —
* whether client-declared (Claude Code v2.1+) or backfilled by defaultClaudeToolType()
* (#2195) — is a hard 400 "unknown variant `custom`" that crashes the client session.
* Versioned/built-in types are preserved; typeless entries stay typeless. Non-object
* entries pass through untouched (same rationale as defaultClaudeToolType).
*/
export function stripClaudeCustomToolType(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => {
if (
tool &&
typeof tool === "object" &&
!Array.isArray(tool) &&
(tool as UnknownRecord).type === "custom"
) {
const { type: _stripped, ...rest } = tool as UnknownRecord;
return rest;
}
return tool;
});
}
/**
* Per-provider dispatch decision for Claude-format tool normalization. AgentRouter
* rejects `type: "custom"` (see stripClaudeCustomToolType) while strict gateways like
* MiniMax REQUIRE the explicit discriminator (#2195) — the two quirks are mutually
* exclusive, so the normalization is provider-scoped, never global.
*/
export function normalizeClaudeToolsForDispatch(tools: unknown, provider: string): unknown {
return provider === "agentrouter"
? stripClaudeCustomToolType(tools)
: defaultClaudeToolType(tools);
}

View File

@@ -17,7 +17,11 @@ import {
} from "./aihordeMapRequest.ts";
const GENERATE_TIMEOUT_MS = 600_000;
const POLL_INTERVAL_MS = 1_000;
// Опрос начинается частым и разряжается по мере ожидания: короткая очередь
// отдаёт картинку за секунды, а длинная иначе стоила бы Horde сотен запросов
// по общему анонимному ключу (600 опросов на один кадр при полном бюджете).
const POLL_INTERVAL_MIN_MS = 1_000;
const POLL_INTERVAL_MAX_MS = 8_000;
// Per-call bound for the Horde API's own submit/check/status/cancel calls
// (a fixed, trusted host — no SSRF guard needed, just a hard timeout so a
// hung upstream cannot stall a request indefinitely). Individual calls are
@@ -119,6 +123,11 @@ async function fetchHordeImageBytes(
return value;
}
/** Числовое поле ответа Horde: отсутствующее или нечисловое читается как «неизвестно». */
function numericField(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export async function handleAiHordeImageGeneration({
model,
provider,
@@ -212,19 +221,23 @@ export async function handleAiHordeImageGeneration({
}
let completed = false;
let pollDelayMs = POLL_INTERVAL_MIN_MS;
try {
while (true) {
if (signal?.aborted) throw new Error("Horde image generation cancelled");
if (Date.now() >= deadline) {
throw Object.assign(new Error("Horde image generation timed out"), { status: 504 });
}
await sleep(POLL_INTERVAL_MS);
const checkRes = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/check/${jobId}`, {
headers: hordeHeaders(apiKey),
signal: signal ?? undefined,
guard: "none",
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
});
await sleep(pollDelayMs);
const checkRes = await safeOutboundFetch(
`${AI_HORDE_API_BASE}/v2/generate/check/${jobId}`,
{
headers: hordeHeaders(apiKey),
signal: signal ?? undefined,
guard: "none",
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
}
);
const check = await safeJson(checkRes);
if (!checkRes.ok || !check || typeof check !== "object") {
throw Object.assign(
@@ -239,14 +252,55 @@ export async function handleAiHordeImageGeneration({
status: 503,
});
}
if (!checkObj.done) continue;
if (!checkObj.done) {
// Horde сообщает оценку ожидания в первом же ответе. Если она не
// помещается в остаток бюджета, ждать нечего: запрос всё равно
// упал бы по таймауту, только молча и десятью минутами позже.
// Отказ называет очередь и число воркеров — по ним видно, что
// выручает не терпение, а модель с большим числом воркеров.
const waitSeconds = numericField(checkObj.wait_time);
const remainingMs = deadline - Date.now();
if (waitSeconds !== null && waitSeconds * 1_000 > remainingMs) {
const queuePosition = numericField(checkObj.queue_position);
const workers = numericField(checkObj.eligible_workers);
const details = [
`queue wait ~${Math.round(waitSeconds)}s`,
queuePosition !== null ? `position ${queuePosition}` : null,
workers !== null ? `${workers} eligible worker(s)` : null,
`budget ${Math.round(remainingMs / 1_000)}s left`,
]
.filter(Boolean)
.join(", ");
throw Object.assign(
new Error(
`Horde queue is longer than the request budget (${details}). ` +
`Pick a model with more workers or raise the timeout.`
),
{ status: 504 }
);
}
// Разрядка опроса: десятая доля оставшегося ожидания, в рамках
// минимума и максимума. Короткая очередь по-прежнему опрашивается
// раз в секунду.
pollDelayMs =
waitSeconds === null
? POLL_INTERVAL_MIN_MS
: Math.min(
POLL_INTERVAL_MAX_MS,
Math.max(POLL_INTERVAL_MIN_MS, Math.round((waitSeconds * 1_000) / 10))
);
continue;
}
const statusRes = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/status/${jobId}`, {
headers: hordeHeaders(apiKey),
signal: signal ?? undefined,
guard: "none",
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
});
const statusRes = await safeOutboundFetch(
`${AI_HORDE_API_BASE}/v2/generate/status/${jobId}`,
{
headers: hordeHeaders(apiKey),
signal: signal ?? undefined,
guard: "none",
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
}
);
const status = await safeJson(statusRes);
if (!statusRes.ok || !status || typeof status !== "object") {
throw Object.assign(

View File

@@ -11,9 +11,27 @@
import { saveCallLog } from "@/lib/usageDb";
import { sanitizeErrorMessage } from "../../utils/error.ts";
import { formatSearchProviderFailure } from "./providerFailure.ts";
import { HTTP_STATUS } from "../../config/constants.ts";
import { isSubscriptionQuotaText } from "../../services/quotaTextCooldowns.ts";
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
import type { SearchResult } from "../search.ts";
const SEARCH_COOLDOWN_STATUSES = new Set([
HTTP_STATUS.PAYMENT_REQUIRED,
HTTP_STATUS.REQUEST_TIMEOUT,
HTTP_STATUS.RATE_LIMITED,
HTTP_STATUS.PLAN_LIMIT_EXCEEDED,
HTTP_STATUS.SERVER_ERROR,
HTTP_STATUS.BAD_GATEWAY,
HTTP_STATUS.SERVICE_UNAVAILABLE,
HTTP_STATUS.GATEWAY_TIMEOUT,
]);
export function shouldCoolDownSearchConnection(status: number, errorText: string): boolean {
if (SEARCH_COOLDOWN_STATUSES.has(status)) return true;
return isSubscriptionQuotaText(errorText.toLowerCase());
}
/** Resolved proxy binding for a single provider attempt. */
export interface ResolvedSearchProxy {
proxy: unknown;
@@ -196,6 +214,14 @@ export async function executeProviderFetch(
if (log) {
log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`);
}
if (connectionId && shouldCoolDownSearchConnection(response.status, errorText)) {
try {
const { markAccountUnavailable } = await import("@/sse/services/auth.ts");
await markAccountUnavailable(connectionId, response.status, errorText, config.id, null);
} catch {
/* non-critical - background cooldown mark must not break search response */
}
}
logCall({
status: response.status,
duration: Date.now() - startTime,

View File

@@ -35,6 +35,11 @@ function resolveVideoEndpoint(credentials: unknown, fallback: string): string {
? creds.baseUrl.trim()
: null;
const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl;
// Узел своего адреса может не иметь — у встроенных провайдеров его и не
// бывает. Тогда работает `fallback`: это готовый endpoint из реестра, а не
// корень, поэтому путь к нему не дописывается (у nanogpt адрес оканчивается
// на /video/generations — в единственном числе).
if (!nodeBaseUrl) return fallback;
let n = nodeBaseUrl;
while (n.endsWith("/")) n = n.slice(0, -1);
if (n.endsWith("/videos/generations")) return n;

View File

@@ -77,6 +77,7 @@ import {
buildSubscriptionQuotaFallback,
buildWeeklyQuotaFallback,
buildSessionQuotaFallback,
SUBSCRIPTION_QUOTA_COOLDOWN_MS,
} from "./quotaTextCooldowns.ts";
import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts";
import { evictLockoutOverflow } from "./accountFallback/lockoutEviction.ts";
@@ -1560,7 +1561,7 @@ export function classifyError(
if (status === HTTP_STATUS.UNAUTHORIZED || status === HTTP_STATUS.FORBIDDEN) {
return RateLimitReason.AUTH_ERROR;
}
if (status === HTTP_STATUS.PAYMENT_REQUIRED) {
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.PLAN_LIMIT_EXCEEDED) {
return RateLimitReason.QUOTA_EXHAUSTED;
}
if (status === HTTP_STATUS.RATE_LIMITED) {
@@ -2131,6 +2132,24 @@ export function checkFallbackError(
return buildRetryableFallback(RateLimitReason.SERVER_ERROR);
}
// 432 -- plan limit reached (e.g. Tavily, Context7, and search upstreams)
if (status === HTTP_STATUS.PLAN_LIMIT_EXCEEDED) {
const subResult = buildSubscriptionQuotaFallback(
errorStr,
() => getUpstreamRetryHint()?.retryAfterMs ?? null,
parseRetryFromErrorText,
provider
);
if (subResult) return subResult;
const cooldownMs = getUpstreamRetryHint()?.retryAfterMs ?? SUBSCRIPTION_QUOTA_COOLDOWN_MS;
return {
shouldFallback: true,
cooldownMs,
baseCooldownMs: cooldownMs,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}
// 400 — context overflow / malformed request / model access denied
if (status === HTTP_STATUS.BAD_REQUEST) {
// Check structured error codes first (more reliable, no false positives)

View File

@@ -36,6 +36,11 @@ export function isSubscriptionQuotaText(lower: string, provider?: string | null)
lower.includes("claude pro usage limit") ||
lower.includes("you've reached your usage limit") ||
lower.includes("you have reached your usage limit") ||
lower.includes("exceeds your plan") ||
lower.includes("plan limit") ||
lower.includes("plan's set usage limit") ||
lower.includes("plan limit exceeded") ||
lower.includes("usage limit exceeded") ||
// Native Claude OAuth uses this otherwise-generic 429 wording for an
// exhausted subscription window. Keep it provider-scoped: other upstreams
// can use the same phrase for a short RPM throttle.
@@ -43,7 +48,7 @@ export function isSubscriptionQuotaText(lower: string, provider?: string | null)
);
}
const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
export const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
/**
* Builds the QUOTA_EXHAUSTED fallback for the subscription-quota text above.

View File

@@ -129,7 +129,7 @@ export function resolveCredentialHealthSweepInterval(
const parsed = parseInt(envVal, 10);
if (!isNaN(parsed) && parsed >= 10_000) return parsed;
}
return 300_000; // default 5 min
return 3_600_000; // default 60 min
}
/**
@@ -142,7 +142,7 @@ function getSweepInterval(): number {
const parsed = parseInt(envVal, 10);
if (!isNaN(parsed) && parsed >= 10_000) return parsed;
}
return 300_000; // default 5 min
return 3_600_000; // default 60 min
}
/**

View File

@@ -184,10 +184,11 @@ export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = {
// default until an operator adds an override here.
providerQuotaOverrides: {},
// Global default cadence for the background credential health check sweep.
// 5 minutes preserves the pre-setting scheduler default (300 000 ms);
// 0 disables the sweep entirely. Per-connection overrides always win.
// 60 minutes: the sweep makes a real upstream probe against EVERY active
// connection, so the previous 5-minute default cost 12 requests/hour per
// connection. 0 disables the sweep entirely. Per-connection overrides win.
credentialHealthCheck: {
intervalMinutes: 5,
intervalMinutes: 60,
},
};

View File

@@ -37,16 +37,21 @@ export default function KiroAuthModal({
const [importingApiKey, setImportingApiKey] = useState(false);
const [autoDetecting, setAutoDetecting] = useState(false);
useEffect(() => {
if (isOpen) return;
setSelectedMethod(null);
setIdcStartUrl("");
setIdcRegion("us-east-1");
setRefreshToken("");
setApiKey("");
setApiKeyRegion("us-east-1");
setError(null);
}, [isOpen]);
// Reset the form when the modal closes (render-time adjustment per react.dev
// "You Might Not Need an Effect" — replaces the old reset effect).
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setSelectedMethod(null);
setIdcStartUrl("");
setIdcRegion("us-east-1");
setRefreshToken("");
setApiKey("");
setApiKeyRegion("us-east-1");
setError(null);
}
}
// Auto-detect token when import method is selected
useEffect(() => {

View File

@@ -148,64 +148,68 @@ export default function ModelSelectModal({
const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null);
const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});
const fetchCombos = async () => {
try {
const res = await fetch("/api/combos");
if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`);
const data = await res.json();
setCombos(data.combos || []);
} catch (error) {
console.error("Error fetching combos:", error);
setCombos([]);
}
};
useEffect(() => {
if (isOpen) fetchCombos();
if (!isOpen) return;
const fetchCombos = async () => {
try {
const res = await fetch("/api/combos");
if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`);
const data = await res.json();
setCombos(data.combos || []);
} catch (error) {
console.error("Error fetching combos:", error);
setCombos([]);
}
};
fetchCombos();
}, [isOpen]);
// Reset provider-test bookkeeping whenever the modal closes so the next
// open starts from a clean selection / progress state.
// open starts from a clean selection / progress state (render-time
// adjustment per react.dev "You Might Not Need an Effect").
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setSelectedProviderIds(new Set());
setTestingProviders(false);
setTestProgress(null);
setModelTestStatus({});
}
}
useEffect(() => {
if (isOpen) return;
setSelectedProviderIds(new Set());
setTestingProviders(false);
setTestProgress(null);
setModelTestStatus({});
if (!isOpen) return;
const fetchProviderNodes = async () => {
try {
const res = await fetch("/api/provider-nodes");
if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`);
const data = await res.json();
setProviderNodes(data.nodes || []);
} catch (error) {
console.error("Error fetching provider nodes:", error);
setProviderNodes([]);
}
};
fetchProviderNodes();
}, [isOpen]);
const fetchProviderNodes = async () => {
try {
const res = await fetch("/api/provider-nodes");
if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`);
const data = await res.json();
setProviderNodes(data.nodes || []);
} catch (error) {
console.error("Error fetching provider nodes:", error);
setProviderNodes([]);
}
};
useEffect(() => {
if (isOpen) fetchProviderNodes();
}, [isOpen]);
const fetchCustomModels = async () => {
try {
const res = await fetch("/api/provider-models");
if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`);
const data = await res.json();
setCustomModels(data.models || {});
// #9203: keep the unified hidden-model map in sync with the model list.
setHiddenModelsByProvider(parseHiddenModelsByProvider(data.hiddenModelsByProvider));
} catch (error) {
console.error("Error fetching custom models:", error);
setCustomModels({});
}
};
useEffect(() => {
if (isOpen) fetchCustomModels();
if (!isOpen) return;
const fetchCustomModels = async () => {
try {
const res = await fetch("/api/provider-models");
if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`);
const data = await res.json();
setCustomModels(data.models || {});
// #9203: keep the unified hidden-model map in sync with the model list.
setHiddenModelsByProvider(parseHiddenModelsByProvider(data.hiddenModelsByProvider));
} catch (error) {
console.error("Error fetching custom models:", error);
setCustomModels({});
}
};
fetchCustomModels();
}, [isOpen]);
// Fetch the live model catalog for one custom provider from its connection's

View File

@@ -144,7 +144,9 @@ export default function OAuthModal({
const [gheUrl, setGheUrl] = useState("");
const [polling, setPolling] = useState(false);
const [deviceCodeExpiresAt, setDeviceCodeExpiresAt] = useState<number | null>(null);
const [deviceCodeSecondsRemaining, setDeviceCodeSecondsRemaining] = useState<number | null>(null);
// Wall-clock tick driving the device-code countdown; ticked by the interval
// effect below and re-anchored whenever a device flow (re)starts.
const [now, setNow] = useState(() => Date.now());
// API-key paste mode for direct-token providers.
const [showPasteToken, setShowPasteToken] = useState(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider));
const [pasteToken, setPasteToken] = useState("");
@@ -204,7 +206,6 @@ export default function OAuthModal({
deviceFlowRunRef.current += 1;
setPolling(false);
setDeviceCodeExpiresAt(null);
setDeviceCodeSecondsRemaining(null);
}, []);
// Define all useCallback hooks BEFORE the useEffects that reference them
@@ -323,6 +324,7 @@ export default function OAuthModal({
setPolling(true);
setDeviceCodeExpiresAt(deadline);
setNow(Date.now());
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, currentInterval * 1000));
@@ -633,33 +635,50 @@ export default function OAuthModal({
);
useEffect(() => {
if (!deviceCodeExpiresAt) {
setDeviceCodeSecondsRemaining(null);
return;
}
const updateRemaining = () => {
setDeviceCodeSecondsRemaining(
Math.max(0, Math.ceil((deviceCodeExpiresAt - Date.now()) / 1000))
);
};
updateRemaining();
const timer = window.setInterval(updateRemaining, 1000);
if (!deviceCodeExpiresAt) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [deviceCodeExpiresAt]);
useEffect(() => {
invalidateDeviceFlow();
flowStartedRef.current = false;
// Derived countdown (replaces the old mirrored deviceCodeSecondsRemaining
// state): `now` is re-anchored when the device flow starts and ticked by the
// interval effect above.
const deviceCodeSecondsRemaining =
deviceCodeExpiresAt == null ? null : Math.max(0, Math.ceil((deviceCodeExpiresAt - now) / 1000));
// When the provider changes, reset the flow state during render (react.dev
// "You Might Not Need an Effect") and invalidate any in-flight device flow
// in a ref-only effect (refs must not be written during render).
const [prevProvider, setPrevProvider] = useState(provider);
if (provider !== prevProvider) {
setPrevProvider(provider);
setPolling(false);
setDeviceCodeExpiresAt(null);
setGrokBrowserMode(false);
}, [provider, invalidateDeviceFlow]);
}
useEffect(() => {
deviceFlowRunRef.current += 1;
flowStartedRef.current = false;
}, [provider]);
// Same split when the modal closes: state reset during render, ref
// invalidation in a ref-only effect.
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setPolling(false);
setDeviceCodeExpiresAt(null);
}
}
useEffect(() => {
if (!isOpen) {
invalidateDeviceFlow();
deviceFlowRunRef.current += 1;
flowStartedRef.current = false;
}
}, [isOpen, invalidateDeviceFlow]);
}, [isOpen]);
useEffect(
() => () => {
@@ -668,26 +687,43 @@ export default function OAuthModal({
[]
);
// Reset state and start OAuth when modal opens
// Reset state and start OAuth when modal opens. The synchronous state resets
// moved from the old effect into this render-time adjustment (react.dev
// "You Might Not Need an Effect"); the flow itself starts in the effect below.
const [prevStartKey, setPrevStartKey] = useState<string | null>(null);
const startKey = isOpen && provider ? String(provider) : null;
if (startKey !== prevStartKey) {
setPrevStartKey(startKey);
if (startKey) {
setShowPasteToken(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider));
setGrokBrowserMode(false);
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
// #8688: show GitLab Duo OAuth app / env setup before authorize error.
if (provider === "gitlab-duo") {
setStep("gitlab-duo-setup");
}
}
}
useEffect(() => {
if (!isOpen || !provider || flowStartedRef.current) return;
flowStartedRef.current = true;
const startsInPasteMode = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider);
// #8688: show GitLab Duo OAuth app / env setup before authorize error.
const startsInGitlabDuoSetup = provider === "gitlab-duo";
setShowPasteToken(startsInPasteMode);
setGrokBrowserMode(false);
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
if (startsInGitlabDuoSetup) {
setStep("gitlab-duo-setup");
// Auto-start is skipped — setStep("gitlab-duo-setup") already happened
// in the render-time adjustment above (#8688).
return;
}
if (!startsInPasteMode) startOAuthFlow();
flowStartedRef.current = true;
const run = async () => {
if (!startsInPasteMode) startOAuthFlow();
};
run();
}, [isOpen, provider, startOAuthFlow]);
// Listen for OAuth callback via multiple methods

View File

@@ -11,31 +11,29 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
loadPricing();
}
}, [isOpen]);
const loadPricing = async () => {
setLoading(true);
try {
const response = await fetch("/api/pricing");
if (response.ok) {
const data = await response.json();
setPricingData(data);
} else {
// Fallback to defaults
if (!isOpen) return;
const loadPricing = async () => {
setLoading(true);
try {
const response = await fetch("/api/pricing");
if (response.ok) {
const data = await response.json();
setPricingData(data);
} else {
// Fallback to defaults
const defaults = getDefaultPricing();
setPricingData(defaults);
}
} catch (error) {
console.error("Failed to load pricing:", error);
const defaults = getDefaultPricing();
setPricingData(defaults);
} finally {
setLoading(false);
}
} catch (error) {
console.error("Failed to load pricing:", error);
const defaults = getDefaultPricing();
setPricingData(defaults);
} finally {
setLoading(false);
}
};
};
loadPricing();
}, [isOpen]);
const handlePricingChange = (provider, model, field, value) => {
const numValue = parseFloat(value);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
@@ -23,7 +23,9 @@ const BUILD_TIME_SOCKS5 = !["false", "0", "no", "off"].includes(
(process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase()
);
export function buildProxyTypes(socks5Enabled: boolean) {
return socks5Enabled ? ALL_PROXY_TYPES : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
return socks5Enabled
? ALL_PROXY_TYPES
: ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
}
type ProxyConfigLevel = "global" | "provider" | "combo" | "key";
@@ -152,12 +154,40 @@ export default function ProxyConfigModal({
return "8080";
};
// Reset transient state when the modal opens (render-time adjustment per
// react.dev "You Might Not Need an Effect" — replaces the synchronous
// setStates the load effect used to issue).
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (isOpen) {
setTestResult(null);
setFormError(null);
setLoading(true);
}
}
const resetFields = useCallback(() => {
// ALL_PROXY_TYPES[0] is "http" whether or not SOCKS5 is enabled, so this
// reset has no reactive dependencies and stays referentially stable.
setProxyType(ALL_PROXY_TYPES[0].value);
setHost("");
setPort("");
setUsername("");
setPassword("");
setShowAuth(false);
setFormError(null);
}, []);
// Translated strings the load effect needs, hoisted so the effect can depend
// on stable string values instead of the `t` function identity (an unstable
// `t` — e.g. the test mock — would otherwise re-run the load loop forever).
const socks5HiddenError = t("errorSocks5Hidden");
const levelGlobalLabel = t("levelGlobal");
// Load existing proxy config when modal opens
useEffect(() => {
if (!isOpen) return;
setTestResult(null);
setFormError(null);
setLoading(true);
const loadProxy = async () => {
try {
@@ -196,7 +226,9 @@ export default function ProxyConfigModal({
const assignedProxy = registryItems.find((item) => item.id === target.proxyId);
if (assignedProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) {
const normalizedType = String(assignedProxy.type || "http").toLowerCase();
const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType);
const hasTypeOption = runtimeProxyTypes.some(
(entry) => entry.value === normalizedType
);
setMode("custom");
setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http");
setHost(assignedProxy.host || "");
@@ -209,7 +241,7 @@ export default function ProxyConfigModal({
);
setShowAuth(!!(assignedProxy.username || assignedProxy.password));
if (normalizedType === "socks5" && !runtimeSocks5) {
setFormError(t("errorSocks5Hidden"));
setFormError(socks5HiddenError);
}
} else {
setMode("saved");
@@ -242,7 +274,7 @@ export default function ProxyConfigModal({
setShowAuth(!!(proxy.username || proxy.password));
setHasOwnProxy(true);
if (normalizedType === "socks5" && !runtimeSocks5) {
setFormError(t("errorSocks5Hidden"));
setFormError(socks5HiddenError);
}
if (!hasSavedAssignment) setMode("custom");
} else {
@@ -263,14 +295,14 @@ export default function ProxyConfigModal({
if (level === "key") {
// Check combo, provider, global
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
// Provider info requires more context, showing global as fallback
} else if (level === "combo") {
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
} else if (level === "provider") {
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
}
}
}
@@ -282,17 +314,7 @@ export default function ProxyConfigModal({
};
loadProxy();
}, [isOpen, level, levelId]);
const resetFields = () => {
setProxyType(proxyTypes[0]?.value || "http");
setHost("");
setPort("");
setUsername("");
setPassword("");
setShowAuth(false);
setFormError(null);
};
}, [isOpen, level, levelId, resetFields, socks5HiddenError, levelGlobalLabel]);
const handleSave = async () => {
if (mode === "saved" && !selectedProxyId) {

View File

@@ -132,7 +132,14 @@ export default function ReasoningRoutingRules({ apiKeyId }: { apiKeyId?: string
}, [t]);
useEffect(() => {
load().catch(() => setMessage(t("loadError")));
const run = async () => {
try {
await load();
} catch {
setMessage(t("loadError"));
}
};
run();
}, [load, t]);
const visibleRules = useMemo(() => {

View File

@@ -155,9 +155,13 @@ export function ConversationContextSection({ log, detail }) {
});
const turnsBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Adjust state when the `detail` prop changes (render-time adjustment per
// react.dev "You Might Not Need an Effect" — replaces the old mirror effect).
const [prevDetail, setPrevDetail] = useState(detail);
if (detail !== prevDetail) {
setPrevDetail(detail);
setLiveDetail(detail);
}, [detail]);
}
// Same live-poll pattern as the SSE Events section (StreamSection below),
// but gated on liveRefresh too: an active request keeps generating either

View File

@@ -1,6 +1,13 @@
"use client";
import { useState, useEffect, useRef, useCallback, type CSSProperties } from "react";
import {
useState,
useEffect,
useRef,
useCallback,
useSyncExternalStore,
type CSSProperties,
} from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn";
@@ -59,11 +66,10 @@ type SidebarProps = {
type HoveredItem = { id: string; label: string; x: number; y: number } | null;
function loadFromStorage<T>(key: string, fallback: T): T {
function parseStoredArray<T>(raw: string | null, fallback: T): T {
try {
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed as T;
}
} catch {}
@@ -76,6 +82,29 @@ function saveToStorage(key: string, value: unknown) {
} catch {}
}
// useSyncExternalStore plumbing for the one-shot localStorage hydration reads:
// nothing to subscribe to (the values are only read once, before
// sidebarExpansionLoaded flips), and the server snapshot is always null so the
// SSR/hydration render matches the server output.
const noopSubscribe = () => () => {};
const getServerSnapshotNull = () => null;
const getHydratedSnapshot = () => true;
const getServerHydratedSnapshot = () => false;
function readStoredExpandedRaw() {
try {
return localStorage.getItem(EXPANDED_SECTIONS_KEY);
} catch {
return null;
}
}
function readStoredPinnedRaw() {
try {
return localStorage.getItem(PINNED_SECTIONS_KEY);
} catch {
return null;
}
}
export default function Sidebar({
onClose,
collapsed = false,
@@ -115,35 +144,47 @@ export default function Sidebar({
);
const [pinnedSections, setPinnedSections] = useState<Set<SidebarSectionId>>(new Set());
const [sidebarExpansionLoaded, setSidebarExpansionLoaded] = useState(false);
const skipInitialActiveExpansion = useRef(false);
const [skipInitialActiveExpansion, setSkipInitialActiveExpansion] = useState(false);
const [hoveredItem, setHoveredItem] = useState<HoveredItem>(null);
const [searchQuery, setSearchQuery] = useState("");
// Load persisted state on mount. A stored [] intentionally means "all sections collapsed".
useEffect(() => {
const storedExpanded = loadFromStorage<SidebarSectionId[]>(EXPANDED_SECTIONS_KEY, [
// Load persisted state once the client has hydrated. A stored [] intentionally
// means "all sections collapsed". localStorage is read through
// useSyncExternalStore snapshots (server snapshot: null) and the states are
// adjusted during render (react.dev "You Might Not Need an Effect") so the
// stored expansion applies before paint without a synchronous effect setState.
const hydrated = useSyncExternalStore(
noopSubscribe,
getHydratedSnapshot,
getServerHydratedSnapshot
);
const storedExpandedRaw = useSyncExternalStore(
noopSubscribe,
readStoredExpandedRaw,
getServerSnapshotNull
);
const storedPinnedRaw = useSyncExternalStore(
noopSubscribe,
readStoredPinnedRaw,
getServerSnapshotNull
);
if (hydrated && !sidebarExpansionLoaded) {
const storedExpanded = parseStoredArray<SidebarSectionId[]>(storedExpandedRaw, [
DEFAULT_EXPANDED,
]);
const pinnedRaw = (() => {
try {
return localStorage.getItem(PINNED_SECTIONS_KEY);
} catch {
return null;
}
})();
const storedPinned: SidebarSectionId[] =
pinnedRaw !== null
? (JSON.parse(pinnedRaw) as SidebarSectionId[])
storedPinnedRaw !== null
? parseStoredArray<SidebarSectionId[]>(storedPinnedRaw, [])
: (SIDEBAR_SECTIONS.filter((s) => s.defaultPinned).map((s) => s.id) as SidebarSectionId[]);
const initialPinned = new Set<SidebarSectionId>(storedPinned);
const initialExpanded = hydrateExpandedSections(storedExpanded, initialPinned);
skipInitialActiveExpansion.current = storedExpanded.length === 0;
setSkipInitialActiveExpansion(storedExpanded.length === 0);
setExpandedSections(initialExpanded);
setPinnedSections(initialPinned);
setSidebarExpansionLoaded(true);
}, []);
}
useEffect(() => {
const applySettings = (data) => {
@@ -292,38 +333,51 @@ export default function Sidebar({
? filterSidebarSectionsByQuery(visibleSections, searchQuery)
: visibleSections;
// Keep the active page visible while preserving accordion semantics for unpinned sections.
useEffect(() => {
if (collapsed || !sidebarExpansionLoaded) return;
if (skipInitialActiveExpansion.current) {
skipInitialActiveExpansion.current = false;
return;
}
for (const section of visibleSections) {
const sectionItems = section.children.flatMap((child: any) =>
child.type === "group" ? child.items : [child]
);
if (sectionItems.some((item: any) => !item.external && item.href === activeHref)) {
setExpandedSections((prev) => {
const next = expandActiveSection(pinnedSections, section.id as SidebarSectionId);
if ([...next].every((id) => prev.has(id)) && next.size === prev.size) return prev;
saveToStorage(EXPANDED_SECTIONS_KEY, [...next]);
return next;
});
break;
// Keep the active page visible while preserving accordion semantics for
// unpinned sections. Render-time adjustment (react.dev "You Might Not Need
// an Effect"): the composite key mirrors the old effect's
// [activeHref, collapsed, pinnedSections, sidebarExpansionLoaded] deps.
const activeExpansionKey = `${collapsed}|${sidebarExpansionLoaded}|${activeHref ?? ""}|${[
...pinnedSections,
]
.sort()
.join(",")}`;
const [prevActiveExpansionKey, setPrevActiveExpansionKey] = useState<string | null>(null);
if (activeExpansionKey !== prevActiveExpansionKey) {
setPrevActiveExpansionKey(activeExpansionKey);
if (!collapsed && sidebarExpansionLoaded) {
if (skipInitialActiveExpansion) {
setSkipInitialActiveExpansion(false);
} else {
for (const section of visibleSections) {
const sectionItems = section.children.flatMap((child: any) =>
child.type === "group" ? child.items : [child]
);
if (sectionItems.some((item: any) => !item.external && item.href === activeHref)) {
setExpandedSections((prev) => {
const next = expandActiveSection(pinnedSections, section.id as SidebarSectionId);
if ([...next].every((id) => prev.has(id)) && next.size === prev.size) return prev;
return next;
});
break;
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeHref, collapsed, pinnedSections, sidebarExpansionLoaded]);
}
// Persist the expanded-section set whenever it changes after hydration —
// single writer replacing the saveToStorage calls that used to run inside
// setState updaters (side effects belong outside updaters).
useEffect(() => {
if (!sidebarExpansionLoaded) return;
saveToStorage(EXPANDED_SECTIONS_KEY, [...expandedSections]);
}, [expandedSections, sidebarExpansionLoaded]);
// Accordion toggle: opening a section closes all non-pinned sections
const toggleSection = useCallback(
(sectionId: SidebarSectionId) => {
setExpandedSections((prev) => {
const next = toggleExpandedSection(prev, pinnedSections, sectionId);
saveToStorage(EXPANDED_SECTIONS_KEY, [...next]);
return next;
});
setExpandedSections((prev) => toggleExpandedSection(prev, pinnedSections, sectionId));
},
[pinnedSections]
);
@@ -340,7 +394,6 @@ export default function Sidebar({
if (prevExp.has(sectionId)) return prevExp;
const nextExp = new Set(prevExp);
nextExp.add(sectionId);
saveToStorage(EXPANDED_SECTIONS_KEY, [...nextExp]);
return nextExp;
});
}

View File

@@ -113,13 +113,15 @@ export default function UsageStats() {
() => sortData(stats?.byModel, stats?.pending?.byModel),
[stats?.byModel, stats?.pending?.byModel, sortData]
);
const statsByAccount = stats?.byAccount;
const statsPendingByAccount = stats?.pending?.byAccount;
const sortedAccounts = useMemo(() => {
// For accounts, pendingMap is by connectionId, but dataMap is by accountKey
// We need to map connectionId pending counts to accountKeys
const accountPendingMap: Record<string, any> = {};
if (stats?.pending?.byAccount) {
Object.entries(stats.byAccount || {}).forEach(([accountKey, data]: [string, any]) => {
const connPending = stats.pending.byAccount[data.connectionId];
if (statsPendingByAccount) {
Object.entries(statsByAccount || {}).forEach(([accountKey, data]: [string, any]) => {
const connPending = statsPendingByAccount[data.connectionId];
if (connPending) {
// Get modelKey (rawModel (provider))
const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel;
@@ -127,11 +129,12 @@ export default function UsageStats() {
}
});
}
return sortData(stats?.byAccount, accountPendingMap);
}, [stats?.byAccount, stats?.pending?.byAccount, sortData]);
return sortData(statsByAccount, accountPendingMap);
}, [statsByAccount, statsPendingByAccount, sortData]);
// Note: no synchronous setLoading(true) here — `loading` starts as true and the
// only showLoading=true call happens on mount, so the skeleton is already up.
const fetchStats = useCallback(async (showLoading = true): Promise<void> => {
if (showLoading) setLoading(true);
try {
const res = await fetch("/api/usage/history");
if (res.ok) {
@@ -157,7 +160,10 @@ export default function UsageStats() {
}, []);
useEffect(() => {
fetchStats();
const run = async () => {
await fetchStats();
};
run();
}, [fetchStats]);
useEffect(() => {

View File

@@ -6,7 +6,7 @@
* max-lines-per-function complexity gate.
*/
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { readFetchErrorMessage } from "@/shared/utils/fetchError";
import type { ProviderDailyUsageRow } from "./RequestCountTable";
@@ -17,30 +17,34 @@ export function useProviderDailyUsage(range: string, dateFilter: string) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchRows = useCallback(async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (dateFilter) {
params.set("date", dateFilter);
} else {
params.set("range", range);
}
const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`);
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
const data = await res.json();
setRows(Array.isArray(data.rows) ? data.rows : []);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [range, dateFilter, tCommon]);
useEffect(() => {
let cancelled = false;
const fetchRows = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (dateFilter) {
params.set("date", dateFilter);
} else {
params.set("range", range);
}
const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`);
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
const data = await res.json();
if (cancelled) return;
setRows(Array.isArray(data.rows) ? data.rows : []);
setError(null);
} catch (err) {
if (!cancelled) setError((err as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchRows();
}, [fetchRows]);
return () => {
cancelled = true;
};
}, [range, dateFilter, tCommon]);
return { rows, loading, error };
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslations } from "next-intl";
export interface ComboCompressionModeSelectCombo {
@@ -53,9 +53,15 @@ export function ComboCompressionModeSelect({
const [compressionOverride, setCompressionOverride] = useState(initialCompressionMode);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
// Re-sync the select when the combo's persisted mode changes (render-time
// adjustment per react.dev "You Might Not Need an Effect" — replaces the old
// mirror effect that called setState synchronously).
const [prevInitialCompressionMode, setPrevInitialCompressionMode] =
useState(initialCompressionMode);
if (initialCompressionMode !== prevInitialCompressionMode) {
setPrevInitialCompressionMode(initialCompressionMode);
setCompressionOverride(initialCompressionMode);
}, [initialCompressionMode]);
}
const handleChange = async (value: string) => {
setCompressionOverride(value);

View File

@@ -265,6 +265,7 @@
"tests/unit/kimi-quota-reset-recovery.test.ts",
"tests/unit/least-used-rotation-10945.test.ts",
"tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts",
"tests/unit/search-432-plan-limit-cooldown.test.ts",
"tests/unit/livews-forward-backoff-4604.test.ts",
"tests/unit/management-auth-hardening.test.ts",
"tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts",

View File

@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
// AgentRouter's upstream (New-API, Rust serde) only accepts versioned Claude tool
// types (web_search_20250305 / web_search_20260209); plain tools must omit `type`
// entirely. A tool carrying `type: "custom"` — whether client-declared (Claude
// Code v2.1+) or backfilled by defaultClaudeToolType() (#2195, MiniMax) — is a
// hard 400 "unknown variant `custom`" that crashes the client session.
// normalizeClaudeToolsForDispatch() routes per provider: agentrouter strips the
// custom discriminator, every other Claude-format target keeps the #2195 default.
const { normalizeClaudeToolsForDispatch } = await import(
"../../open-sse/handlers/chatCore/claudeToolDefaults.ts"
);
test("agentrouter: strips an explicit type:'custom' discriminator, preserving all other fields", () => {
const tools = [
{
type: "custom",
name: "get_weather",
description: "Get weather",
input_schema: { type: "object", properties: {} },
},
];
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, undefined, "type:'custom' must be removed");
assert.equal(out[0].name, "get_weather");
assert.equal(out[0].description, "Get weather");
assert.deepEqual(out[0].input_schema, { type: "object", properties: {} });
});
test("agentrouter: does NOT default a missing type (typeless tools stay typeless)", () => {
const tools = [{ name: "get_weather", description: "Get weather", input_schema: {} }];
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, undefined, "no type:'custom' may be backfilled for agentrouter");
});
test("agentrouter: preserves versioned/built-in tool types (only 'custom' is stripped)", () => {
const tools = [
{ type: "web_search_20260209", name: "web_search" },
{ type: "computer_20241022", name: "computer" },
{ type: "custom", name: "plain" },
{ name: "typeless" },
];
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, "web_search_20260209");
assert.equal(out[1].type, "computer_20241022");
assert.equal(out[2].type, undefined, "custom is stripped");
assert.equal(out[3].type, undefined, "typeless stays typeless");
});
test("non-agentrouter providers keep the #2195 behavior: missing type defaults to 'custom'", () => {
const tools = [{ name: "get_weather", input_schema: {} }];
for (const provider of ["minimax", "anthropic", "some-gateway"]) {
const out = normalizeClaudeToolsForDispatch(tools, provider) as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, "custom", `${provider} must keep the MiniMax #2195 default`);
}
});
test("non-agentrouter providers leave an explicit type:'custom' untouched", () => {
const tools = [{ type: "custom", name: "a", input_schema: {} }];
const out = normalizeClaudeToolsForDispatch(tools, "minimax") as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, "custom");
});
test("returns non-array input unchanged for any provider", () => {
assert.equal(normalizeClaudeToolsForDispatch(undefined, "agentrouter"), undefined);
assert.equal(normalizeClaudeToolsForDispatch(null, "agentrouter"), null);
const obj = { not: "an array" };
assert.equal(normalizeClaudeToolsForDispatch(obj, "agentrouter"), obj);
assert.equal(normalizeClaudeToolsForDispatch(obj, "minimax"), obj);
});
test("does not mutate the original tool objects", () => {
const explicit = { type: "custom", name: "x", input_schema: {} };
const typeless = { name: "y", input_schema: {} };
const out = normalizeClaudeToolsForDispatch([explicit, typeless], "agentrouter") as Array<
Record<string, unknown>
>;
assert.equal(explicit.type, "custom", "original explicit tool must stay untouched");
assert.equal(typeless.type, undefined, "original typeless tool must stay untouched");
assert.equal(out[0].type, undefined);
});
test("passes non-object array entries through unchanged (no garbage wrapping)", () => {
const tools = [
{ type: "custom", name: "real_tool", input_schema: {} }, // object → stripped
null,
"weird",
42,
];
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as unknown[];
assert.equal((out[0] as Record<string, unknown>).type, undefined, "real object gets stripped");
assert.equal(out[1], null, "null passes through unchanged");
assert.equal(out[2], "weird", "string passes through unchanged");
assert.equal(out[3], 42, "number passes through unchanged");
});

View File

@@ -0,0 +1,114 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-aihorde-queue-"));
import { handleAiHordeImageGeneration } from "../../open-sse/handlers/imageGeneration/providers/aihorde.ts";
import { aiHordeImageCatalog } from "../../open-sse/services/aihordeImageCatalog.ts";
/**
* Очередь Horde известна с первого ответа — отказывать надо там же.
*
* `/v2/generate/check` возвращает `wait_time` и `queue_position` сразу. Пока
* они не читались, запрос на модель с длинной очередью опрашивал Horde раз в
* секунду весь бюджет и падал по таймауту, ничего не объяснив. Живая проверка
* 2026-08-30: модель `Deliberate` (3 воркера) ответила `wait_time: 1478,
* queue_position: 321` — 25 минут при бюджете в 10. Ждать было бессмысленно
* ещё до первого опроса, а пользователь узнавал об этом через десять минут.
*
* Для сравнения `stable_diffusion` (10 воркеров) в тот же момент отдал картинку
* за 10.4 секунды — то есть отказ должен быть про эту модель и эту очередь, а
* не про Horde вообще, и должен подсказывать, что делать.
*/
const HORDE_JOB_ID = "queue-budget-job";
function stubHordeQueue({ waitTimeSeconds }: { waitTimeSeconds: number }) {
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method || "GET").toUpperCase();
calls.push(`${method} ${url}`);
if (url.endsWith("/v2/generate/async")) {
return new Response(JSON.stringify({ id: HORDE_JOB_ID, kudos: 6 }), { status: 202 });
}
if (url.includes("/v2/generate/check/")) {
return new Response(
JSON.stringify({
done: false,
faulted: false,
is_possible: true,
waiting: 1,
wait_time: waitTimeSeconds,
queue_position: 321,
eligible_workers: 3,
}),
{ status: 200 }
);
}
return new Response("{}", { status: 200 });
}) as typeof fetch;
return calls;
}
const originalFetch = globalThis.fetch;
test.beforeEach(() => {
aiHordeImageCatalog.replace([
{ name: "Deliberate", count: 3, queued: 0, eta: 1478, performance: 1, jobs: 0 },
]);
});
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
test("refuses immediately when the queue cannot fit the remaining budget", async () => {
const calls = stubHordeQueue({ waitTimeSeconds: 1478 });
const started = Date.now();
const result = await handleAiHordeImageGeneration({
model: "Deliberate",
provider: "aihorde",
body: { model: "aihorde/Deliberate", prompt: "hello world" },
credentials: { apiKey: "horde-key" },
timeoutMs: 600_000,
});
assert.equal(result.success, false);
assert.equal(result.status, 504);
assert.match(String(result.error), /queue/i);
assert.match(String(result.error), /1478|25/, "в отказе должно быть названо ожидание");
assert.ok(
Date.now() - started < 30_000,
"отказ обязан прийти сразу, а не после выработки бюджета"
);
const checks = calls.filter((c) => c.includes("/v2/generate/check/"));
assert.equal(checks.length, 1, "хватает одного опроса, чтобы узнать очередь");
});
test("keeps waiting when the queue fits the budget", async () => {
// Ожидание заведомо помещается в бюджет: 2 секунды очереди против 6.
const calls = stubHordeQueue({ waitTimeSeconds: 2 });
const result = await handleAiHordeImageGeneration({
model: "Deliberate",
provider: "aihorde",
body: { model: "aihorde/Deliberate", prompt: "hello world" },
credentials: { apiKey: "horde-key" },
timeoutMs: 6_000,
});
// Заглушка никогда не отвечает done, поэтому запрос доходит до собственного
// таймаута — важно, что он до него дошёл, а не был отбит по очереди.
assert.equal(result.success, false);
assert.ok(
calls.filter((c) => c.includes("/v2/generate/check/")).length > 1,
"короткая очередь не должна приводить к раннему отказу"
);
});

View File

@@ -4,6 +4,23 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Typed views over the raw sqlite rows / payload envelopes this suite inspects —
// keeps the assertions honest without @typescript-eslint/no-explicit-any (#12146).
type CallLogRow = {
name?: string;
cnt?: number;
detail_state?: string | null;
cache_source?: string | null;
has_request_body?: number;
has_response_body?: number;
has_pipeline_details?: number;
artifact_relpath?: string;
artifact_size_bytes?: number;
error_summary?: string | null;
};
type PayloadEnvelope = { body?: Record<string, unknown> };
type PayloadMap = { providerResponse?: PayloadEnvelope; clientResponse?: PayloadEnvelope };
import { useDecollidedMigrationsDir } from "./helpers/decollidedMigrationsDir.ts";
useDecollidedMigrationsDir();
@@ -133,9 +150,18 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art
assert.equal(detail?.comboStepId, "step-openai-a");
assert.equal(detail?.comboExecutionKey, "combo-a:0:step-openai-a");
assert.equal(detail?.pipelinePayloads?.clientRawRequest?.body?.raw, true);
assert.equal((detail?.pipelinePayloads?.providerRequest as any).body?.translated, true);
assert.equal((detail?.pipelinePayloads as any).providerResponse?.body?.upstream, true);
assert.equal((detail?.pipelinePayloads as any).clientResponse?.body?.final, true);
assert.equal(
(detail?.pipelinePayloads?.providerRequest as PayloadEnvelope | undefined)?.body?.translated,
true
);
assert.equal(
(detail?.pipelinePayloads as PayloadMap | undefined)?.providerResponse?.body?.upstream,
true
);
assert.equal(
(detail?.pipelinePayloads as PayloadMap | undefined)?.clientResponse?.body?.final,
true
);
assert.match(
detail?.artifactRelPath || "",
/^2026-03-30\/2026-03-30T12-34-56\.789Z_req_artifact_1\.json$/
@@ -145,7 +171,7 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art
const columns = db
.prepare("SELECT name FROM pragma_table_info('call_logs') ORDER BY cid")
.all()
.map((row) => (row as any).name);
.map((row) => (row as CallLogRow).name);
assert.equal(columns.includes("request_body"), false);
assert.equal(columns.includes("response_body"), false);
assert.equal(columns.includes("error"), false);
@@ -158,12 +184,12 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art
`
)
.get(logId);
(assert as any).equal((summaryRow as any).detail_state, "ready");
assert.equal((summaryRow as any).cache_source, "semantic");
assert.equal((summaryRow as any).has_request_body, 1);
assert.equal((summaryRow as any).has_response_body, 1);
assert.equal((summaryRow as any).has_pipeline_details, 1);
assert.equal(typeof (summaryRow as any).artifact_relpath, "string");
assert.equal((summaryRow as CallLogRow).detail_state, "ready");
assert.equal((summaryRow as CallLogRow).cache_source, "semantic");
assert.equal((summaryRow as CallLogRow).has_request_body, 1);
assert.equal((summaryRow as CallLogRow).has_response_body, 1);
assert.equal((summaryRow as CallLogRow).has_pipeline_details, 1);
assert.equal(typeof (summaryRow as CallLogRow).artifact_relpath, "string");
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", detail.artifactRelPath);
const serializedArtifact = fs.readFileSync(artifactPath, "utf8");
@@ -241,14 +267,18 @@ test("rotateCallLogs removes expired rows and orphaned artifacts but keeps fresh
.getDbInstance()
.prepare("SELECT artifact_relpath FROM call_logs WHERE id = ?")
.get("fresh-log");
const freshAbsPath = path.join(TEST_DATA_DIR, "call_logs", (freshRow as any).artifact_relpath);
const freshAbsPath = path.join(
TEST_DATA_DIR,
"call_logs",
(freshRow as CallLogRow).artifact_relpath
);
assert.equal(
(
core
.getDbInstance()
.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?")
.get("expired-log") as any
.get("expired-log") as CallLogRow
).cnt,
1
);
@@ -259,13 +289,20 @@ test("rotateCallLogs removes expired rows and orphaned artifacts but keeps fresh
const db = core.getDbInstance();
assert.equal(
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?").get("expired-log") as any)
.cnt,
(
db
.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?")
.get("expired-log") as CallLogRow
).cnt,
0
);
assert.equal(fs.existsSync(oldAbsPath), false);
assert.equal(
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?").get("fresh-log") as any).cnt,
(
db
.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?")
.get("fresh-log") as CallLogRow
).cnt,
1
);
assert.equal(fs.existsSync(freshAbsPath), true);
@@ -424,15 +461,15 @@ test("getCallLogById falls back to legacy inline rows and request_detail_logs",
assert.deepEqual(detail?.error, { message: "legacy-error" });
assert.equal(detail?.pipelinePayloads?.clientRequest?.body?.from, "detail-client");
assert.equal(
(detail?.pipelinePayloads?.providerRequest as any).body?.from,
(detail?.pipelinePayloads?.providerRequest as PayloadEnvelope | undefined)?.body?.from,
"detail-provider-request"
);
(assert as any).equal(
(detail?.pipelinePayloads?.providerResponse as any).body?.from,
assert.equal(
(detail?.pipelinePayloads?.providerResponse as PayloadEnvelope | undefined)?.body?.from,
"detail-provider-response"
);
assert.equal(
(detail?.pipelinePayloads?.clientResponse as any).body?.from,
(detail?.pipelinePayloads?.clientResponse as PayloadEnvelope | undefined)?.body?.from,
"detail-client-response"
);
assert.equal(detail?.hasPipelineDetails, true);
@@ -461,8 +498,8 @@ test("getCallLogById marks missing artifacts explicitly and clears stale DB poin
const row = db
.prepare("SELECT artifact_relpath, detail_state FROM call_logs WHERE id = ?")
.get("missing-artifact");
assert.equal((row as any).artifact_relpath, null);
assert.equal((row as any).detail_state, "missing");
assert.equal((row as CallLogRow).artifact_relpath, null);
assert.equal((row as CallLogRow).detail_state, "missing");
});
test("saveCallLog keeps large payloads out of SQLite while preserving explicit detail export", async () => {
@@ -491,12 +528,12 @@ test("saveCallLog keeps large payloads out of SQLite while preserving explicit d
`
)
.get("artifact-only-large-payload");
assert.equal((row as any).detail_state, "ready");
assert.equal((row as any).has_request_body, 1);
(assert as any).equal(typeof (row as any).artifact_relpath, "string");
assert.equal((row as any).error_summary, "upstream unavailable");
assert.equal((row as CallLogRow).detail_state, "ready");
assert.equal((row as CallLogRow).has_request_body, 1);
assert.equal(typeof (row as CallLogRow).artifact_relpath, "string");
assert.equal((row as CallLogRow).error_summary, "upstream unavailable");
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.equal(artifact.requestBody.payload.length, requestBody.payload.length);
@@ -505,7 +542,10 @@ test("saveCallLog keeps large payloads out of SQLite while preserving explicit d
const exported = await callLogs.exportCallLogsSince("2026-03-31T00:00:00.000Z");
assert.equal(exported.length, 1);
assert.equal((exported[0] as any).requestBody.payload.length, requestBody.payload.length);
assert.equal(
(exported[0] as { requestBody: { payload: string } }).requestBody.payload.length,
requestBody.payload.length
);
});
test("saveCallLog truncates oversized call log artifacts for storage", async () => {
@@ -539,10 +579,10 @@ test("saveCallLog truncates oversized call log artifacts for storage", async ()
`
)
.get("truncated-artifact");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes <= 512 * 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes <= 512 * 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.deepEqual(artifact.requestBody, { payload: "request" });
assert.deepEqual(artifact.responseBody, { output: "response" });
@@ -582,10 +622,10 @@ test("saveCallLog omits oversized non-stream pipeline payloads to enforce artifa
`
)
.get("truncated-pipeline-artifact");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes <= 512 * 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes <= 512 * 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.deepEqual(artifact.requestBody, { payload: "request" });
assert.deepEqual(artifact.responseBody, { output: "response" });
@@ -626,10 +666,10 @@ test("saveCallLog honors CALL_LOG_PIPELINE_MAX_SIZE_KB for pipeline artifacts",
`
)
.get("configured-pipeline-artifact-cap");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes <= 8 * 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes <= 8 * 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.deepEqual(artifact.pipeline, {
error: {
@@ -668,10 +708,10 @@ test("saveCallLog falls back to a compact sentinel when the configured cap is ve
`
)
.get("tiny-pipeline-artifact-cap");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes <= 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes <= 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.deepEqual(artifact, {
schemaVersion: 5,
@@ -716,9 +756,9 @@ test("saveCallLog preserves a truncated error in size-limit-fallback artifacts (
`
)
.get("tiny-cap-preserves-error");
assert.equal((row as any).detail_state, "ready");
assert.equal((row as CallLogRow).detail_state, "ready");
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.equal(
artifact.error,
@@ -758,10 +798,10 @@ test("CALL_LOG_PIPELINE_MAX_SIZE_KB does not cap artifacts without pipeline deta
`
)
.get("non-pipeline-artifact-ignores-pipeline-cap");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes > 8 * 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes > 8 * 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.equal(artifact.requestBody.payload.length, requestBody.payload.length);
});

View File

@@ -0,0 +1,167 @@
/**
* RED regression coverage for issue #12073: Node 26 deprecates
* module.register() in favor of the synchronous module.registerHooks() API.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseNodeVersion } from "../../../src/shared/utils/nodeRuntimeSupport.ts";
import { registerAliasResolver, resolveAlias } from "../../../bin/aliasResolver.mjs";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const REPO_ROOT = join(__dirname, "..", "..", "..");
const NODE_VERSION = parseNodeVersion(process.versions.node);
const NODE_26_SKIP_REASON =
`running Node ${process.versions.node}; DEP0205 assertion is unverified on this runtime ` +
"(requires Node >= 26)";
// Child import() specifiers must be real file URLs. A Windows drive letter in
// a bare path would otherwise be parsed as a URL scheme.
const repoFileUrl = (relPath: string) => pathToFileURL(join(REPO_ROOT, relPath)).href;
function runChild(script: string, cwd = REPO_ROOT) {
const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], {
cwd,
env: {
...process.env,
DATA_DIR: mkdtempSync(join(tmpdir(), "alias-resolver-12073-")),
OMNIROUTE_CLI_SKIP_REPO_ENV: "1",
},
encoding: "utf8",
});
return { stdout: result.stdout, stderr: result.stderr, status: result.status };
}
describe("aliasResolver Node 26 registration (#12073)", () => {
it(
"uses the real entry point without emitting DEP0205",
{ skip: NODE_VERSION.major >= 26 ? false : NODE_26_SKIP_REASON },
() => {
const script = `
await import("tsx/esm");
const { registerAliasResolver } = await import(${JSON.stringify(repoFileUrl("bin/aliasResolver.mjs"))});
const ok = await registerAliasResolver(${JSON.stringify(REPO_ROOT)});
if (!ok) { console.error("FAIL: registerAliasResolver returned false"); process.exit(2); }
try {
const m = await import(${JSON.stringify(repoFileUrl("src/shared/network/outboundUrlGuard.ts"))});
console.log("OK:" + Object.keys(m).sort().join(","));
} catch (err) {
console.error("FAIL:" + (err && err.message || err));
process.exit(3);
}
`;
const { stdout, stderr, status } = runChild(script);
assert.equal(status, 0, `expected exit 0, got ${status}. stderr=${stderr.slice(0, 500)}`);
assert.match(stdout.trim(), /^OK:/);
assert.doesNotMatch(stderr, /DEP0205|DeprecationWarning/);
}
);
it("keeps global-install-style alias imports working", () => {
// A foreign cwd has no repository tsconfig or package.json to let tsx
// resolve the bare alias by itself. This makes the import a tripwire for a
// hook that registers without throwing but silently never runs.
const globalInstallCwd = mkdtempSync(join(tmpdir(), "alias-resolver-global-install-"));
try {
const script = `
await import(${JSON.stringify(repoFileUrl("node_modules/tsx/dist/esm/index.mjs"))});
const { registerAliasResolver } = await import(${JSON.stringify(repoFileUrl("bin/aliasResolver.mjs"))});
const ok = await registerAliasResolver(${JSON.stringify(REPO_ROOT)});
if (!ok) { console.error("FAIL: registerAliasResolver returned false"); process.exit(2); }
try {
const m = await import("@/shared/network/outboundUrlGuard");
console.log("OK:" + Object.keys(m).sort().join(","));
} catch (err) {
console.error("FAIL:" + (err && err.message || err));
process.exit(3);
}
`;
const { stdout, stderr, status } = runChild(script, globalInstallCwd);
assert.equal(status, 0, `expected exit 0, got ${status}. stderr=${stderr.slice(0, 500)}`);
const trimmed = stdout.trim();
assert.match(trimmed, /^OK:/, `expected OK:<exports>, got: ${trimmed}`);
assert.match(trimmed, /OutboundUrlGuardError|PROVIDER_URL_BLOCKED_MESSAGE/);
} finally {
rmSync(globalInstallCwd, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
}
});
it("retains module.register() for simulated legacy runtimes", () => {
const script = `
await import("tsx/esm");
const { createRequire, syncBuiltinESMExports } = await import("node:module");
const require = createRequire(import.meta.url);
const cjsModule = require("node:module");
const saved = cjsModule.registerHooks;
try {
cjsModule.registerHooks = undefined;
syncBuiltinESMExports();
const esmModule = await import("node:module");
if (esmModule.registerHooks !== undefined) {
console.error("FAIL: registerHooks was not blanked");
process.exitCode = 2;
} else {
const { registerAliasResolver } = await import(${JSON.stringify(repoFileUrl("bin/aliasResolver.mjs"))});
const ok = await registerAliasResolver(${JSON.stringify(REPO_ROOT)});
const m = await import(${JSON.stringify(repoFileUrl("src/shared/network/outboundUrlGuard.ts"))});
const hasExpectedExport = "OutboundUrlGuardError" in m || "PROVIDER_URL_BLOCKED_MESSAGE" in m;
if (!ok || !hasExpectedExport) {
console.error("FAIL: legacy registration did not resolve the alias");
process.exitCode = 3;
} else {
console.log("OK:true:" + Object.keys(m).sort().join(","));
}
}
} catch (err) {
console.error("FAIL:" + (err && err.message || err));
process.exitCode = 4;
} finally {
cjsModule.registerHooks = saved;
syncBuiltinESMExports();
}
`;
const { stdout, stderr, status } = runChild(script);
assert.equal(status, 0, `expected exit 0, got ${status}. stderr=${stderr.slice(0, 500)}`);
assert.match(stdout.trim(), /^OK:true:/);
if (NODE_VERSION.major >= 26) {
assert.match(stderr, /DEP0205|DeprecationWarning/);
}
});
it("keeps idempotency, input validation, and traversal guards characterized", async () => {
const noSrcRoot = mkdtempSync(join(tmpdir(), "alias-resolver-no-src-"));
const emptySrcRoot = mkdtempSync(join(tmpdir(), "alias-resolver-empty-src-"));
try {
assert.equal(await registerAliasResolver(noSrcRoot), false);
mkdirSync(join(emptySrcRoot, "src"), { recursive: true });
assert.equal(await registerAliasResolver(emptySrcRoot), true);
assert.equal(await registerAliasResolver(emptySrcRoot), true);
await assert.rejects(() => registerAliasResolver(""), TypeError);
await assert.rejects(() => registerAliasResolver(null), TypeError);
await assert.rejects(() => registerAliasResolver(123), TypeError);
assert.equal(resolveAlias("@/../../../etc/hostname", REPO_ROOT), null);
assert.equal(resolveAlias("@omniroute/open-sse/../../etc/passwd", REPO_ROOT), null);
} finally {
rmSync(noSrcRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
rmSync(emptySrcRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
});

View File

@@ -20,13 +20,13 @@ function withEnv(value: string | undefined, fn: () => void) {
}
}
test("default resilience settings include a 5-minute credential health check cadence", () => {
assert.equal(DEFAULT_RESILIENCE_SETTINGS.credentialHealthCheck.intervalMinutes, 5);
test("default resilience settings include a 60-minute credential health check cadence", () => {
assert.equal(DEFAULT_RESILIENCE_SETTINGS.credentialHealthCheck.intervalMinutes, 60);
});
test("resolveResilienceSettings returns the default interval when nothing is stored", () => {
const resolved = resolveResilienceSettings({});
assert.equal(resolved.credentialHealthCheck.intervalMinutes, 5);
assert.equal(resolved.credentialHealthCheck.intervalMinutes, 60);
});
test("mergeResilienceSettings stores an operator interval and preserves other sections", () => {
@@ -45,9 +45,9 @@ test("mergeResilienceSettings clamps the interval into the 0-1440 band", () => {
assert.equal(high.credentialHealthCheck.intervalMinutes, 1440);
});
test("sweep interval: no operator setting and no env → built-in 5 min default", () => {
test("sweep interval: no operator setting and no env → built-in 60 min default", () => {
withEnv(undefined, () => {
assert.equal(resolveCredentialHealthSweepInterval({}), 300_000);
assert.equal(resolveCredentialHealthSweepInterval({}), 60 * 60_000);
});
});
@@ -87,6 +87,6 @@ test("sweep interval: non-numeric stored interval falls back to env/default", ()
const settings = {
resilienceSettings: { credentialHealthCheck: { intervalMinutes: "abc" } },
};
assert.equal(resolveCredentialHealthSweepInterval(settings), 300_000);
assert.equal(resolveCredentialHealthSweepInterval(settings), 60 * 60_000);
});
});

View File

@@ -0,0 +1,288 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
interface TestConnectionRecord {
id?: string | number;
isActive?: boolean;
testStatus?: string;
rateLimitedUntil?: string | null;
}
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-search-432-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "search-432-test-secret";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../src/lib/db/core.ts");
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
const quotaTextCooldowns = await import("../../open-sse/services/quotaTextCooldowns.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const auth = await import("../../src/sse/services/auth.ts");
const connectionRecovery = await import("../../src/lib/quota/connectionRecovery.ts");
const searchProxy = await import("../../open-sse/handlers/search/searchProxy.ts");
const { closeCallLogSaves } = await import("../../src/lib/usage/callLogs.ts");
test.after(async () => {
await closeCallLogSaves(500).catch(() => {});
try {
core.resetDbInstance();
} catch {}
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {}
});
test("Tavily 432 plan limit body is detected as transient plan usage limit", () => {
const tavily432Body = JSON.stringify({
error: "This request exceeds your plan's set usage limit. Please upgrade your plan or contact support@tavily.com",
});
const isMatched = quotaTextCooldowns.isSubscriptionQuotaText(tavily432Body.toLowerCase(), "tavily-search");
assert.equal(isMatched, true, "Should recognize Tavily plan limit error message");
});
test("checkFallbackError classifies status 432 and plan limit text as non-permanent quota_exhausted", () => {
const tavily432Body = JSON.stringify({
error: "This request exceeds your plan's set usage limit. Please upgrade your plan or contact support@tavily.com",
});
const result = accountFallback.checkFallbackError(
432,
tavily432Body,
"tavily-search",
null,
undefined,
undefined,
0
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.equal(result.permanent, undefined);
assert.equal(result.creditsExhausted, undefined);
assert.ok(result.cooldownMs > 0, "Should have a positive cooldown duration");
});
test("markAccountUnavailable sets transient unavailable status without deactivating the connection", async () => {
const conn = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: "tavily-plan-limit-test",
apiKey: "tvly-test-key-12345",
isActive: true,
testStatus: "active",
});
const connId = String(conn.id);
const errorText = JSON.stringify({
error: "This request exceeds your plan's set usage limit.",
});
await auth.markAccountUnavailable(connId, 432, errorText, "tavily-search", null);
const updatedRaw = (await providersDb.getProviderConnections({
provider: "tavily-search",
})) as TestConnectionRecord[];
const updated = (Array.isArray(updatedRaw) ? updatedRaw : []).find(
(c) => String(c.id) === connId
);
assert.ok(updated, "Connection should exist in DB");
assert.equal(updated.isActive, true, "Connection must remain isActive=1");
assert.equal(updated.testStatus, "unavailable", "Connection should be marked transient unavailable");
assert.ok(updated.rateLimitedUntil, "rateLimitedUntil must be populated");
const untilMs = new Date(updated.rateLimitedUntil).getTime();
assert.ok(untilMs > Date.now(), "rateLimitedUntil should be in the future");
});
test("executeProviderFetch calls markAccountUnavailable on 432 error response when connectionId is present", async () => {
let serverPort = 0;
const server = http.createServer((_req, res) => {
res.writeHead(432, { "Content-Type": "application/json", Connection: "close" });
res.end(JSON.stringify({
error: "This request exceeds your plan's set usage limit. Please upgrade your plan or contact support@tavily.com",
}));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (addr && typeof addr === "object") serverPort = addr.port;
resolve();
});
});
const conn = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: "tavily-fetch-432-test",
apiKey: "tvly-test-key-fetch-432",
isActive: true,
testStatus: "active",
});
const connId = String(conn.id);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
timer.unref?.();
try {
const { SEARCH_PROVIDERS } = await import("../../open-sse/config/searchRegistry.ts");
const result = await searchProxy.executeProviderFetch({
config: SEARCH_PROVIDERS["tavily-search"],
url: `http://127.0.0.1:${serverPort}/search`,
init: { method: "POST", headers: { "Content-Type": "application/json" } },
controller,
timer,
query: "test query",
searchType: "web",
maxResults: 5,
startTime: Date.now(),
connectionId: connId,
proxy: null,
proxyLevel: "none",
normalize: () => ({ results: [], totalResults: 0 }),
});
assert.equal(result.success, false);
assert.equal(result.status, 432);
const updatedRaw = (await providersDb.getProviderConnections({
provider: "tavily-search",
})) as TestConnectionRecord[];
const updated = (Array.isArray(updatedRaw) ? updatedRaw : []).find(
(c) => String(c.id) === connId
);
assert.ok(updated);
assert.equal(updated.isActive, true, "isActive should stay true");
assert.equal(updated.testStatus, "unavailable", "Connection should become unavailable");
assert.ok(updated.rateLimitedUntil, "rateLimitedUntil should be set");
} finally {
clearTimeout(timer);
await new Promise<void>((resolve) => {
server.closeAllConnections?.();
server.close(() => resolve());
});
}
});
test("executeProviderFetch does NOT mark account unavailable on non-quota client errors (400, 401, 403, 404)", async () => {
for (const statusCode of [400, 401, 403, 404]) {
let serverPort = 0;
const server = http.createServer((_req, res) => {
res.writeHead(statusCode, { "Content-Type": "application/json", Connection: "close" });
res.end(JSON.stringify({ error: `Generic client error ${statusCode}` }));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (addr && typeof addr === "object") serverPort = addr.port;
resolve();
});
});
const conn = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: `tavily-fetch-${statusCode}-test`,
apiKey: `tvly-test-key-fetch-${statusCode}`,
isActive: true,
testStatus: "active",
});
const connId = String(conn.id);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
timer.unref?.();
try {
const { SEARCH_PROVIDERS } = await import("../../open-sse/config/searchRegistry.ts");
const result = await searchProxy.executeProviderFetch({
config: SEARCH_PROVIDERS["tavily-search"],
url: `http://127.0.0.1:${serverPort}/search`,
init: { method: "POST", headers: { "Content-Type": "application/json" } },
controller,
timer,
query: "test query",
searchType: "web",
maxResults: 5,
startTime: Date.now(),
connectionId: connId,
proxy: null,
proxyLevel: "none",
normalize: () => ({ results: [], totalResults: 0 }),
});
assert.equal(result.success, false);
assert.equal(result.status, statusCode);
const updatedRaw = (await providersDb.getProviderConnections({
provider: "tavily-search",
})) as TestConnectionRecord[];
const updated = (Array.isArray(updatedRaw) ? updatedRaw : []).find(
(c) => String(c.id) === connId
);
assert.ok(updated);
assert.equal(updated.isActive, true, `isActive must remain true on ${statusCode}`);
assert.equal(updated.testStatus, "active", `testStatus must remain 'active' on ${statusCode}`);
assert.ok(!updated.rateLimitedUntil, `rateLimitedUntil must be null/empty on ${statusCode}`);
} finally {
clearTimeout(timer);
await new Promise<void>((resolve) => {
server.closeAllConnections?.();
server.close(() => resolve());
});
}
}
});
test("cooldown on key1 allows getProviderCredentials to auto-rotate to healthy key2", async () => {
const conn1 = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: "tavily-key-1",
apiKey: "tvly-key-1",
priority: 1,
isActive: true,
testStatus: "active",
});
const conn2 = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: "tavily-key-2",
apiKey: "tvly-key-2",
priority: 2,
isActive: true,
testStatus: "active",
});
// Mark key1 as unavailable due to 432
await auth.markAccountUnavailable(String(conn1.id), 432, "plan limit reached", "tavily-search", null);
// Next credential resolution should skip key1 and return key2
const selected = await auth.getProviderCredentials("tavily-search");
assert.ok(selected, "Should return available credentials");
assert.equal(String(selected.connectionId), String(conn2.id), "Should rotate to healthy key2");
});
test("connectionRecovery restores elapsed unavailable connections", () => {
const pastTime = new Date(Date.now() - 5000).toISOString();
const connInput = {
id: "test-conn-1",
testStatus: "unavailable",
rateLimitedUntil: pastTime,
lastErrorAt: pastTime,
};
const isRecoverable = connectionRecovery.isRecoverableCooldownConnection(connInput, Date.now());
assert.equal(isRecoverable, true, "Elapsed unavailable connection should be recoverable");
});

View File

@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
/**
* `resolveVideoEndpoint` принимает запасной адрес и обязан им пользоваться.
*
* Функция объявлена как `(credentials, fallback)`, и вызывающий передаёт
* `providerConfig.baseUrl` — адрес из реестра. Но пока адрес брался только из
* учётных данных: у встроенного провайдера, где узел своего baseUrl не хранит,
* получался `null.endsWith(...)` и маршрут отвечал 500 с пустым телом.
*
* Дефект был не виден, потому что единственный встроенный провайдер формата
* `openai-video` (nanogpt) до этой ветки не доходил — его формат в реестре был
* записан как `openai`, и диспетчер отбрасывал его раньше (400 Unsupported
* video format). Живая проверка 2026-08-30: как только имя формата исправили,
* тот же запрос дал 500 и стек с `Cannot read properties of null`.
*
* Адрес из реестра — готовый endpoint, а не корень узла: у nanogpt это
* `/api/v1/video/generations` (единственное число), и дописывать к нему
* `/videos/generations` нельзя.
*/
const { handleOpenAIVideoGeneration } =
await import("../../open-sse/handlers/videoGeneration/openai.ts");
const REGISTRY_ENDPOINT = "https://nano-gpt.com/api/v1/video/generations";
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
function captureRequestUrl() {
const seen: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
seen.push(typeof input === "string" ? input : input.toString());
return new Response(JSON.stringify({ data: [{ url: "https://example.test/out.mp4" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
return seen;
}
test("falls back to the registry endpoint when the node carries no baseUrl", async () => {
const seen = captureRequestUrl();
const result = await handleOpenAIVideoGeneration({
model: "default",
provider: "nanogpt",
providerConfig: { baseUrl: REGISTRY_ENDPOINT, authHeader: "bearer" },
body: { prompt: "hello world" },
credentials: { apiKey: "test-key" },
});
assert.notEqual(result, undefined, "обработчик обязан вернуть результат, а не упасть");
assert.deepEqual(seen, [REGISTRY_ENDPOINT]);
});
test("still prefers the node's own baseUrl and appends the OpenAI path", async () => {
const seen = captureRequestUrl();
await handleOpenAIVideoGeneration({
model: "default",
provider: "custom-node",
providerConfig: { baseUrl: REGISTRY_ENDPOINT, authHeader: "bearer" },
body: { prompt: "hello world" },
credentials: { apiKey: "test-key", baseUrl: "https://node.test/v1/" },
});
assert.deepEqual(seen, ["https://node.test/v1/videos/generations"]);
});

View File

@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Страж соответствия реестра и диспетчера видео.
*
* `GET /v1/videos/generations` рекламирует всё, что лежит в VIDEO_PROVIDERS без
* пометки `unsupported`. Диспетчер `handleVideoGeneration` умеет меньше: он
* разбирает `providerConfig.format` цепочкой ветвлений плюс job-пресетами, а на
* незнакомом формате отвечает `Unsupported video format`. Пока списки
* расходятся, каталог обещает модели, которые исполнитель гарантированно
* отвергает с 400 — независимо от ключей и баланса. Живая проверка 2026-08-30:
* `minimax/MiniMax-Hailuo-02`, `pollinations/default` и `nanogpt/default`
* отдавали ровно этот 400, будучи в выдаче каталога.
*
* Разбор идёт по тексту диспетчера, а не вызовом: неизвестный формат виден до
* первого сетевого запроса, а живой вызов каждого провайдера в юнит-тесте либо
* уходит в сеть, либо виснет на ретраях. Цена — тест надо поправить, если
* цепочку `format === "..."` заменят на другую конструкцию; тогда счётчик
* распознанных форматов упадёт до нуля и страж ниже скажет об этом прямо.
*/
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, "../..");
const { VIDEO_PROVIDERS } = await import("../../open-sse/config/videoRegistry.ts");
function readSource(relativePath: string) {
return fs.readFileSync(path.join(REPO_ROOT, relativePath), "utf8");
}
/** Форматы, для которых у диспетчера есть собственная ветка. */
function branchFormats() {
const source = readSource("open-sse/handlers/videoGeneration.ts");
return new Set(
[...source.matchAll(/providerConfig\.format === "([a-z0-9-]+)"/g)].map((match) => match[1])
);
}
/** Форматы, которые обслуживает общий submit → poll конвейер job-пресетов. */
function jobPresetFormats() {
const source = readSource("open-sse/handlers/videoGeneration/job.ts");
const block = source.slice(source.indexOf("VIDEO_JOB_PRESETS"));
return new Set([...block.matchAll(/^\s{2}"([a-z0-9-]+)":\s*\{/gm)].map((match) => match[1]));
}
test("dispatcher formats are still discoverable in the source", () => {
assert.ok(
branchFormats().size >= 10,
"в videoGeneration.ts не нашлось ветвлений по providerConfig.format — " +
"диспетчер переписан, и разбор в этом тесте пора обновить"
);
assert.ok(
jobPresetFormats().size >= 1,
"в videoGeneration/job.ts не нашлось job-пресетов — разбор пора обновить"
);
});
test("every advertised video provider declares a format the dispatcher handles", () => {
const dispatchable = new Set([...branchFormats(), ...jobPresetFormats()]);
const broken = Object.entries(VIDEO_PROVIDERS)
.filter(([, config]) => !config.unsupported)
.filter(([, config]) => !dispatchable.has(config.format))
.map(([providerId, config]) => `${providerId} (format: ${config.format})`);
assert.deepEqual(
broken,
[],
"каталог рекламирует провайдеров, чей формат диспетчер не разбирает — " +
"либо добавьте ветку, либо пометьте провайдера unsupported:\n " +
broken.join("\n ")
);
});
test("unsupported providers state a reason", () => {
const silent = Object.entries(VIDEO_PROVIDERS)
.filter(([, config]) => config.unsupported)
.filter(([, config]) => !config.unsupportedReason?.trim())
.map(([providerId]) => providerId);
assert.deepEqual(silent, [], "провайдер снят с витрины без причины: " + silent.join(", "));
});