mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-05 06:22:12 +03:00
Compare commits
12 Commits
docs/opena
...
fix/11804-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
972744a0c3 | ||
|
|
7ca5e1c671 | ||
|
|
73db936f98 | ||
|
|
4b5266d3f8 | ||
|
|
f8b01c966e | ||
|
|
e12fb110f9 | ||
|
|
2fbd0f5c25 | ||
|
|
18c71b91dc | ||
|
|
9392bd55c2 | ||
|
|
90366903c4 | ||
|
|
668beed5b8 | ||
|
|
298ad0fd64 |
@@ -0,0 +1 @@
|
||||
- **feat(routing):** add a `score` Auto router strategy that selects the highest configured weighted score and reuses `explorationRate`.
|
||||
@@ -0,0 +1,5 @@
|
||||
- Keep the embedding registry's vector width and `embedding` type on models when a synced model exists
|
||||
for the same id, so `/v1/models` no longer reports registry-described embedding models widthless or
|
||||
untyped (#11761)
|
||||
- Correct `google/gemini-embedding-001` on the OpenRouter route to 3072 dimensions, the width it
|
||||
returns when `dimensions` is not sent (#11761)
|
||||
@@ -0,0 +1 @@
|
||||
- Absorb `Error [AbortError]: request_signal_aborted` and DOMException AbortError shapes in the process-level client-abort crash guard so routine client disconnects no longer kill the server (exit code 7).
|
||||
@@ -829,12 +829,6 @@
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/a2a/page.tsx": {
|
||||
@@ -850,62 +844,16 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx": {
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/analytics/CacheHealthTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -926,24 +874,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/components/ReasoningCacheTab.tsx": {
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/page.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 4
|
||||
@@ -1060,31 +990,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/memory/components/EditMemoryModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/onboarding/page.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -1185,29 +1090,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/radar/intel/page.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/radar/page.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/radar/setup/page.tsx": {
|
||||
"react-hooks/preserve-manual-memoization": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -1376,11 +1258,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
@@ -1391,11 +1268,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -1404,17 +1276,11 @@
|
||||
"src/app/(dashboard)/dashboard/usage/components/RateLimitStatus.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/SessionsTab.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx": {
|
||||
@@ -1437,17 +1303,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/home/ProviderQuotaWidget.tsx": {
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/api/assess/route.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -2298,9 +2153,6 @@
|
||||
"src/shared/components/RequestLoggerV2.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"src/shared/components/RequestTimeline.tsx": {
|
||||
|
||||
@@ -414,6 +414,8 @@ Persisted `strategy: "auto"` combos can set `config.routerStrategy` (or legacy
|
||||
`config.auto.routerStrategy`) to one of:
|
||||
|
||||
- `rules` — default weighted scoring
|
||||
- `score` — selects the highest configured weighted score. Exact ties preserve configured
|
||||
candidate order; the existing `explorationRate` samples from the full ranked pool.
|
||||
- `cost` / `eco` — cheapest healthy provider
|
||||
- `latency` / `fast` — lowest p95 latency with reliability penalty
|
||||
- `sla-aware` / `sla` — prefer candidates that satisfy p95 latency, error-rate, and optional
|
||||
@@ -422,7 +424,7 @@ Persisted `strategy: "auto"` combos can set `config.routerStrategy` (or legacy
|
||||
|
||||
### Router strategies in detail
|
||||
|
||||
The auto-combo engine exposes 5 pluggable **RouterStrategy** implementations that
|
||||
The auto-combo engine exposes 6 pluggable **RouterStrategy** implementations that
|
||||
you can swap via `config.routerStrategy` (or the legacy `config.auto.routerStrategy`).
|
||||
Each strategy picks one provider from the candidate pool, given a `RoutingContext`
|
||||
(task type, tool/vision hints, token estimate, optional SLA policy, optional
|
||||
|
||||
@@ -570,3 +570,37 @@ export function isVerifiedNativeCodexRequest(
|
||||
): boolean {
|
||||
return isCodexOriginatedHeaders(headers) && hasNativeCodexTurnBinding(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the Claude Code CLI as the request *client* from request headers.
|
||||
* Used to auto-enable model echo so session restores work when the resolved
|
||||
* upstream model (e.g. `oc/nemotron-3-ultra-free`) is not recognized by the
|
||||
* Claude Code client on `--resume`.
|
||||
*/
|
||||
export function isClaudeCodeOriginatedHeaders(
|
||||
headers: Headers | Record<string, unknown> | null | undefined
|
||||
): boolean {
|
||||
const getHeader = (name: string): string => {
|
||||
if (headers instanceof Headers) {
|
||||
return headers.get(name)?.toLowerCase() ?? "";
|
||||
}
|
||||
if (headers && typeof headers === "object") {
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (key.toLowerCase() === name && typeof value === "string") {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// Claude Code identifies itself via the user-agent header
|
||||
const userAgent = getHeader("user-agent");
|
||||
if (userAgent.includes("claude-code") || userAgent.includes("anthropic-ai/claude-code")) {
|
||||
return true;
|
||||
}
|
||||
// Also check originator if present
|
||||
const originator = getHeader("originator");
|
||||
if (originator.startsWith("claude-code")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
{
|
||||
id: "google/gemini-embedding-001",
|
||||
name: "Gemini Embedding 001 (OpenRouter)",
|
||||
dimensions: 768,
|
||||
dimensions: 3072,
|
||||
},
|
||||
{
|
||||
id: "google/gemini-embedding-2",
|
||||
|
||||
54
open-sse/executors/credential.ts
Normal file
54
open-sse/executors/credential.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { assertCommonChatGptWebProviderAvailable } from "@/shared/constants/chatgptWebRetirement";
|
||||
import { assertMicrosoftDesignerWebProviderAvailable } from "@/shared/constants/designerWebRetirement";
|
||||
import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
|
||||
import type { BaseExecutor } from "./base.ts";
|
||||
import { getDefaultExecutor } from "./defaultResolver.ts";
|
||||
|
||||
type CredentialExecutorLoader = () => Promise<BaseExecutor>;
|
||||
|
||||
const specializedCredentialExecutors: Record<string, CredentialExecutorLoader> = {
|
||||
antigravity: () => import("./antigravity.ts").then((m) => new m.AntigravityExecutor()),
|
||||
agy: () => import("./antigravity.ts").then((m) => new m.AntigravityExecutor()),
|
||||
github: () => import("./github.ts").then((m) => new m.GithubExecutor()),
|
||||
"ghe-copilot": () => import("./ghe-copilot.ts").then((m) => new m.GheCopilotExecutor()),
|
||||
kiro: () => import("./kiro.ts").then((m) => new m.KiroExecutor()),
|
||||
"amazon-q": () => import("./kiro.ts").then((m) => new m.KiroExecutor("amazon-q")),
|
||||
codex: () => import("./codex.ts").then((m) => new m.CodexExecutor()),
|
||||
cursor: () => import("./cursor.ts").then((m) => new m.CursorExecutor()),
|
||||
cu: () => import("./cursor.ts").then((m) => new m.CursorExecutor()),
|
||||
"cursor-api": () => import("./cursor.ts").then((m) => new m.CursorExecutor("cursor-api")),
|
||||
cua: () => import("./cursor.ts").then((m) => new m.CursorExecutor("cursor-api")),
|
||||
trae: () => import("./trae.ts").then((m) => new m.TraeExecutor()),
|
||||
gitlab: () => import("./gitlab.ts").then((m) => new m.GitlabExecutor()),
|
||||
"gitlab-duo": () => import("./gitlab.ts").then((m) => new m.GitlabExecutor("gitlab-duo")),
|
||||
"zed-hosted": () => import("./zed-hosted.ts").then((m) => new m.ZedHostedExecutor()),
|
||||
"grok-cli": () => import("./grok-cli.ts").then((m) => new m.GrokCliExecutor()),
|
||||
gc: () => import("./grok-cli.ts").then((m) => new m.GrokCliExecutor()),
|
||||
auggie: () => import("./auggie.ts").then((m) => new m.AuggieExecutor()),
|
||||
xai: () => import("./xai.ts").then((m) => new m.XaiExecutor()),
|
||||
"xai-oauth": () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
|
||||
xao: () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
|
||||
};
|
||||
|
||||
const credentialExecutorCache = new Map<string, Promise<BaseExecutor>>();
|
||||
|
||||
/** Resolve only executors with credential-refresh behavior, without loading the chat registry. */
|
||||
export async function getCredentialRefreshExecutor(provider: string): Promise<BaseExecutor> {
|
||||
assertMicrosoftDesignerWebProviderAvailable(provider);
|
||||
assertRuntimeProviderAvailable(provider);
|
||||
assertCommonChatGptWebProviderAvailable(provider);
|
||||
|
||||
let executor = credentialExecutorCache.get(provider);
|
||||
if (!executor) {
|
||||
const specializedLoader = specializedCredentialExecutors[provider];
|
||||
executor = specializedLoader
|
||||
? specializedLoader()
|
||||
: Promise.resolve(getDefaultExecutor(provider));
|
||||
executor = executor.catch((error) => {
|
||||
credentialExecutorCache.delete(provider);
|
||||
throw error;
|
||||
});
|
||||
credentialExecutorCache.set(provider, executor);
|
||||
}
|
||||
return executor;
|
||||
}
|
||||
13
open-sse/executors/defaultResolver.ts
Normal file
13
open-sse/executors/defaultResolver.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
|
||||
const defaultExecutorCache = new Map<string, DefaultExecutor>();
|
||||
|
||||
/** Resolve the shared fallback executor without initializing the specialized executor registry. */
|
||||
export function getDefaultExecutor(provider: string): DefaultExecutor {
|
||||
let executor = defaultExecutorCache.get(provider);
|
||||
if (!executor) {
|
||||
executor = new DefaultExecutor(provider);
|
||||
defaultExecutorCache.set(provider, executor);
|
||||
}
|
||||
return executor;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./registry.ts";
|
||||
// Type-only: pulls no runtime code, keeps DefaultExecutor the only eager class.
|
||||
import type { BaseExecutor } from "./base.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import { getDefaultExecutor } from "./defaultResolver.ts";
|
||||
|
||||
// R0.3 — declarative built-in table, made LAZY by #11220.
|
||||
//
|
||||
@@ -207,8 +207,6 @@ for (const [alias, load] of Object.entries(lazyExecutors)) {
|
||||
registerLazyExecutor(alias, load);
|
||||
}
|
||||
|
||||
const defaultCache = new Map();
|
||||
|
||||
// #6699 — providers that exist ONLY as Cloud Agent task-API entries
|
||||
// (CLOUD_AGENT_PROVIDERS / staticModels "Available Models" catalog) and have no
|
||||
// chat-completions REGISTRY entry anywhere in open-sse/. Without this guard,
|
||||
@@ -251,8 +249,7 @@ export async function getExecutor(provider: string): Promise<BaseExecutor> {
|
||||
(err as Error & { status?: number }).status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider));
|
||||
return defaultCache.get(provider)!;
|
||||
return getDefaultExecutor(provider);
|
||||
}
|
||||
|
||||
export function hasSpecializedExecutor(provider: string): boolean {
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
isStripReasoningRequested,
|
||||
} from "./chatCore/headers.ts";
|
||||
import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts";
|
||||
import { getCodexClientSessionId, isCodexOriginatedHeaders } from "../config/codexIdentity.ts";
|
||||
import { getCodexClientSessionId, isCodexOriginatedHeaders, isClaudeCodeOriginatedHeaders } from "../config/codexIdentity.ts";
|
||||
import {
|
||||
noteCodexTurnStateProvenance,
|
||||
readCodexTurnStateHeader,
|
||||
@@ -981,8 +981,14 @@ export async function handleChatCore({
|
||||
const isCodexResponsesEcho =
|
||||
(isResponsesEndpoint || sourceFormat === FORMATS.OPENAI_RESPONSES) &&
|
||||
isCodexOriginatedHeaders(clientRawRequest?.headers);
|
||||
|
||||
// Detect Claude Code CLI so we can auto-enable model echo — this prevents
|
||||
// session restore failures when the resolved upstream model (e.g.
|
||||
// `oc/nemotron-3-ultra-free`) is not recognized by the client on `--resume`.
|
||||
const isClaudeCodeClient = isClaudeCodeOriginatedHeaders(clientRawRequest?.headers);
|
||||
|
||||
let echoModel =
|
||||
(settings.echoRequestedModelName === true || isCodexResponsesEcho) &&
|
||||
(settings.echoRequestedModelName === true || isCodexResponsesEcho || isClaudeCodeClient) &&
|
||||
typeof requestedModel === "string" &&
|
||||
requestedModel
|
||||
? requestedModel
|
||||
@@ -5465,6 +5471,7 @@ export async function handleChatCore({
|
||||
|
||||
const streamReadiness = await ensureStreamReadiness(providerResponse, {
|
||||
timeoutMs: streamReadinessPolicy.timeoutMs,
|
||||
maxTimeoutMs: streamReadinessPolicy.maxTimeoutMs,
|
||||
provider,
|
||||
model,
|
||||
log,
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
* Inspired by ClawRouter commit 14c83c258 "refactor: extract routing into pluggable RouterStrategy system".
|
||||
* Provides a RouterStrategy interface and built-in implementations:
|
||||
* - RulesStrategy (default): wraps the existing 15-factor scoring engine
|
||||
* - ScoreStrategy: highest configured weighted score, with explicit exploration
|
||||
* - CostStrategy: always picks cheapest available model
|
||||
* - LatencyStrategy: prioritizes low p95 latency with reliability weighting
|
||||
* - SLAStrategy: prefers candidates that satisfy latency/error/cost SLOs
|
||||
* - LKGPStrategy: tries last known good provider first
|
||||
*/
|
||||
|
||||
import type { ProviderCandidate, ScoredProvider } from "./scoring.ts";
|
||||
import type { ProviderCandidate, ScoredProvider, ScoringWeights } from "./scoring.ts";
|
||||
import { scorePool } from "./scoring.ts";
|
||||
import { getTaskFitness } from "./taskFitness.ts";
|
||||
import { clamp01 } from "../../utils/number.ts";
|
||||
@@ -32,6 +33,8 @@ export interface RoutingContext {
|
||||
lastKnownGoodProvider?: string;
|
||||
lkgpEnabled?: boolean;
|
||||
sla?: SlaRoutingPolicy;
|
||||
weights?: ScoringWeights;
|
||||
explorationRate?: number;
|
||||
}
|
||||
|
||||
export interface RoutingDecision {
|
||||
@@ -108,6 +111,38 @@ class RulesStrategyImpl implements RouterStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ScoreStrategy: configured score wins, with explicit exploration ──────────
|
||||
|
||||
class ScoreStrategyImpl implements RouterStrategy {
|
||||
readonly name = "score";
|
||||
readonly description = "Selects the highest configured weighted score, with explicit exploration";
|
||||
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision {
|
||||
const eligible = pool.filter((candidate) => candidate.circuitBreakerState !== "OPEN");
|
||||
const ranked = scorePool(
|
||||
eligible.length > 0 ? eligible : pool,
|
||||
context.taskType,
|
||||
context.weights,
|
||||
getTaskFitness
|
||||
);
|
||||
if (ranked.length === 0) throw new Error("[ScoreStrategy] No candidates to score");
|
||||
|
||||
const explorationRate = Math.min(1, Math.max(0, context.explorationRate ?? 0));
|
||||
const isExploration = Math.random() < explorationRate && ranked.length > 1;
|
||||
const selected = isExploration ? ranked[Math.floor(Math.random() * ranked.length)] : ranked[0];
|
||||
|
||||
return {
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
strategy: this.name,
|
||||
reason: `ScoreStrategy: score=${selected.score.toFixed(3)}${isExploration ? " (exploration)" : ""}`,
|
||||
candidatesConsidered: ranked.length,
|
||||
finalScore: selected.score,
|
||||
connectionId: selected.connectionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── CostStrategy: always picks cheapest healthy provider ─────────────────────
|
||||
|
||||
class CostStrategyImpl implements RouterStrategy {
|
||||
@@ -337,12 +372,14 @@ class LKGPStrategyImpl implements RouterStrategy {
|
||||
const strategyRegistry = new Map<string, RouterStrategy>();
|
||||
|
||||
const rulesStrategy = new RulesStrategyImpl();
|
||||
const scoreStrategy = new ScoreStrategyImpl();
|
||||
const costStrategy = new CostStrategyImpl();
|
||||
const latencyStrategy = new LatencyStrategyImpl();
|
||||
const slaStrategy = new SLAStrategyImpl();
|
||||
const lkgpStrategy = new LKGPStrategyImpl();
|
||||
|
||||
strategyRegistry.set("rules", rulesStrategy);
|
||||
strategyRegistry.set("score", scoreStrategy);
|
||||
strategyRegistry.set("cost", costStrategy);
|
||||
strategyRegistry.set("eco", costStrategy); // alias
|
||||
strategyRegistry.set("latency", latencyStrategy);
|
||||
|
||||
@@ -1111,8 +1111,19 @@ async function handleComboChatInner({
|
||||
let lastError: string | null = null;
|
||||
let earliestRetryAfter: ComboRetryAfter | null = null;
|
||||
let lastStatus: number | null = null;
|
||||
// #11804: the loop-safety timer is armed per setTry iteration but must be
|
||||
// cleared on EVERY exit path, not just the happy one. Hoisted to function
|
||||
// scope so the `finally` at the end of this function always reaches it —
|
||||
// otherwise each error path (all_targets_skipped / all_accounts_inactive /
|
||||
// aggregated status / final fallback / global timeout) returned to the client
|
||||
// leaving a 10-minute timer pending, whose closure retains orderedTargets and
|
||||
// the exhausted provider/connection sets. Field evidence on the issue: the
|
||||
// client got a 502 immediately, and "Combo loop safety timeout ...
|
||||
// force-terminating" was logged exactly 600s later.
|
||||
let activeLoopSafetyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
for (let setTry = 0; setTry <= maxSetRetries; setTry++) {
|
||||
try {
|
||||
for (let setTry = 0; setTry <= maxSetRetries; setTry++) {
|
||||
// #1731: Per-set-iteration set of providers whose quota is fully exhausted.
|
||||
// Reset each retry so providers excluded in a previous attempt get another chance.
|
||||
const exhaustedProviders = new Set<string>();
|
||||
@@ -1198,6 +1209,7 @@ async function handleComboChatInner({
|
||||
);
|
||||
}, loopSafetyMs);
|
||||
loopSafetyTimer.unref?.();
|
||||
activeLoopSafetyTimer = loopSafetyTimer;
|
||||
});
|
||||
const runningTasks = new Set<Promise<void>>();
|
||||
let anySuccess = false;
|
||||
@@ -2870,11 +2882,20 @@ async function handleComboChatInner({
|
||||
// Surface the recovery hint with a generic retry recommendation so the client at least
|
||||
// gets a non-opaque message instead of "Combo routing completed without an upstream response".
|
||||
recordComboFailure(effectiveSessionId, combo.name);
|
||||
return errorResponseWithComboDiagnostics(
|
||||
503,
|
||||
"Combo routing completed without an upstream response",
|
||||
buildNoUpstreamResponseDiagnostics(orderedTargets.length)
|
||||
);
|
||||
return errorResponseWithComboDiagnostics(
|
||||
503,
|
||||
"Combo routing completed without an upstream response",
|
||||
buildNoUpstreamResponseDiagnostics(orderedTargets.length)
|
||||
);
|
||||
} finally {
|
||||
// #11804: always release the loop-safety timer. Covering every exit path by
|
||||
// construction here means a future `return` added to this function cannot
|
||||
// silently reintroduce the leak.
|
||||
if (activeLoopSafetyTimer) {
|
||||
clearTimeout(activeLoopSafetyTimer);
|
||||
activeLoopSafetyTimer = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// FASE 2.1: acquire the per-connection concurrency slot for the selected
|
||||
|
||||
@@ -377,6 +377,8 @@ export async function resolveAutoStrategyOrder(
|
||||
boolean | undefined,
|
||||
estimatedInputTokens,
|
||||
sla: slaPolicy,
|
||||
weights,
|
||||
explorationRate,
|
||||
},
|
||||
routingStrategy
|
||||
);
|
||||
|
||||
@@ -43,7 +43,19 @@ export function resolveReasoningTransport(
|
||||
): ReasoningTransport {
|
||||
const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : "";
|
||||
const transport = REASONING_TRANSPORTS.get(normalized);
|
||||
return transport ?? (preserveEncryptedReasoning ? "opaque" : "plaintext");
|
||||
if (transport) return transport;
|
||||
// #12128: Generic Responses-protocol endpoints (e.g. openai-compatible-responses-*,
|
||||
// custom-openai-responses, proxy backends) implement the OpenAI/Codex Responses API
|
||||
// where reasoning input items cannot accept plaintext content (maxItems: 0).
|
||||
if (
|
||||
normalized.startsWith("openai-compatible-responses") ||
|
||||
normalized.startsWith("custom-openai-responses") ||
|
||||
normalized.includes("codex") ||
|
||||
normalized.includes("responses")
|
||||
) {
|
||||
return "opaque";
|
||||
}
|
||||
return preserveEncryptedReasoning ? "opaque" : "plaintext";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
normalizeResponsesReasoningEffort,
|
||||
RESPONSES_STORE_MARKER,
|
||||
} from "./request/openai-responses/helpers.ts";
|
||||
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
|
||||
|
||||
bootstrapTranslatorRegistry();
|
||||
export { register } from "./registry.ts";
|
||||
@@ -575,6 +576,14 @@ export function translateRequest(
|
||||
// Normalize openai-responses input shape for providers that require list input.
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
result = normalizeOpenAIResponsesRequest(result);
|
||||
// #12128: Sanitize reasoning input items for Responses targets (strip plaintext content for opaque backends)
|
||||
applyReasoningInputPolicy(result as Record<string, unknown>, "responses", {
|
||||
provider,
|
||||
preserveEncryptedReasoning:
|
||||
(credentials as { providerSpecificData?: { preserveEncryptedReasoning?: boolean } } | null)
|
||||
?.providerSpecificData?.preserveEncryptedReasoning === true,
|
||||
onIncompatibleReasoning: "drop",
|
||||
});
|
||||
}
|
||||
|
||||
// Second role normalization: only for OPENAI_RESPONSES. Here messages are built from input
|
||||
|
||||
@@ -220,12 +220,15 @@ function preserveRequired(obj: unknown): void {
|
||||
return;
|
||||
}
|
||||
const record = obj as JsonRecord;
|
||||
if (Array.isArray(record.required) && record.properties && typeof record.properties === "object") {
|
||||
if (
|
||||
Array.isArray(record.required) &&
|
||||
record.properties &&
|
||||
typeof record.properties === "object"
|
||||
) {
|
||||
const properties = record.properties as JsonRecord;
|
||||
const valid = (record.required as unknown[]).filter(
|
||||
(field) =>
|
||||
typeof field === "string" &&
|
||||
Object.prototype.hasOwnProperty.call(properties, field)
|
||||
typeof field === "string" && Object.prototype.hasOwnProperty.call(properties, field)
|
||||
);
|
||||
if (valid.length === 0) {
|
||||
delete record.required;
|
||||
@@ -298,12 +301,13 @@ function convertContent(content) {
|
||||
|
||||
// Function response → collect all, each becomes a separate tool message
|
||||
if (part.functionResponse) {
|
||||
const resp = part.functionResponse.response;
|
||||
const resultPayload =
|
||||
resp && typeof resp === "object" && "result" in resp ? resp.result : (resp ?? {});
|
||||
toolResults.push({
|
||||
role: "tool",
|
||||
tool_call_id: part.functionResponse.id || part.functionResponse.name,
|
||||
content: JSON.stringify(
|
||||
part.functionResponse.response?.result || part.functionResponse.response || {}
|
||||
),
|
||||
content: JSON.stringify(resultPayload),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -316,9 +320,7 @@ function convertContent(content) {
|
||||
const assistantMsg: JsonRecord = { role: "assistant" };
|
||||
if (textParts.length > 0) {
|
||||
assistantMsg.content =
|
||||
textParts.length === 1 && textParts[0].type === "text"
|
||||
? textParts[0].text
|
||||
: textParts;
|
||||
textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts;
|
||||
}
|
||||
if (reasoningContent) {
|
||||
assistantMsg.reasoning_content = reasoningContent;
|
||||
|
||||
@@ -147,12 +147,13 @@ function convertGeminiContent(content) {
|
||||
}
|
||||
|
||||
if (part.functionResponse) {
|
||||
const resp = part.functionResponse.response;
|
||||
const resultPayload =
|
||||
resp && typeof resp === "object" && "result" in resp ? resp.result : (resp ?? {});
|
||||
return {
|
||||
role: "tool",
|
||||
tool_call_id: part.functionResponse.id || part.functionResponse.name,
|
||||
content: JSON.stringify(
|
||||
part.functionResponse.response?.result || part.functionResponse.response || {}
|
||||
),
|
||||
content: JSON.stringify(resultPayload),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +471,9 @@ export async function ensureStreamReadiness(
|
||||
response: Response,
|
||||
options: {
|
||||
timeoutMs: number;
|
||||
/** Hard ceiling for liveness-extended deadlines. When omitted, no hard ceiling
|
||||
* is applied beyond `timeoutMs`. */
|
||||
maxTimeoutMs?: number;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
log?: StreamReadinessLogger | null;
|
||||
@@ -489,7 +492,14 @@ export async function ensureStreamReadiness(
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
const effectiveTimeoutMs = Math.max(0, Math.floor(options.timeoutMs));
|
||||
const deadline = startedAt + effectiveTimeoutMs;
|
||||
// Hard ceiling: the deadline may extend on liveness signals (bytes arriving),
|
||||
// but never past this absolute maximum. When maxTimeoutMs is omitted the
|
||||
// initial timeoutMs itself acts as the ceiling (no extension).
|
||||
const maxDeadline =
|
||||
options.maxTimeoutMs != null
|
||||
? startedAt + Math.max(effectiveTimeoutMs, Math.floor(options.maxTimeoutMs))
|
||||
: startedAt + effectiveTimeoutMs;
|
||||
let deadline = startedAt + effectiveTimeoutMs;
|
||||
let handedOffReader = false;
|
||||
|
||||
const buildReadyResponse = () =>
|
||||
@@ -500,7 +510,7 @@ export async function ensureStreamReadiness(
|
||||
});
|
||||
|
||||
const timeoutReason = () =>
|
||||
`Stream produced no non-ping SSE event within ${effectiveTimeoutMs}ms`;
|
||||
`Stream produced no non-ping SSE event within ${deadline - startedAt}ms (max=${maxDeadline - startedAt}ms)`;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -593,6 +603,22 @@ export async function ensureStreamReadiness(
|
||||
chunks.push(readResult.value);
|
||||
const decodedChunk = decoder.decode(readResult.value, { stream: true });
|
||||
|
||||
// Liveness extension: bytes arrived → connection is alive, not dead.
|
||||
// Reset the deadline so slow-but-alive upstreams (reasoning warm-ups,
|
||||
// keepalive-only phases) are not aborted. The hard ceiling (maxDeadline)
|
||||
// prevents unbounded waits and preserves the operator's fast-fail intent
|
||||
// for truly dead connections.
|
||||
const now = Date.now();
|
||||
if (deadline < maxDeadline) {
|
||||
deadline = Math.min(now + effectiveTimeoutMs, maxDeadline);
|
||||
if (now - startedAt > effectiveTimeoutMs) {
|
||||
options.log?.debug?.(
|
||||
"STREAM",
|
||||
`readiness deadline extended to ${deadline - startedAt}ms (liveness signal) (${options.provider || "provider"}/${options.model || "unknown"})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (appendStreamReadinessSignal(readinessState, decodedChunk)) {
|
||||
options.log?.debug?.(
|
||||
"STREAM",
|
||||
|
||||
@@ -14,6 +14,7 @@ export type StreamReadinessPolicyInput = {
|
||||
export type StreamReadinessPolicyResult = {
|
||||
timeoutMs: number;
|
||||
baseTimeoutMs: number;
|
||||
maxTimeoutMs: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
@@ -121,7 +122,7 @@ export function resolveStreamReadinessTimeout(
|
||||
): StreamReadinessPolicyResult {
|
||||
const baseTimeoutMs = Math.max(0, Math.floor(input.baseTimeoutMs || 0));
|
||||
if (baseTimeoutMs <= 0) {
|
||||
return { timeoutMs: baseTimeoutMs, baseTimeoutMs, reasons: ["disabled"] };
|
||||
return { timeoutMs: baseTimeoutMs, baseTimeoutMs, maxTimeoutMs: baseTimeoutMs, reasons: ["disabled"] };
|
||||
}
|
||||
|
||||
const maxTimeoutMs = Math.max(baseTimeoutMs, input.maxTimeoutMs ?? DEFAULT_MAX_TIMEOUT_MS);
|
||||
@@ -197,5 +198,5 @@ export function resolveStreamReadinessTimeout(
|
||||
timeoutMs = Math.min(timeoutMs, maxTimeoutMs);
|
||||
if (timeoutMs === baseTimeoutMs) reasons.push("base");
|
||||
|
||||
return { timeoutMs, baseTimeoutMs, reasons };
|
||||
return { timeoutMs, baseTimeoutMs, maxTimeoutMs, reasons };
|
||||
}
|
||||
|
||||
@@ -137,6 +137,11 @@ function createNextApp() {
|
||||
});
|
||||
}
|
||||
|
||||
// The custom HTTP server owns process exit. Application instrumentation still
|
||||
// registers its cleanup function, but must not install a competing signal
|
||||
// listener that can race this runner's async server/Next teardown.
|
||||
globalThis.__omnirouteCustomServerOwnsShutdown = true;
|
||||
|
||||
let nextApp = createNextApp();
|
||||
|
||||
// Best-effort self-heal for a corrupted Turbopack persistent dev cache (#6289):
|
||||
@@ -231,6 +236,7 @@ async function start() {
|
||||
systemdNotifier.stopping();
|
||||
try {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await globalThis.__omnirouteRequestShutdown?.(signal);
|
||||
await nextApp.close();
|
||||
} catch (error) {
|
||||
console.error("[SHUTDOWN] Failed during signal:", signal, error);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback, useRef, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
|
||||
@@ -106,6 +106,12 @@ const INLINE_LINK = "text-primary hover:underline";
|
||||
const DOCS_LINK =
|
||||
"hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors";
|
||||
|
||||
// Stable no-op subscription for useSyncExternalStore reads of never-changing
|
||||
// browser globals (location.origin does not change without a full navigation).
|
||||
function emptySubscribe() {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const router = useRouter();
|
||||
const isElectron = useIsElectron();
|
||||
@@ -115,7 +121,13 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const [providerConnections, setProviderConnections] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
// useSyncExternalStore keeps SSR/hydration consistent ("/v1" on the server,
|
||||
// the real origin after hydration) without a setState-in-effect round-trip.
|
||||
const baseUrl = useSyncExternalStore(
|
||||
emptySubscribe,
|
||||
() => `${globalThis.location.origin}/v1`,
|
||||
() => "/v1"
|
||||
);
|
||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||
const [providerMetrics, setProviderMetrics] = useState<Record<string, ProviderMetricSummary>>({});
|
||||
const [providerTopology, setProviderTopology] = useState({ lastProvider: "", errorProvider: "" });
|
||||
@@ -135,36 +147,39 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
// Platform detection and download links for Electron
|
||||
const platform =
|
||||
typeof globalThis.window === "undefined" ? undefined : globalThis.window.electronAPI?.platform;
|
||||
// Destructured to locals: `versionInfo?.current` in a dependency array trips
|
||||
// the lint heuristic that treats any `.current` access as a mutable ref read.
|
||||
const installedVersion = versionInfo?.current || "";
|
||||
const latestVersion = versionInfo?.latest || "";
|
||||
const electronDownload = useMemo(() => {
|
||||
const latest = versionInfo?.latest || "";
|
||||
const cleanLatest = latest.replace(/^v/, "");
|
||||
const cleanLatest = latestVersion.replace(/^v/, "");
|
||||
if (platform === "darwin") {
|
||||
return {
|
||||
label: t("downloadDmg"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
|
||||
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
|
||||
desc: t("downloadDmgDescription", { version: installedVersion }),
|
||||
};
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return {
|
||||
label: t("downloadExe"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
|
||||
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
|
||||
desc: t("downloadExeDescription", { version: installedVersion }),
|
||||
};
|
||||
}
|
||||
if (platform === "linux") {
|
||||
return {
|
||||
label: t("downloadAppImage"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
|
||||
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
|
||||
desc: t("downloadAppImageDescription", { version: installedVersion }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: t("downloadUpdate"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
||||
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
|
||||
desc: t("downloadUpdateDescription", { version: installedVersion }),
|
||||
};
|
||||
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
|
||||
}, [platform, t, latestVersion, installedVersion]);
|
||||
|
||||
// Electron internal auto-updater state and listeners
|
||||
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
|
||||
@@ -234,12 +249,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof globalThis.window !== "undefined") {
|
||||
setBaseUrl(`${globalThis.location.origin}/v1`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [provRes, modelsRes, versionRes] = await Promise.all([
|
||||
@@ -267,7 +276,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
void (async () => {
|
||||
await fetchData();
|
||||
})();
|
||||
}, [fetchData]);
|
||||
|
||||
// Fetch provider nodes for display labels (compat providers)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { AuditLogEntry } from "@/lib/compliance/index";
|
||||
import ActivityFeed from "./components/ActivityFeed";
|
||||
@@ -14,7 +14,9 @@ export default function ActivityFeedClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState<EventCategory>("all");
|
||||
const referenceNowMs = useRef<number>(Date.now());
|
||||
// State (not a ref) because it is rendered: refs cannot be read during
|
||||
// render, and Date.now() cannot run there either — the fetch settles it.
|
||||
const [referenceNowMs, setReferenceNowMs] = useState<number>(0);
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -30,7 +32,7 @@ export default function ActivityFeedClient() {
|
||||
}
|
||||
const data = (await res.json()) as AuditLogEntry[];
|
||||
// Reset reference time on fresh load so relative timestamps are stable
|
||||
referenceNowMs.current = Date.now();
|
||||
setReferenceNowMs(Date.now());
|
||||
setAllEntries(Array.isArray(data) ? data : []);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t("fetchFailed");
|
||||
@@ -41,7 +43,9 @@ export default function ActivityFeedClient() {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
void (async () => {
|
||||
await fetchEntries();
|
||||
})();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const filtered =
|
||||
@@ -114,7 +118,7 @@ export default function ActivityFeedClient() {
|
||||
<span className="text-sm">{t("loadingActivity")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<ActivityFeed entries={filtered} referenceNowMs={referenceNowMs.current} />
|
||||
<ActivityFeed entries={filtered} referenceNowMs={referenceNowMs} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,9 @@ export default function CacheHealthTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(range);
|
||||
void (async () => {
|
||||
await load(range);
|
||||
})();
|
||||
}, [load, range]);
|
||||
|
||||
if (loading) return <Skeleton className="h-64 w-full" />;
|
||||
@@ -179,8 +181,8 @@ export default function CacheHealthTab() {
|
||||
{text(t, "cacheHealthConcentration", "Where the writes are concentrated")}
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted">
|
||||
{text(t, "cacheHealthThreshold", "outlier above")} {compact(data.heavyWriteThreshold)}{" "}
|
||||
{text(t, "cacheHealthTokens", "tokens")}
|
||||
{text(t, "cacheHealthThreshold", "outlier above")}{" "}
|
||||
{compact(data.heavyWriteThreshold)} {text(t, "cacheHealthTokens", "tokens")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-main">
|
||||
@@ -216,7 +218,9 @@ export default function CacheHealthTab() {
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase text-text-muted">
|
||||
<th className="pb-2 pr-4 font-medium">{text(t, "cacheHealthModel", "Model")}</th>
|
||||
<th className="pb-2 pr-4 font-medium">
|
||||
{text(t, "cacheHealthModel", "Model")}
|
||||
</th>
|
||||
<th className="pb-2 pr-4 text-right font-medium">
|
||||
{text(t, "cacheHealthCalls", "Calls")}
|
||||
</th>
|
||||
|
||||
@@ -852,7 +852,9 @@ export default function ComboHealthTab() {
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchData(controller, false);
|
||||
void (async () => {
|
||||
await fetchData(controller, false);
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [fetchData]);
|
||||
|
||||
|
||||
@@ -131,7 +131,9 @@ export default function ProviderUtilizationTab() {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetchUtilization(range, aggregateBy, controller.signal);
|
||||
void (async () => {
|
||||
await fetchUtilization(range, aggregateBy, controller.signal);
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [fetchUtilization, range, aggregateBy]);
|
||||
@@ -340,12 +342,8 @@ export default function ProviderUtilizationTab() {
|
||||
<ProviderIcon providerId={providerPart} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-main">
|
||||
{cardTitle}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{cardSubtitle}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-text-main">{cardTitle}</p>
|
||||
<p className="text-xs text-text-muted">{cardSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
|
||||
@@ -522,14 +522,18 @@ export default function RouteExplainabilityTab({
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchLogs(controller.signal);
|
||||
void (async () => {
|
||||
await fetchLogs(controller.signal);
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
const controller = new AbortController();
|
||||
fetchExplanation(selectedId, controller.signal);
|
||||
void (async () => {
|
||||
await fetchExplanation(selectedId, controller.signal);
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [fetchExplanation, selectedId]);
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ export default function A2aAuditTab() {
|
||||
}, [offset, skillFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTasks();
|
||||
void (async () => {
|
||||
await fetchTasks();
|
||||
})();
|
||||
}, [fetchTasks]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -110,7 +110,9 @@ export default function ComplianceTab() {
|
||||
}, [actor, eventType, from, offset, t, to]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchEntries();
|
||||
void (async () => {
|
||||
await fetchEntries();
|
||||
})();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const visibleEntries = useMemo(() => {
|
||||
@@ -330,7 +332,9 @@ export default function ComplianceTab() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-md border border-border bg-surface px-2 py-1 font-mono text-xs text-text-main">
|
||||
{t.has(`eventTypes.${entry.action}`) ? t(`eventTypes.${entry.action}`) : entry.action}
|
||||
{t.has(`eventTypes.${entry.action}`)
|
||||
? t(`eventTypes.${entry.action}`)
|
||||
: entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -56,7 +56,9 @@ export default function McpAuditTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStats();
|
||||
void (async () => {
|
||||
await fetchStats();
|
||||
})();
|
||||
}, [fetchStats]);
|
||||
|
||||
const fetchAudit = useCallback(async () => {
|
||||
@@ -86,7 +88,9 @@ export default function McpAuditTab() {
|
||||
}, [offset, successFilter, t, toolFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchAudit();
|
||||
void (async () => {
|
||||
await fetchAudit();
|
||||
})();
|
||||
}, [fetchAudit]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,7 +63,9 @@ export default function CacheEntriesTab() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
void (async () => {
|
||||
await fetchEntries();
|
||||
})();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const handleDelete = async (signature: string) => {
|
||||
|
||||
@@ -130,9 +130,12 @@ export default function ReasoningCacheTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
// Snapshot of "now" taken when the data lands (never during render — the
|
||||
// purity rule bars Date.now() there); entries only render after a fetch.
|
||||
const [nowMs, setNowMs] = useState(0);
|
||||
|
||||
const timeAgo = (dateStr: string): string => {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const diff = nowMs - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return t("justNow");
|
||||
if (minutes < 60) return t("minutesAgo", { minutes });
|
||||
@@ -147,6 +150,7 @@ export default function ReasoningCacheTab() {
|
||||
const res = await fetch("/api/cache/reasoning");
|
||||
if (res.ok) {
|
||||
const json: ReasoningCacheData = await res.json();
|
||||
setNowMs(Date.now());
|
||||
setData(json);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -157,7 +161,9 @@ export default function ReasoningCacheTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
void (async () => {
|
||||
await fetchData();
|
||||
})();
|
||||
const id = setInterval(() => void fetchData(), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchData]);
|
||||
|
||||
4
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
4
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
@@ -374,7 +374,9 @@ export default function CachePage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStats();
|
||||
void (async () => {
|
||||
await fetchStats();
|
||||
})();
|
||||
const id = setInterval(() => void fetchStats(), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchStats]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, Input, Select } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -29,7 +29,14 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Adjust-during-render (React docs pattern): when the modal (re)opens for a
|
||||
// memory, seed the form fields from it before painting — no effect round-trip.
|
||||
const [prevSync, setPrevSync] = useState<{ memory: Memory | null; isOpen: boolean }>({
|
||||
memory: null,
|
||||
isOpen: false,
|
||||
});
|
||||
if (memory !== prevSync.memory || isOpen !== prevSync.isOpen) {
|
||||
setPrevSync({ memory, isOpen });
|
||||
if (memory && isOpen) {
|
||||
setType(memory.type);
|
||||
setKey(memory.key);
|
||||
@@ -38,7 +45,7 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr
|
||||
setMetadataError("");
|
||||
setError("");
|
||||
}
|
||||
}, [memory, isOpen]);
|
||||
}
|
||||
|
||||
const handleMetadataChange = (value: string) => {
|
||||
setMetadataStr(value);
|
||||
@@ -151,9 +158,7 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr
|
||||
metadataError ? "border-red-500" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{metadataError && (
|
||||
<p className="text-xs text-red-400 mt-1">{metadataError}</p>
|
||||
)}
|
||||
{metadataError && <p className="text-xs text-red-400 mt-1">{metadataError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -40,7 +40,8 @@ export default function QdrantConfigCard() {
|
||||
collection?: { exists: boolean; vectorSize?: number; vectorName?: string | null };
|
||||
} | null>(null);
|
||||
const [searchValidated, setSearchValidated] = useState(false);
|
||||
const [tutorialOpen, setTutorialOpen] = useState(false); const [checking, setChecking] = useState(false);
|
||||
const [tutorialOpen, setTutorialOpen] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
@@ -109,7 +110,8 @@ export default function QdrantConfigCard() {
|
||||
// invalidate in-flight checks so they cannot overwrite the new state.
|
||||
healthSeqRef.current += 1;
|
||||
setHealth(null);
|
||||
setSearchValidated(false); setQdrant(next);
|
||||
setSearchValidated(false);
|
||||
setQdrant(next);
|
||||
setSaving(true);
|
||||
setSaveStatus("");
|
||||
try {
|
||||
@@ -153,7 +155,8 @@ export default function QdrantConfigCard() {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[qdrant, checkHealth] );
|
||||
[qdrant, checkHealth]
|
||||
);
|
||||
|
||||
// Auto-check on mount once settings load: without this the status badge
|
||||
// renders red after a page refresh because `health` starts as null and the
|
||||
@@ -161,7 +164,9 @@ export default function QdrantConfigCard() {
|
||||
// connection button still drives the same check manually.
|
||||
useEffect(() => {
|
||||
if (!loading && qdrant.enabled && health === null) {
|
||||
void checkHealth();
|
||||
void (async () => {
|
||||
await checkHealth();
|
||||
})();
|
||||
}
|
||||
}, [loading, qdrant.enabled, health, checkHealth]);
|
||||
|
||||
@@ -245,7 +250,8 @@ export default function QdrantConfigCard() {
|
||||
? "text-text-muted"
|
||||
: health.ok
|
||||
? "text-emerald-500"
|
||||
: "text-red-500" }`}
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full ${
|
||||
|
||||
@@ -190,9 +190,7 @@ export default function MemoriesTab() {
|
||||
else skipped++;
|
||||
}
|
||||
fetchMemories();
|
||||
setImportStatus(
|
||||
t("importResult", { imported, skipped }),
|
||||
);
|
||||
setImportStatus(t("importResult", { imported, skipped }));
|
||||
} catch {
|
||||
setImportStatus(t("importError"));
|
||||
} finally {
|
||||
@@ -239,7 +237,9 @@ export default function MemoriesTab() {
|
||||
// Auto-run health check on mount + poll every 30s, so the indicator reflects
|
||||
// engine health without requiring a manual click.
|
||||
useEffect(() => {
|
||||
void checkHealth();
|
||||
void (async () => {
|
||||
await checkHealth();
|
||||
})();
|
||||
const id = setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30_000);
|
||||
@@ -260,8 +260,9 @@ export default function MemoriesTab() {
|
||||
body: JSON.stringify({ dryRun: true, olderThanDays: 30 }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
const candidates: string[] =
|
||||
Array.isArray(data?.candidates) ? data.candidates.map((c: { key?: string }) => c?.key ?? String(c)) : [];
|
||||
const candidates: string[] = Array.isArray(data?.candidates)
|
||||
? data.candidates.map((c: { key?: string }) => c?.key ?? String(c))
|
||||
: [];
|
||||
setSummarizeCandidates(candidates);
|
||||
setSummarizeDialogOpen(true);
|
||||
} catch {
|
||||
@@ -289,8 +290,7 @@ export default function MemoriesTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const showHitRate =
|
||||
(stats.cacheStats?.hits ?? 0) + (stats.cacheStats?.misses ?? 0) > 0;
|
||||
const showHitRate = (stats.cacheStats?.hits ?? 0) + (stats.cacheStats?.misses ?? 0) > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -401,9 +401,7 @@ export default function MemoriesTab() {
|
||||
info
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{((stats.hitRate ?? 0) * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{((stats.hitRate ?? 0) * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -448,12 +446,8 @@ export default function MemoriesTab() {
|
||||
<span className="material-symbols-outlined text-[40px] text-text-muted mb-3">
|
||||
psychology
|
||||
</span>
|
||||
<p className="text-sm font-medium text-text-main mb-1">
|
||||
{t("emptyState.title")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted max-w-xs">
|
||||
{t("emptyState.description")}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-text-main mb-1">{t("emptyState.title")}</p>
|
||||
<p className="text-xs text-text-muted max-w-xs">{t("emptyState.description")}</p>
|
||||
<Button className="mt-4" size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||
{t("addMemory")}
|
||||
</Button>
|
||||
@@ -477,7 +471,9 @@ export default function MemoriesTab() {
|
||||
<td className="py-2 px-4">
|
||||
<Badge
|
||||
variant={getTypeColor(memory.type)}
|
||||
title={t(TYPE_TOOLTIPS[memory.type]?.replace("memory.", "") ?? memory.type)}
|
||||
title={t(
|
||||
TYPE_TOOLTIPS[memory.type]?.replace("memory.", "") ?? memory.type
|
||||
)}
|
||||
>
|
||||
{t(memory.type)}
|
||||
</Badge>
|
||||
@@ -665,7 +661,10 @@ export default function MemoriesTab() {
|
||||
</p>
|
||||
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{summarizeCandidates.map((key, i) => (
|
||||
<li key={i} className="text-xs font-mono text-text-main truncate px-2 py-1 bg-surface/30 rounded">
|
||||
<li
|
||||
key={i}
|
||||
className="text-xs font-mono text-text-main truncate px-2 py-1 bg-surface/30 rounded"
|
||||
>
|
||||
{key}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -39,7 +39,9 @@ export function useEngineStatus(refreshIntervalMs = 5000): UseEngineStatusResult
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void fetchOnce();
|
||||
void (async () => {
|
||||
await fetchOnce();
|
||||
})();
|
||||
if (!refreshIntervalMs || refreshIntervalMs <= 0) {
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
|
||||
@@ -40,7 +40,9 @@ export function useMemorySettings(): UseMemorySettingsResult {
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void fetchOnce();
|
||||
void (async () => {
|
||||
await fetchOnce();
|
||||
})();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
|
||||
@@ -95,7 +95,9 @@ export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCat
|
||||
}, [onError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadState();
|
||||
void (async () => {
|
||||
await loadState();
|
||||
})();
|
||||
}, [loadState]);
|
||||
|
||||
const stateByKey = useMemo(
|
||||
|
||||
@@ -67,9 +67,15 @@ export default function RadarIntelPage() {
|
||||
}, [load, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
.catch(() => setError(t("loadFailed")))
|
||||
.finally(() => setLoading(false));
|
||||
void (async () => {
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
setError(t("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [load, t]);
|
||||
|
||||
if (flagOff) notFound();
|
||||
|
||||
@@ -201,7 +201,9 @@ export default function RadarPage() {
|
||||
}, [fetchCatalog, fetchReferrals]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
void (async () => {
|
||||
await fetchSettings();
|
||||
})();
|
||||
}, [fetchSettings]);
|
||||
|
||||
// Sync (defined before handleActivate which depends on it)
|
||||
@@ -242,7 +244,9 @@ export default function RadarPage() {
|
||||
if (loading || syncing || optIn !== true || autoSyncFiredRef.current) return;
|
||||
if (!shouldAutoSyncOnOpen(meta?.fetchedAt ?? null, Date.now())) return;
|
||||
autoSyncFiredRef.current = true;
|
||||
void handleSync();
|
||||
void (async () => {
|
||||
await handleSync();
|
||||
})();
|
||||
}, [loading, syncing, optIn, meta, handleSync]);
|
||||
|
||||
// Activate opt-in
|
||||
|
||||
@@ -55,7 +55,16 @@ export default function RadarSetupPage() {
|
||||
const provider = searchParams.get("provider");
|
||||
|
||||
const [setupData, setSetupData] = useState<ProviderSetupData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(provider !== null);
|
||||
|
||||
// Adjust-during-render when the provider query param changes (React docs
|
||||
// pattern): a null provider has nothing to load, any other transition
|
||||
// restarts the loading state before the fetch effect fires.
|
||||
const [prevProvider, setPrevProvider] = useState(provider);
|
||||
if (provider !== prevProvider) {
|
||||
setPrevProvider(provider);
|
||||
setLoading(provider !== null);
|
||||
}
|
||||
const [error, setError] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
@@ -63,7 +72,6 @@ export default function RadarSetupPage() {
|
||||
// Fetch catalog to find the provider's setup data
|
||||
useEffect(() => {
|
||||
if (!provider) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,12 +131,13 @@ export default function RadarSetupPage() {
|
||||
}, [provider, t]);
|
||||
|
||||
// Test connection — uses the EXISTING connection-test endpoint
|
||||
const connectionId = setupData?.connectionId ?? null;
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!setupData?.connectionId) return;
|
||||
if (!connectionId) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, {
|
||||
const res = await fetch(`/api/providers/${encodeURIComponent(connectionId)}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
@@ -147,7 +156,7 @@ export default function RadarSetupPage() {
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [setupData?.connectionId, t]);
|
||||
}, [connectionId, t]);
|
||||
|
||||
if (!provider) {
|
||||
return (
|
||||
|
||||
@@ -490,18 +490,18 @@ export default function EvalsTab() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetOptions.length === 0) return;
|
||||
if (targetOptions.some((option) => option.key === selectedTargetKey)) return;
|
||||
// Adjust-during-render (React docs pattern): keep the selected target inside
|
||||
// the current option set, and never compare a target against itself. Both
|
||||
// guards self-extinguish after their setState, so the re-render settles.
|
||||
if (
|
||||
targetOptions.length > 0 &&
|
||||
!targetOptions.some((option) => option.key === selectedTargetKey)
|
||||
) {
|
||||
setSelectedTargetKey(targetOptions[0]?.key || "suite-default:__default__");
|
||||
}, [selectedTargetKey, targetOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compareTargetKey) return;
|
||||
if (compareTargetKey === selectedTargetKey) {
|
||||
setCompareTargetKey("");
|
||||
}
|
||||
}, [compareTargetKey, selectedTargetKey]);
|
||||
}
|
||||
if (compareTargetKey && compareTargetKey === selectedTargetKey) {
|
||||
setCompareTargetKey("");
|
||||
}
|
||||
|
||||
const filteredSuites = !search.trim()
|
||||
? suites
|
||||
@@ -1846,7 +1846,11 @@ export default function EvalsTab() {
|
||||
);
|
||||
}
|
||||
|
||||
const HeroSection = memo(function HeroSection({ t }: { t: (key: string, values?: Record<string, unknown>) => string }) {
|
||||
const HeroSection = memo(function HeroSection({
|
||||
t,
|
||||
}: {
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div
|
||||
|
||||
@@ -41,6 +41,23 @@ interface ResetCreditRequestState {
|
||||
tr: TranslateUsage;
|
||||
}
|
||||
|
||||
// Module-level so the ref-store mutation stays outside any hook body — the
|
||||
// immutability rule bars in-callback writes to `state.idempotencyKeysRef.current`.
|
||||
function resetIdempotencyKeys(keys: React.MutableRefObject<Record<string, string>>): void {
|
||||
keys.current = {};
|
||||
}
|
||||
|
||||
function ensureIdempotencyKey(
|
||||
keys: React.MutableRefObject<Record<string, string>>,
|
||||
selectionToken: string
|
||||
): string {
|
||||
const existing = keys.current[selectionToken];
|
||||
if (existing) return existing;
|
||||
const created = createIdempotencyKey();
|
||||
keys.current[selectionToken] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
@@ -112,9 +129,7 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) {
|
||||
async (selectionToken: string) => {
|
||||
const picker = state.resetCreditPicker;
|
||||
if (!picker || state.redeemingResetCreditId || !selectionToken) return;
|
||||
const idempotencyKey =
|
||||
state.idempotencyKeysRef.current[selectionToken] ??
|
||||
(state.idempotencyKeysRef.current[selectionToken] = createIdempotencyKey());
|
||||
const idempotencyKey = ensureIdempotencyKey(state.idempotencyKeysRef, selectionToken);
|
||||
state.setRedeemingResetCreditId(picker.connectionId);
|
||||
state.setErrors((prev) => ({ ...prev, [picker.connectionId]: null }));
|
||||
try {
|
||||
@@ -145,7 +160,7 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) {
|
||||
[picker.connectionId]: new Date().toISOString(),
|
||||
}));
|
||||
state.setResetCreditPicker(null);
|
||||
state.idempotencyKeysRef.current = {};
|
||||
resetIdempotencyKeys(state.idempotencyKeysRef);
|
||||
notify.success(state.tr("resetCreditRedeemed", "Reset redeemed"));
|
||||
} catch (error) {
|
||||
const message = getRequestErrorMessage(
|
||||
|
||||
@@ -22,7 +22,9 @@ export default function RateLimitStatus() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
void (async () => {
|
||||
await load();
|
||||
})();
|
||||
const interval = setInterval(load, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
@@ -49,7 +49,9 @@ export default function SessionsTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
void (async () => {
|
||||
await loadSessions();
|
||||
})();
|
||||
const interval = setInterval(loadSessions, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
@@ -161,7 +161,9 @@ export default function ProviderQuotaWidget({
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const refreshingAllRef = useRef(false);
|
||||
const lastRefreshAllAtRef = useRef(Date.now());
|
||||
// State (not a ref): the countdown renders it, and refs cannot be read during
|
||||
// render nor initialized with Date.now() (purity rule).
|
||||
const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => Date.now());
|
||||
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
|
||||
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
|
||||
|
||||
@@ -188,14 +190,16 @@ export default function ProviderQuotaWidget({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
void (async () => {
|
||||
await loadData();
|
||||
})();
|
||||
}, [loadData]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (refreshingAllRef.current) return;
|
||||
refreshingAllRef.current = true;
|
||||
const now = Date.now();
|
||||
lastRefreshAllAtRef.current = now;
|
||||
setLastRefreshAllAt(now);
|
||||
setAutoRefreshClock(now);
|
||||
setRefreshingAll(true);
|
||||
try {
|
||||
@@ -235,10 +239,12 @@ export default function ProviderQuotaWidget({
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (refreshingAllRef.current) return;
|
||||
|
||||
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||
void refreshAll();
|
||||
if (autoRefreshClock - lastRefreshAllAt >= autoRefreshIntervalMs) {
|
||||
void (async () => {
|
||||
await refreshAll();
|
||||
})();
|
||||
}
|
||||
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
|
||||
}, [autoRefreshClock, lastRefreshAllAt, autoRefreshIntervalMs, refreshAll]);
|
||||
|
||||
const providerGroups = useMemo(() => {
|
||||
const groups = new Map<string, Connection[]>();
|
||||
@@ -286,10 +292,7 @@ export default function ProviderQuotaWidget({
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAt))
|
||||
)}`
|
||||
: tr("forceRefresh", "Refresh now")}
|
||||
</button>
|
||||
|
||||
@@ -1432,13 +1432,13 @@ async function buildUnifiedModelsResponseCore(
|
||||
return activeAliases.has(alias) || activeAliases.has(provider);
|
||||
};
|
||||
|
||||
const hasEquivalentSpecialtyModel = (
|
||||
const findEquivalentSpecialtyModel = (
|
||||
providerId: string,
|
||||
rawModelId: string,
|
||||
type: string,
|
||||
scopedModelId: string
|
||||
) =>
|
||||
models.some((model: any) => {
|
||||
models.find((model: any) => {
|
||||
if (model?.id === scopedModelId) return true;
|
||||
if (model?.owned_by !== providerId || model?.type !== type) return false;
|
||||
const existingRoot =
|
||||
@@ -1450,6 +1450,13 @@ async function buildUnifiedModelsResponseCore(
|
||||
return existingRoot === rawModelId;
|
||||
});
|
||||
|
||||
const hasEquivalentSpecialtyModel = (
|
||||
providerId: string,
|
||||
rawModelId: string,
|
||||
type: string,
|
||||
scopedModelId: string
|
||||
) => findEquivalentSpecialtyModel(providerId, rawModelId, type, scopedModelId) !== undefined;
|
||||
|
||||
// Helper: strip the provider prefix from a specialty model ID to get the
|
||||
// provider-relative path (e.g. "openrouter/google/chirp-3" -> "google/chirp-3").
|
||||
// This is the correct key used by the hidden-model lookup — using .split("/").pop()
|
||||
@@ -1464,7 +1471,22 @@ async function buildUnifiedModelsResponseCore(
|
||||
const rawModelId = getSpecialtyModelRelativeId(embModel.id, embModel.provider);
|
||||
if (!providerSupportsModel(embModel.provider, rawModelId)) continue;
|
||||
if (isModelHiddenBulk(embModel.provider, rawModelId)) continue;
|
||||
if (hasEquivalentSpecialtyModel(embModel.provider, rawModelId, "embedding", embModel.id)) {
|
||||
const existingEmbedding = findEquivalentSpecialtyModel(
|
||||
embModel.provider,
|
||||
rawModelId,
|
||||
"embedding",
|
||||
embModel.id
|
||||
);
|
||||
if (existingEmbedding) {
|
||||
// Discovery publishes no vector width, so the registry is the authority.
|
||||
if (embModel.dimensions !== undefined) {
|
||||
existingEmbedding.dimensions = embModel.dimensions;
|
||||
}
|
||||
// A provider that does not report its endpoints leaves the model unclassified. Being in
|
||||
// the embedding registry is that statement, so make it rather than leave it untyped.
|
||||
if (!existingEmbedding.type) {
|
||||
existingEmbedding.type = "embedding";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
models.push({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, getLocale, getTranslations } from "next-intl/server";
|
||||
import { RTL_LOCALES } from "@/i18n/config";
|
||||
import { normalizeComplianceEventTypes } from "@/i18n/request";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { getRootLayoutSettings } from "@/lib/db/rootLayoutSettings";
|
||||
import type { Viewport } from "next";
|
||||
import { PwaRegister } from "@/shared/components/PwaRegister";
|
||||
import { LocaleAutoDetect } from "@/shared/components/LocaleAutoDetect";
|
||||
@@ -22,9 +22,9 @@ export const viewport: Viewport = {
|
||||
};
|
||||
|
||||
export async function generateMetadata() {
|
||||
const settings = await getSettings();
|
||||
const instanceName = settings?.instanceName || "OmniRoute";
|
||||
const customFaviconUrl = settings?.customFaviconUrl || settings?.customFaviconBase64;
|
||||
const settings = await getRootLayoutSettings();
|
||||
const instanceName = settings.instanceName;
|
||||
const customFaviconUrl = settings.customFaviconUrl || settings.customFaviconBase64;
|
||||
|
||||
return {
|
||||
title: `${instanceName} — AI Gateway for Multi-Provider LLMs`,
|
||||
|
||||
@@ -319,7 +319,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
process.title = renameProcessTitle(process.title);
|
||||
|
||||
// Initialize proxy fetch patch FIRST (before any HTTP requests)
|
||||
await import("@omniroute/open-sse/index.ts");
|
||||
await import("@omniroute/open-sse/utils/proxyFetch.ts");
|
||||
console.log("[STARTUP] Global fetch proxy patch initialized");
|
||||
|
||||
// Register quota fetchers early so combo routing can use real quota-aware
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints";
|
||||
|
||||
type BatchRouteHandler = (request: Request) => Promise<Response> | Response;
|
||||
|
||||
const handlerLoaders: Record<SupportedBatchEndpoint, () => Promise<BatchRouteHandler>> = {
|
||||
"/v1/responses": async () => (await import("@/app/api/v1/responses/route")).POST,
|
||||
"/v1/chat/completions": async () => (await import("@/app/api/v1/chat/completions/route")).POST,
|
||||
"/v1/embeddings": async () => (await import("@/app/api/v1/embeddings/route")).POST,
|
||||
"/v1/completions": async () => (await import("@/app/api/v1/completions/route")).POST,
|
||||
"/v1/moderations": async () => (await import("@/app/api/v1/moderations/route")).POST,
|
||||
"/v1/images/generations": async () =>
|
||||
(await import("@/app/api/v1/images/generations/route")).POST,
|
||||
"/v1/videos/generations": async () =>
|
||||
(await import("@/app/api/v1/videos/generations/route")).POST,
|
||||
};
|
||||
|
||||
const handlerCache = new Map<SupportedBatchEndpoint, BatchRouteHandler>();
|
||||
|
||||
async function getHandler(endpoint: SupportedBatchEndpoint): Promise<BatchRouteHandler> {
|
||||
const cached = handlerCache.get(endpoint);
|
||||
if (cached) return cached;
|
||||
|
||||
const handler = await handlerLoaders[endpoint]();
|
||||
handlerCache.set(endpoint, handler);
|
||||
return handler;
|
||||
}
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { normalizeBasePath } from "@/shared/utils/basePath";
|
||||
|
||||
async function dispatchBatchApiRequest({
|
||||
endpoint,
|
||||
@@ -39,13 +16,17 @@ async function dispatchBatchApiRequest({
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
|
||||
const handler = await getHandler(endpoint);
|
||||
const request = new Request(`http://localhost${endpoint}`, {
|
||||
const { dashboardPort } = getRuntimePorts();
|
||||
const basePath = normalizeBasePath(process.env.OMNIROUTE_BASE_PATH);
|
||||
const url = `http://127.0.0.1:${dashboardPort}${basePath}${endpoint}`;
|
||||
|
||||
return await globalThis.fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
// Never follow a redirect while carrying the stored batch API key.
|
||||
redirect: "error",
|
||||
});
|
||||
return await handler(request);
|
||||
}
|
||||
|
||||
export const dispatch = {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const MODE_PACK_OPTIONS = [
|
||||
|
||||
export const ROUTER_STRATEGY_OPTIONS = [
|
||||
{ id: "rules", label: "Rules (6-Factor Scoring)" },
|
||||
{ id: "score", label: "Highest Weighted Score" },
|
||||
{ id: "cost", label: "Cost Optimized" },
|
||||
{ id: "latency", label: "Latency Optimized" },
|
||||
{ id: "sla-aware", label: "SLA-aware" },
|
||||
|
||||
@@ -40,6 +40,7 @@ import { invalidateDbCache } from "./readCache";
|
||||
import { rowToCamel } from "./caseMapping";
|
||||
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
|
||||
import { parseModelAccessMode } from "./apiKeys/modelAccessMode";
|
||||
import { getExistingDbInstance as getDb, setDbInstance as setDb } from "./singleton";
|
||||
// Re-exported so existing call sites that pull these helpers off the core module keep working.
|
||||
export { toSnakeCase, toCamelCase, objToSnake, rowToCamel, cleanNulls } from "./caseMapping";
|
||||
import {
|
||||
@@ -514,7 +515,6 @@ const SCHEMA_SQL = `
|
||||
// Module-level `let` resets on every webpack recompile, causing connection leaks.
|
||||
|
||||
declare global {
|
||||
var __omnirouteDb: SqliteAdapter | undefined;
|
||||
// Cycle-breaker counter for the probe-failed/restore cascade. Survives
|
||||
// Next.js HMR re-evaluations so concurrent subsystems all see the same
|
||||
// count and we abort with a clear error instead of looping forever.
|
||||
@@ -529,18 +529,6 @@ declare global {
|
||||
var __omnirouteDbOomFailureCount: number | undefined;
|
||||
}
|
||||
|
||||
function getDb(): SqliteDatabase | null {
|
||||
return globalThis.__omnirouteDb ?? null;
|
||||
}
|
||||
|
||||
function setDb(db: SqliteDatabase | null): void {
|
||||
if (db) {
|
||||
globalThis.__omnirouteDb = db;
|
||||
} else {
|
||||
delete globalThis.__omnirouteDb;
|
||||
}
|
||||
}
|
||||
|
||||
function checkpointDb(db: SqliteDatabase, mode: CheckpointMode = "TRUNCATE"): boolean {
|
||||
if (isCloud || isBuildPhase || !SQLITE_FILE) return false;
|
||||
db.pragma(`wal_checkpoint(${mode})`);
|
||||
|
||||
62
src/lib/db/rootLayoutSettings.ts
Normal file
62
src/lib/db/rootLayoutSettings.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getExistingDbInstance } from "./singleton";
|
||||
|
||||
export interface RootLayoutSettings {
|
||||
instanceName: string;
|
||||
customFaviconUrl: string;
|
||||
customFaviconBase64: string;
|
||||
}
|
||||
|
||||
type RootLayoutSettingKey = keyof RootLayoutSettings;
|
||||
type SettingsRow = {
|
||||
key?: unknown;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
const ROOT_LAYOUT_SETTING_KEYS = [
|
||||
"instanceName",
|
||||
"customFaviconUrl",
|
||||
"customFaviconBase64",
|
||||
] as const satisfies readonly RootLayoutSettingKey[];
|
||||
|
||||
const ROOT_LAYOUT_SETTING_KEY_SET = new Set<string>(ROOT_LAYOUT_SETTING_KEYS);
|
||||
|
||||
const DEFAULT_ROOT_LAYOUT_SETTINGS: RootLayoutSettings = {
|
||||
instanceName: "OmniRoute",
|
||||
customFaviconUrl: "",
|
||||
customFaviconBase64: "",
|
||||
};
|
||||
|
||||
function parseStoredString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return typeof parsed === "string" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read only the settings needed while compiling and rendering the root layout. */
|
||||
export async function getRootLayoutSettings(): Promise<RootLayoutSettings> {
|
||||
const db = getExistingDbInstance();
|
||||
if (!db) return { ...DEFAULT_ROOT_LAYOUT_SETTINGS };
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT key, value FROM key_value
|
||||
WHERE namespace = 'settings' AND key IN (?, ?, ?)`
|
||||
)
|
||||
.all(...ROOT_LAYOUT_SETTING_KEYS) as SettingsRow[];
|
||||
const settings = { ...DEFAULT_ROOT_LAYOUT_SETTINGS };
|
||||
|
||||
for (const row of rows) {
|
||||
if (typeof row.key !== "string" || !ROOT_LAYOUT_SETTING_KEY_SET.has(row.key)) continue;
|
||||
const value = parseStoredString(row.value);
|
||||
if (value === null) continue;
|
||||
settings[row.key as RootLayoutSettingKey] = value;
|
||||
}
|
||||
|
||||
if (!settings.instanceName) settings.instanceName = DEFAULT_ROOT_LAYOUT_SETTINGS.instanceName;
|
||||
return settings;
|
||||
}
|
||||
19
src/lib/db/singleton.ts
Normal file
19
src/lib/db/singleton.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { SqliteAdapter } from "./adapters/types";
|
||||
|
||||
declare global {
|
||||
var __omnirouteDb: SqliteAdapter | undefined;
|
||||
}
|
||||
|
||||
/** Read the process-wide DB handle without initializing storage. */
|
||||
export function getExistingDbInstance(): SqliteAdapter | null {
|
||||
return globalThis.__omnirouteDb ?? null;
|
||||
}
|
||||
|
||||
/** Replace the process-wide DB handle while preserving it across Next.js HMR. */
|
||||
export function setDbInstance(db: SqliteAdapter | null): void {
|
||||
if (db) {
|
||||
globalThis.__omnirouteDb = db;
|
||||
} else {
|
||||
delete globalThis.__omnirouteDb;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,15 @@ const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000",
|
||||
|
||||
declare global {
|
||||
var __omnirouteShutdown:
|
||||
{ init: boolean; shuttingDown: boolean; activeRequests: number } | undefined;
|
||||
| {
|
||||
init: boolean;
|
||||
shuttingDown: boolean;
|
||||
activeRequests: number;
|
||||
shutdownPromise?: Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
var __omnirouteRequestShutdown: ((signal: string) => Promise<void>) | undefined;
|
||||
var __omnirouteCustomServerOwnsShutdown: boolean | undefined;
|
||||
}
|
||||
|
||||
function getShutdownState() {
|
||||
@@ -102,12 +110,14 @@ async function cleanup(): Promise<void> {
|
||||
{ closeDbInstance },
|
||||
{ flushSpendBatchWriter },
|
||||
{ closeLogRotation },
|
||||
{ closeSharedLoggerResource },
|
||||
{ closeCallLogSaves },
|
||||
] = await Promise.all([
|
||||
import("@omniroute/open-sse/mcp-server/audit.ts"),
|
||||
import("@/lib/db/core"),
|
||||
import("@/lib/spend/batchWriter"),
|
||||
import("@/lib/logRotation"),
|
||||
import("@/shared/utils/loggerResource"),
|
||||
import("@/lib/usage/callLogs"),
|
||||
]);
|
||||
const flushResult = await flushSpendBatchWriter();
|
||||
@@ -123,9 +133,6 @@ async function cleanup(): Promise<void> {
|
||||
if (closeDbInstance()) {
|
||||
console.log("[Shutdown] SQLite database checkpointed and closed.");
|
||||
}
|
||||
closeLogRotation();
|
||||
console.log("[Shutdown] Log rotation timer stopped.");
|
||||
|
||||
// Tear down any persistent VNC login browser containers so they don't leak
|
||||
// past the server process. Best-effort; no-op if the feature was never used
|
||||
// or the docker CLI is unavailable.
|
||||
@@ -147,41 +154,62 @@ async function cleanup(): Promise<void> {
|
||||
} catch {
|
||||
/* feature unused */
|
||||
}
|
||||
|
||||
await closeSharedLoggerResource();
|
||||
closeLogRotation();
|
||||
console.log("[Shutdown] Logger transport and log rotation stopped.");
|
||||
} catch (err) {
|
||||
console.error("[Shutdown] Error during cleanup:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the process-wide shutdown sequence, or join the sequence already in progress.
|
||||
*/
|
||||
export function requestGracefulShutdown(signal: string): Promise<void> {
|
||||
const state = getShutdownState();
|
||||
if (state.shutdownPromise) return state.shutdownPromise;
|
||||
|
||||
state.shuttingDown = true;
|
||||
markServerStopping();
|
||||
state.shutdownPromise = (async () => {
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`);
|
||||
|
||||
await waitForDrain();
|
||||
await cleanup();
|
||||
|
||||
console.log("[Shutdown] Bye.");
|
||||
})();
|
||||
|
||||
return state.shutdownPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize graceful shutdown handlers.
|
||||
* Should be called once during server startup.
|
||||
*/
|
||||
export function initGracefulShutdown(): void {
|
||||
const state = getShutdownState();
|
||||
globalThis.__omnirouteRequestShutdown ??= requestGracefulShutdown;
|
||||
if (state.init) return;
|
||||
state.init = true;
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
if (state.shuttingDown) return;
|
||||
state.shuttingDown = true;
|
||||
markServerStopping();
|
||||
if (globalThis.__omnirouteCustomServerOwnsShutdown) {
|
||||
console.log("[Shutdown] Cleanup registered with the custom server shutdown owner.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`);
|
||||
|
||||
await waitForDrain();
|
||||
await cleanup();
|
||||
|
||||
console.log("[Shutdown] Bye.");
|
||||
process.exit(0);
|
||||
const shutdown = (signal: string) => {
|
||||
void globalThis.__omnirouteRequestShutdown?.(signal).then(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
// #8045: on Windows, closing the console window delivers CTRL_CLOSE_EVENT, which
|
||||
// Node/libuv maps to a JS-visible "SIGHUP" event — without this listener, closing
|
||||
// the window never runs cleanup() (WAL checkpoint + closeDbInstance()), leaving
|
||||
// storage.sqlite's WAL un-checkpointed for the next launch.
|
||||
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
||||
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
||||
|
||||
console.log("[Shutdown] Graceful shutdown handlers registered.");
|
||||
}
|
||||
|
||||
@@ -42,8 +42,18 @@ export function getAppLogRotationCheckInterval(): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** Module-level timer handle — cleared by closeLogRotation(). */
|
||||
let rotationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
interface LogRotationState {
|
||||
timer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __omnirouteLogRotationState: LogRotationState | undefined;
|
||||
}
|
||||
|
||||
/** Process-wide state survives Next.js development HMR and split server chunks. */
|
||||
function getLogRotationState(): LogRotationState {
|
||||
return (globalThis.__omnirouteLogRotationState ??= { timer: null });
|
||||
}
|
||||
|
||||
export function getLogConfig() {
|
||||
const logToFile = getAppLogToFile();
|
||||
@@ -172,6 +182,9 @@ export function cleanupOverflowLogs(logFilePath: string, maxFiles: number): void
|
||||
* Call closeLogRotation() during application shutdown to clear the timer.
|
||||
*/
|
||||
export function initLogRotation(): void {
|
||||
const state = getLogRotationState();
|
||||
if (state.timer !== null) return;
|
||||
|
||||
const config = getLogConfig();
|
||||
if (!config.logToFile) return;
|
||||
|
||||
@@ -181,7 +194,7 @@ export function initLogRotation(): void {
|
||||
cleanupOverflowLogs(config.logFilePath, config.maxFiles);
|
||||
|
||||
const intervalMs = getAppLogRotationCheckInterval();
|
||||
rotationTimer = setInterval(
|
||||
state.timer = setInterval(
|
||||
(filePath: string, maxSize: number, maxFiles: number) => {
|
||||
rotateIfNeeded(filePath, maxSize);
|
||||
cleanupOverflowLogs(filePath, maxFiles);
|
||||
@@ -191,7 +204,7 @@ export function initLogRotation(): void {
|
||||
config.maxFileSize,
|
||||
config.maxFiles
|
||||
);
|
||||
rotationTimer.unref?.();
|
||||
state.timer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,8 +212,9 @@ export function initLogRotation(): void {
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export function closeLogRotation(): void {
|
||||
if (rotationTimer !== null) {
|
||||
clearInterval(rotationTimer);
|
||||
rotationTimer = null;
|
||||
const state = getLogRotationState();
|
||||
if (state.timer !== null) {
|
||||
clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
joinBaseUrlAndPath,
|
||||
} from "@omniroute/open-sse/services/claudeCodeCompatible.ts";
|
||||
import { getDefaultExecutor } from "@omniroute/open-sse/executors/defaultResolver.ts";
|
||||
import {
|
||||
addModelsSuffix,
|
||||
normalizeAnthropicBaseUrl,
|
||||
@@ -137,8 +138,7 @@ export async function validateClaudeOAuthInline({
|
||||
typeof override === "string" && override ? override : modelId || "claude-haiku-4-5-20251001";
|
||||
|
||||
try {
|
||||
const { getExecutor } = await import("@omniroute/open-sse/executors/index.ts");
|
||||
const executed = await (await getExecutor("claude")).execute({
|
||||
const executed = await getDefaultExecutor("claude").execute({
|
||||
model: testModelId,
|
||||
body: {
|
||||
model: testModelId,
|
||||
|
||||
@@ -22,13 +22,12 @@
|
||||
|
||||
import { logger } from "@omniroute/open-sse/utils/logger.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
||||
import type { BaseExecutor } from "@omniroute/open-sse/executors/base";
|
||||
import { getCodexUsage } from "@omniroute/open-sse/services/usage/codex.ts";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers";
|
||||
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
|
||||
import { refreshAndUpdateCredentials } from "@/lib/usage/providerLimits";
|
||||
import { refreshAndUpdateCredentialsWithResolver } from "@/lib/usage/providerLimits/credentialRefresh";
|
||||
import { getCircuitBreaker } from "@/shared/utils/circuitBreaker";
|
||||
import {
|
||||
QUOTA_AUTOPING_FAILURE_COOLDOWN_MS,
|
||||
@@ -63,8 +62,11 @@ export interface QuotaAutoPingDeps {
|
||||
refreshAndUpdateCredentials: (
|
||||
connection: QuotaAutoPingConnection
|
||||
) => Promise<{ connection: QuotaAutoPingConnection }>;
|
||||
getCodexUsage: (accessToken?: string, providerSpecificData?: JsonRecord) => Promise<JsonRecord>;
|
||||
getExecutor: (provider: string) => Promise<BaseExecutor>;
|
||||
getCodexUsage: (
|
||||
accessToken?: string,
|
||||
providerSpecificData?: JsonRecord
|
||||
) => Promise<JsonRecord>;
|
||||
getExecutor: (provider: "codex") => Promise<BaseExecutor>;
|
||||
canExecuteProvider: (provider: string) => boolean;
|
||||
isConnectionUnavailableToAuxiliaryActivity: (connectionId: string) => Promise<boolean>;
|
||||
}
|
||||
@@ -79,15 +81,33 @@ export function createQuotaAutoPingState(): QuotaAutoPingState {
|
||||
return { running: false, resetCache: {}, failureCache: {} };
|
||||
}
|
||||
|
||||
let codexExecutorPromise: Promise<BaseExecutor> | null = null;
|
||||
|
||||
async function loadQuotaAutoPingExecutor(provider: string): Promise<BaseExecutor> {
|
||||
if (provider !== "codex") {
|
||||
throw new Error(`Quota auto-ping does not support provider "${provider}"`);
|
||||
}
|
||||
|
||||
try {
|
||||
codexExecutorPromise ??= import("@omniroute/open-sse/executors/codex.ts").then(
|
||||
({ CodexExecutor }) => new CodexExecutor()
|
||||
);
|
||||
return await codexExecutorPromise;
|
||||
} catch (error) {
|
||||
codexExecutorPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultQuotaAutoPingDeps(): QuotaAutoPingDeps {
|
||||
return {
|
||||
getSettings,
|
||||
getProviderConnections,
|
||||
updateProviderConnection,
|
||||
refreshAndUpdateCredentials: async (connection) =>
|
||||
refreshAndUpdateCredentials(connection as never),
|
||||
refreshAndUpdateCredentialsWithResolver(connection, loadQuotaAutoPingExecutor),
|
||||
getCodexUsage,
|
||||
getExecutor,
|
||||
getExecutor: loadQuotaAutoPingExecutor,
|
||||
canExecuteProvider: (provider) => getCircuitBreaker(provider).canExecute(),
|
||||
isConnectionUnavailableToAuxiliaryActivity,
|
||||
};
|
||||
|
||||
@@ -18,13 +18,10 @@ import { clearRecoveredProviderState } from "@/sse/services/auth";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { mergeProviderLimitsCacheEntry, toProviderLimitsCacheEntry } from "./providerLimitsCache";
|
||||
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
||||
import { getCredentialRefreshExecutor } from "@omniroute/open-sse/executors/credential.ts";
|
||||
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
|
||||
import { cooldownUntilMs } from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import {
|
||||
rotationGroupFor,
|
||||
serializeRefresh,
|
||||
} from "@omniroute/open-sse/services/refreshSerializer.ts";
|
||||
import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts";
|
||||
import {
|
||||
extractCodeAssistOnboardTierId,
|
||||
extractCodeAssistSubscriptionTier,
|
||||
@@ -42,29 +39,15 @@ import {
|
||||
sanitizeUsageQuotasForProvider,
|
||||
} from "./providerLimits/quotaNormalize";
|
||||
import { syncInChunksWithSpacing } from "./providerLimits/chunkedSpacingSync";
|
||||
import {
|
||||
refreshAndUpdateCredentialsWithResolver,
|
||||
type CredentialRefreshOptions,
|
||||
type ProviderConnectionLike,
|
||||
} from "./providerLimits/credentialRefresh";
|
||||
export { shouldAttemptRotatingRefresh } from "./providerLimits/credentialRefresh";
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type SyncSource = "manual" | "scheduled";
|
||||
|
||||
interface ProviderConnectionLike {
|
||||
id: string;
|
||||
provider: string;
|
||||
authType?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: string;
|
||||
tokenExpiresAt?: string;
|
||||
providerSpecificData?: JsonRecord;
|
||||
testStatus?: string;
|
||||
isActive?: boolean;
|
||||
lastError?: string | null;
|
||||
lastErrorAt?: string | null;
|
||||
lastErrorType?: string | null;
|
||||
lastErrorSource?: string | null;
|
||||
errorCode?: string | number | null;
|
||||
rateLimitedUntil?: string | null;
|
||||
backoffLevel?: number;
|
||||
}
|
||||
|
||||
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"glm",
|
||||
"glm-cn",
|
||||
@@ -218,122 +201,15 @@ async function syncToCloudIfEnabled() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the quota path may refresh this provider's token. Exported for testing.
|
||||
*
|
||||
* Rotating-refresh providers (Codex/OpenAI share one Auth0 client_id, etc.) mint a
|
||||
* single-use refresh_token on every refresh. The BULK quota-sync path runs many
|
||||
* connections concurrently; refreshing sibling accounts in parallel makes Auth0
|
||||
* revoke the whole token family (openai/codex#9648) and kills every account but
|
||||
* the last (#3019). So the bulk path never refreshes rotating providers
|
||||
* (`allowRotatingRefresh` falsy). The on-demand, per-connection path opts in and
|
||||
* is made safe by `serializeRefresh` (one token mint at a time per rotation group,
|
||||
* so even N concurrent per-account requests can never refresh siblings in
|
||||
* parallel). Non-rotating providers are always eligible.
|
||||
*/
|
||||
export function shouldAttemptRotatingRefresh(
|
||||
provider: string,
|
||||
allowRotatingRefresh: boolean | undefined
|
||||
): boolean {
|
||||
if (rotationGroupFor(provider) === null) return true;
|
||||
return allowRotatingRefresh === true;
|
||||
}
|
||||
|
||||
export async function refreshAndUpdateCredentials(
|
||||
connection: ProviderConnectionLike,
|
||||
opts: { allowRotatingRefresh?: boolean; force?: boolean } = {}
|
||||
opts: CredentialRefreshOptions = {}
|
||||
) {
|
||||
if (!shouldAttemptRotatingRefresh(connection.provider, opts.allowRotatingRefresh)) {
|
||||
return { connection, refreshed: false };
|
||||
}
|
||||
const executor = await getExecutor(connection.provider);
|
||||
const credentials = {
|
||||
connectionId: connection.id,
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
expiresAt: connection.tokenExpiresAt || connection.expiresAt || null,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
copilotToken: connection.providerSpecificData?.copilotToken,
|
||||
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
|
||||
};
|
||||
|
||||
// `force` is used ONLY on the reactive 401 recovery path (a usage fetch came
|
||||
// back unauthorized) — it bypasses the proactive `needsRefresh` heuristic so
|
||||
// imported accounts (expiresAt=null, where needsRefresh is always false) can
|
||||
// still re-mint. The mint stays serialized per rotation group; this never
|
||||
// refreshes proactively from the bulk path (#3019 guard above is unchanged).
|
||||
if (!opts.force && !executor.needsRefresh(credentials)) {
|
||||
return { connection, refreshed: false };
|
||||
}
|
||||
|
||||
// Serialize the actual token mint per rotation group so two sibling accounts
|
||||
// never hit Auth0 concurrently (passthrough for non-rotating providers).
|
||||
const refreshResult = (await serializeRefresh(connection.provider, () =>
|
||||
executor.refreshCredentials(credentials, console)
|
||||
)) as
|
||||
| (JsonRecord & {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
expiresAt?: string;
|
||||
copilotToken?: string;
|
||||
copilotTokenExpiresAt?: string;
|
||||
})
|
||||
| null;
|
||||
|
||||
if (!refreshResult) {
|
||||
// Refresh failed but we still have an accessToken — fall back to the
|
||||
// existing token for ANY OAuth provider (graceful degradation) instead of
|
||||
// hard-failing. Previously this was qualified to `provider === "github"`,
|
||||
// which left every other provider stuck on a transient refresh failure even
|
||||
// when a usable access token was still on hand.
|
||||
if (connection.accessToken) {
|
||||
return { connection, refreshed: false };
|
||||
}
|
||||
throw withStatus(
|
||||
new Error("Failed to refresh credentials. Please re-authorize the connection."),
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const updateData: JsonRecord = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (refreshResult.accessToken) {
|
||||
updateData.accessToken = refreshResult.accessToken;
|
||||
}
|
||||
if (refreshResult.refreshToken) {
|
||||
updateData.refreshToken = refreshResult.refreshToken;
|
||||
}
|
||||
if (refreshResult.expiresIn) {
|
||||
const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
|
||||
updateData.expiresAt = expiresAt;
|
||||
updateData.tokenExpiresAt = expiresAt;
|
||||
} else if (refreshResult.expiresAt) {
|
||||
updateData.expiresAt = refreshResult.expiresAt;
|
||||
updateData.tokenExpiresAt = refreshResult.expiresAt;
|
||||
}
|
||||
if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) {
|
||||
updateData.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
copilotToken: refreshResult.copilotToken,
|
||||
copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
await updateProviderConnection(connection.id, updateData);
|
||||
|
||||
return {
|
||||
connection: {
|
||||
...connection,
|
||||
...updateData,
|
||||
providerSpecificData:
|
||||
(updateData.providerSpecificData as JsonRecord | undefined) ||
|
||||
connection.providerSpecificData,
|
||||
},
|
||||
refreshed: true,
|
||||
};
|
||||
return refreshAndUpdateCredentialsWithResolver(
|
||||
connection,
|
||||
getCredentialRefreshExecutor,
|
||||
opts
|
||||
);
|
||||
}
|
||||
|
||||
function isUsageAuthError(message: unknown): boolean {
|
||||
|
||||
150
src/lib/usage/providerLimits/credentialRefresh.ts
Normal file
150
src/lib/usage/providerLimits/credentialRefresh.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { updateProviderConnection } from "@/lib/db/providers";
|
||||
import type { BaseExecutor } from "@omniroute/open-sse/executors/base";
|
||||
import {
|
||||
rotationGroupFor,
|
||||
serializeRefresh,
|
||||
} from "@omniroute/open-sse/services/refreshSerializer.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type CredentialRefreshResult = JsonRecord & {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
expiresAt?: string;
|
||||
copilotToken?: string;
|
||||
copilotTokenExpiresAt?: string;
|
||||
};
|
||||
|
||||
export interface ProviderConnectionLike {
|
||||
id: string;
|
||||
provider: string;
|
||||
authType?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: string | null;
|
||||
tokenExpiresAt?: string | null;
|
||||
providerSpecificData?: JsonRecord;
|
||||
testStatus?: string;
|
||||
isActive?: boolean;
|
||||
lastError?: string | null;
|
||||
lastErrorAt?: string | null;
|
||||
lastErrorType?: string | null;
|
||||
lastErrorSource?: string | null;
|
||||
errorCode?: string | number | null;
|
||||
rateLimitedUntil?: string | null;
|
||||
backoffLevel?: number;
|
||||
}
|
||||
|
||||
export interface CredentialRefreshOptions {
|
||||
allowRotatingRefresh?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export type CredentialExecutorResolver = (provider: string) => Promise<BaseExecutor>;
|
||||
|
||||
function withStatus(error: Error, status: number): Error & { status: number } {
|
||||
return Object.assign(error, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the quota path may refresh this provider's token.
|
||||
*
|
||||
* Rotating-refresh providers mint a single-use refresh token on every refresh,
|
||||
* so bulk quota sync must not refresh siblings concurrently. The on-demand path
|
||||
* explicitly opts in and remains serialized per rotation group.
|
||||
*/
|
||||
export function shouldAttemptRotatingRefresh(
|
||||
provider: string,
|
||||
allowRotatingRefresh: boolean | undefined
|
||||
): boolean {
|
||||
if (rotationGroupFor(provider) === null) return true;
|
||||
return allowRotatingRefresh === true;
|
||||
}
|
||||
|
||||
function buildCredentialUpdateData(
|
||||
connection: ProviderConnectionLike,
|
||||
refreshResult: CredentialRefreshResult
|
||||
): JsonRecord {
|
||||
const updateData: JsonRecord = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (refreshResult.accessToken) {
|
||||
updateData.accessToken = refreshResult.accessToken;
|
||||
}
|
||||
if (refreshResult.refreshToken) {
|
||||
updateData.refreshToken = refreshResult.refreshToken;
|
||||
}
|
||||
if (refreshResult.expiresIn) {
|
||||
const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
|
||||
updateData.expiresAt = expiresAt;
|
||||
updateData.tokenExpiresAt = expiresAt;
|
||||
} else if (refreshResult.expiresAt) {
|
||||
updateData.expiresAt = refreshResult.expiresAt;
|
||||
updateData.tokenExpiresAt = refreshResult.expiresAt;
|
||||
}
|
||||
if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) {
|
||||
updateData.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
copilotToken: refreshResult.copilotToken,
|
||||
copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return updateData;
|
||||
}
|
||||
|
||||
/** Refresh and persist credentials using a caller-supplied executor resolver. */
|
||||
export async function refreshAndUpdateCredentialsWithResolver(
|
||||
connection: ProviderConnectionLike,
|
||||
resolveExecutor: CredentialExecutorResolver,
|
||||
opts: CredentialRefreshOptions = {}
|
||||
) {
|
||||
if (!shouldAttemptRotatingRefresh(connection.provider, opts.allowRotatingRefresh)) {
|
||||
return { connection, refreshed: false };
|
||||
}
|
||||
const executor = await resolveExecutor(connection.provider);
|
||||
const credentials = {
|
||||
connectionId: connection.id,
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
expiresAt: connection.tokenExpiresAt || connection.expiresAt || null,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
copilotToken: connection.providerSpecificData?.copilotToken,
|
||||
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
|
||||
};
|
||||
|
||||
if (!opts.force && !executor.needsRefresh(credentials)) {
|
||||
return { connection, refreshed: false };
|
||||
}
|
||||
|
||||
const refreshResult = (await serializeRefresh(connection.provider, () =>
|
||||
executor.refreshCredentials(credentials, console)
|
||||
)) as CredentialRefreshResult | null;
|
||||
|
||||
if (!refreshResult) {
|
||||
if (connection.accessToken) {
|
||||
return { connection, refreshed: false };
|
||||
}
|
||||
throw withStatus(
|
||||
new Error("Failed to refresh credentials. Please re-authorize the connection."),
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = buildCredentialUpdateData(connection, refreshResult);
|
||||
|
||||
await updateProviderConnection(connection.id, updateData);
|
||||
|
||||
return {
|
||||
connection: {
|
||||
...connection,
|
||||
...updateData,
|
||||
providerSpecificData:
|
||||
(updateData.providerSpecificData as JsonRecord | undefined) ||
|
||||
connection.providerSpecificData,
|
||||
},
|
||||
refreshed: true,
|
||||
};
|
||||
}
|
||||
@@ -55,6 +55,16 @@ import {
|
||||
// Reduced from 300 → 50 to avoid browser freeze and network saturation.
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Column sort toggle mapping: clicking a column header toggles asc/desc.
|
||||
const COLUMN_SORT_MAP = {
|
||||
status: { desc: "status_desc", asc: "status_asc" },
|
||||
model: { desc: "model_desc", asc: "model_asc" },
|
||||
tokens: { desc: "tokens_desc", asc: "tokens_asc" },
|
||||
tps: { desc: "tps_desc", asc: "tps_asc" },
|
||||
duration: { desc: "duration_desc", asc: "duration_asc" },
|
||||
time: { desc: "newest", asc: "oldest" },
|
||||
} as const;
|
||||
|
||||
function getLogTotalTokens(log) {
|
||||
return (log?.tokens?.in || 0) + (log?.tokens?.out || 0);
|
||||
}
|
||||
@@ -147,17 +157,8 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
const [groupedView, setGroupedView] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// Column sort toggle: clicking a column header toggles asc/desc
|
||||
const columnSortMap = {
|
||||
status: { desc: "status_desc", asc: "status_asc" },
|
||||
model: { desc: "model_desc", asc: "model_asc" },
|
||||
tokens: { desc: "tokens_desc", asc: "tokens_asc" },
|
||||
tps: { desc: "tps_desc", asc: "tps_asc" },
|
||||
duration: { desc: "duration_desc", asc: "duration_asc" },
|
||||
time: { desc: "newest", asc: "oldest" },
|
||||
};
|
||||
const toggleSort = useCallback((column: string) => {
|
||||
const mapping = columnSortMap[column as keyof typeof columnSortMap];
|
||||
const mapping = COLUMN_SORT_MAP[column as keyof typeof COLUMN_SORT_MAP];
|
||||
if (!mapping) return;
|
||||
setSortBy((prev) => {
|
||||
if (prev === mapping.desc) return mapping.asc;
|
||||
@@ -166,7 +167,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
}, []);
|
||||
const getSortIndicator = useCallback(
|
||||
(column: string) => {
|
||||
const mapping = columnSortMap[column as keyof typeof columnSortMap];
|
||||
const mapping = COLUMN_SORT_MAP[column as keyof typeof COLUMN_SORT_MAP];
|
||||
if (!mapping) return "";
|
||||
if (sortBy === mapping.desc) return " ↓";
|
||||
if (sortBy === mapping.asc) return " ↑";
|
||||
@@ -535,89 +536,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
// endpoint until the row appears.
|
||||
const router = useRouter();
|
||||
|
||||
const openDetail = async (logEntry) => {
|
||||
// Guard: if no valid id provided, close instead of opening an empty modal
|
||||
if (!logEntry?.id) {
|
||||
try {
|
||||
closeDetail();
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestToken = `${logEntry.id}:${Date.now()}:${Math.random()}`;
|
||||
detailRequestRef.current = requestToken;
|
||||
const isCurrentDetailRequest = () => detailRequestRef.current === requestToken;
|
||||
|
||||
setSelectedLog(logEntry);
|
||||
try {
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.set("id", logEntry.id);
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
} catch (e) {
|
||||
// ignore navigation errors
|
||||
}
|
||||
setDetailLoading(true);
|
||||
setDetailData(null);
|
||||
try {
|
||||
const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (!isCurrentDetailRequest()) return;
|
||||
const dataHasPipeline =
|
||||
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
|
||||
setDetailData((prev: { pipelinePayloads: any }) => ({
|
||||
...prev,
|
||||
...data,
|
||||
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
|
||||
}));
|
||||
// ensure the modal summary reflects the fetched call log summary
|
||||
if (data && typeof data === "object") {
|
||||
setSelectedLog((prev: any) => ({
|
||||
...prev,
|
||||
...data,
|
||||
active: data.active === true,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// A deep-linked id can legitimately 404 while the request is still
|
||||
// finalizing. Keep the modal open and poll /api/logs/[id] instead of
|
||||
// falling back to an in-memory active-request endpoint.
|
||||
if (!isCurrentDetailRequest()) return;
|
||||
if (res.status === 404) {
|
||||
if (logEntry.pendingLookup || logEntry.active) {
|
||||
setSelectedLog((prev: { method: any; path: any }) => ({
|
||||
...prev,
|
||||
id: logEntry.id,
|
||||
status: 0,
|
||||
method: prev?.method,
|
||||
path: prev?.path || "",
|
||||
}));
|
||||
setDetailData({ detailState: "pending" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.warn("Log not found:", logEntry.id);
|
||||
} catch {}
|
||||
try {
|
||||
closeDetail();
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
// other errors: show a minimal error indicator by setting detailData to an error object
|
||||
try {
|
||||
const body = await res.text().catch(() => null);
|
||||
if (!isCurrentDetailRequest()) return;
|
||||
setDetailData({ error: `Failed to fetch log (status ${res.status})`, body });
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch log detail:", error);
|
||||
} finally {
|
||||
if (isCurrentDetailRequest()) setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
const closeDetail = useCallback(() => {
|
||||
detailRequestRef.current = "";
|
||||
setSelectedLog(null);
|
||||
setDetailData(null);
|
||||
@@ -629,7 +548,92 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
} catch (e) {
|
||||
// ignore navigation errors
|
||||
}
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
async (logEntry) => {
|
||||
// Guard: if no valid id provided, close instead of opening an empty modal
|
||||
if (!logEntry?.id) {
|
||||
try {
|
||||
closeDetail();
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestToken = `${logEntry.id}:${Date.now()}:${Math.random()}`;
|
||||
detailRequestRef.current = requestToken;
|
||||
const isCurrentDetailRequest = () => detailRequestRef.current === requestToken;
|
||||
|
||||
setSelectedLog(logEntry);
|
||||
try {
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.set("id", logEntry.id);
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
} catch (e) {
|
||||
// ignore navigation errors
|
||||
}
|
||||
setDetailLoading(true);
|
||||
setDetailData(null);
|
||||
try {
|
||||
const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (!isCurrentDetailRequest()) return;
|
||||
const dataHasPipeline =
|
||||
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
|
||||
setDetailData((prev: { pipelinePayloads: any }) => ({
|
||||
...prev,
|
||||
...data,
|
||||
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
|
||||
}));
|
||||
// ensure the modal summary reflects the fetched call log summary
|
||||
if (data && typeof data === "object") {
|
||||
setSelectedLog((prev: any) => ({
|
||||
...prev,
|
||||
...data,
|
||||
active: data.active === true,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// A deep-linked id can legitimately 404 while the request is still
|
||||
// finalizing. Keep the modal open and poll /api/logs/[id] instead of
|
||||
// falling back to an in-memory active-request endpoint.
|
||||
if (!isCurrentDetailRequest()) return;
|
||||
if (res.status === 404) {
|
||||
if (logEntry.pendingLookup || logEntry.active) {
|
||||
setSelectedLog((prev: { method: any; path: any }) => ({
|
||||
...prev,
|
||||
id: logEntry.id,
|
||||
status: 0,
|
||||
method: prev?.method,
|
||||
path: prev?.path || "",
|
||||
}));
|
||||
setDetailData({ detailState: "pending" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.warn("Log not found:", logEntry.id);
|
||||
} catch {}
|
||||
try {
|
||||
closeDetail();
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
// other errors: show a minimal error indicator by setting detailData to an error object
|
||||
try {
|
||||
const body = await res.text().catch(() => null);
|
||||
if (!isCurrentDetailRequest()) return;
|
||||
setDetailData({ error: `Failed to fetch log (status ${res.status})`, body });
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch log detail:", error);
|
||||
} finally {
|
||||
if (isCurrentDetailRequest()) setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[closeDetail, router]
|
||||
);
|
||||
|
||||
const sortedLogsForNav = useMemo(() => sortedLogs, [sortedLogs]);
|
||||
|
||||
@@ -654,7 +658,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
console.error("Failed to open initial log id:", error_);
|
||||
});
|
||||
}
|
||||
}, [initialSelectedId]);
|
||||
}, [initialSelectedId, openDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
const isActive = selectedLog?.active === true;
|
||||
@@ -765,7 +769,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
pendingBoundaryNavRef.current = "prev";
|
||||
fetchLogs(false);
|
||||
}
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs, openDetail]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const idx = currentLogIndex;
|
||||
@@ -781,7 +785,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
pendingBoundaryNavRef.current = "next";
|
||||
fetchLogs(false);
|
||||
}
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs, openDetail]);
|
||||
|
||||
// Resolves a pending boundary nav (see handlePrev/handleNext) once a
|
||||
// triggered fetchLogs() resync has landed in sortedLogsForNav. Only fires
|
||||
|
||||
@@ -37,6 +37,7 @@ export type AnyRoutingStrategyValue = RoutingStrategyValue | InternalRoutingStra
|
||||
|
||||
export const AUTO_ROUTING_STRATEGY_VALUES = [
|
||||
"rules",
|
||||
"score",
|
||||
"cost",
|
||||
"eco",
|
||||
"latency",
|
||||
|
||||
@@ -42,6 +42,13 @@ export function isClientAbortError(err) {
|
||||
const e = /** @type {NodeJS.ErrnoException} */ (err);
|
||||
// Node emits `Error: aborted` (no code) from http.Server#abortIncoming.
|
||||
if (e.message === "aborted" || e.message === "Aborted") return true;
|
||||
// OmniRoute's SSE teardown aborts in-flight legs with
|
||||
// `Error [AbortError]: request_signal_aborted` on client disconnects
|
||||
// (open-sse/utils/streamHandler.ts), and fetch/DOM cancellation surfaces as
|
||||
// `AbortError` with an abort-flavoured message. Same benign class as
|
||||
// `Error: aborted` — an emitter-left 'error' event on any of these used to
|
||||
// kill the process (#fix-dev-server-aborted).
|
||||
if (e.name === "AbortError" && /abort/i.test(String(e.message))) return true;
|
||||
switch (e.code) {
|
||||
case "ERR_STREAM_PREMATURE_CLOSE":
|
||||
case "ECONNRESET":
|
||||
|
||||
@@ -18,6 +18,10 @@ import { resolve } from "path";
|
||||
import { getLogConfig, initLogRotation } from "@/lib/logRotation";
|
||||
import { getAppLogLevel } from "@/lib/logEnv";
|
||||
import { redactLogArgs } from "@/shared/utils/logRedaction";
|
||||
import {
|
||||
getOrCreateSharedLoggerResource,
|
||||
type SharedLoggerResource,
|
||||
} from "@/shared/utils/loggerResource";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
@@ -61,7 +65,7 @@ function getTransportCompatibleConfig(): pino.LoggerOptions {
|
||||
* vanished, so failed writes are dropped (best-effort stderr notice) instead of
|
||||
* escalating.
|
||||
*/
|
||||
function buildFileTransportStream(targets: NonNullable<pino.TransportMultiOptions["targets"]>) {
|
||||
function buildTransportStream(targets: NonNullable<pino.TransportMultiOptions["targets"]>) {
|
||||
const stream = pino.transport({ targets });
|
||||
stream.on("error", (err: unknown) => {
|
||||
try {
|
||||
@@ -75,11 +79,65 @@ function buildFileTransportStream(targets: NonNullable<pino.TransportMultiOption
|
||||
return stream;
|
||||
}
|
||||
|
||||
interface OwnedLogStream {
|
||||
flushSync?: () => void;
|
||||
end?: () => void;
|
||||
once?: (event: string, listener: () => void) => unknown;
|
||||
}
|
||||
|
||||
async function closeOwnedStream(stream: OwnedLogStream | null): Promise<void> {
|
||||
if (!stream) return;
|
||||
|
||||
try {
|
||||
stream.flushSync?.();
|
||||
} catch {
|
||||
// Best-effort shutdown: a missing log destination must not block process exit.
|
||||
}
|
||||
|
||||
if (!stream.end) return;
|
||||
|
||||
await new Promise<void>((resolveClose) => {
|
||||
let resolved = false;
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolveClose();
|
||||
};
|
||||
const fallback = setTimeout(finish, 1_000);
|
||||
|
||||
stream.once?.("close", () => {
|
||||
clearTimeout(fallback);
|
||||
finish();
|
||||
});
|
||||
|
||||
try {
|
||||
stream.end?.();
|
||||
if (!stream.once) {
|
||||
clearTimeout(fallback);
|
||||
finish();
|
||||
}
|
||||
} catch {
|
||||
clearTimeout(fallback);
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createLoggerResource(
|
||||
logger: pino.Logger,
|
||||
stream: OwnedLogStream | null
|
||||
): SharedLoggerResource {
|
||||
return {
|
||||
logger,
|
||||
close: () => closeOwnedStream(stream),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the logger with optional file transport.
|
||||
* Uses pino transport targets for all destinations.
|
||||
*/
|
||||
function buildLogger(): pino.Logger {
|
||||
function buildLoggerResource(): SharedLoggerResource {
|
||||
const logConfig = getLogConfig();
|
||||
const logLevel = (baseConfig.level as string) || "info";
|
||||
const transportConfig = getTransportCompatibleConfig();
|
||||
@@ -95,7 +153,7 @@ function buildLogger(): pino.Logger {
|
||||
|
||||
if (isDev) {
|
||||
// Dev: pino-pretty → stdout, JSON → file
|
||||
const stream = buildFileTransportStream([
|
||||
const stream = buildTransportStream([
|
||||
{
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
@@ -113,12 +171,12 @@ function buildLogger(): pino.Logger {
|
||||
level: logLevel,
|
||||
},
|
||||
]);
|
||||
return pino(transportConfig, stream);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
}
|
||||
|
||||
// Production: JSON → stdout + JSON → file
|
||||
{
|
||||
const stream = buildFileTransportStream([
|
||||
const stream = buildTransportStream([
|
||||
{
|
||||
target: "pino/file",
|
||||
options: { destination: 1 }, // stdout
|
||||
@@ -130,7 +188,7 @@ function buildLogger(): pino.Logger {
|
||||
level: logLevel,
|
||||
},
|
||||
]);
|
||||
return pino(transportConfig, stream);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
}
|
||||
} catch (err) {
|
||||
// Log the actual error for diagnostics (issue #165)
|
||||
@@ -156,12 +214,15 @@ function buildLogger(): pino.Logger {
|
||||
});
|
||||
|
||||
// Production fallback: JSON to both stdout and file via multistream
|
||||
return pino(
|
||||
baseConfig,
|
||||
pino.multistream([
|
||||
{ stream: process.stdout, level: logLevel as pino.Level },
|
||||
{ stream: fileDestination, level: logLevel as pino.Level },
|
||||
])
|
||||
return createLoggerResource(
|
||||
pino(
|
||||
baseConfig,
|
||||
pino.multistream([
|
||||
{ stream: process.stdout, level: logLevel as pino.Level },
|
||||
{ stream: fileDestination, level: logLevel as pino.Level },
|
||||
])
|
||||
),
|
||||
fileDestination
|
||||
);
|
||||
} catch (fallbackErr) {
|
||||
try {
|
||||
@@ -175,9 +236,8 @@ function buildLogger(): pino.Logger {
|
||||
|
||||
// Console-only (no file logging)
|
||||
if (isDev) {
|
||||
return pino({
|
||||
...baseConfig,
|
||||
transport: {
|
||||
const stream = buildTransportStream([
|
||||
{
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
@@ -185,14 +245,18 @@ function buildLogger(): pino.Logger {
|
||||
ignore: "pid,hostname,service",
|
||||
messageFormat: "[{module}] {msg}",
|
||||
},
|
||||
level: logLevel,
|
||||
},
|
||||
});
|
||||
]);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
}
|
||||
|
||||
return pino(baseConfig);
|
||||
return createLoggerResource(pino(baseConfig), null);
|
||||
}
|
||||
|
||||
export const logger = buildLogger();
|
||||
const sharedLoggerResource = getOrCreateSharedLoggerResource(buildLoggerResource);
|
||||
|
||||
export const logger = sharedLoggerResource.logger;
|
||||
|
||||
/**
|
||||
* Create a child logger with a module tag.
|
||||
|
||||
32
src/shared/utils/loggerResource.ts
Normal file
32
src/shared/utils/loggerResource.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
export interface SharedLoggerResource {
|
||||
logger: Logger;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __omnirouteLoggerResource: SharedLoggerResource | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the process-wide logger resource, creating it only once.
|
||||
*
|
||||
* Next.js development HMR can evaluate logger.ts in more than one server chunk.
|
||||
* Keeping the resource on globalThis prevents each evaluation from spawning a
|
||||
* new pino worker transport.
|
||||
*/
|
||||
export function getOrCreateSharedLoggerResource(
|
||||
create: () => SharedLoggerResource
|
||||
): SharedLoggerResource {
|
||||
return (globalThis.__omnirouteLoggerResource ??= create());
|
||||
}
|
||||
|
||||
/** Close and forget the shared transport. Idempotent across HMR module copies. */
|
||||
export async function closeSharedLoggerResource(): Promise<void> {
|
||||
const resource = globalThis.__omnirouteLoggerResource;
|
||||
if (!resource) return;
|
||||
|
||||
delete globalThis.__omnirouteLoggerResource;
|
||||
await resource.close();
|
||||
}
|
||||
155
tests/unit/11759-embedding-registry-width-and-type.test.ts
Normal file
155
tests/unit/11759-embedding-registry-width-and-type.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Regression test for #11759 — `/v1/models` dropped the vector width and the
|
||||
* `embedding` type from embedding models that `embeddingRegistry.ts` describes,
|
||||
* whenever a synced model existed for the same id.
|
||||
*
|
||||
* `embeddingRegistry.ts` is the only machine-readable source of two facts: a model's
|
||||
* vector width, and that it is an embedding model at all. No upstream provider
|
||||
* publishes either — OpenRouter's catalogue carries no `dimensions`, and OpenAI's
|
||||
* `/v1/models` returns ids with no capability information.
|
||||
*
|
||||
* Two paths lost them:
|
||||
*
|
||||
* - Width. The registry loop skipped its entry outright when discovery had already
|
||||
* produced the model (`hasEquivalentSpecialtyModel(...) continue`), so the surviving
|
||||
* entry was the discovered one — typed, but with no `dimensions`.
|
||||
*
|
||||
* - Type. Classification comes from `sm.supportedEndpoints`, which falls back to
|
||||
* `["chat"]`. A provider that does not report endpoints therefore yielded
|
||||
* `modelType === undefined` and the `type` key was omitted entirely, so an embedding
|
||||
* model read as a chat model.
|
||||
*
|
||||
* A consumer that stores vectors needs both: collections are keyed on width, and an
|
||||
* untyped model cannot be identified as an embedding model. These tests assert the
|
||||
* merged `/v1/models` output, not the registry in isolation — the registry was always
|
||||
* correct; the loss happened in `catalog.ts`.
|
||||
*/
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-11759-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
const embeddingRegistry = await import("../../open-sse/config/embeddingRegistry.ts");
|
||||
|
||||
/** Both are real registry entries, so the expected widths come from the registry itself. */
|
||||
const OPENROUTER_MODEL = "qwen/qwen3-embedding-8b";
|
||||
const OPENAI_MODEL = "text-embedding-3-small";
|
||||
|
||||
function registryWidth(providerId: string, modelId: string): number {
|
||||
const provider = embeddingRegistry.getEmbeddingProvider(providerId);
|
||||
assert.ok(provider, `embeddingRegistry has no provider "${providerId}"`);
|
||||
const model = provider!.models.find((m) => m.id === modelId);
|
||||
assert.ok(model, `embeddingRegistry has no model "${modelId}" on "${providerId}"`);
|
||||
const { dimensions } = model!;
|
||||
assert.equal(
|
||||
typeof dimensions,
|
||||
"number",
|
||||
`this test assumes a single advertised width for "${modelId}"`
|
||||
);
|
||||
return dimensions as number;
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
|
||||
}
|
||||
|
||||
/** An active connection, which is what makes the provider's registry models eligible. */
|
||||
async function connectProvider(provider: string) {
|
||||
return (await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: `${provider}-conn`,
|
||||
apiKey: "sk-test",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
})) as { id: string };
|
||||
}
|
||||
|
||||
async function modelEntry(id: string) {
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { data: Array<Record<string, unknown>> };
|
||||
const entry = body.data.find((model) => model.id === id);
|
||||
assert.ok(
|
||||
entry,
|
||||
`expected "${id}" in /v1/models, got ${JSON.stringify(
|
||||
body.data.filter((m) => String(m.id).includes("embedding")).map((m) => m.id)
|
||||
)}`
|
||||
);
|
||||
return entry!;
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#11759: a synced embedding model keeps the width the registry states", async () => {
|
||||
const connection = await connectProvider("openrouter");
|
||||
|
||||
// What discovery produces for OpenRouter: typed as an embedding model, and carrying no
|
||||
// width, because the upstream catalogue does not publish one.
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
|
||||
{
|
||||
id: OPENROUTER_MODEL,
|
||||
name: "Qwen3 Embedding 8B",
|
||||
source: "imported",
|
||||
supportedEndpoints: ["embeddings"],
|
||||
},
|
||||
]);
|
||||
|
||||
const entry = await modelEntry(`openrouter/${OPENROUTER_MODEL}`);
|
||||
const expected = registryWidth("openrouter", OPENROUTER_MODEL);
|
||||
|
||||
assert.equal(
|
||||
entry.dimensions,
|
||||
expected,
|
||||
`the registry states ${expected} for "${OPENROUTER_MODEL}"; the synced entry must not drop it — got ${JSON.stringify(entry.dimensions)}`
|
||||
);
|
||||
assert.equal(entry.type, "embedding", "a synced embedding model must stay typed as one");
|
||||
});
|
||||
|
||||
test("#11759: a synced model the embedding registry names is typed as an embedding model", async () => {
|
||||
const connection = await connectProvider("openai");
|
||||
|
||||
// What discovery produces for OpenAI: no `supportedEndpoints`, because `/v1/models`
|
||||
// returns ids with no capability information, so classification falls back to `["chat"]`
|
||||
// and the model is emitted with no `type` at all.
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("openai", connection.id, [
|
||||
{
|
||||
id: OPENAI_MODEL,
|
||||
name: "Text Embedding 3 Small",
|
||||
source: "imported",
|
||||
},
|
||||
]);
|
||||
|
||||
const entry = await modelEntry(`openai/${OPENAI_MODEL}`);
|
||||
|
||||
assert.equal(
|
||||
entry.type,
|
||||
"embedding",
|
||||
`"${OPENAI_MODEL}" is in the embedding registry, so it must not be published untyped — got ${JSON.stringify(entry.type)}`
|
||||
);
|
||||
assert.equal(
|
||||
entry.dimensions,
|
||||
registryWidth("openai", OPENAI_MODEL),
|
||||
"an embedding model published without its width cannot be indexed by a consumer"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const dispatchSourcePath = join(import.meta.dirname, "../../src/lib/batches/dispatch.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = {
|
||||
OMNIROUTE_PORT: process.env.OMNIROUTE_PORT,
|
||||
PORT: process.env.PORT,
|
||||
DASHBOARD_PORT: process.env.DASHBOARD_PORT,
|
||||
OMNIROUTE_BASE_PATH: process.env.OMNIROUTE_BASE_PATH,
|
||||
};
|
||||
|
||||
function restoreEnv(): void {
|
||||
for (const key of Object.keys(originalEnv) as Array<keyof typeof originalEnv>) {
|
||||
const value = originalEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
test("batch dispatch does not pull API route modules into the instrumentation graph", () => {
|
||||
const source = readFileSync(dispatchSourcePath, "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /@\/app\/api\/v1\/.+\/route/);
|
||||
assert.doesNotMatch(source, /handlerLoaders|BatchRouteHandler/);
|
||||
});
|
||||
|
||||
test("batch dispatch posts to the active dashboard loopback listener", async () => {
|
||||
process.env.OMNIROUTE_PORT = "24120";
|
||||
process.env.PORT = "24121";
|
||||
process.env.DASHBOARD_PORT = "24122";
|
||||
process.env.OMNIROUTE_BASE_PATH = "/omniroute/";
|
||||
|
||||
const calls: Array<{ input: string; init?: RequestInit }> = [];
|
||||
const upstreamResponse = new Response("accepted", { status: 202 });
|
||||
globalThis.fetch = async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return upstreamResponse;
|
||||
};
|
||||
|
||||
const { dispatch } = await import("../../src/lib/batches/dispatch.ts");
|
||||
const response = await dispatch.dispatchBatchApiRequest({
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: { model: "provider/model", messages: [] },
|
||||
apiKey: "batch-secret",
|
||||
});
|
||||
|
||||
assert.strictEqual(response, upstreamResponse);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, "http://127.0.0.1:24122/omniroute/v1/chat/completions");
|
||||
assert.equal(calls[0].init?.method, "POST");
|
||||
assert.equal(calls[0].init?.redirect, "error");
|
||||
assert.equal(new Headers(calls[0].init?.headers).get("authorization"), "Bearer batch-secret");
|
||||
assert.deepEqual(JSON.parse(String(calls[0].init?.body)), {
|
||||
model: "provider/model",
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
@@ -202,6 +202,13 @@ test("Batch API and Processing", async () => {
|
||||
});
|
||||
|
||||
test("Batch handles and counts failures correctly", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ error: { message: "Model not found" } }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
initBatchProcessor();
|
||||
try {
|
||||
// 1. Create a file with a request that will fail (invalid provider/model)
|
||||
@@ -264,6 +271,7 @@ test("Batch handles and counts failures correctly", async () => {
|
||||
}
|
||||
} finally {
|
||||
stopBatchProcessor();
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -390,6 +398,21 @@ test("Batch rejects input lines whose url does not match the batch endpoint", as
|
||||
});
|
||||
|
||||
test("Batch forces stream: false for all requests", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let dispatchedBody: Record<string, unknown> | null = null;
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
dispatchedBody = JSON.parse(String(init?.body));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { role: "assistant", content: "batch response" } }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
initBatchProcessor();
|
||||
try {
|
||||
const batchItems = [
|
||||
@@ -449,8 +472,10 @@ test("Batch forces stream: false for all requests", async () => {
|
||||
"Should not have JSON parsing error from SSE stream"
|
||||
);
|
||||
}
|
||||
assert.strictEqual(dispatchedBody?.stream, false, "Batch dispatch must disable streaming");
|
||||
} finally {
|
||||
stopBatchProcessor();
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,11 +27,12 @@ import path from "node:path";
|
||||
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-quota-"));
|
||||
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const { getCredentialRefreshExecutor } =
|
||||
await import("../../open-sse/executors/credential.ts");
|
||||
const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts");
|
||||
|
||||
test("codex: quota-sync must NOT proactively rotate the refresh_token (Auth0 family-revocation cascade guard)", async () => {
|
||||
const exec = await getExecutor("codex");
|
||||
const exec = await getCredentialRefreshExecutor("codex");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
@@ -68,7 +69,7 @@ test("codex: quota-sync must NOT proactively rotate the refresh_token (Auth0 fam
|
||||
});
|
||||
|
||||
test("non-rotating OAuth provider is still refreshed proactively from quota-sync (gate is not over-broad)", async () => {
|
||||
const exec = await getExecutor("cursor");
|
||||
const exec = await getCredentialRefreshExecutor("cursor");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
|
||||
74
tests/unit/combo-loop-safety-timer-leak-11804.test.ts
Normal file
74
tests/unit/combo-loop-safety-timer-leak-11804.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* #11804 — the combo loop-safety timer must be cleared on EVERY exit path.
|
||||
*
|
||||
* `dispatchWithCooldownRetry` (open-sse/services/combo.ts) arms a
|
||||
* `setTimeout(..., loopSafetyMs)` — 10 minutes by default — once per `setTry`
|
||||
* iteration, so a combo that never produces a terminal response still answers
|
||||
* the client with a 504 instead of hanging forever.
|
||||
*
|
||||
* Before this fix the only `clearTimeout` lived inside the `if (anySuccess)`
|
||||
* branch (the code comment said so verbatim: "clear the safety timer on the
|
||||
* happy path"). Every error exit — all_targets_skipped, all_accounts_inactive,
|
||||
* the aggregated-status return, the final fallback, the global-timeout branch —
|
||||
* returned the response to the client and left a 600s timer pending, its
|
||||
* closure retaining `orderedTargets` and the exhausted provider/connection
|
||||
* sets. Field evidence on the issue: a client received 502 immediately and the
|
||||
* "Combo loop safety timeout ... force-terminating" line was logged exactly
|
||||
* 600s later, long after the request was gone.
|
||||
*
|
||||
* This is a source-level guard rather than a runtime one: driving a real combo
|
||||
* through each terminal branch needs the full provider/credential/DB stack, and
|
||||
* the invariant we actually care about ("no exit path may skip the clear") is a
|
||||
* structural property of the function. The guard fails if someone reintroduces
|
||||
* a happy-path-only clear or drops the finally.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const comboSrc = readFileSync(resolve(here, "../../open-sse/services/combo.ts"), "utf8");
|
||||
|
||||
test("#11804: the loop-safety timer is released in a finally, not only on success", () => {
|
||||
assert.match(
|
||||
comboSrc,
|
||||
/finally\s*\{[^}]*clearTimeout\(activeLoopSafetyTimer\)/s,
|
||||
"dispatchWithCooldownRetry must clear the loop-safety timer in a finally block so every " +
|
||||
"exit path (including future ones) releases it"
|
||||
);
|
||||
});
|
||||
|
||||
test("#11804: the timer handle is reachable from the function-scope cleanup", () => {
|
||||
// The timer is created inside the `for (setTry...)` loop; the cleanup lives at
|
||||
// function scope. If the handle is not published to that outer binding, the
|
||||
// finally silently clears nothing.
|
||||
assert.match(
|
||||
comboSrc,
|
||||
/activeLoopSafetyTimer = loopSafetyTimer/,
|
||||
"the per-iteration timer must be published to the function-scope handle"
|
||||
);
|
||||
const declIdx = comboSrc.indexOf("let activeLoopSafetyTimer");
|
||||
const loopIdx = comboSrc.indexOf("for (let setTry = 0");
|
||||
assert.ok(declIdx > 0, "function-scope timer handle must be declared");
|
||||
assert.ok(
|
||||
declIdx < loopIdx,
|
||||
"the handle must be declared OUTSIDE the setTry loop, otherwise each iteration " +
|
||||
"gets a fresh binding and the previous iteration's timer leaks"
|
||||
);
|
||||
});
|
||||
|
||||
test("#11804: the safety timeout itself is preserved (fix must not disarm the 504)", () => {
|
||||
// Guard against 'fixing' the leak by simply never arming the timer.
|
||||
assert.match(
|
||||
comboSrc,
|
||||
/loopSafetyTimer = setTimeout\(/,
|
||||
"the loop-safety timer must still be armed — the 504 backstop is the reason it exists"
|
||||
);
|
||||
assert.match(
|
||||
comboSrc,
|
||||
/Combo loop safety timeout/,
|
||||
"the force-termination path must still exist"
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ const { registerExecutor, getRegisteredExecutor, hasRegisteredExecutor, listExec
|
||||
await import("../../open-sse/executors/registry.ts");
|
||||
const { getExecutor, hasSpecializedExecutor, BaseExecutor, DefaultExecutor } =
|
||||
await import("../../open-sse/executors/index.ts");
|
||||
const { getDefaultExecutor } = await import("../../open-sse/executors/defaultResolver.ts");
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
@@ -54,3 +55,9 @@ test("registry lookup is exact — Object.prototype names are not executors", as
|
||||
assert.ok((await getExecutor(name)) instanceof DefaultExecutor, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("the registry and leaf resolver share fallback executor instances", async () => {
|
||||
const provider = "default-resolver-test-provider";
|
||||
assert.equal(await getExecutor(provider), getDefaultExecutor(provider));
|
||||
assert.equal(getDefaultExecutor(provider), getDefaultExecutor(provider));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
type GracefulShutdownModule = typeof import("../../src/lib/gracefulShutdown.ts");
|
||||
|
||||
const gracefulShutdownUrl = pathToFileURL(join(process.cwd(), "src/lib/gracefulShutdown.ts")).href;
|
||||
const shutdownSignals = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
|
||||
|
||||
// #8045: on Windows, closing the console window delivers CTRL_CLOSE_EVENT, which
|
||||
// Node/libuv maps to a JS-visible "SIGHUP" event (confirmed: nodejs/node#10165,
|
||||
@@ -7,17 +14,90 @@ import assert from "node:assert/strict";
|
||||
// closed"). Before this fix, initGracefulShutdown() only registered SIGTERM/SIGINT,
|
||||
// so the "close the window" path never ran cleanup() (WAL checkpoint(TRUNCATE) +
|
||||
// closeDbInstance()), leaving storage.sqlite's WAL un-checkpointed for the next launch.
|
||||
test("initGracefulShutdown registers a SIGHUP handler (Windows console-close path)", async () => {
|
||||
const before = process.listenerCount("SIGHUP");
|
||||
const { initGracefulShutdown } = await import("../../src/lib/gracefulShutdown.ts");
|
||||
initGracefulShutdown();
|
||||
const after = process.listenerCount("SIGHUP");
|
||||
assert.ok(
|
||||
after > before,
|
||||
`Expected initGracefulShutdown() to add a SIGHUP listener (before=${before}, after=${after}).`
|
||||
test("graceful shutdown listeners remain process-singletons across HMR module instances", async () => {
|
||||
const previousState = globalThis.__omnirouteShutdown;
|
||||
const previousRequestShutdown = globalThis.__omnirouteRequestShutdown;
|
||||
const previousCustomServerOwner = globalThis.__omnirouteCustomServerOwnsShutdown;
|
||||
const listenersBefore = new Map(
|
||||
shutdownSignals.map((signal) => [signal, process.listeners(signal)] as const)
|
||||
);
|
||||
delete globalThis.__omnirouteShutdown;
|
||||
delete globalThis.__omnirouteRequestShutdown;
|
||||
delete globalThis.__omnirouteCustomServerOwnsShutdown;
|
||||
|
||||
try {
|
||||
const first = (await import(
|
||||
`${gracefulShutdownUrl}?phase4=shutdown-a`
|
||||
)) as GracefulShutdownModule;
|
||||
const second = (await import(
|
||||
`${gracefulShutdownUrl}?phase4=shutdown-b`
|
||||
)) as GracefulShutdownModule;
|
||||
|
||||
first.initGracefulShutdown();
|
||||
assert.equal(globalThis.__omnirouteRequestShutdown, first.requestGracefulShutdown);
|
||||
for (const signal of shutdownSignals) {
|
||||
assert.equal(process.listenerCount(signal), listenersBefore.get(signal)!.length + 1);
|
||||
}
|
||||
|
||||
second.initGracefulShutdown();
|
||||
for (const signal of shutdownSignals) {
|
||||
assert.equal(
|
||||
process.listenerCount(signal),
|
||||
listenersBefore.get(signal)!.length + 1,
|
||||
`${signal} listener must not be duplicated by HMR re-initialization`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
for (const signal of shutdownSignals) {
|
||||
const previousListeners = listenersBefore.get(signal)!;
|
||||
for (const listener of process.listeners(signal)) {
|
||||
if (!previousListeners.includes(listener)) process.removeListener(signal, listener);
|
||||
}
|
||||
}
|
||||
|
||||
if (previousState === undefined) delete globalThis.__omnirouteShutdown;
|
||||
else globalThis.__omnirouteShutdown = previousState;
|
||||
if (previousRequestShutdown === undefined) delete globalThis.__omnirouteRequestShutdown;
|
||||
else globalThis.__omnirouteRequestShutdown = previousRequestShutdown;
|
||||
if (previousCustomServerOwner === undefined) {
|
||||
delete globalThis.__omnirouteCustomServerOwnsShutdown;
|
||||
} else {
|
||||
globalThis.__omnirouteCustomServerOwnsShutdown = previousCustomServerOwner;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a custom server owner receives cleanup without duplicate process signal listeners", async () => {
|
||||
const previousState = globalThis.__omnirouteShutdown;
|
||||
const previousRequestShutdown = globalThis.__omnirouteRequestShutdown;
|
||||
const previousCustomServerOwner = globalThis.__omnirouteCustomServerOwnsShutdown;
|
||||
const listenerCounts = new Map(
|
||||
shutdownSignals.map((signal) => [signal, process.listenerCount(signal)] as const)
|
||||
);
|
||||
|
||||
// Clean up: remove all SIGHUP listeners added by this test so it doesn't leak
|
||||
// into other test files sharing the same process.
|
||||
process.removeAllListeners("SIGHUP");
|
||||
delete globalThis.__omnirouteShutdown;
|
||||
delete globalThis.__omnirouteRequestShutdown;
|
||||
globalThis.__omnirouteCustomServerOwnsShutdown = true;
|
||||
|
||||
try {
|
||||
const shutdownModule = (await import(
|
||||
`${gracefulShutdownUrl}?phase4=custom-owner`
|
||||
)) as GracefulShutdownModule;
|
||||
shutdownModule.initGracefulShutdown();
|
||||
|
||||
assert.equal(globalThis.__omnirouteRequestShutdown, shutdownModule.requestGracefulShutdown);
|
||||
for (const signal of shutdownSignals) {
|
||||
assert.equal(process.listenerCount(signal), listenerCounts.get(signal));
|
||||
}
|
||||
} finally {
|
||||
if (previousState === undefined) delete globalThis.__omnirouteShutdown;
|
||||
else globalThis.__omnirouteShutdown = previousState;
|
||||
if (previousRequestShutdown === undefined) delete globalThis.__omnirouteRequestShutdown;
|
||||
else globalThis.__omnirouteRequestShutdown = previousRequestShutdown;
|
||||
if (previousCustomServerOwner === undefined) {
|
||||
delete globalThis.__omnirouteCustomServerOwnsShutdown;
|
||||
} else {
|
||||
globalThis.__omnirouteCustomServerOwnsShutdown = previousCustomServerOwner;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import assert from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
isClientAbortError,
|
||||
shouldSwallowUncaught,
|
||||
@@ -115,5 +117,88 @@ test("shouldSwallowUncaught preserves crash semantics for genuine errors", () =>
|
||||
|
||||
test("installProcessCrashGuard does not throw on import and is idempotent", () => {
|
||||
assert.doesNotThrow(() => installProcessCrashGuard(() => {}));
|
||||
assert.doesNotThrow(() => installProcessCrashGuard(() => {}));
|
||||
});
|
||||
test("isClientAbortError matches OmniRoute SSE AbortError shapes (#fix-crash-guard-logger-7)", () => {
|
||||
// Exact production shape from the 2026-08-31 crash log:
|
||||
// ⨯ unhandledRejection: Error [AbortError]: request_signal_aborted
|
||||
const sseAbort = Object.assign(new Error("request_signal_aborted"), { name: "AbortError" });
|
||||
assert.equal(isClientAbortError(sseAbort), true, "SSE teardown AbortError must be absorbed");
|
||||
// fetch / DOMException-style cancellation
|
||||
const domAbort = new DOMException("This operation was aborted", "AbortError");
|
||||
assert.equal(isClientAbortError(domAbort), true, "DOMException AbortError must be absorbed");
|
||||
// A genuine TypeError that merely MENTIONS 'abort' must NOT be absorbed.
|
||||
const typo = new TypeError("Cannot read properties of undefined (reading 'abort')");
|
||||
assert.equal(isClientAbortError(typo), false);
|
||||
});
|
||||
|
||||
test("shouldSwallowUncaught absorbs SSE AbortError rejections", () => {
|
||||
const sseAbort = Object.assign(new Error("request_signal_aborted"), { name: "AbortError" });
|
||||
assert.equal(shouldSwallowUncaught(sseAbort, "unhandledRejection"), true);
|
||||
});
|
||||
|
||||
// Production crash (2026-08-25 → 08-31, ~170 restarts, exit code 7):
|
||||
// every real call site installs the guard with NO logger, so the old
|
||||
// `const logger = log ?? console` default invoked the console OBJECT as a
|
||||
// function inside the uncaughtException handler → TypeError inside
|
||||
// process._fatalException → Node exit code 7. These children run the REAL
|
||||
// production call shape; the process must survive benign aborts and still
|
||||
// crash on genuine errors.
|
||||
test("installProcessCrashGuard() with no logger swallows aborts instead of dying (exit-7 regression)", async () => {
|
||||
const guardPath = fileURLToPath(
|
||||
new URL("../../src/shared/utils/httpClientAbortGuard.mjs", import.meta.url)
|
||||
);
|
||||
const script = `
|
||||
const { installProcessCrashGuard } = await import(process.argv[1]);
|
||||
installProcessCrashGuard(); // production call sites pass NO logger
|
||||
process.emit(
|
||||
"uncaughtException",
|
||||
Object.assign(new Error("aborted"), { code: "ECONNRESET" }),
|
||||
"uncaughtException"
|
||||
);
|
||||
process.emit(
|
||||
"unhandledRejection",
|
||||
Object.assign(new Error("request_signal_aborted"), { name: "AbortError" }),
|
||||
Promise.resolve()
|
||||
);
|
||||
console.log("ALIVE");
|
||||
process.exit(0);
|
||||
`;
|
||||
const { status, stdout, stderr } = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ["--input-type=module", "-e", script, guardPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
child.on("close", (status) => resolve({ status, stdout: out, stderr: err }));
|
||||
child.on("error", reject);
|
||||
});
|
||||
assert.equal(status, 0, `child must survive benign aborts; stderr: ${stderr}`);
|
||||
assert.match(stdout, /ALIVE/);
|
||||
});
|
||||
|
||||
test("installProcessCrashGuard still crashes on genuine errors (no over-swallowing)", async () => {
|
||||
const guardPath = fileURLToPath(
|
||||
new URL("../../src/shared/utils/httpClientAbortGuard.mjs", import.meta.url)
|
||||
);
|
||||
const script = `
|
||||
const { installProcessCrashGuard } = await import(process.argv[1]);
|
||||
installProcessCrashGuard();
|
||||
process.emit("uncaughtException", new Error("genuine failure"), "uncaughtException");
|
||||
console.log("SHOULD_NOT_REACH");
|
||||
`;
|
||||
const { status, stdout, stderr: _stderr } = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ["--input-type=module", "-e", script, guardPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
child.on("close", (status) => resolve({ status, stdout: out, stderr: err }));
|
||||
child.on("error", reject);
|
||||
});
|
||||
assert.notEqual(status, 0, "genuine errors must keep crash semantics");
|
||||
assert.doesNotMatch(stdout, /SHOULD_NOT_REACH/);
|
||||
});
|
||||
|
||||
107
tests/unit/instrumentation-import-graph-12074.test.ts
Normal file
107
tests/unit/instrumentation-import-graph-12074.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const instrumentationPath = path.join(process.cwd(), "src/instrumentation-node.ts");
|
||||
const quotaAutoPingPath = path.join(process.cwd(), "src/lib/services/quotaAutoPing.ts");
|
||||
const credentialRefreshPath = path.join(
|
||||
process.cwd(),
|
||||
"src/lib/usage/providerLimits/credentialRefresh.ts"
|
||||
);
|
||||
const providerLimitsPath = path.join(process.cwd(), "src/lib/usage/providerLimits.ts");
|
||||
const credentialExecutorPath = path.join(process.cwd(), "open-sse/executors/credential.ts");
|
||||
const executorDirectory = path.join(process.cwd(), "open-sse/executors");
|
||||
const anthropicValidationPath = path.join(
|
||||
process.cwd(),
|
||||
"src/lib/providers/validation/anthropicFormat.ts"
|
||||
);
|
||||
const defaultExecutorResolverPath = path.join(
|
||||
process.cwd(),
|
||||
"open-sse/executors/defaultResolver.ts"
|
||||
);
|
||||
|
||||
test("node instrumentation loads the proxy patch leaf before quota registration", () => {
|
||||
const source = fs.readFileSync(instrumentationPath, "utf8");
|
||||
const proxyPatchImport = 'await import("@omniroute/open-sse/utils/proxyFetch.ts")';
|
||||
const proxyPatchIndex = source.indexOf(proxyPatchImport);
|
||||
const quotaRegistrationIndex = source.indexOf("await registerQuotaFetchers()");
|
||||
|
||||
assert.ok(proxyPatchIndex >= 0, "startup must load the proxyFetch side-effect leaf");
|
||||
assert.ok(quotaRegistrationIndex > proxyPatchIndex, "proxy patch must run before quota setup");
|
||||
assert.doesNotMatch(source, /import\("@omniroute\/open-sse\/index\.ts"\)/);
|
||||
});
|
||||
|
||||
test("quota auto-ping lazily loads only the Codex executor", () => {
|
||||
const source = fs.readFileSync(quotaAutoPingPath, "utf8");
|
||||
const credentialRefreshSource = fs.readFileSync(credentialRefreshPath, "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /open-sse\/executors\/index(?:\.ts)?/);
|
||||
assert.doesNotMatch(source, /@\/lib\/usage\/providerLimits["']/);
|
||||
assert.match(source, /import\("@omniroute\/open-sse\/executors\/codex\.ts"\)/);
|
||||
assert.match(source, /@\/lib\/usage\/providerLimits\/credentialRefresh/);
|
||||
assert.match(source, /getExecutor: loadQuotaAutoPingExecutor/);
|
||||
assert.doesNotMatch(credentialRefreshSource, /open-sse\/executors\/index(?:\.ts)?/);
|
||||
});
|
||||
|
||||
test("startup still registers bespoke, batch, and generic quota fetchers", async () => {
|
||||
const [{ registerQuotaFetchers }, { getQuotaFetcher }] = await Promise.all([
|
||||
import("../../src/instrumentation-node.ts"),
|
||||
import("../../open-sse/services/quotaPreflight.ts"),
|
||||
]);
|
||||
|
||||
await registerQuotaFetchers();
|
||||
|
||||
for (const provider of [
|
||||
"agentrouter",
|
||||
"codex",
|
||||
"bailian-coding-plan",
|
||||
"qwen-cloud-token-plan",
|
||||
"crof",
|
||||
"deepseek",
|
||||
"openrouter",
|
||||
"opencode-go",
|
||||
"grok-web",
|
||||
"antigravity",
|
||||
]) {
|
||||
assert.equal(typeof getQuotaFetcher(provider), "function", `${provider} quota fetcher missing`);
|
||||
}
|
||||
});
|
||||
|
||||
test("provider-limit startup uses the refresh-only executor resolver", () => {
|
||||
const providerLimitsSource = fs.readFileSync(providerLimitsPath, "utf8");
|
||||
const credentialExecutorSource = fs.readFileSync(credentialExecutorPath, "utf8");
|
||||
|
||||
assert.doesNotMatch(providerLimitsSource, /open-sse\/executors\/index(?:\.ts)?/);
|
||||
assert.match(providerLimitsSource, /open-sse\/executors\/credential\.ts/);
|
||||
assert.doesNotMatch(credentialExecutorSource, /\.\/index(?:\.ts)?/);
|
||||
assert.match(credentialExecutorSource, /export async function getCredentialRefreshExecutor/);
|
||||
});
|
||||
|
||||
test("credential resolver covers every executor with custom refresh behavior", () => {
|
||||
const credentialExecutorSource = fs.readFileSync(credentialExecutorPath, "utf8");
|
||||
const refreshOverrideFiles = fs
|
||||
.readdirSync(executorDirectory)
|
||||
.filter((file) => file.endsWith(".ts") && !["base.ts", "default.ts"].includes(file))
|
||||
.filter((file) => {
|
||||
const source = fs.readFileSync(path.join(executorDirectory, file), "utf8");
|
||||
return /^\s*(?:async\s+)?(?:needsRefresh|refreshCredentials)\s*\(/m.test(source);
|
||||
});
|
||||
|
||||
for (const file of refreshOverrideFiles) {
|
||||
assert.ok(
|
||||
credentialExecutorSource.includes(`import("./${file}")`),
|
||||
`${file} must be registered in the refresh-only resolver`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude OAuth validation resolves the default executor without the chat registry", () => {
|
||||
const validationSource = fs.readFileSync(anthropicValidationPath, "utf8");
|
||||
const resolverSource = fs.readFileSync(defaultExecutorResolverPath, "utf8");
|
||||
|
||||
assert.doesNotMatch(validationSource, /open-sse\/executors\/index(?:\.ts)?/);
|
||||
assert.match(validationSource, /open-sse\/executors\/defaultResolver\.ts/);
|
||||
assert.doesNotMatch(resolverSource, /\.\/index(?:\.ts)?/);
|
||||
assert.match(resolverSource, /export function getDefaultExecutor/);
|
||||
});
|
||||
132
tests/unit/logger-process-singleton-12074.test.ts
Normal file
132
tests/unit/logger-process-singleton-12074.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import pino from "pino";
|
||||
|
||||
type LoggerModule = typeof import("../../src/shared/utils/logger.ts");
|
||||
type LoggerResourceModule = typeof import("../../src/shared/utils/loggerResource.ts");
|
||||
type LogRotationModule = typeof import("../../src/lib/logRotation.ts");
|
||||
|
||||
const loggerUrl = pathToFileURL(join(process.cwd(), "src/shared/utils/logger.ts")).href;
|
||||
const loggerResourceUrl = pathToFileURL(
|
||||
join(process.cwd(), "src/shared/utils/loggerResource.ts")
|
||||
).href;
|
||||
const logRotationUrl = pathToFileURL(join(process.cwd(), "src/lib/logRotation.ts")).href;
|
||||
const envKeys = [
|
||||
"NODE_ENV",
|
||||
"APP_LOG_TO_FILE",
|
||||
"APP_LOG_FILE_PATH",
|
||||
"APP_LOG_LEVEL",
|
||||
"APP_LOG_ROTATION_CHECK_INTERVAL_MS",
|
||||
] as const;
|
||||
|
||||
function saveEnv(): Record<(typeof envKeys)[number], string | undefined> {
|
||||
return Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
|
||||
(typeof envKeys)[number],
|
||||
string | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
function restoreEnv(saved: ReturnType<typeof saveEnv>): void {
|
||||
for (const key of envKeys) {
|
||||
const value = saved[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function closeLoggerStream(logger: LoggerModule["logger"]): void {
|
||||
const stream = (logger as unknown as Record<symbol, unknown>)[pino.symbols.streamSym] as
|
||||
{ flushSync?: () => void; end?: () => void } | undefined;
|
||||
try {
|
||||
stream?.flushSync?.();
|
||||
} catch {}
|
||||
try {
|
||||
stream?.end?.();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
test("logger transport and rotation timer remain process-singletons across HMR module instances", async () => {
|
||||
const savedEnv = saveEnv();
|
||||
const testDir = mkdtempSync(join(tmpdir(), "omniroute-logger-singleton-12074-"));
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
let firstLogger: LoggerModule | undefined;
|
||||
let secondLogger: LoggerModule | undefined;
|
||||
let firstLoggerResource: LoggerResourceModule | undefined;
|
||||
let secondLoggerResource: LoggerResourceModule | undefined;
|
||||
let firstRotation: LogRotationModule | undefined;
|
||||
let secondRotation: LogRotationModule | undefined;
|
||||
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.APP_LOG_TO_FILE = "true";
|
||||
process.env.APP_LOG_FILE_PATH = join(testDir, "application.log");
|
||||
process.env.APP_LOG_LEVEL = "debug";
|
||||
process.env.APP_LOG_ROTATION_CHECK_INTERVAL_MS = "60000";
|
||||
|
||||
try {
|
||||
firstRotation = (await import(`${logRotationUrl}?phase4=rotation-a`)) as LogRotationModule;
|
||||
secondRotation = (await import(`${logRotationUrl}?phase4=rotation-b`)) as LogRotationModule;
|
||||
firstRotation.closeLogRotation();
|
||||
secondRotation.closeLogRotation();
|
||||
|
||||
let intervalCreations = 0;
|
||||
globalThis.setInterval = ((...args: unknown[]) => {
|
||||
intervalCreations++;
|
||||
return Reflect.apply(originalSetInterval, globalThis, args);
|
||||
}) as typeof setInterval;
|
||||
|
||||
firstRotation.initLogRotation();
|
||||
secondRotation.initLogRotation();
|
||||
assert.equal(intervalCreations, 1, "HMR reloads must share one log rotation timer");
|
||||
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
firstLoggerResource = (await import(
|
||||
`${loggerResourceUrl}?phase4=resource-a`
|
||||
)) as LoggerResourceModule;
|
||||
secondLoggerResource = (await import(
|
||||
`${loggerResourceUrl}?phase4=resource-b`
|
||||
)) as LoggerResourceModule;
|
||||
firstLogger = (await import(`${loggerUrl}?phase4=logger-a`)) as LoggerModule;
|
||||
secondLogger = (await import(`${loggerUrl}?phase4=logger-b`)) as LoggerModule;
|
||||
|
||||
assert.equal(firstLogger.logger, secondLogger.logger, "HMR reloads must reuse one logger");
|
||||
const firstStream = (firstLogger.logger as unknown as Record<symbol, unknown>)[
|
||||
pino.symbols.streamSym
|
||||
];
|
||||
const secondStream = (secondLogger.logger as unknown as Record<symbol, unknown>)[
|
||||
pino.symbols.streamSym
|
||||
];
|
||||
assert.equal(firstStream, secondStream, "HMR reloads must reuse one pino transport");
|
||||
|
||||
const resource = globalThis.__omnirouteLoggerResource;
|
||||
assert.ok(resource, "expected the process-wide logger resource to be registered");
|
||||
const originalClose = resource.close;
|
||||
let closeCalls = 0;
|
||||
resource.close = async () => {
|
||||
closeCalls++;
|
||||
await originalClose();
|
||||
};
|
||||
|
||||
await firstLoggerResource.closeSharedLoggerResource();
|
||||
await secondLoggerResource.closeSharedLoggerResource();
|
||||
assert.equal(closeCalls, 1, "shared logger teardown must be idempotent across HMR modules");
|
||||
assert.equal(globalThis.__omnirouteLoggerResource, undefined);
|
||||
} finally {
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
firstRotation?.closeLogRotation();
|
||||
secondRotation?.closeLogRotation();
|
||||
if (firstLoggerResource) {
|
||||
await firstLoggerResource.closeSharedLoggerResource();
|
||||
} else {
|
||||
if (firstLogger) closeLoggerStream(firstLogger.logger);
|
||||
if (secondLogger && secondLogger.logger !== firstLogger?.logger) {
|
||||
closeLoggerStream(secondLogger.logger);
|
||||
}
|
||||
}
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(testDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
@@ -22,7 +22,8 @@ process.env.DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-accesstoken-fallback-")
|
||||
);
|
||||
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const { getCredentialRefreshExecutor } =
|
||||
await import("../../open-sse/executors/credential.ts");
|
||||
const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts");
|
||||
|
||||
// `gemini` is a non-rotating (no rotation lock group), non-github OAuth provider,
|
||||
@@ -39,7 +40,7 @@ function geminiConnection() {
|
||||
}
|
||||
|
||||
test("falls back to the existing accessToken for a non-github provider when refreshCredentials returns null", async () => {
|
||||
const exec = await getExecutor("gemini");
|
||||
const exec = await getCredentialRefreshExecutor("gemini");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
exec.needsRefresh = () => true; // force the refresh attempt
|
||||
@@ -65,7 +66,7 @@ test("falls back to the existing accessToken for a non-github provider when refr
|
||||
});
|
||||
|
||||
test("still throws when refresh fails AND there is no accessToken to fall back on", async () => {
|
||||
const exec = await getExecutor("gemini");
|
||||
const exec = await getCredentialRefreshExecutor("gemini");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
exec.needsRefresh = () => true;
|
||||
|
||||
@@ -18,7 +18,8 @@ import path from "node:path";
|
||||
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-force-refresh-imported-"));
|
||||
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const { getCredentialRefreshExecutor } =
|
||||
await import("../../open-sse/executors/credential.ts");
|
||||
const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts");
|
||||
|
||||
function importedCodexConnection() {
|
||||
@@ -33,7 +34,7 @@ function importedCodexConnection() {
|
||||
}
|
||||
|
||||
test("force re-mints an imported rotating account that needsRefresh would skip (#3019 reactive)", async () => {
|
||||
const exec = await getExecutor("codex");
|
||||
const exec = await getCredentialRefreshExecutor("codex");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
@@ -65,7 +66,7 @@ test("force re-mints an imported rotating account that needsRefresh would skip (
|
||||
});
|
||||
|
||||
test("force does NOT override the bulk #3019 guard (no allowRotatingRefresh → no mint)", async () => {
|
||||
const exec = await getExecutor("codex");
|
||||
const exec = await getCredentialRefreshExecutor("codex");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
|
||||
88
tests/unit/responses-plaintext-reasoning-input-12128.test.ts
Normal file
88
tests/unit/responses-plaintext-reasoning-input-12128.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { resolveReasoningTransport, applyReasoningInputPolicy } = await import(
|
||||
"../../open-sse/services/reasoningInputPolicy.ts"
|
||||
);
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
test("#12128: resolveReasoningTransport identifies openai-compatible-responses and custom responses variants as opaque transport", () => {
|
||||
assert.equal(resolveReasoningTransport("openai-compatible-responses-codex"), "opaque");
|
||||
assert.equal(resolveReasoningTransport("custom-openai-responses"), "opaque");
|
||||
assert.equal(resolveReasoningTransport("codex-proxy"), "opaque");
|
||||
assert.equal(resolveReasoningTransport("openai"), "opaque");
|
||||
assert.equal(resolveReasoningTransport("codex"), "opaque");
|
||||
});
|
||||
|
||||
test("#12128: translateRequest to Responses target strips plaintext reasoning.content for opaque responses providers", () => {
|
||||
const chatBody = {
|
||||
model: "gpt-5.6-codex",
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hi there!",
|
||||
reasoning_content: "Let me think about how to greet the user properly.",
|
||||
},
|
||||
{ role: "user", content: "what is 2+2?" },
|
||||
],
|
||||
};
|
||||
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.6-codex",
|
||||
chatBody,
|
||||
false,
|
||||
null,
|
||||
"openai-compatible-responses-codex"
|
||||
) as Record<string, unknown>;
|
||||
|
||||
assert.ok(Array.isArray(translated.input), "translated.input must be an array");
|
||||
|
||||
const input = translated.input as Record<string, unknown>[];
|
||||
const reasoningItems = input.filter((item) => item.type === "reasoning");
|
||||
|
||||
// For opaque responses targets without encrypted continuation, orphaned plaintext reasoning items
|
||||
// must either be stripped entirely or have content.length === 0, so strict Codex backends do not 400.
|
||||
for (const r of reasoningItems) {
|
||||
assert.ok(
|
||||
!r.content || (Array.isArray(r.content) && r.content.length === 0),
|
||||
"reasoning item must not carry non-empty content array to strict responses endpoints"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#12128: applyReasoningInputPolicy directly sanitizes replayed plaintext reasoning for openai-compatible-responses", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
content: [{ type: "reasoning_text", text: "step 1 plan" }],
|
||||
summary: [],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Done" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "openai-compatible-responses-vllm",
|
||||
onIncompatibleReasoning: "drop",
|
||||
});
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
const reasoningItems = input.filter((item) => item.type === "reasoning");
|
||||
|
||||
for (const r of reasoningItems) {
|
||||
assert.equal(
|
||||
r.content,
|
||||
undefined,
|
||||
"plaintext reasoning content must be dropped for opaque responses providers"
|
||||
);
|
||||
}
|
||||
});
|
||||
111
tests/unit/root-layout-settings-boundary-12074.test.ts
Normal file
111
tests/unit/root-layout-settings-boundary-12074.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-layout-settings-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { getRootLayoutSettings } = await import("../../src/lib/db/rootLayoutSettings.ts");
|
||||
|
||||
function resetStorage(): void {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetStorage();
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
} else {
|
||||
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
}
|
||||
});
|
||||
|
||||
test("root layout imports the read-only settings leaf", () => {
|
||||
const layoutSource = fs.readFileSync(path.join(process.cwd(), "src/app/layout.tsx"), "utf8");
|
||||
const leafSource = fs.readFileSync(
|
||||
path.join(process.cwd(), "src/lib/db/rootLayoutSettings.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const coreSource = fs.readFileSync(path.join(process.cwd(), "src/lib/db/core.ts"), "utf8");
|
||||
const singletonSource = fs.readFileSync(
|
||||
path.join(process.cwd(), "src/lib/db/singleton.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
layoutSource,
|
||||
/import \{ getRootLayoutSettings \} from "@\/lib\/db\/rootLayoutSettings";/
|
||||
);
|
||||
assert.doesNotMatch(layoutSource, /@\/lib\/db\/settings/);
|
||||
assert.match(leafSource, /from "\.\/singleton"/);
|
||||
assert.doesNotMatch(
|
||||
leafSource,
|
||||
/db\/settings|\.\/core|runtimeSettings|tokenHealth|providerModels/
|
||||
);
|
||||
assert.doesNotMatch(leafSource, /\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i);
|
||||
assert.match(coreSource, /from "\.\/singleton"/);
|
||||
assert.doesNotMatch(singletonSource, /db\/settings|\.\/core|readCache|runtimeSettings/);
|
||||
});
|
||||
|
||||
test("root layout settings reader does not initialize the database", async () => {
|
||||
process.env.INITIAL_PASSWORD = "must-not-trigger-startup";
|
||||
|
||||
assert.deepEqual(await getRootLayoutSettings(), {
|
||||
instanceName: "OmniRoute",
|
||||
customFaviconUrl: "",
|
||||
customFaviconBase64: "",
|
||||
});
|
||||
assert.equal(fs.existsSync(path.join(TEST_DATA_DIR, "storage.sqlite")), false);
|
||||
});
|
||||
|
||||
test("root layout settings reader returns only the persisted metadata fields", async () => {
|
||||
const db = core.getDbInstance();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
|
||||
);
|
||||
|
||||
insert.run("instanceName", JSON.stringify("Route Lab"));
|
||||
insert.run("customFaviconUrl", JSON.stringify("https://example.com/favicon.png"));
|
||||
insert.run("customFaviconBase64", JSON.stringify("data:image/png;base64,AA=="));
|
||||
insert.run("proxyEnabled", JSON.stringify(false));
|
||||
|
||||
assert.deepEqual(await getRootLayoutSettings(), {
|
||||
instanceName: "Route Lab",
|
||||
customFaviconUrl: "https://example.com/favicon.png",
|
||||
customFaviconBase64: "data:image/png;base64,AA==",
|
||||
});
|
||||
});
|
||||
|
||||
test("root layout settings reader is read-only and falls back safely", async () => {
|
||||
process.env.INITIAL_PASSWORD = "must-not-trigger-onboarding";
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
"INSERT INTO key_value (namespace, key, value) VALUES ('settings', 'instanceName', ?)"
|
||||
).run("not-json");
|
||||
|
||||
assert.deepEqual(await getRootLayoutSettings(), {
|
||||
instanceName: "OmniRoute",
|
||||
customFaviconUrl: "",
|
||||
customFaviconBase64: "",
|
||||
});
|
||||
|
||||
const onboardingRows = db
|
||||
.prepare(
|
||||
"SELECT key FROM key_value WHERE namespace = 'settings' AND key IN ('setupComplete', 'requireLogin')"
|
||||
)
|
||||
.all();
|
||||
assert.deepEqual(onboardingRows, []);
|
||||
});
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
listStrategies,
|
||||
type RoutingContext,
|
||||
} from "../../open-sse/services/autoCombo/routerStrategy.ts";
|
||||
import type { ProviderCandidate } from "../../open-sse/services/autoCombo/scoring.ts";
|
||||
import {
|
||||
DEFAULT_WEIGHTS,
|
||||
type ProviderCandidate,
|
||||
} from "../../open-sse/services/autoCombo/scoring.ts";
|
||||
|
||||
function cand(p: Partial<ProviderCandidate> & { provider: string }): ProviderCandidate {
|
||||
return {
|
||||
@@ -33,6 +36,32 @@ function cand(p: Partial<ProviderCandidate> & { provider: string }): ProviderCan
|
||||
|
||||
const ctx: RoutingContext = { taskType: "default" };
|
||||
|
||||
// ── score ────────────────────────────────────────────────────────────────────
|
||||
test("score — exploits the configured winner and uses explorationRate", (t) => {
|
||||
const pool = [
|
||||
cand({ provider: "cheap", costPer1MTokens: 1 }),
|
||||
cand({ provider: "expensive", costPer1MTokens: 9 }),
|
||||
];
|
||||
const weights = { ...DEFAULT_WEIGHTS, costInv: 1, quota: 0, health: 0, latencyInv: 0 };
|
||||
|
||||
t.mock.method(Math, "random", () => 0.99);
|
||||
|
||||
assert.equal(
|
||||
getStrategy("score").select(pool, { ...ctx, weights, explorationRate: 0 }).provider,
|
||||
"cheap"
|
||||
);
|
||||
assert.equal(
|
||||
getStrategy("score").select(pool, { ...ctx, weights, explorationRate: 1 }).provider,
|
||||
"expensive"
|
||||
);
|
||||
});
|
||||
|
||||
test("score — exact ties preserve configured candidate order", () => {
|
||||
const pool = [cand({ provider: "first" }), cand({ provider: "second" })];
|
||||
|
||||
assert.equal(getStrategy("score").select(pool, { ...ctx, explorationRate: 0 }).provider, "first");
|
||||
});
|
||||
|
||||
// ── cost ─────────────────────────────────────────────────────────────────────
|
||||
test("cost — selects the cheapest healthy candidate", () => {
|
||||
const pool = [
|
||||
@@ -307,6 +336,7 @@ test("selectWithStrategy — unknown strategy silently falls back to rules", ()
|
||||
|
||||
test("listStrategies — exposes every registered strategy + aliases", () => {
|
||||
const names = listStrategies().map((s) => s.name);
|
||||
assert.ok(names.includes("score"), "listStrategies missing 'score'");
|
||||
for (const n of ["rules", "cost", "eco", "latency", "fast", "sla-aware", "sla", "lkgp"]) {
|
||||
assert.ok(names.includes(n), `listStrategies missing '${n}'`);
|
||||
}
|
||||
|
||||
29
tests/unit/run-next-graceful-shutdown-12074.test.ts
Normal file
29
tests/unit/run-next-graceful-shutdown-12074.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const runNextSource = readFileSync(join(process.cwd(), "scripts/dev/run-next.mjs"), "utf8");
|
||||
|
||||
test("the custom Next runner owns exit and awaits application cleanup before closing Next", () => {
|
||||
const ownerRegistration = runNextSource.indexOf(
|
||||
"globalThis.__omnirouteCustomServerOwnsShutdown = true"
|
||||
);
|
||||
const prepareCall = runNextSource.indexOf("await prepareWithHeal()");
|
||||
assert.ok(ownerRegistration >= 0, "custom server shutdown ownership must be registered");
|
||||
assert.ok(
|
||||
ownerRegistration < prepareCall,
|
||||
"shutdown ownership must exist before instrumentation"
|
||||
);
|
||||
|
||||
const serverClose = runNextSource.indexOf("server.close(resolve)");
|
||||
const applicationCleanup = runNextSource.indexOf(
|
||||
"await globalThis.__omnirouteRequestShutdown?.(signal)"
|
||||
);
|
||||
const nextClose = runNextSource.indexOf("await nextApp.close()", applicationCleanup);
|
||||
const processExit = runNextSource.indexOf("process.exit(0)", nextClose);
|
||||
|
||||
assert.ok(serverClose < applicationCleanup, "stop accepting requests before application cleanup");
|
||||
assert.ok(applicationCleanup < nextClose, "application cleanup must finish before Next closes");
|
||||
assert.ok(nextClose < processExit, "process exit must remain the final shutdown action");
|
||||
});
|
||||
@@ -9,8 +9,8 @@ import { dirname, join } from "node:path";
|
||||
// top-level IIFE at *require time* — merely importing the `playwright` package crashes,
|
||||
// no browser needs to be launched. `claudeTurnstileSolver.ts` used to `import { chromium }
|
||||
// from "playwright"` statically, and that module is unconditionally reachable from the
|
||||
// Next.js instrumentation hook on every boot via open-sse/executors/index.ts, so any
|
||||
// unsupported platform crashed the whole server at startup regardless of configured provider.
|
||||
// Next.js instrumentation hook used to reach it on every boot via open-sse/executors/index.ts,
|
||||
// so any unsupported platform crashed the whole server regardless of configured provider.
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SOLVER = join(HERE, "../../open-sse/services/claudeTurnstileSolver.ts");
|
||||
|
||||
@@ -28,10 +28,9 @@ test("importing the real executor chain does not throw on an unsupported process
|
||||
Object.defineProperty(process, "platform", { value: "android", configurable: true });
|
||||
|
||||
try {
|
||||
// This is the exact reachability chain from the Next.js instrumentation hook:
|
||||
// instrumentation-node.ts -> open-sse/index.ts -> executors/index.ts -> claude-web*.ts
|
||||
// -> claudeTurnstileSolver.ts. Before the fix, this threw
|
||||
// "Unsupported platform: android" purely from the static playwright import.
|
||||
// Keep the executor-registry import safe for direct runtime consumers. Before the
|
||||
// original fix, this threw "Unsupported platform: android" purely from the static
|
||||
// playwright import; instrumentation no longer imports this graph at boot (#12074).
|
||||
await import("../../open-sse/executors/index.ts");
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", originalDescriptor);
|
||||
|
||||
@@ -162,7 +162,13 @@ test("Antigravity -> OpenAI keeps co-located function call and text but strips t
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Let me look that up." },
|
||||
{ functionResponse: { id: "call_9", name: "lookup", response: { result: { ok: true } } } },
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_9",
|
||||
name: "lookup",
|
||||
response: { result: { ok: true } },
|
||||
},
|
||||
},
|
||||
{ functionCall: { id: "call_10", name: "lookup", args: { q: "weather" } } },
|
||||
],
|
||||
},
|
||||
@@ -249,7 +255,7 @@ test("Antigravity -> OpenAI lowers schema types recursively", () => {
|
||||
false
|
||||
);
|
||||
|
||||
assert.deepEqual((result.tools[0].function as any).parameters, {
|
||||
assert.deepEqual((result.tools[0].function as Record<string, unknown>).parameters, {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
@@ -298,12 +304,17 @@ test("Antigravity -> OpenAI strips enumDescriptions from tool schema (top-level
|
||||
false
|
||||
);
|
||||
|
||||
const parameters = (result.tools[0].function as any).parameters;
|
||||
const parameters = (result.tools[0].function as Record<string, unknown>).parameters as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const props = parameters.properties as Record<string, unknown>;
|
||||
const tags = props.tags as Record<string, unknown>;
|
||||
|
||||
// enumDescriptions must be removed at every level of the schema tree...
|
||||
assert.equal("enumDescriptions" in parameters, false);
|
||||
assert.equal("enumDescriptions" in parameters.properties.mode, false);
|
||||
assert.equal("enumDescriptions" in parameters.properties.tags.items, false);
|
||||
assert.equal("enumDescriptions" in (props.mode as Record<string, unknown>), false);
|
||||
assert.equal("enumDescriptions" in (tags.items as Record<string, unknown>), false);
|
||||
|
||||
// ...while leaving the rest of the schema (incl. enum values) intact.
|
||||
assert.deepEqual(parameters, {
|
||||
@@ -349,7 +360,10 @@ test("Antigravity -> OpenAI preserves the required array on Draft 2020-12 tool s
|
||||
false
|
||||
);
|
||||
|
||||
const params = (result.tools[0].function as any).parameters;
|
||||
const params = (result.tools[0].function as Record<string, unknown>).parameters as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// The required array must survive so the model treats mandatory args as mandatory.
|
||||
assert.deepEqual(params.required, ["path", "contents"]);
|
||||
// Types are still lowered and Draft 2020-12 meta keywords are stripped.
|
||||
@@ -385,6 +399,104 @@ test("Antigravity -> OpenAI drops required entries that no longer exist in prope
|
||||
false
|
||||
);
|
||||
|
||||
const params = (result.tools[0].function as any).parameters;
|
||||
const params = (result.tools[0].function as Record<string, unknown>).parameters as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.deepEqual(params.required, ["kept"]);
|
||||
});
|
||||
|
||||
test("Antigravity -> OpenAI preserves falsy primitive results in function responses (false, 0, empty string, null)", () => {
|
||||
const cases: Array<[unknown, string]> = [
|
||||
[false, "false"],
|
||||
[0, "0"],
|
||||
["", '""'],
|
||||
[null, "null"],
|
||||
[true, "true"],
|
||||
[42, "42"],
|
||||
["done", '"done"'],
|
||||
];
|
||||
|
||||
for (const [inputVal, expected] of cases) {
|
||||
const result = antigravityToOpenAIRequest(
|
||||
"gpt-4o",
|
||||
{
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: "call_test",
|
||||
name: "check_condition",
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_test",
|
||||
name: "check_condition",
|
||||
response: { result: inputVal },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const toolMsg = result.messages.find((m) => m.role === "tool");
|
||||
assert.ok(toolMsg, "expected role:tool message");
|
||||
assert.equal(toolMsg.tool_call_id, "call_test");
|
||||
assert.equal(toolMsg.content, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("Antigravity -> OpenAI preserves custom response objects without result key", () => {
|
||||
const result = antigravityToOpenAIRequest(
|
||||
"gpt-4o",
|
||||
{
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: "call_custom",
|
||||
name: "custom_op",
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_custom",
|
||||
name: "custom_op",
|
||||
response: { output: "value", success: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const toolMsg = result.messages.find((m) => m.role === "tool");
|
||||
assert.ok(toolMsg, "expected role:tool message");
|
||||
assert.equal(toolMsg.content, '{"output":"value","success":false}');
|
||||
});
|
||||
|
||||
@@ -237,3 +237,70 @@ test("Gemini -> OpenAI maintains matching IDs across multi-turn tool call and re
|
||||
assert.equal(toolResponseCallId, "call_calc_456");
|
||||
assert.equal(assistantCallId, toolResponseCallId);
|
||||
});
|
||||
|
||||
test("Gemini -> OpenAI preserves falsy primitive results in function responses (false, 0, empty string, null)", () => {
|
||||
const cases: Array<[unknown, string]> = [
|
||||
[false, "false"],
|
||||
[0, "0"],
|
||||
["", '""'],
|
||||
[null, "null"],
|
||||
[true, "true"],
|
||||
[42, "42"],
|
||||
["done", '"done"'],
|
||||
];
|
||||
|
||||
for (const [inputVal, expected] of cases) {
|
||||
const result = geminiToOpenAIRequest(
|
||||
"gpt-4o",
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_test",
|
||||
name: "check_condition",
|
||||
response: { result: inputVal },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(result.messages.length, 1);
|
||||
assert.equal(result.messages[0].role, "tool");
|
||||
assert.equal(result.messages[0].tool_call_id, "call_test");
|
||||
assert.equal(result.messages[0].content, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("Gemini -> OpenAI preserves custom response objects without result key", () => {
|
||||
const result = geminiToOpenAIRequest(
|
||||
"gpt-4o",
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_custom",
|
||||
name: "custom_op",
|
||||
response: { output: "value", success: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(result.messages.length, 1);
|
||||
assert.equal(result.messages[0].role, "tool");
|
||||
assert.equal(result.messages[0].content, '{"output":"value","success":false}');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user