From 43f2b2c288a12aea90d93ae75fbdde8b4adf6b89 Mon Sep 17 00:00:00 2001 From: quiterunner-commits <276358597+quiterunner-commits@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:39:33 +0300 Subject: [PATCH 1/7] 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! --- .../imageGeneration/providers/aihorde.ts | 84 ++++++++++--- tests/unit/aihorde-queue-budget.test.ts | 114 ++++++++++++++++++ 2 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 tests/unit/aihorde-queue-budget.test.ts diff --git a/open-sse/handlers/imageGeneration/providers/aihorde.ts b/open-sse/handlers/imageGeneration/providers/aihorde.ts index 13fa50f5ab..782f641438 100644 --- a/open-sse/handlers/imageGeneration/providers/aihorde.ts +++ b/open-sse/handlers/imageGeneration/providers/aihorde.ts @@ -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( diff --git a/tests/unit/aihorde-queue-budget.test.ts b/tests/unit/aihorde-queue-budget.test.ts new file mode 100644 index 0000000000..8f8ac0e782 --- /dev/null +++ b/tests/unit/aihorde-queue-budget.test.ts @@ -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, + "короткая очередь не должна приводить к раннему отказу" + ); +}); From ececf91e9e3c23c05811f26d7a00e7d46846e0fe Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Sun, 30 Aug 2026 18:39:36 -0400 Subject: [PATCH 2/7] 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! --- open-sse/config/constants.ts | 1 + open-sse/handlers/search/searchProxy.ts | 26 ++ open-sse/services/accountFallback.ts | 21 +- open-sse/services/quotaTextCooldowns.ts | 7 +- .../search-432-plan-limit-cooldown.test.ts | 288 ++++++++++++++++++ 5 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 tests/unit/search-432-plan-limit-cooldown.test.ts diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index d99da4725b..ebe568d2d3 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -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, diff --git a/open-sse/handlers/search/searchProxy.ts b/open-sse/handlers/search/searchProxy.ts index ec2d855858..ffc7872299 100644 --- a/open-sse/handlers/search/searchProxy.ts +++ b/open-sse/handlers/search/searchProxy.ts @@ -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, diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 4a29105e6a..b5e5fe7b34 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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) diff --git a/open-sse/services/quotaTextCooldowns.ts b/open-sse/services/quotaTextCooldowns.ts index 2717d128dc..e5a13cf62f 100644 --- a/open-sse/services/quotaTextCooldowns.ts +++ b/open-sse/services/quotaTextCooldowns.ts @@ -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. diff --git a/tests/unit/search-432-plan-limit-cooldown.test.ts b/tests/unit/search-432-plan-limit-cooldown.test.ts new file mode 100644 index 0000000000..074054678d --- /dev/null +++ b/tests/unit/search-432-plan-limit-cooldown.test.ts @@ -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((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((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((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((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"); +}); From 8a1d9bf910ae899aeae4b440dc616fd3e8801d1b Mon Sep 17 00:00:00 2001 From: Abhishek Divekar Date: Mon, 31 Aug 2026 04:09:42 +0530 Subject: [PATCH 3/7] 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! --- src/lib/credentialHealth/scheduler.ts | 4 ++-- src/lib/resilience/settings.ts | 7 ++++--- tests/unit/credential-health-sweep-interval.test.ts | 12 ++++++------ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/lib/credentialHealth/scheduler.ts b/src/lib/credentialHealth/scheduler.ts index 8c2241bc92..d5922e87d2 100644 --- a/src/lib/credentialHealth/scheduler.ts +++ b/src/lib/credentialHealth/scheduler.ts @@ -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 } /** diff --git a/src/lib/resilience/settings.ts b/src/lib/resilience/settings.ts index e512b18f78..ddb67220a5 100644 --- a/src/lib/resilience/settings.ts +++ b/src/lib/resilience/settings.ts @@ -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, }, }; diff --git a/tests/unit/credential-health-sweep-interval.test.ts b/tests/unit/credential-health-sweep-interval.test.ts index 135d25576f..e79a31dedc 100644 --- a/tests/unit/credential-health-sweep-interval.test.ts +++ b/tests/unit/credential-health-sweep-interval.test.ts @@ -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); }); }); From 9aa7c2459a5d0bb13a79cb85b9d5fba4b433f92c Mon Sep 17 00:00:00 2001 From: quiterunner-commits <276358597+quiterunner-commits@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:39:46 +0300 Subject: [PATCH 4/7] 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! --- open-sse/config/videoRegistry.ts | 40 ++++++++- open-sse/handlers/videoGeneration/openai.ts | 5 ++ .../video-openai-endpoint-fallback.test.ts | 73 ++++++++++++++++ .../video-registry-dispatch-parity.test.ts | 86 +++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 tests/unit/video-openai-endpoint-fallback.test.ts create mode 100644 tests/unit/video-registry-dispatch-parity.test.ts diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index df2f297aab..5be229636f 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -190,6 +190,13 @@ export const VIDEO_PROVIDERS: Record = { 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 = { 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 = { 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 = { 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 = { 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" }], }, }; diff --git a/open-sse/handlers/videoGeneration/openai.ts b/open-sse/handlers/videoGeneration/openai.ts index b53ae51fea..ce9771ce1e 100644 --- a/open-sse/handlers/videoGeneration/openai.ts +++ b/open-sse/handlers/videoGeneration/openai.ts @@ -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; diff --git a/tests/unit/video-openai-endpoint-fallback.test.ts b/tests/unit/video-openai-endpoint-fallback.test.ts new file mode 100644 index 0000000000..43d3edd960 --- /dev/null +++ b/tests/unit/video-openai-endpoint-fallback.test.ts @@ -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"]); +}); diff --git a/tests/unit/video-registry-dispatch-parity.test.ts b/tests/unit/video-registry-dispatch-parity.test.ts new file mode 100644 index 0000000000..af9316f8d4 --- /dev/null +++ b/tests/unit/video-registry-dispatch-parity.test.ts @@ -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(", ")); +}); From 4e4522c285e1a2ea6877c46d149f49a2fa5cf22d Mon Sep 17 00:00:00 2001 From: Wahid Sadik Date: Mon, 31 Aug 2026 04:39:50 +0600 Subject: [PATCH 5/7] 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! --- open-sse/handlers/chatCore.ts | 10 +- .../handlers/chatCore/claudeToolDefaults.ts | 38 +++++++ .../unit/agentrouter-custom-tool-type.test.ts | 107 ++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 tests/unit/agentrouter-custom-tool-type.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index bbb75cc4da..658b4084da 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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; } diff --git a/open-sse/handlers/chatCore/claudeToolDefaults.ts b/open-sse/handlers/chatCore/claudeToolDefaults.ts index bf758b6a5a..724f5a0f76 100644 --- a/open-sse/handlers/chatCore/claudeToolDefaults.ts +++ b/open-sse/handlers/chatCore/claudeToolDefaults.ts @@ -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); +} diff --git a/tests/unit/agentrouter-custom-tool-type.test.ts b/tests/unit/agentrouter-custom-tool-type.test.ts new file mode 100644 index 0000000000..4a1248f336 --- /dev/null +++ b/tests/unit/agentrouter-custom-tool-type.test.ts @@ -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 + >; + 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 + >; + 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 + >; + 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 + >; + 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 + >; + 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 + >; + 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).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"); +}); From 897c3f8c9d9413537b0b0e004a215bd5713e436d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A6=8D=E5=84=BF=20=E2=9C=A8?= Date: Mon, 31 Aug 2026 06:40:14 +0800 Subject: [PATCH 6/7] 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! --- bin/aliasResolver.mjs | 28 +-- changelog.d/fixes/12073-node26-alias-hooks.md | 1 + tests/unit/cli/alias-resolver-12073.test.ts | 167 ++++++++++++++++++ 3 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/12073-node26-alias-hooks.md create mode 100644 tests/unit/cli/alias-resolver-12073.test.ts diff --git a/bin/aliasResolver.mjs b/bin/aliasResolver.mjs index 4070e0c0a0..e701f5358c 100644 --- a/bin/aliasResolver.mjs +++ b/bin/aliasResolver.mjs @@ -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} 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; } } diff --git a/changelog.d/fixes/12073-node26-alias-hooks.md b/changelog.d/fixes/12073-node26-alias-hooks.md new file mode 100644 index 0000000000..ebce8fcee2 --- /dev/null +++ b/changelog.d/fixes/12073-node26-alias-hooks.md @@ -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)). diff --git a/tests/unit/cli/alias-resolver-12073.test.ts b/tests/unit/cli/alias-resolver-12073.test.ts new file mode 100644 index 0000000000..b2568ff2b1 --- /dev/null +++ b/tests/unit/cli/alias-resolver-12073.test.ts @@ -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:, 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 }); + } + }); +}); From 7f49b342b5c743264706dd746efe14068ee6fa8a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 30 Aug 2026 20:35:33 -0300 Subject: [PATCH 7/7] =?UTF-8?q?chore(lint):=20batch=200=20of=20#12146=20?= =?UTF-8?q?=E2=80=94=20type=20the=20call-log-cap=20sqlite=20rows=20instead?= =?UTF-8?q?=20of=2045=20as-any=20casts=20(#12157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/quality/eslint-suppressions.json | 5 - tests/unit/call-log-cap.test.ts | 128 ++++++++++++++++-------- 2 files changed, 84 insertions(+), 49 deletions(-) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 7228fdd51e..c7e6c174ba 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -3552,11 +3552,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 diff --git a/tests/unit/call-log-cap.test.ts b/tests/unit/call-log-cap.test.ts index 15bbf3a78e..baa68e57b8 100644 --- a/tests/unit/call-log-cap.test.ts +++ b/tests/unit/call-log-cap.test.ts @@ -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 }; +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); });