mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-01 03:52:17 +03:00
Compare commits
1 Commits
release/v3
...
docs/tier-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b39e3d1c8 |
@@ -1 +0,0 @@
|
||||
- **feat(routing):** add a `score` Auto router strategy that selects the highest configured weighted score and reuses `explorationRate`.
|
||||
@@ -1,5 +0,0 @@
|
||||
- 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)
|
||||
@@ -1 +0,0 @@
|
||||
- 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,6 +829,12 @@
|
||||
"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": {
|
||||
@@ -844,16 +850,62 @@
|
||||
"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
|
||||
@@ -874,6 +926,24 @@
|
||||
"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
|
||||
@@ -990,6 +1060,31 @@
|
||||
"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
|
||||
@@ -1090,6 +1185,29 @@
|
||||
"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
|
||||
@@ -1258,6 +1376,11 @@
|
||||
"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
|
||||
@@ -1268,6 +1391,11 @@
|
||||
"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
|
||||
@@ -1276,11 +1404,17 @@
|
||||
"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": {
|
||||
@@ -1303,6 +1437,17 @@
|
||||
"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
|
||||
@@ -2153,6 +2298,9 @@
|
||||
"src/shared/components/RequestLoggerV2.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"src/shared/components/RequestTimeline.tsx": {
|
||||
|
||||
@@ -414,8 +414,6 @@ 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
|
||||
@@ -424,7 +422,7 @@ Persisted `strategy: "auto"` combos can set `config.routerStrategy` (or legacy
|
||||
|
||||
### Router strategies in detail
|
||||
|
||||
The auto-combo engine exposes 6 pluggable **RouterStrategy** implementations that
|
||||
The auto-combo engine exposes 5 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,37 +570,3 @@ 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: 3072,
|
||||
dimensions: 768,
|
||||
},
|
||||
{
|
||||
id: "google/gemini-embedding-2",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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 { getDefaultExecutor } from "./defaultResolver.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
|
||||
// R0.3 — declarative built-in table, made LAZY by #11220.
|
||||
//
|
||||
@@ -207,6 +207,8 @@ 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,
|
||||
@@ -249,7 +251,8 @@ export async function getExecutor(provider: string): Promise<BaseExecutor> {
|
||||
(err as Error & { status?: number }).status = 400;
|
||||
throw err;
|
||||
}
|
||||
return getDefaultExecutor(provider);
|
||||
if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider));
|
||||
return defaultCache.get(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, isClaudeCodeOriginatedHeaders } from "../config/codexIdentity.ts";
|
||||
import { getCodexClientSessionId, isCodexOriginatedHeaders } from "../config/codexIdentity.ts";
|
||||
import {
|
||||
noteCodexTurnStateProvenance,
|
||||
readCodexTurnStateHeader,
|
||||
@@ -981,14 +981,8 @@ 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 || isClaudeCodeClient) &&
|
||||
(settings.echoRequestedModelName === true || isCodexResponsesEcho) &&
|
||||
typeof requestedModel === "string" &&
|
||||
requestedModel
|
||||
? requestedModel
|
||||
@@ -5471,7 +5465,6 @@ export async function handleChatCore({
|
||||
|
||||
const streamReadiness = await ensureStreamReadiness(providerResponse, {
|
||||
timeoutMs: streamReadinessPolicy.timeoutMs,
|
||||
maxTimeoutMs: streamReadinessPolicy.maxTimeoutMs,
|
||||
provider,
|
||||
model,
|
||||
log,
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
* 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, ScoringWeights } from "./scoring.ts";
|
||||
import type { ProviderCandidate, ScoredProvider } from "./scoring.ts";
|
||||
import { scorePool } from "./scoring.ts";
|
||||
import { getTaskFitness } from "./taskFitness.ts";
|
||||
import { clamp01 } from "../../utils/number.ts";
|
||||
@@ -33,8 +32,6 @@ export interface RoutingContext {
|
||||
lastKnownGoodProvider?: string;
|
||||
lkgpEnabled?: boolean;
|
||||
sla?: SlaRoutingPolicy;
|
||||
weights?: ScoringWeights;
|
||||
explorationRate?: number;
|
||||
}
|
||||
|
||||
export interface RoutingDecision {
|
||||
@@ -111,38 +108,6 @@ 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 {
|
||||
@@ -372,14 +337,12 @@ 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);
|
||||
|
||||
@@ -377,8 +377,6 @@ export async function resolveAutoStrategyOrder(
|
||||
boolean | undefined,
|
||||
estimatedInputTokens,
|
||||
sla: slaPolicy,
|
||||
weights,
|
||||
explorationRate,
|
||||
},
|
||||
routingStrategy
|
||||
);
|
||||
|
||||
@@ -43,19 +43,7 @@ export function resolveReasoningTransport(
|
||||
): ReasoningTransport {
|
||||
const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : "";
|
||||
const transport = REASONING_TRANSPORTS.get(normalized);
|
||||
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";
|
||||
return transport ?? (preserveEncryptedReasoning ? "opaque" : "plaintext");
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
normalizeResponsesReasoningEffort,
|
||||
RESPONSES_STORE_MARKER,
|
||||
} from "./request/openai-responses/helpers.ts";
|
||||
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
|
||||
|
||||
bootstrapTranslatorRegistry();
|
||||
export { register } from "./registry.ts";
|
||||
@@ -576,14 +575,6 @@ 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,15 +220,12 @@ 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;
|
||||
@@ -301,13 +298,12 @@ 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(resultPayload),
|
||||
content: JSON.stringify(
|
||||
part.functionResponse.response?.result || part.functionResponse.response || {}
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -320,7 +316,9 @@ 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,13 +147,12 @@ 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(resultPayload),
|
||||
content: JSON.stringify(
|
||||
part.functionResponse.response?.result || part.functionResponse.response || {}
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,9 +471,6 @@ 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;
|
||||
@@ -492,14 +489,7 @@ export async function ensureStreamReadiness(
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
const effectiveTimeoutMs = Math.max(0, Math.floor(options.timeoutMs));
|
||||
// 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;
|
||||
const deadline = startedAt + effectiveTimeoutMs;
|
||||
let handedOffReader = false;
|
||||
|
||||
const buildReadyResponse = () =>
|
||||
@@ -510,7 +500,7 @@ export async function ensureStreamReadiness(
|
||||
});
|
||||
|
||||
const timeoutReason = () =>
|
||||
`Stream produced no non-ping SSE event within ${deadline - startedAt}ms (max=${maxDeadline - startedAt}ms)`;
|
||||
`Stream produced no non-ping SSE event within ${effectiveTimeoutMs}ms`;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -603,22 +593,6 @@ 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,7 +14,6 @@ export type StreamReadinessPolicyInput = {
|
||||
export type StreamReadinessPolicyResult = {
|
||||
timeoutMs: number;
|
||||
baseTimeoutMs: number;
|
||||
maxTimeoutMs: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
@@ -122,7 +121,7 @@ export function resolveStreamReadinessTimeout(
|
||||
): StreamReadinessPolicyResult {
|
||||
const baseTimeoutMs = Math.max(0, Math.floor(input.baseTimeoutMs || 0));
|
||||
if (baseTimeoutMs <= 0) {
|
||||
return { timeoutMs: baseTimeoutMs, baseTimeoutMs, maxTimeoutMs: baseTimeoutMs, reasons: ["disabled"] };
|
||||
return { timeoutMs: baseTimeoutMs, baseTimeoutMs, reasons: ["disabled"] };
|
||||
}
|
||||
|
||||
const maxTimeoutMs = Math.max(baseTimeoutMs, input.maxTimeoutMs ?? DEFAULT_MAX_TIMEOUT_MS);
|
||||
@@ -198,5 +197,5 @@ export function resolveStreamReadinessTimeout(
|
||||
timeoutMs = Math.min(timeoutMs, maxTimeoutMs);
|
||||
if (timeoutMs === baseTimeoutMs) reasons.push("base");
|
||||
|
||||
return { timeoutMs, baseTimeoutMs, maxTimeoutMs, reasons };
|
||||
return { timeoutMs, baseTimeoutMs, reasons };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420" font-family="system-ui, -apple-system, sans-serif">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420" font-family="system-ui, -apple-system, Segoe UI, Arial, Helvetica, sans-serif" role="img" aria-label="OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 352 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free.">
|
||||
<title>OmniRoute 4-tier fallback</title>
|
||||
<desc>OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 352 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free.</desc>
|
||||
<defs>
|
||||
<marker id="arrow-dark" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#9ca3af"/>
|
||||
@@ -12,7 +14,7 @@
|
||||
<rect width="800" height="420" fill="#111827" rx="12"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="400" y="32" text-anchor="middle" font-size="18" font-weight="700" fill="#f9fafb">OmniRoute 3-tier fallback</text>
|
||||
<text x="400" y="32" text-anchor="middle" font-size="18" font-weight="700" fill="#f9fafb">OmniRoute 4-tier fallback</text>
|
||||
<text x="400" y="52" text-anchor="middle" font-size="12" fill="#6b7280">Never stop building — automatic zero-config failover across 352 providers</text>
|
||||
|
||||
<!-- Client box -->
|
||||
@@ -31,35 +33,45 @@
|
||||
<text x="400" y="213" text-anchor="middle" font-size="10" fill="#60a5fa">Circuit breaker · MCP server (110 tools) · A2A · Memory · Guardrails</text>
|
||||
|
||||
<!-- Arrows from router to tiers -->
|
||||
<line x1="310" y1="224" x2="150" y2="285" stroke="#9ca3af" stroke-width="1.5" marker-end="url(#arrow-dark)"/>
|
||||
<line x1="400" y1="224" x2="400" y2="285" stroke="#9ca3af" stroke-width="1.5" marker-end="url(#arrow-dark)"/>
|
||||
<line x1="490" y1="224" x2="650" y2="285" stroke="#9ca3af" stroke-width="1.5" marker-end="url(#arrow-dark)"/>
|
||||
<line x1="260" y1="224" x2="109" y2="285" stroke="#9ca3af" stroke-width="1.5" marker-end="url(#arrow-dark)"/>
|
||||
<line x1="355" y1="224" x2="303" y2="285" stroke="#9ca3af" stroke-width="1.5" marker-end="url(#arrow-dark)"/>
|
||||
<line x1="445" y1="224" x2="497" y2="285" stroke="#9ca3af" stroke-width="1.5" marker-end="url(#arrow-dark)"/>
|
||||
<line x1="540" y1="224" x2="691" y2="285" stroke="#9ca3af" stroke-width="1.5" marker-end="url(#arrow-dark)"/>
|
||||
|
||||
<!-- Tier 1: Subscription -->
|
||||
<rect x="30" y="287" width="220" height="110" rx="8" fill="#1c1a00" stroke="#d97706" stroke-width="1.5"/>
|
||||
<text x="140" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#fbbf24">Tier 1 — SUBSCRIPTION</text>
|
||||
<text x="140" y="328" text-anchor="middle" font-size="10" fill="#d97706">Pay flat-rate, use every drop</text>
|
||||
<text x="140" y="346" text-anchor="middle" font-size="9.5" fill="#fbbf24">Claude Code · Codex · Copilot</text>
|
||||
<text x="140" y="362" text-anchor="middle" font-size="9.5" fill="#fbbf24">Cursor · Antigravity · Windsurf</text>
|
||||
<text x="140" y="387" text-anchor="middle" font-size="9" fill="#f59e0b">quota exhausted → Tier 2</text>
|
||||
<line x1="140" y1="378" x2="300" y2="378" stroke="#f87171" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-dark)"/>
|
||||
<rect x="16" y="287" width="186" height="110" rx="8" fill="#1c1a00" stroke="#d97706" stroke-width="1.5"/>
|
||||
<text x="109" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#fbbf24">Tier 1 — SUBSCRIPTION</text>
|
||||
<text x="109" y="328" text-anchor="middle" font-size="9.5" fill="#d97706">Pay flat-rate, use every drop</text>
|
||||
<text x="109" y="346" text-anchor="middle" font-size="9" fill="#fbbf24">Claude Code · Codex</text>
|
||||
<text x="109" y="362" text-anchor="middle" font-size="9" fill="#fbbf24">Copilot · Cursor · Windsurf</text>
|
||||
<text x="109" y="390" text-anchor="middle" font-size="8.5" fill="#f59e0b">quota exhausted → Tier 2</text>
|
||||
<line x1="109" y1="376" x2="240" y2="376" stroke="#f87171" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-dark)"/>
|
||||
|
||||
<!-- Tier 2: Cheap -->
|
||||
<rect x="290" y="287" width="220" height="110" rx="8" fill="#052e16" stroke="#16a34a" stroke-width="1.5"/>
|
||||
<text x="400" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#4ade80">Tier 2 — CHEAP</text>
|
||||
<text x="400" y="328" text-anchor="middle" font-size="10" fill="#22c55e">Pay-per-token, under $1/1M</text>
|
||||
<text x="400" y="346" text-anchor="middle" font-size="9.5" fill="#4ade80">DeepSeek $0.27 · GLM $0.60</text>
|
||||
<text x="400" y="362" text-anchor="middle" font-size="9.5" fill="#4ade80">MiniMax $0.20 · Qwen $0.30</text>
|
||||
<text x="400" y="387" text-anchor="middle" font-size="9" fill="#16a34a">budget hit → Tier 3</text>
|
||||
<line x1="400" y1="378" x2="560" y2="378" stroke="#f87171" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-dark)"/>
|
||||
<!-- Tier 2: API -->
|
||||
<rect x="210" y="287" width="186" height="110" rx="8" fill="#083344" stroke="#0891b2" stroke-width="1.5"/>
|
||||
<text x="303" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#67e8f9">Tier 2 — API</text>
|
||||
<text x="303" y="328" text-anchor="middle" font-size="9.5" fill="#22d3ee">Your own pay-as-you-go keys</text>
|
||||
<text x="303" y="346" text-anchor="middle" font-size="9" fill="#67e8f9">OpenAI · Anthropic · Gemini</text>
|
||||
<text x="303" y="362" text-anchor="middle" font-size="9" fill="#67e8f9">Groq · Mistral · xAI</text>
|
||||
<text x="303" y="390" text-anchor="middle" font-size="8.5" fill="#06b6d4">rate-limited → Tier 3</text>
|
||||
<line x1="303" y1="376" x2="434" y2="376" stroke="#f87171" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-dark)"/>
|
||||
|
||||
<!-- Tier 3: Free -->
|
||||
<rect x="550" y="287" width="220" height="110" rx="8" fill="#1e1b4b" stroke="#6366f1" stroke-width="1.5"/>
|
||||
<text x="660" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#a5b4fc">Tier 3 — FREE</text>
|
||||
<text x="660" y="328" text-anchor="middle" font-size="10" fill="#818cf8">Free tiers & credit programs</text>
|
||||
<text x="660" y="346" text-anchor="middle" font-size="9.5" fill="#a5b4fc">Kiro · OpenCode · Qoder</text>
|
||||
<text x="660" y="362" text-anchor="middle" font-size="9.5" fill="#a5b4fc">Antigravity · Vertex $300cr</text>
|
||||
<text x="660" y="387" text-anchor="middle" font-size="9" fill="#6366f1">always available</text>
|
||||
<!-- Tier 3: Cheap -->
|
||||
<rect x="404" y="287" width="186" height="110" rx="8" fill="#052e16" stroke="#16a34a" stroke-width="1.5"/>
|
||||
<text x="497" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#4ade80">Tier 3 — CHEAP</text>
|
||||
<text x="497" y="328" text-anchor="middle" font-size="9.5" fill="#22c55e">Pay-per-token, under $1/1M</text>
|
||||
<text x="497" y="346" text-anchor="middle" font-size="9" fill="#4ade80">DeepSeek $0.27 · GLM $0.60</text>
|
||||
<text x="497" y="362" text-anchor="middle" font-size="9" fill="#4ade80">MiniMax $0.20 · Qwen $0.30</text>
|
||||
<text x="497" y="390" text-anchor="middle" font-size="8.5" fill="#16a34a">budget hit → Tier 4</text>
|
||||
<line x1="497" y1="376" x2="628" y2="376" stroke="#f87171" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-dark)"/>
|
||||
|
||||
<!-- Tier 4: Free -->
|
||||
<rect x="598" y="287" width="186" height="110" rx="8" fill="#1e1b4b" stroke="#6366f1" stroke-width="1.5"/>
|
||||
<text x="691" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#a5b4fc">Tier 4 — FREE</text>
|
||||
<text x="691" y="328" text-anchor="middle" font-size="9.5" fill="#818cf8">Free tiers & credit programs</text>
|
||||
<text x="691" y="346" text-anchor="middle" font-size="9" fill="#a5b4fc">Kiro · OpenCode · Qoder</text>
|
||||
<text x="691" y="362" text-anchor="middle" font-size="9" fill="#a5b4fc">Antigravity · Vertex $300cr</text>
|
||||
<text x="691" y="390" text-anchor="middle" font-size="8.5" fill="#6366f1">always available</text>
|
||||
|
||||
<!-- Footer note -->
|
||||
<text x="400" y="413" text-anchor="middle" font-size="9" fill="#4b5563">Fallback happens in milliseconds — transparent to the calling tool</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -1,4 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420" font-family="system-ui, -apple-system, sans-serif">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420" font-family="system-ui, -apple-system, Segoe UI, Arial, Helvetica, sans-serif" role="img" aria-label="OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 352 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free.">
|
||||
<title>OmniRoute 4-tier fallback</title>
|
||||
<desc>OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 352 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free.</desc>
|
||||
<defs>
|
||||
<marker id="arrow-light" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#6b7280"/>
|
||||
@@ -12,7 +14,7 @@
|
||||
<rect width="800" height="420" fill="#f9fafb" rx="12"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="400" y="32" text-anchor="middle" font-size="18" font-weight="700" fill="#111827">OmniRoute 3-tier fallback</text>
|
||||
<text x="400" y="32" text-anchor="middle" font-size="18" font-weight="700" fill="#111827">OmniRoute 4-tier fallback</text>
|
||||
<text x="400" y="52" text-anchor="middle" font-size="12" fill="#6b7280">Never stop building — automatic zero-config failover across 352 providers</text>
|
||||
|
||||
<!-- Client box -->
|
||||
@@ -31,35 +33,45 @@
|
||||
<text x="400" y="213" text-anchor="middle" font-size="10" fill="#3b82f6">Circuit breaker · MCP server (110 tools) · A2A · Memory · Guardrails</text>
|
||||
|
||||
<!-- Arrows from router to tiers -->
|
||||
<line x1="310" y1="224" x2="150" y2="285" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow-light)"/>
|
||||
<line x1="400" y1="224" x2="400" y2="285" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow-light)"/>
|
||||
<line x1="490" y1="224" x2="650" y2="285" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow-light)"/>
|
||||
<line x1="260" y1="224" x2="109" y2="285" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow-light)"/>
|
||||
<line x1="355" y1="224" x2="303" y2="285" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow-light)"/>
|
||||
<line x1="445" y1="224" x2="497" y2="285" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow-light)"/>
|
||||
<line x1="540" y1="224" x2="691" y2="285" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow-light)"/>
|
||||
|
||||
<!-- Tier 1: Subscription -->
|
||||
<rect x="30" y="287" width="220" height="110" rx="8" fill="#fffbeb" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<text x="140" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#92400e">Tier 1 — SUBSCRIPTION</text>
|
||||
<text x="140" y="328" text-anchor="middle" font-size="10" fill="#78350f">Pay flat-rate, use every drop</text>
|
||||
<text x="140" y="346" text-anchor="middle" font-size="9.5" fill="#92400e">Claude Code · Codex · Copilot</text>
|
||||
<text x="140" y="362" text-anchor="middle" font-size="9.5" fill="#92400e">Cursor · Antigravity · Windsurf</text>
|
||||
<text x="140" y="387" text-anchor="middle" font-size="9" fill="#b45309">quota exhausted → Tier 2</text>
|
||||
<line x1="140" y1="378" x2="300" y2="378" stroke="#ef4444" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-light)"/>
|
||||
<rect x="16" y="287" width="186" height="110" rx="8" fill="#fffbeb" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<text x="109" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#92400e">Tier 1 — SUBSCRIPTION</text>
|
||||
<text x="109" y="328" text-anchor="middle" font-size="9.5" fill="#78350f">Pay flat-rate, use every drop</text>
|
||||
<text x="109" y="346" text-anchor="middle" font-size="9" fill="#92400e">Claude Code · Codex</text>
|
||||
<text x="109" y="362" text-anchor="middle" font-size="9" fill="#92400e">Copilot · Cursor · Windsurf</text>
|
||||
<text x="109" y="390" text-anchor="middle" font-size="8.5" fill="#b45309">quota exhausted → Tier 2</text>
|
||||
<line x1="109" y1="376" x2="240" y2="376" stroke="#ef4444" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-light)"/>
|
||||
|
||||
<!-- Tier 2: Cheap -->
|
||||
<rect x="290" y="287" width="220" height="110" rx="8" fill="#f0fdf4" stroke="#22c55e" stroke-width="1.5"/>
|
||||
<text x="400" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#14532d">Tier 2 — CHEAP</text>
|
||||
<text x="400" y="328" text-anchor="middle" font-size="10" fill="#166534">Pay-per-token, under $1/1M</text>
|
||||
<text x="400" y="346" text-anchor="middle" font-size="9.5" fill="#14532d">DeepSeek $0.27 · GLM $0.60</text>
|
||||
<text x="400" y="362" text-anchor="middle" font-size="9.5" fill="#14532d">MiniMax $0.20 · Qwen $0.30</text>
|
||||
<text x="400" y="387" text-anchor="middle" font-size="9" fill="#15803d">budget hit → Tier 3</text>
|
||||
<line x1="400" y1="378" x2="560" y2="378" stroke="#ef4444" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-light)"/>
|
||||
<!-- Tier 2: API -->
|
||||
<rect x="210" y="287" width="186" height="110" rx="8" fill="#ecfeff" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<text x="303" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#155e75">Tier 2 — API</text>
|
||||
<text x="303" y="328" text-anchor="middle" font-size="9.5" fill="#0e7490">Your own pay-as-you-go keys</text>
|
||||
<text x="303" y="346" text-anchor="middle" font-size="9" fill="#155e75">OpenAI · Anthropic · Gemini</text>
|
||||
<text x="303" y="362" text-anchor="middle" font-size="9" fill="#155e75">Groq · Mistral · xAI</text>
|
||||
<text x="303" y="390" text-anchor="middle" font-size="8.5" fill="#0891b2">rate-limited → Tier 3</text>
|
||||
<line x1="303" y1="376" x2="434" y2="376" stroke="#ef4444" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-light)"/>
|
||||
|
||||
<!-- Tier 3: Free -->
|
||||
<rect x="550" y="287" width="220" height="110" rx="8" fill="#eef2ff" stroke="#6366f1" stroke-width="1.5"/>
|
||||
<text x="660" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#312e81">Tier 3 — FREE</text>
|
||||
<text x="660" y="328" text-anchor="middle" font-size="10" fill="#3730a3">Free tiers & credit programs</text>
|
||||
<text x="660" y="346" text-anchor="middle" font-size="9.5" fill="#312e81">Kiro · OpenCode · Qoder</text>
|
||||
<text x="660" y="362" text-anchor="middle" font-size="9.5" fill="#312e81">Antigravity · Vertex $300cr</text>
|
||||
<text x="660" y="387" text-anchor="middle" font-size="9" fill="#4338ca">always available</text>
|
||||
<!-- Tier 3: Cheap -->
|
||||
<rect x="404" y="287" width="186" height="110" rx="8" fill="#f0fdf4" stroke="#22c55e" stroke-width="1.5"/>
|
||||
<text x="497" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#14532d">Tier 3 — CHEAP</text>
|
||||
<text x="497" y="328" text-anchor="middle" font-size="9.5" fill="#166534">Pay-per-token, under $1/1M</text>
|
||||
<text x="497" y="346" text-anchor="middle" font-size="9" fill="#14532d">DeepSeek $0.27 · GLM $0.60</text>
|
||||
<text x="497" y="362" text-anchor="middle" font-size="9" fill="#14532d">MiniMax $0.20 · Qwen $0.30</text>
|
||||
<text x="497" y="390" text-anchor="middle" font-size="8.5" fill="#15803d">budget hit → Tier 4</text>
|
||||
<line x1="497" y1="376" x2="628" y2="376" stroke="#ef4444" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-fallback-light)"/>
|
||||
|
||||
<!-- Tier 4: Free -->
|
||||
<rect x="598" y="287" width="186" height="110" rx="8" fill="#eef2ff" stroke="#6366f1" stroke-width="1.5"/>
|
||||
<text x="691" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#312e81">Tier 4 — FREE</text>
|
||||
<text x="691" y="328" text-anchor="middle" font-size="9.5" fill="#3730a3">Free tiers & credit programs</text>
|
||||
<text x="691" y="346" text-anchor="middle" font-size="9" fill="#312e81">Kiro · OpenCode · Qoder</text>
|
||||
<text x="691" y="362" text-anchor="middle" font-size="9" fill="#312e81">Antigravity · Vertex $300cr</text>
|
||||
<text x="691" y="390" text-anchor="middle" font-size="8.5" fill="#4338ca">always available</text>
|
||||
|
||||
<!-- Footer note -->
|
||||
<text x="400" y="413" text-anchor="middle" font-size="9" fill="#9ca3af">Fallback happens in milliseconds — transparent to the calling tool</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -137,11 +137,6 @@ 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):
|
||||
@@ -236,7 +231,6 @@ 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, useSyncExternalStore } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
|
||||
@@ -106,12 +106,6 @@ 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();
|
||||
@@ -121,13 +115,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const [providerConnections, setProviderConnections] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// 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 [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||
const [providerMetrics, setProviderMetrics] = useState<Record<string, ProviderMetricSummary>>({});
|
||||
const [providerTopology, setProviderTopology] = useState({ lastProvider: "", errorProvider: "" });
|
||||
@@ -147,39 +135,36 @@ 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 cleanLatest = latestVersion.replace(/^v/, "");
|
||||
const latest = versionInfo?.latest || "";
|
||||
const cleanLatest = latest.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: installedVersion }),
|
||||
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
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: installedVersion }),
|
||||
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
if (platform === "linux") {
|
||||
return {
|
||||
label: t("downloadAppImage"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
|
||||
desc: t("downloadAppImageDescription", { version: installedVersion }),
|
||||
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: t("downloadUpdate"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
||||
desc: t("downloadUpdateDescription", { version: installedVersion }),
|
||||
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}, [platform, t, latestVersion, installedVersion]);
|
||||
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
|
||||
|
||||
// Electron internal auto-updater state and listeners
|
||||
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
|
||||
@@ -249,6 +234,12 @@ 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([
|
||||
@@ -276,9 +267,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchData();
|
||||
})();
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Fetch provider nodes for display labels (compat providers)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { AuditLogEntry } from "@/lib/compliance/index";
|
||||
import ActivityFeed from "./components/ActivityFeed";
|
||||
@@ -14,9 +14,7 @@ export default function ActivityFeedClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState<EventCategory>("all");
|
||||
// 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 referenceNowMs = useRef<number>(Date.now());
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -32,7 +30,7 @@ export default function ActivityFeedClient() {
|
||||
}
|
||||
const data = (await res.json()) as AuditLogEntry[];
|
||||
// Reset reference time on fresh load so relative timestamps are stable
|
||||
setReferenceNowMs(Date.now());
|
||||
referenceNowMs.current = Date.now();
|
||||
setAllEntries(Array.isArray(data) ? data : []);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t("fetchFailed");
|
||||
@@ -43,9 +41,7 @@ export default function ActivityFeedClient() {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchEntries();
|
||||
})();
|
||||
fetchEntries();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const filtered =
|
||||
@@ -118,7 +114,7 @@ export default function ActivityFeedClient() {
|
||||
<span className="text-sm">{t("loadingActivity")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<ActivityFeed entries={filtered} referenceNowMs={referenceNowMs} />
|
||||
<ActivityFeed entries={filtered} referenceNowMs={referenceNowMs.current} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,9 +95,7 @@ export default function CacheHealthTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await load(range);
|
||||
})();
|
||||
void load(range);
|
||||
}, [load, range]);
|
||||
|
||||
if (loading) return <Skeleton className="h-64 w-full" />;
|
||||
@@ -181,8 +179,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">
|
||||
@@ -218,9 +216,7 @@ 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,9 +852,7 @@ export default function ComboHealthTab() {
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
await fetchData(controller, false);
|
||||
})();
|
||||
fetchData(controller, false);
|
||||
return () => controller.abort();
|
||||
}, [fetchData]);
|
||||
|
||||
|
||||
@@ -131,9 +131,7 @@ export default function ProviderUtilizationTab() {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void (async () => {
|
||||
await fetchUtilization(range, aggregateBy, controller.signal);
|
||||
})();
|
||||
fetchUtilization(range, aggregateBy, controller.signal);
|
||||
|
||||
return () => controller.abort();
|
||||
}, [fetchUtilization, range, aggregateBy]);
|
||||
@@ -342,8 +340,12 @@ 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,18 +522,14 @@ export default function RouteExplainabilityTab({
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
await fetchLogs(controller.signal);
|
||||
})();
|
||||
fetchLogs(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
await fetchExplanation(selectedId, controller.signal);
|
||||
})();
|
||||
fetchExplanation(selectedId, controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [fetchExplanation, selectedId]);
|
||||
|
||||
|
||||
@@ -64,9 +64,7 @@ export default function A2aAuditTab() {
|
||||
}, [offset, skillFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchTasks();
|
||||
})();
|
||||
void fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -110,9 +110,7 @@ export default function ComplianceTab() {
|
||||
}, [actor, eventType, from, offset, t, to]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchEntries();
|
||||
})();
|
||||
void fetchEntries();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const visibleEntries = useMemo(() => {
|
||||
@@ -332,9 +330,7 @@ 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,9 +56,7 @@ export default function McpAuditTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchStats();
|
||||
})();
|
||||
void fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
const fetchAudit = useCallback(async () => {
|
||||
@@ -88,9 +86,7 @@ export default function McpAuditTab() {
|
||||
}, [offset, successFilter, t, toolFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchAudit();
|
||||
})();
|
||||
void fetchAudit();
|
||||
}, [fetchAudit]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,9 +63,7 @@ export default function CacheEntriesTab() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchEntries();
|
||||
})();
|
||||
fetchEntries();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const handleDelete = async (signature: string) => {
|
||||
|
||||
@@ -130,12 +130,9 @@ 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 = nowMs - new Date(dateStr).getTime();
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return t("justNow");
|
||||
if (minutes < 60) return t("minutesAgo", { minutes });
|
||||
@@ -150,7 +147,6 @@ 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) {
|
||||
@@ -161,9 +157,7 @@ export default function ReasoningCacheTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchData();
|
||||
})();
|
||||
void 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,9 +374,7 @@ export default function CachePage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchStats();
|
||||
})();
|
||||
void fetchStats();
|
||||
const id = setInterval(() => void fetchStats(), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchStats]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Input, Select } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -29,14 +29,7 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// 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 });
|
||||
useEffect(() => {
|
||||
if (memory && isOpen) {
|
||||
setType(memory.type);
|
||||
setKey(memory.key);
|
||||
@@ -45,7 +38,7 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr
|
||||
setMetadataError("");
|
||||
setError("");
|
||||
}
|
||||
}
|
||||
}, [memory, isOpen]);
|
||||
|
||||
const handleMetadataChange = (value: string) => {
|
||||
setMetadataStr(value);
|
||||
@@ -158,7 +151,9 @@ 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,8 +40,7 @@ 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<
|
||||
@@ -110,8 +109,7 @@ 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 {
|
||||
@@ -155,8 +153,7 @@ 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
|
||||
@@ -164,9 +161,7 @@ export default function QdrantConfigCard() {
|
||||
// connection button still drives the same check manually.
|
||||
useEffect(() => {
|
||||
if (!loading && qdrant.enabled && health === null) {
|
||||
void (async () => {
|
||||
await checkHealth();
|
||||
})();
|
||||
void checkHealth();
|
||||
}
|
||||
}, [loading, qdrant.enabled, health, checkHealth]);
|
||||
|
||||
@@ -250,8 +245,7 @@ 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,7 +190,9 @@ export default function MemoriesTab() {
|
||||
else skipped++;
|
||||
}
|
||||
fetchMemories();
|
||||
setImportStatus(t("importResult", { imported, skipped }));
|
||||
setImportStatus(
|
||||
t("importResult", { imported, skipped }),
|
||||
);
|
||||
} catch {
|
||||
setImportStatus(t("importError"));
|
||||
} finally {
|
||||
@@ -237,9 +239,7 @@ 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 (async () => {
|
||||
await checkHealth();
|
||||
})();
|
||||
void checkHealth();
|
||||
const id = setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30_000);
|
||||
@@ -260,9 +260,8 @@ 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 {
|
||||
@@ -290,7 +289,8 @@ 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,7 +401,9 @@ 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>
|
||||
)}
|
||||
@@ -446,8 +448,12 @@ 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>
|
||||
@@ -471,9 +477,7 @@ 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>
|
||||
@@ -661,10 +665,7 @@ 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,9 +39,7 @@ export function useEngineStatus(refreshIntervalMs = 5000): UseEngineStatusResult
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void (async () => {
|
||||
await fetchOnce();
|
||||
})();
|
||||
void fetchOnce();
|
||||
if (!refreshIntervalMs || refreshIntervalMs <= 0) {
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
|
||||
@@ -40,9 +40,7 @@ export function useMemorySettings(): UseMemorySettingsResult {
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void (async () => {
|
||||
await fetchOnce();
|
||||
})();
|
||||
void fetchOnce();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
/** Polls the 3 agent sources (allSettled), listens to the `requests` WS channel as a refetch trigger. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLiveDashboard } from "@/hooks/useLiveDashboard";
|
||||
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
|
||||
import type { A2ATask } from "@/lib/a2a/taskManager";
|
||||
import type { FleetSnapshot } from "@/lib/conductor/hubProxy";
|
||||
import { fromCloudAgent } from "../model/fromCloudAgent";
|
||||
import { fromA2A } from "../model/fromA2A";
|
||||
import { fromConductor } from "../model/fromConductor";
|
||||
import { mergeSnapshot } from "../model/mergeSnapshot";
|
||||
import type { OrchSnapshot, SourceStatus } from "../model/orchestrationTypes";
|
||||
|
||||
export const POLL_MS = 5_000;
|
||||
export const WS_REFETCH_DEBOUNCE_MS = 1_000;
|
||||
|
||||
interface Raw {
|
||||
cloudAgent: CloudAgentTask[];
|
||||
a2a: A2ATask[];
|
||||
conductor: FleetSnapshot;
|
||||
}
|
||||
const EMPTY_RAW: Raw = {
|
||||
cloudAgent: [],
|
||||
a2a: [],
|
||||
conductor: { offline: true, runners: [], tasks: [] },
|
||||
};
|
||||
|
||||
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
|
||||
const res = await fetch(url, { signal, cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Builds the 3-source status list from a `Promise.allSettled` triple. */
|
||||
function buildSourceStatuses(
|
||||
ca: PromiseSettledResult<{ data: CloudAgentTask[] }>,
|
||||
a2a: PromiseSettledResult<{ tasks: A2ATask[] }>,
|
||||
cond: PromiseSettledResult<FleetSnapshot>,
|
||||
nowIso: string
|
||||
): SourceStatus[] {
|
||||
const next: SourceStatus[] = [];
|
||||
if (ca.status === "fulfilled") next.push({ source: "cloud-agent", ok: true });
|
||||
else
|
||||
next.push({
|
||||
source: "cloud-agent",
|
||||
ok: false,
|
||||
error: String(ca.reason),
|
||||
staleSince: nowIso,
|
||||
});
|
||||
if (a2a.status === "fulfilled") next.push({ source: "a2a", ok: true });
|
||||
else next.push({ source: "a2a", ok: false, error: String(a2a.reason), staleSince: nowIso });
|
||||
if (cond.status === "fulfilled") {
|
||||
next.push({ source: "conductor", ok: true, offline: cond.value.offline });
|
||||
} else
|
||||
next.push({
|
||||
source: "conductor",
|
||||
ok: false,
|
||||
error: String(cond.reason),
|
||||
staleSince: nowIso,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function useOrchestrationSnapshot() {
|
||||
// `raw` and `polledAt` are React state (not refs) so the merge below reads them
|
||||
// during render like any other state — a ref read during render trips the
|
||||
// `react-hooks/refs` lint rule, and computing `Date.now()` inline in the memo
|
||||
// factory trips `react-hooks/purity`. Sampling `Date.now()` once per poll (inside
|
||||
// the effect, not during render) keeps `mergeSnapshot`'s staleness math correct
|
||||
// without either violation.
|
||||
const [raw, setRaw] = useState<Raw>(EMPTY_RAW);
|
||||
const [statuses, setStatuses] = useState<SourceStatus[]>([]);
|
||||
// Lazy initializer (not a literal 0) so the pre-first-poll render already has a
|
||||
// real timestamp — with `0` the very first `mergeSnapshot` call stamped
|
||||
// `generatedAt` as the 1970 epoch. Safe: with EMPTY_RAW there is nothing to
|
||||
// staleness-filter at mount, so seeding `Date.now()` here changes no behavior.
|
||||
const [polledAt, setPolledAt] = useState<number>(() => Date.now());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Populated by the mount effect below; lets `refetch()` (and the WS debounce
|
||||
// trigger) reach the same poll loop without hoisting it out of the effect —
|
||||
// hoisting to a top-level `useCallback` invoked from the effect body trips
|
||||
// `react-hooks/set-state-in-effect`.
|
||||
const pollRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const poll = async () => {
|
||||
const [ca, a2a, cond] = await Promise.allSettled([
|
||||
fetchJson<{ data: CloudAgentTask[] }>("/api/v1/agents/tasks?limit=100", controller.signal),
|
||||
fetchJson<{ tasks: A2ATask[] }>("/api/a2a/tasks?limit=200", controller.signal),
|
||||
fetchJson<FleetSnapshot>("/api/conductor/fleet", controller.signal),
|
||||
]);
|
||||
if (controller.signal.aborted) return;
|
||||
const nowMs = Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const next = buildSourceStatuses(ca, a2a, cond, nowIso);
|
||||
|
||||
// Failed sources keep the previously stored slice — only overwrite what
|
||||
// actually resolved this round ("last good data" contract from the brief).
|
||||
setRaw((prev) => ({
|
||||
cloudAgent: ca.status === "fulfilled" ? ca.value.data : prev.cloudAgent,
|
||||
a2a: a2a.status === "fulfilled" ? a2a.value.tasks : prev.a2a,
|
||||
conductor: cond.status === "fulfilled" ? cond.value : prev.conductor,
|
||||
}));
|
||||
setStatuses(next);
|
||||
setPolledAt(nowMs);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
pollRef.current = () => void poll();
|
||||
void poll();
|
||||
const id = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
controller.abort();
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
pollRef.current();
|
||||
}, []);
|
||||
|
||||
useLiveDashboard({
|
||||
channels: ["requests"],
|
||||
onEvent: (payload) => {
|
||||
if (payload.channel !== "requests") return;
|
||||
if (debounceRef.current) return; // debounce burst → one refetch
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
refetch();
|
||||
}, WS_REFETCH_DEBOUNCE_MS);
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot: OrchSnapshot = useMemo(
|
||||
() =>
|
||||
mergeSnapshot(
|
||||
{
|
||||
cloudAgent: fromCloudAgent(raw.cloudAgent),
|
||||
a2a: fromA2A(raw.a2a),
|
||||
conductor: fromConductor(raw.conductor),
|
||||
},
|
||||
statuses,
|
||||
{ now: polledAt, showCompleted }
|
||||
),
|
||||
[raw, statuses, showCompleted, polledAt]
|
||||
);
|
||||
|
||||
return { snapshot, isLoading, showCompleted, setShowCompleted, refetch };
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/** A2A tasks → unified orchestration nodes. Pure. */
|
||||
import type { A2ATask } from "@/lib/a2a/taskManager";
|
||||
import type { OrchEdge, OrchNode, OrchState } from "./orchestrationTypes";
|
||||
|
||||
const STATE_MAP: Record<string, OrchState> = {
|
||||
submitted: "queued",
|
||||
working: "running",
|
||||
completed: "succeeded",
|
||||
failed: "failed",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
const TERMINAL: ReadonlySet<OrchState> = new Set(["succeeded", "failed", "cancelled"]);
|
||||
|
||||
function truncate(s: string, n = 60): string {
|
||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
||||
}
|
||||
|
||||
export function fromA2A(tasks: A2ATask[]): { nodes: OrchNode[]; edges: OrchEdge[] } {
|
||||
if (tasks.length === 0) return { nodes: [], edges: [] };
|
||||
const nodes: OrchNode[] = [];
|
||||
const edges: OrchEdge[] = [];
|
||||
const counts: Partial<Record<OrchState, number>> = {};
|
||||
|
||||
for (const t of tasks) {
|
||||
const mapped = STATE_MAP[t.state];
|
||||
const state: OrchState = mapped ?? "failed";
|
||||
counts[state] = (counts[state] ?? 0) + 1;
|
||||
const id = `a2a:${t.id}`;
|
||||
const firstUser = t.input.messages.find((m) => m.role === "user")?.content ?? "";
|
||||
nodes.push({
|
||||
id,
|
||||
kind: "work",
|
||||
source: "a2a",
|
||||
state,
|
||||
label: t.skill,
|
||||
sublabel: mapped ? truncate(firstUser) : `unknown state: ${String(t.state)}`,
|
||||
startedAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
endedAt: TERMINAL.has(state) ? t.updatedAt : undefined,
|
||||
raw: t,
|
||||
});
|
||||
edges.push({
|
||||
id: `e:source:a2a→${id}`,
|
||||
from: "source:a2a",
|
||||
to: id,
|
||||
kind: "owns",
|
||||
active: state === "running",
|
||||
});
|
||||
}
|
||||
|
||||
nodes.unshift({ id: "source:a2a", kind: "source", source: "a2a", label: "A2A", counts });
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/** Cloud Agent tasks → unified orchestration nodes. Pure. */
|
||||
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
|
||||
import type { OrchEdge, OrchNode, OrchState } from "./orchestrationTypes";
|
||||
|
||||
const STATE_MAP: Record<string, OrchState> = {
|
||||
queued: "queued",
|
||||
running: "running",
|
||||
awaiting_approval: "waiting_approval",
|
||||
completed: "succeeded",
|
||||
failed: "failed",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
|
||||
function truncate(s: string, n = 60): string {
|
||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
||||
}
|
||||
|
||||
export function fromCloudAgent(tasks: CloudAgentTask[]): { nodes: OrchNode[]; edges: OrchEdge[] } {
|
||||
if (tasks.length === 0) return { nodes: [], edges: [] };
|
||||
const nodes: OrchNode[] = [];
|
||||
const edges: OrchEdge[] = [];
|
||||
const counts: Partial<Record<OrchState, number>> = {};
|
||||
|
||||
for (const t of tasks) {
|
||||
const mapped = STATE_MAP[t.status];
|
||||
const state: OrchState = mapped ?? "failed";
|
||||
counts[state] = (counts[state] ?? 0) + 1;
|
||||
const id = `cloud-agent:${t.id}`;
|
||||
const active = state === "running";
|
||||
nodes.push({
|
||||
id,
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state,
|
||||
label: truncate(t.prompt),
|
||||
sublabel: mapped ? t.providerId : `${t.providerId} — unknown status: ${String(t.status)}`,
|
||||
startedAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
endedAt: t.completedAt,
|
||||
cost: t.result?.cost,
|
||||
raw: t,
|
||||
});
|
||||
edges.push({
|
||||
id: `e:source:cloud-agent→${id}`,
|
||||
from: "source:cloud-agent",
|
||||
to: id,
|
||||
kind: "owns",
|
||||
active,
|
||||
});
|
||||
|
||||
const last = t.activities[t.activities.length - 1];
|
||||
if (active && last) {
|
||||
const actId = `${id}:activity`;
|
||||
nodes.push({
|
||||
id: actId,
|
||||
kind: "activity",
|
||||
source: "cloud-agent",
|
||||
state,
|
||||
label: truncate(last.content),
|
||||
sublabel: last.type,
|
||||
updatedAt: last.timestamp,
|
||||
});
|
||||
edges.push({ id: `e:${id}→${actId}`, from: id, to: actId, kind: "owns", active: true });
|
||||
}
|
||||
}
|
||||
|
||||
nodes.unshift({
|
||||
id: "source:cloud-agent",
|
||||
kind: "source",
|
||||
source: "cloud-agent",
|
||||
label: "Cloud Agent",
|
||||
counts,
|
||||
});
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/** Conductor fleet snapshot → unified orchestration nodes. Pure. */
|
||||
import type { FleetRunner, FleetSnapshot, FleetTask } from "@/lib/conductor/hubProxy";
|
||||
import type { OrchEdge, OrchNode, OrchState } from "./orchestrationTypes";
|
||||
|
||||
const TERMINAL: ReadonlySet<OrchState> = new Set(["succeeded", "failed", "cancelled"]);
|
||||
|
||||
function mapHubStatus(status: string): OrchState | null {
|
||||
const s = status.toLowerCase();
|
||||
if (s === "queued" || s === "pending") return "queued";
|
||||
if (s === "running" || s === "working" || s === "scheduled") return "running";
|
||||
if (s === "done" || s === "completed" || s === "succeeded") return "succeeded";
|
||||
if (s === "failed" || s === "error") return "failed";
|
||||
if (s === "cancelled" || s === "canceled") return "cancelled";
|
||||
return null;
|
||||
}
|
||||
|
||||
function taskNode(t: FleetTask, kind: "work" | "activity"): OrchNode {
|
||||
const mapped = mapHubStatus(t.status);
|
||||
const state: OrchState = mapped ?? "failed";
|
||||
return {
|
||||
id: `conductor:task:${t.id}`,
|
||||
kind,
|
||||
source: "conductor",
|
||||
state,
|
||||
label: t.summary ?? t.id,
|
||||
sublabel: mapped ? (t.repo ?? t.mode) : `unknown status: ${t.status}`,
|
||||
updatedAt: t.updated_at ?? undefined,
|
||||
// FleetTask has no dedicated completion timestamp — updated_at is the closest
|
||||
// proxy, same pattern as fromA2A.ts (A2ATask has no completedAt either).
|
||||
endedAt: TERMINAL.has(state) ? (t.updated_at ?? undefined) : undefined,
|
||||
raw: t,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks whose runner actually exists in `snap.runners` AND is currently "running" — those
|
||||
* get "absorbed" into that runner's ActivityNode instead of getting their own work node.
|
||||
* A running task pointing at a runner id that has since deregistered falls through to the
|
||||
* normal work-node loop instead of being silently skipped as "already an activity".
|
||||
*/
|
||||
function computeActiveByRunner(snap: FleetSnapshot): Map<string, FleetTask> {
|
||||
const runnerIds = new Set(snap.runners.map((r) => r.id));
|
||||
const activeByRunner = new Map<string, FleetTask>();
|
||||
for (const t of snap.tasks) {
|
||||
if (t.runner && runnerIds.has(t.runner) && mapHubStatus(t.status) === "running") {
|
||||
activeByRunner.set(t.runner, t);
|
||||
}
|
||||
}
|
||||
return activeByRunner;
|
||||
}
|
||||
|
||||
function runnerState(r: FleetRunner, activeTask: FleetTask | undefined): OrchState {
|
||||
if (!r.online) return "failed";
|
||||
if (r.draining) return "cancelled";
|
||||
return activeTask ? "running" : "queued";
|
||||
}
|
||||
|
||||
/** One work node per runner, plus an activity node for its currently-active task. */
|
||||
function runnerWorkNodes(
|
||||
snap: FleetSnapshot,
|
||||
activeByRunner: Map<string, FleetTask>,
|
||||
bump: (s: OrchState) => void
|
||||
): { nodes: OrchNode[]; edges: OrchEdge[] } {
|
||||
const nodes: OrchNode[] = [];
|
||||
const edges: OrchEdge[] = [];
|
||||
for (const r of snap.runners) {
|
||||
const id = `conductor:runner:${r.id}`;
|
||||
const activeTask = activeByRunner.get(r.id);
|
||||
const state = runnerState(r, activeTask);
|
||||
bump(state);
|
||||
nodes.push({
|
||||
id,
|
||||
kind: "work",
|
||||
source: "conductor",
|
||||
state,
|
||||
label: r.name,
|
||||
sublabel: r.clis.join(", "),
|
||||
raw: r,
|
||||
});
|
||||
edges.push({
|
||||
id: `e:source:conductor→${id}`,
|
||||
from: "source:conductor",
|
||||
to: id,
|
||||
kind: "owns",
|
||||
active: state === "running",
|
||||
});
|
||||
if (activeTask) {
|
||||
nodes.push(taskNode(activeTask, "activity"));
|
||||
edges.push({
|
||||
id: `e:${id}→conductor:task:${activeTask.id}`,
|
||||
from: id,
|
||||
to: `conductor:task:${activeTask.id}`,
|
||||
kind: "owns",
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/** Work nodes for tasks not already absorbed as a runner's activity node. */
|
||||
function remainingTaskWorkNodes(
|
||||
snap: FleetSnapshot,
|
||||
activeByRunner: Map<string, FleetTask>,
|
||||
bump: (s: OrchState) => void
|
||||
): { nodes: OrchNode[]; edges: OrchEdge[] } {
|
||||
const nodes: OrchNode[] = [];
|
||||
const edges: OrchEdge[] = [];
|
||||
for (const t of snap.tasks) {
|
||||
if (t.runner && activeByRunner.get(t.runner)?.id === t.id) continue; // already an activity
|
||||
const node = taskNode(t, "work");
|
||||
bump(node.state as OrchState);
|
||||
nodes.push(node);
|
||||
edges.push({
|
||||
id: `e:source:conductor→${node.id}`,
|
||||
from: "source:conductor",
|
||||
to: node.id,
|
||||
kind: "owns",
|
||||
active: node.state === "running",
|
||||
});
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export function fromConductor(snap: FleetSnapshot): { nodes: OrchNode[]; edges: OrchEdge[] } {
|
||||
if (snap.offline || (snap.runners.length === 0 && snap.tasks.length === 0)) {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
const counts: Partial<Record<OrchState, number>> = {};
|
||||
const bump = (s: OrchState) => {
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
};
|
||||
|
||||
const activeByRunner = computeActiveByRunner(snap);
|
||||
const runners = runnerWorkNodes(snap, activeByRunner, bump);
|
||||
const tasks = remainingTaskWorkNodes(snap, activeByRunner, bump);
|
||||
const nodes = [...runners.nodes, ...tasks.nodes];
|
||||
const edges = [...runners.edges, ...tasks.edges];
|
||||
|
||||
nodes.unshift({
|
||||
id: "source:conductor",
|
||||
kind: "source",
|
||||
source: "conductor",
|
||||
label: "Conductor",
|
||||
counts,
|
||||
});
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
/** Merge the three source mappers into one snapshot: root, dedupe, staleness filter, cap. Pure. */
|
||||
import {
|
||||
MAX_WORK_NODES,
|
||||
STALE_COMPLETED_MS,
|
||||
type OrchEdge,
|
||||
type OrchNode,
|
||||
type OrchSnapshot,
|
||||
type OrchSource,
|
||||
type OrchState,
|
||||
type SourceStatus,
|
||||
} from "./orchestrationTypes";
|
||||
|
||||
const TERMINAL: ReadonlySet<OrchState> = new Set(["succeeded", "failed", "cancelled"]);
|
||||
|
||||
export interface MergeOptions {
|
||||
now: number;
|
||||
showCompleted?: boolean;
|
||||
}
|
||||
|
||||
interface Part {
|
||||
nodes: OrchNode[];
|
||||
edges: OrchEdge[];
|
||||
}
|
||||
|
||||
interface NodesAndEdges {
|
||||
nodes: OrchNode[];
|
||||
edges: OrchEdge[];
|
||||
}
|
||||
|
||||
function conductorMirrorId(node: OrchNode): string | null {
|
||||
const raw = node.raw as { metadata?: { conductor?: { task_id?: unknown } } } | undefined;
|
||||
const id = raw?.metadata?.conductor?.task_id;
|
||||
return typeof id === "string" ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (2) Conductor↔A2A dedupe — key verified in src/lib/conductor/bridge.ts::ensureMirrored.
|
||||
* Mutates `dropped` in place; returns the (possibly patched) nodes/edges.
|
||||
*/
|
||||
function dedupeConductorMirrors(
|
||||
nodes: OrchNode[],
|
||||
edges: OrchEdge[],
|
||||
dropped: Set<string>
|
||||
): NodesAndEdges {
|
||||
const conductorTaskIds = new Set(
|
||||
nodes
|
||||
.filter((n) => n.source === "conductor" && n.id.startsWith("conductor:task:"))
|
||||
.map((n) => n.id.slice("conductor:task:".length))
|
||||
);
|
||||
const nextNodes = [...nodes];
|
||||
const nextEdges = [...edges];
|
||||
for (const n of nextNodes) {
|
||||
if (n.source !== "a2a" || n.kind !== "work") continue;
|
||||
const mirror = conductorMirrorId(n);
|
||||
if (mirror && conductorTaskIds.has(mirror)) {
|
||||
dropped.add(n.id);
|
||||
const cIndex = nextNodes.findIndex((c) => c.id === `conductor:task:${mirror}`);
|
||||
if (cIndex !== -1) {
|
||||
// Copy rather than mutate — the original object is still referenced by
|
||||
// parts.conductor.nodes, and this function's contract is Pure.
|
||||
const cNode: OrchNode = { ...nextNodes[cIndex], mirrorOf: n.id };
|
||||
nextNodes[cIndex] = cNode;
|
||||
nextEdges.push({
|
||||
id: `e:mirror:${cNode.id}`,
|
||||
from: cNode.id,
|
||||
to: "source:a2a",
|
||||
kind: "mirror",
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { nodes: nextNodes, edges: nextEdges };
|
||||
}
|
||||
|
||||
/** (3) staleness filter — adds stale terminal work/activity node ids to `dropped`. */
|
||||
function markStaleCompleted(nodes: OrchNode[], now: number, dropped: Set<string>): void {
|
||||
for (const n of nodes) {
|
||||
if (n.kind !== "work" && n.kind !== "activity") continue;
|
||||
if (
|
||||
n.state &&
|
||||
TERMINAL.has(n.state) &&
|
||||
n.endedAt &&
|
||||
now - Date.parse(n.endedAt) > STALE_COMPLETED_MS
|
||||
) {
|
||||
dropped.add(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One source's overflow placeholder node, or null when it fits under `budgetPer`. */
|
||||
function overflowNodeForSource(
|
||||
source: OrchSource,
|
||||
list: OrchNode[],
|
||||
budgetPer: number,
|
||||
dropped: Set<string>
|
||||
): OrchNode | null {
|
||||
if (list.length <= budgetPer) return null;
|
||||
list.sort((a, b) => Date.parse(b.updatedAt ?? "0") - Date.parse(a.updatedAt ?? "0"));
|
||||
const excess = list.slice(budgetPer);
|
||||
const counts: Partial<Record<OrchState, number>> = {};
|
||||
for (const n of excess) {
|
||||
dropped.add(n.id);
|
||||
if (n.state) counts[n.state] = (counts[n.state] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
id: `overflow:${source}`,
|
||||
kind: "overflow",
|
||||
source,
|
||||
label: `+${excess.length} more`,
|
||||
counts,
|
||||
// Additive: lets overviewProjection fold true per-state totals into its
|
||||
// counters even though these nodes no longer render on the canvas
|
||||
// (operator ruling — spec governs, counters must show TRUE totals).
|
||||
droppedByState: counts,
|
||||
};
|
||||
}
|
||||
|
||||
/** (4) cap with per-source overflow, newest kept. */
|
||||
function capWorkNodesWithOverflow(
|
||||
nodes: OrchNode[],
|
||||
edges: OrchEdge[],
|
||||
dropped: Set<string>
|
||||
): NodesAndEdges {
|
||||
const works = nodes.filter((n) => n.kind === "work");
|
||||
if (works.length <= MAX_WORK_NODES) return { nodes, edges };
|
||||
|
||||
const bySource = new Map<OrchSource, OrchNode[]>();
|
||||
for (const w of works) {
|
||||
const list = bySource.get(w.source as OrchSource) ?? [];
|
||||
list.push(w);
|
||||
bySource.set(w.source as OrchSource, list);
|
||||
}
|
||||
const budgetPer = Math.max(1, Math.floor(MAX_WORK_NODES / bySource.size));
|
||||
const overflowNodes: OrchNode[] = [];
|
||||
for (const [source, list] of bySource) {
|
||||
const overflow = overflowNodeForSource(source, list, budgetPer, dropped);
|
||||
if (overflow) overflowNodes.push(overflow);
|
||||
}
|
||||
|
||||
const nextNodes = nodes.filter((n) => !dropped.has(n.id)).concat(overflowNodes);
|
||||
const nextEdges = edges.filter((e) => !dropped.has(e.from) && !dropped.has(e.to));
|
||||
for (const o of overflowNodes) {
|
||||
nextEdges.push({
|
||||
id: `e:source:${o.source}→${o.id}`,
|
||||
from: `source:${o.source}`,
|
||||
to: o.id,
|
||||
kind: "owns",
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
return { nodes: nextNodes, edges: nextEdges };
|
||||
}
|
||||
|
||||
/**
|
||||
* (1) root — link every present SourceNode, plus failed sources so the UI can show them
|
||||
* stale. Returns nodes with the root prepended.
|
||||
*/
|
||||
function buildRootAndSourceEdges(
|
||||
nodes: OrchNode[],
|
||||
edges: OrchEdge[],
|
||||
sources: SourceStatus[]
|
||||
): NodesAndEdges {
|
||||
const root: OrchNode = { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" };
|
||||
const nextNodes = [...nodes];
|
||||
const nextEdges = [...edges];
|
||||
const sourceIds = new Set(nextNodes.filter((n) => n.kind === "source").map((n) => n.id));
|
||||
for (const s of sources) {
|
||||
// `!s.ok` covers hard failures; `s.offline` also materializes a placeholder
|
||||
// for a source that reported ok:true but offline:true (e.g. Conductor with
|
||||
// no hub configured) — otherwise that source never gets a SourceNode at all
|
||||
// and its "offline" sublabel can never render.
|
||||
if ((!s.ok || s.offline) && !sourceIds.has(`source:${s.source}`) && s.source !== "routing") {
|
||||
nextNodes.push({
|
||||
id: `source:${s.source}`,
|
||||
kind: "source",
|
||||
source: s.source,
|
||||
label: s.source,
|
||||
sublabel: s.offline ? "offline" : "error",
|
||||
});
|
||||
sourceIds.add(`source:${s.source}`);
|
||||
}
|
||||
}
|
||||
for (const id of sourceIds) {
|
||||
nextEdges.push({
|
||||
id: `e:orchestrator→${id}`,
|
||||
from: "orchestrator",
|
||||
to: id,
|
||||
kind: "owns",
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
return { nodes: [root, ...nextNodes], edges: nextEdges };
|
||||
}
|
||||
|
||||
export function mergeSnapshot(
|
||||
parts: { cloudAgent: Part; a2a: Part; conductor: Part },
|
||||
sources: SourceStatus[],
|
||||
opts: MergeOptions
|
||||
): OrchSnapshot {
|
||||
let nodes: OrchNode[] = [...parts.cloudAgent.nodes, ...parts.a2a.nodes, ...parts.conductor.nodes];
|
||||
let edges: OrchEdge[] = [...parts.cloudAgent.edges, ...parts.a2a.edges, ...parts.conductor.edges];
|
||||
|
||||
const dropped = new Set<string>();
|
||||
({ nodes, edges } = dedupeConductorMirrors(nodes, edges, dropped));
|
||||
|
||||
if (!opts.showCompleted) {
|
||||
markStaleCompleted(nodes, opts.now, dropped);
|
||||
}
|
||||
nodes = nodes.filter((n) => !dropped.has(n.id));
|
||||
edges = edges.filter((e) => !dropped.has(e.from) && !dropped.has(e.to));
|
||||
|
||||
({ nodes, edges } = capWorkNodesWithOverflow(nodes, edges, dropped));
|
||||
({ nodes, edges } = buildRootAndSourceEdges(nodes, edges, sources));
|
||||
|
||||
return { nodes, edges, sources, generatedAt: new Date(opts.now).toISOString() };
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/** OrchSnapshot → @xyflow nodes/edges with a deterministic shallow 3-layer layout. Pure. */
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { edgeStyle } from "@/shared/components/flow/edgeStyles";
|
||||
import type { OrchNodeKind, OrchSnapshot } from "./orchestrationTypes";
|
||||
|
||||
const LAYER_Y: Record<OrchNodeKind, number> = {
|
||||
orchestrator: 0,
|
||||
source: 150,
|
||||
work: 320,
|
||||
overflow: 320,
|
||||
activity: 470,
|
||||
};
|
||||
const X_GAP = 260;
|
||||
|
||||
export function orchestrationToFlow(snap: OrchSnapshot): {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
fitKey: string;
|
||||
} {
|
||||
const byLayer = new Map<number, string[]>();
|
||||
for (const n of [...snap.nodes].sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
const y = LAYER_Y[n.kind];
|
||||
const ids = byLayer.get(y) ?? [];
|
||||
ids.push(n.id);
|
||||
byLayer.set(y, ids);
|
||||
}
|
||||
const pos = new Map<string, { x: number; y: number }>();
|
||||
for (const [y, ids] of byLayer) {
|
||||
const width = (ids.length - 1) * X_GAP;
|
||||
ids.forEach((id, i) => pos.set(id, { x: i * X_GAP - width / 2, y }));
|
||||
}
|
||||
|
||||
const stateOf = new Map(snap.nodes.map((n) => [n.id, n.state]));
|
||||
const nodes: Node[] = snap.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.kind,
|
||||
position: pos.get(n.id)!,
|
||||
data: n as unknown as Record<string, unknown>,
|
||||
}));
|
||||
const edges: Edge[] = snap.edges.map((e) => {
|
||||
const target = stateOf.get(e.to);
|
||||
const style = edgeStyle(e.active, false, target === "failed", target === "succeeded");
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
animated: e.active,
|
||||
style: e.kind === "mirror" ? { ...style, strokeDasharray: "6 4" } : style,
|
||||
};
|
||||
});
|
||||
|
||||
const fitKey = snap.nodes
|
||||
.filter((n) => n.kind === "work")
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join("|");
|
||||
return { nodes, edges, fitKey };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Pure domain vocabulary for the Orchestration Canvas — no React, no side effects.
|
||||
* Spec: _tasks/superpowers/specs/2026-08-30-orchestration-canvas-design.md
|
||||
*/
|
||||
import { STATUS_HEX } from "@/shared/constants/statusColors";
|
||||
|
||||
export type OrchState =
|
||||
"queued" | "running" | "waiting_approval" | "succeeded" | "failed" | "cancelled";
|
||||
export type OrchSource = "cloud-agent" | "a2a" | "conductor" | "routing";
|
||||
export type OrchNodeKind = "orchestrator" | "source" | "work" | "activity" | "overflow";
|
||||
|
||||
export interface OrchNode {
|
||||
id: string; // `${source}:${sourceId}` for work nodes
|
||||
kind: OrchNodeKind;
|
||||
source?: OrchSource;
|
||||
state?: OrchState;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
startedAt?: string;
|
||||
updatedAt?: string;
|
||||
endedAt?: string;
|
||||
cost?: number;
|
||||
counts?: Partial<Record<OrchState, number>>;
|
||||
// Overflow nodes only: per-state counts of the work nodes folded into this
|
||||
// overflow node when the MAX_WORK_NODES cap engages. overviewProjection folds
|
||||
// this into its `counts` totals (never into `columns`) so operators still see
|
||||
// TRUE totals even when the canvas caps the rendered node count.
|
||||
droppedByState?: Partial<Record<OrchState, number>>;
|
||||
mirrorOf?: string;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface OrchEdge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
kind: "owns" | "mirror";
|
||||
active: boolean; // true while the target work is `running`
|
||||
}
|
||||
|
||||
export interface SourceStatus {
|
||||
source: OrchSource;
|
||||
ok: boolean;
|
||||
offline?: boolean;
|
||||
error?: string;
|
||||
staleSince?: string;
|
||||
}
|
||||
|
||||
export interface OrchSnapshot {
|
||||
nodes: OrchNode[];
|
||||
edges: OrchEdge[];
|
||||
sources: SourceStatus[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export const ORCH_STATES = [
|
||||
"queued",
|
||||
"running",
|
||||
"waiting_approval",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
] as const satisfies readonly OrchState[];
|
||||
|
||||
const STATE_HEX: Record<OrchState, string> = {
|
||||
queued: STATUS_HEX.muted,
|
||||
running: STATUS_HEX.warning,
|
||||
waiting_approval: STATUS_HEX.approval,
|
||||
succeeded: STATUS_HEX.success,
|
||||
failed: STATUS_HEX.error,
|
||||
cancelled: STATUS_HEX.muted,
|
||||
};
|
||||
|
||||
export function orchStateColor(state: OrchState): string {
|
||||
return STATE_HEX[state];
|
||||
}
|
||||
|
||||
export const STALE_COMPLETED_MS = 600_000; // completed >10 min ago drop out of the live view
|
||||
export const MAX_WORK_NODES = 40; // beyond this, per-source overflow nodes take over
|
||||
@@ -1,50 +0,0 @@
|
||||
/** OrchSnapshot → overview counters + kanban columns. Pure. */
|
||||
import {
|
||||
ORCH_STATES,
|
||||
type OrchNode,
|
||||
type OrchSnapshot,
|
||||
type OrchState,
|
||||
} from "./orchestrationTypes";
|
||||
|
||||
export interface OverviewData {
|
||||
counts: Record<OrchState, number>;
|
||||
columns: {
|
||||
queued: OrchNode[];
|
||||
running: OrchNode[];
|
||||
waiting_approval: OrchNode[];
|
||||
done: OrchNode[];
|
||||
};
|
||||
}
|
||||
|
||||
export function overviewProjection(snap: OrchSnapshot, comboActive: number): OverviewData {
|
||||
const counts = Object.fromEntries(ORCH_STATES.map((s) => [s, 0])) as Record<OrchState, number>;
|
||||
const columns: OverviewData["columns"] = {
|
||||
queued: [],
|
||||
running: [],
|
||||
waiting_approval: [],
|
||||
done: [],
|
||||
};
|
||||
|
||||
for (const n of snap.nodes) {
|
||||
// Overflow nodes (MAX_WORK_NODES cap) fold their dropped work nodes' true
|
||||
// per-state counts into `counts` only — never into `columns`, since those
|
||||
// nodes are not rendered on the canvas. Counters must show TRUE totals
|
||||
// even when the canvas caps the rendered node count (operator ruling).
|
||||
if (n.kind === "overflow" && n.droppedByState) {
|
||||
for (const s of ORCH_STATES) {
|
||||
counts[s] += n.droppedByState[s] ?? 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (n.kind !== "work" || !n.state) continue;
|
||||
counts[n.state] += 1;
|
||||
if (n.state === "queued" || n.state === "running" || n.state === "waiting_approval") {
|
||||
columns[n.state].push(n);
|
||||
} else {
|
||||
columns.done.push(n);
|
||||
}
|
||||
}
|
||||
columns.done.sort((a, b) => Date.parse(b.updatedAt ?? "0") - Date.parse(a.updatedAt ?? "0"));
|
||||
counts.running += comboActive;
|
||||
return { counts, columns };
|
||||
}
|
||||
@@ -95,9 +95,7 @@ export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCat
|
||||
}, [onError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await loadState();
|
||||
})();
|
||||
void loadState();
|
||||
}, [loadState]);
|
||||
|
||||
const stateByKey = useMemo(
|
||||
|
||||
@@ -67,15 +67,9 @@ export default function RadarIntelPage() {
|
||||
}, [load, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
setError(t("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
load()
|
||||
.catch(() => setError(t("loadFailed")))
|
||||
.finally(() => setLoading(false));
|
||||
}, [load, t]);
|
||||
|
||||
if (flagOff) notFound();
|
||||
|
||||
@@ -201,9 +201,7 @@ export default function RadarPage() {
|
||||
}, [fetchCatalog, fetchReferrals]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchSettings();
|
||||
})();
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
// Sync (defined before handleActivate which depends on it)
|
||||
@@ -244,9 +242,7 @@ export default function RadarPage() {
|
||||
if (loading || syncing || optIn !== true || autoSyncFiredRef.current) return;
|
||||
if (!shouldAutoSyncOnOpen(meta?.fetchedAt ?? null, Date.now())) return;
|
||||
autoSyncFiredRef.current = true;
|
||||
void (async () => {
|
||||
await handleSync();
|
||||
})();
|
||||
void handleSync();
|
||||
}, [loading, syncing, optIn, meta, handleSync]);
|
||||
|
||||
// Activate opt-in
|
||||
|
||||
@@ -55,16 +55,7 @@ export default function RadarSetupPage() {
|
||||
const provider = searchParams.get("provider");
|
||||
|
||||
const [setupData, setSetupData] = useState<ProviderSetupData | null>(null);
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
@@ -72,6 +63,7 @@ export default function RadarSetupPage() {
|
||||
// Fetch catalog to find the provider's setup data
|
||||
useEffect(() => {
|
||||
if (!provider) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,13 +123,12 @@ export default function RadarSetupPage() {
|
||||
}, [provider, t]);
|
||||
|
||||
// Test connection — uses the EXISTING connection-test endpoint
|
||||
const connectionId = setupData?.connectionId ?? null;
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!connectionId) return;
|
||||
if (!setupData?.connectionId) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${encodeURIComponent(connectionId)}/test`, {
|
||||
const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
@@ -156,7 +147,7 @@ export default function RadarSetupPage() {
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [connectionId, t]);
|
||||
}, [setupData?.connectionId, t]);
|
||||
|
||||
if (!provider) {
|
||||
return (
|
||||
|
||||
@@ -490,18 +490,18 @@ export default function EvalsTab() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 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)
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (targetOptions.length === 0) return;
|
||||
if (targetOptions.some((option) => option.key === selectedTargetKey)) return;
|
||||
setSelectedTargetKey(targetOptions[0]?.key || "suite-default:__default__");
|
||||
}
|
||||
if (compareTargetKey && compareTargetKey === selectedTargetKey) {
|
||||
setCompareTargetKey("");
|
||||
}
|
||||
}, [selectedTargetKey, targetOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compareTargetKey) return;
|
||||
if (compareTargetKey === selectedTargetKey) {
|
||||
setCompareTargetKey("");
|
||||
}
|
||||
}, [compareTargetKey, selectedTargetKey]);
|
||||
|
||||
const filteredSuites = !search.trim()
|
||||
? suites
|
||||
@@ -1846,11 +1846,7 @@ 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,23 +41,6 @@ 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()
|
||||
@@ -129,7 +112,9 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) {
|
||||
async (selectionToken: string) => {
|
||||
const picker = state.resetCreditPicker;
|
||||
if (!picker || state.redeemingResetCreditId || !selectionToken) return;
|
||||
const idempotencyKey = ensureIdempotencyKey(state.idempotencyKeysRef, selectionToken);
|
||||
const idempotencyKey =
|
||||
state.idempotencyKeysRef.current[selectionToken] ??
|
||||
(state.idempotencyKeysRef.current[selectionToken] = createIdempotencyKey());
|
||||
state.setRedeemingResetCreditId(picker.connectionId);
|
||||
state.setErrors((prev) => ({ ...prev, [picker.connectionId]: null }));
|
||||
try {
|
||||
@@ -160,7 +145,7 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) {
|
||||
[picker.connectionId]: new Date().toISOString(),
|
||||
}));
|
||||
state.setResetCreditPicker(null);
|
||||
resetIdempotencyKeys(state.idempotencyKeysRef);
|
||||
state.idempotencyKeysRef.current = {};
|
||||
notify.success(state.tr("resetCreditRedeemed", "Reset redeemed"));
|
||||
} catch (error) {
|
||||
const message = getRequestErrorMessage(
|
||||
|
||||
@@ -22,9 +22,7 @@ export default function RateLimitStatus() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await load();
|
||||
})();
|
||||
load();
|
||||
const interval = setInterval(load, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
@@ -49,9 +49,7 @@ export default function SessionsTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await loadSessions();
|
||||
})();
|
||||
loadSessions();
|
||||
const interval = setInterval(loadSessions, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
@@ -161,9 +161,7 @@ export default function ProviderQuotaWidget({
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const refreshingAllRef = useRef(false);
|
||||
// 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 lastRefreshAllAtRef = useRef(Date.now());
|
||||
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
|
||||
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
|
||||
|
||||
@@ -190,16 +188,14 @@ export default function ProviderQuotaWidget({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await loadData();
|
||||
})();
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (refreshingAllRef.current) return;
|
||||
refreshingAllRef.current = true;
|
||||
const now = Date.now();
|
||||
setLastRefreshAllAt(now);
|
||||
lastRefreshAllAtRef.current = now;
|
||||
setAutoRefreshClock(now);
|
||||
setRefreshingAll(true);
|
||||
try {
|
||||
@@ -239,12 +235,10 @@ export default function ProviderQuotaWidget({
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (refreshingAllRef.current) return;
|
||||
|
||||
if (autoRefreshClock - lastRefreshAllAt >= autoRefreshIntervalMs) {
|
||||
void (async () => {
|
||||
await refreshAll();
|
||||
})();
|
||||
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||
void refreshAll();
|
||||
}
|
||||
}, [autoRefreshClock, lastRefreshAllAt, autoRefreshIntervalMs, refreshAll]);
|
||||
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
|
||||
|
||||
const providerGroups = useMemo(() => {
|
||||
const groups = new Map<string, Connection[]>();
|
||||
@@ -292,7 +286,10 @@ export default function ProviderQuotaWidget({
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAt))
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: tr("forceRefresh", "Refresh now")}
|
||||
</button>
|
||||
|
||||
@@ -1432,13 +1432,13 @@ async function buildUnifiedModelsResponseCore(
|
||||
return activeAliases.has(alias) || activeAliases.has(provider);
|
||||
};
|
||||
|
||||
const findEquivalentSpecialtyModel = (
|
||||
const hasEquivalentSpecialtyModel = (
|
||||
providerId: string,
|
||||
rawModelId: string,
|
||||
type: string,
|
||||
scopedModelId: string
|
||||
) =>
|
||||
models.find((model: any) => {
|
||||
models.some((model: any) => {
|
||||
if (model?.id === scopedModelId) return true;
|
||||
if (model?.owned_by !== providerId || model?.type !== type) return false;
|
||||
const existingRoot =
|
||||
@@ -1450,13 +1450,6 @@ 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()
|
||||
@@ -1471,22 +1464,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
const rawModelId = getSpecialtyModelRelativeId(embModel.id, embModel.provider);
|
||||
if (!providerSupportsModel(embModel.provider, rawModelId)) continue;
|
||||
if (isModelHiddenBulk(embModel.provider, rawModelId)) continue;
|
||||
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";
|
||||
}
|
||||
if (hasEquivalentSpecialtyModel(embModel.provider, rawModelId, "embedding", embModel.id)) {
|
||||
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 { getRootLayoutSettings } from "@/lib/db/rootLayoutSettings";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
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 getRootLayoutSettings();
|
||||
const instanceName = settings.instanceName;
|
||||
const customFaviconUrl = settings.customFaviconUrl || settings.customFaviconBase64;
|
||||
const settings = await getSettings();
|
||||
const instanceName = settings?.instanceName || "OmniRoute";
|
||||
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/utils/proxyFetch.ts");
|
||||
await import("@omniroute/open-sse/index.ts");
|
||||
console.log("[STARTUP] Global fetch proxy patch initialized");
|
||||
|
||||
// Register quota fetchers early so combo routing can use real quota-aware
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { normalizeBasePath } from "@/shared/utils/basePath";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function dispatchBatchApiRequest({
|
||||
endpoint,
|
||||
@@ -16,17 +39,13 @@ async function dispatchBatchApiRequest({
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
|
||||
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, {
|
||||
const handler = await getHandler(endpoint);
|
||||
const request = new Request(`http://localhost${endpoint}`, {
|
||||
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,7 +67,6 @@ 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,7 +40,6 @@ 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 {
|
||||
@@ -515,6 +514,7 @@ 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,6 +529,18 @@ 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})`);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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,15 +19,7 @@ const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000",
|
||||
|
||||
declare global {
|
||||
var __omnirouteShutdown:
|
||||
| {
|
||||
init: boolean;
|
||||
shuttingDown: boolean;
|
||||
activeRequests: number;
|
||||
shutdownPromise?: Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
var __omnirouteRequestShutdown: ((signal: string) => Promise<void>) | undefined;
|
||||
var __omnirouteCustomServerOwnsShutdown: boolean | undefined;
|
||||
{ init: boolean; shuttingDown: boolean; activeRequests: number } | undefined;
|
||||
}
|
||||
|
||||
function getShutdownState() {
|
||||
@@ -110,14 +102,12 @@ 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();
|
||||
@@ -133,6 +123,9 @@ 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.
|
||||
@@ -154,62 +147,41 @@ 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;
|
||||
|
||||
if (globalThis.__omnirouteCustomServerOwnsShutdown) {
|
||||
console.log("[Shutdown] Cleanup registered with the custom server shutdown owner.");
|
||||
return;
|
||||
}
|
||||
const shutdown = async (signal: string) => {
|
||||
if (state.shuttingDown) return;
|
||||
state.shuttingDown = true;
|
||||
markServerStopping();
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
void globalThis.__omnirouteRequestShutdown?.(signal).then(() => process.exit(0));
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`);
|
||||
|
||||
await waitForDrain();
|
||||
await cleanup();
|
||||
|
||||
console.log("[Shutdown] Bye.");
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => 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", () => void shutdown("SIGHUP"));
|
||||
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
||||
|
||||
console.log("[Shutdown] Graceful shutdown handlers registered.");
|
||||
}
|
||||
|
||||
@@ -42,18 +42,8 @@ export function getAppLogRotationCheckInterval(): number {
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
/** Module-level timer handle — cleared by closeLogRotation(). */
|
||||
let rotationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function getLogConfig() {
|
||||
const logToFile = getAppLogToFile();
|
||||
@@ -182,9 +172,6 @@ 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;
|
||||
|
||||
@@ -194,7 +181,7 @@ export function initLogRotation(): void {
|
||||
cleanupOverflowLogs(config.logFilePath, config.maxFiles);
|
||||
|
||||
const intervalMs = getAppLogRotationCheckInterval();
|
||||
state.timer = setInterval(
|
||||
rotationTimer = setInterval(
|
||||
(filePath: string, maxSize: number, maxFiles: number) => {
|
||||
rotateIfNeeded(filePath, maxSize);
|
||||
cleanupOverflowLogs(filePath, maxFiles);
|
||||
@@ -204,7 +191,7 @@ export function initLogRotation(): void {
|
||||
config.maxFileSize,
|
||||
config.maxFiles
|
||||
);
|
||||
state.timer.unref?.();
|
||||
rotationTimer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,9 +199,8 @@ export function initLogRotation(): void {
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export function closeLogRotation(): void {
|
||||
const state = getLogRotationState();
|
||||
if (state.timer !== null) {
|
||||
clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
if (rotationTimer !== null) {
|
||||
clearInterval(rotationTimer);
|
||||
rotationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
joinBaseUrlAndPath,
|
||||
} from "@omniroute/open-sse/services/claudeCodeCompatible.ts";
|
||||
import { getDefaultExecutor } from "@omniroute/open-sse/executors/defaultResolver.ts";
|
||||
import {
|
||||
addModelsSuffix,
|
||||
normalizeAnthropicBaseUrl,
|
||||
@@ -138,7 +137,8 @@ export async function validateClaudeOAuthInline({
|
||||
typeof override === "string" && override ? override : modelId || "claude-haiku-4-5-20251001";
|
||||
|
||||
try {
|
||||
const executed = await getDefaultExecutor("claude").execute({
|
||||
const { getExecutor } = await import("@omniroute/open-sse/executors/index.ts");
|
||||
const executed = await (await getExecutor("claude")).execute({
|
||||
model: testModelId,
|
||||
body: {
|
||||
model: testModelId,
|
||||
|
||||
@@ -22,12 +22,13 @@
|
||||
|
||||
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 { refreshAndUpdateCredentialsWithResolver } from "@/lib/usage/providerLimits/credentialRefresh";
|
||||
import { refreshAndUpdateCredentials } from "@/lib/usage/providerLimits";
|
||||
import { getCircuitBreaker } from "@/shared/utils/circuitBreaker";
|
||||
import {
|
||||
QUOTA_AUTOPING_FAILURE_COOLDOWN_MS,
|
||||
@@ -62,11 +63,8 @@ export interface QuotaAutoPingDeps {
|
||||
refreshAndUpdateCredentials: (
|
||||
connection: QuotaAutoPingConnection
|
||||
) => Promise<{ connection: QuotaAutoPingConnection }>;
|
||||
getCodexUsage: (
|
||||
accessToken?: string,
|
||||
providerSpecificData?: JsonRecord
|
||||
) => Promise<JsonRecord>;
|
||||
getExecutor: (provider: "codex") => Promise<BaseExecutor>;
|
||||
getCodexUsage: (accessToken?: string, providerSpecificData?: JsonRecord) => Promise<JsonRecord>;
|
||||
getExecutor: (provider: string) => Promise<BaseExecutor>;
|
||||
canExecuteProvider: (provider: string) => boolean;
|
||||
isConnectionUnavailableToAuxiliaryActivity: (connectionId: string) => Promise<boolean>;
|
||||
}
|
||||
@@ -81,33 +79,15 @@ 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) =>
|
||||
refreshAndUpdateCredentialsWithResolver(connection, loadQuotaAutoPingExecutor),
|
||||
refreshAndUpdateCredentials(connection as never),
|
||||
getCodexUsage,
|
||||
getExecutor: loadQuotaAutoPingExecutor,
|
||||
getExecutor,
|
||||
canExecuteProvider: (provider) => getCircuitBreaker(provider).canExecute(),
|
||||
isConnectionUnavailableToAuxiliaryActivity,
|
||||
};
|
||||
|
||||
@@ -18,10 +18,13 @@ 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 { getCredentialRefreshExecutor } from "@omniroute/open-sse/executors/credential.ts";
|
||||
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
||||
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
|
||||
import { cooldownUntilMs } from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts";
|
||||
import {
|
||||
rotationGroupFor,
|
||||
serializeRefresh,
|
||||
} from "@omniroute/open-sse/services/refreshSerializer.ts";
|
||||
import {
|
||||
extractCodeAssistOnboardTierId,
|
||||
extractCodeAssistSubscriptionTier,
|
||||
@@ -39,15 +42,29 @@ 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",
|
||||
@@ -201,15 +218,122 @@ 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: CredentialRefreshOptions = {}
|
||||
opts: { allowRotatingRefresh?: boolean; force?: boolean } = {}
|
||||
) {
|
||||
return refreshAndUpdateCredentialsWithResolver(
|
||||
connection,
|
||||
getCredentialRefreshExecutor,
|
||||
opts
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function isUsageAuthError(message: unknown): boolean {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
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,16 +55,6 @@ 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);
|
||||
}
|
||||
@@ -157,8 +147,17 @@ 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 = COLUMN_SORT_MAP[column as keyof typeof COLUMN_SORT_MAP];
|
||||
const mapping = columnSortMap[column as keyof typeof columnSortMap];
|
||||
if (!mapping) return;
|
||||
setSortBy((prev) => {
|
||||
if (prev === mapping.desc) return mapping.asc;
|
||||
@@ -167,7 +166,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
}, []);
|
||||
const getSortIndicator = useCallback(
|
||||
(column: string) => {
|
||||
const mapping = COLUMN_SORT_MAP[column as keyof typeof COLUMN_SORT_MAP];
|
||||
const mapping = columnSortMap[column as keyof typeof columnSortMap];
|
||||
if (!mapping) return "";
|
||||
if (sortBy === mapping.desc) return " ↓";
|
||||
if (sortBy === mapping.asc) return " ↑";
|
||||
@@ -536,7 +535,89 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
// endpoint until the row appears.
|
||||
const router = useRouter();
|
||||
|
||||
const closeDetail = useCallback(() => {
|
||||
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 = () => {
|
||||
detailRequestRef.current = "";
|
||||
setSelectedLog(null);
|
||||
setDetailData(null);
|
||||
@@ -548,92 +629,7 @@ 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]);
|
||||
|
||||
@@ -658,7 +654,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
console.error("Failed to open initial log id:", error_);
|
||||
});
|
||||
}
|
||||
}, [initialSelectedId, openDetail]);
|
||||
}, [initialSelectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
const isActive = selectedLog?.active === true;
|
||||
@@ -769,7 +765,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
pendingBoundaryNavRef.current = "prev";
|
||||
fetchLogs(false);
|
||||
}
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs, openDetail]);
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const idx = currentLogIndex;
|
||||
@@ -785,7 +781,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
pendingBoundaryNavRef.current = "next";
|
||||
fetchLogs(false);
|
||||
}
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs, openDetail]);
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
|
||||
|
||||
// Resolves a pending boundary nav (see handlePrev/handleNext) once a
|
||||
// triggered fetchLogs() resync has landed in sortedLogsForNav. Only fires
|
||||
|
||||
@@ -37,7 +37,6 @@ export type AnyRoutingStrategyValue = RoutingStrategyValue | InternalRoutingStra
|
||||
|
||||
export const AUTO_ROUTING_STRATEGY_VALUES = [
|
||||
"rules",
|
||||
"score",
|
||||
"cost",
|
||||
"eco",
|
||||
"latency",
|
||||
|
||||
@@ -14,6 +14,4 @@ export const STATUS_HEX = {
|
||||
warning: "#f59e0b",
|
||||
error: "#ef4444",
|
||||
muted: "#6b7280",
|
||||
/** Human-approval gate (waiting_approval) — violet, matching the industry de-facto palette. */
|
||||
approval: "#8b5cf6",
|
||||
} as const;
|
||||
|
||||
@@ -42,13 +42,6 @@ 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,10 +18,6 @@ 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";
|
||||
|
||||
@@ -65,7 +61,7 @@ function getTransportCompatibleConfig(): pino.LoggerOptions {
|
||||
* vanished, so failed writes are dropped (best-effort stderr notice) instead of
|
||||
* escalating.
|
||||
*/
|
||||
function buildTransportStream(targets: NonNullable<pino.TransportMultiOptions["targets"]>) {
|
||||
function buildFileTransportStream(targets: NonNullable<pino.TransportMultiOptions["targets"]>) {
|
||||
const stream = pino.transport({ targets });
|
||||
stream.on("error", (err: unknown) => {
|
||||
try {
|
||||
@@ -79,65 +75,11 @@ function buildTransportStream(targets: NonNullable<pino.TransportMultiOptions["t
|
||||
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 buildLoggerResource(): SharedLoggerResource {
|
||||
function buildLogger(): pino.Logger {
|
||||
const logConfig = getLogConfig();
|
||||
const logLevel = (baseConfig.level as string) || "info";
|
||||
const transportConfig = getTransportCompatibleConfig();
|
||||
@@ -153,7 +95,7 @@ function buildLoggerResource(): SharedLoggerResource {
|
||||
|
||||
if (isDev) {
|
||||
// Dev: pino-pretty → stdout, JSON → file
|
||||
const stream = buildTransportStream([
|
||||
const stream = buildFileTransportStream([
|
||||
{
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
@@ -171,12 +113,12 @@ function buildLoggerResource(): SharedLoggerResource {
|
||||
level: logLevel,
|
||||
},
|
||||
]);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
return pino(transportConfig, stream);
|
||||
}
|
||||
|
||||
// Production: JSON → stdout + JSON → file
|
||||
{
|
||||
const stream = buildTransportStream([
|
||||
const stream = buildFileTransportStream([
|
||||
{
|
||||
target: "pino/file",
|
||||
options: { destination: 1 }, // stdout
|
||||
@@ -188,7 +130,7 @@ function buildLoggerResource(): SharedLoggerResource {
|
||||
level: logLevel,
|
||||
},
|
||||
]);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
return pino(transportConfig, stream);
|
||||
}
|
||||
} catch (err) {
|
||||
// Log the actual error for diagnostics (issue #165)
|
||||
@@ -214,15 +156,12 @@ function buildLoggerResource(): SharedLoggerResource {
|
||||
});
|
||||
|
||||
// Production fallback: JSON to both stdout and file via multistream
|
||||
return createLoggerResource(
|
||||
pino(
|
||||
baseConfig,
|
||||
pino.multistream([
|
||||
{ stream: process.stdout, level: logLevel as pino.Level },
|
||||
{ stream: fileDestination, level: logLevel as pino.Level },
|
||||
])
|
||||
),
|
||||
fileDestination
|
||||
return pino(
|
||||
baseConfig,
|
||||
pino.multistream([
|
||||
{ stream: process.stdout, level: logLevel as pino.Level },
|
||||
{ stream: fileDestination, level: logLevel as pino.Level },
|
||||
])
|
||||
);
|
||||
} catch (fallbackErr) {
|
||||
try {
|
||||
@@ -236,8 +175,9 @@ function buildLoggerResource(): SharedLoggerResource {
|
||||
|
||||
// Console-only (no file logging)
|
||||
if (isDev) {
|
||||
const stream = buildTransportStream([
|
||||
{
|
||||
return pino({
|
||||
...baseConfig,
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
@@ -245,18 +185,14 @@ function buildLoggerResource(): SharedLoggerResource {
|
||||
ignore: "pid,hostname,service",
|
||||
messageFormat: "[{module}] {msg}",
|
||||
},
|
||||
level: logLevel,
|
||||
},
|
||||
]);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
});
|
||||
}
|
||||
|
||||
return createLoggerResource(pino(baseConfig), null);
|
||||
return pino(baseConfig);
|
||||
}
|
||||
|
||||
const sharedLoggerResource = getOrCreateSharedLoggerResource(buildLoggerResource);
|
||||
|
||||
export const logger = sharedLoggerResource.logger;
|
||||
export const logger = buildLogger();
|
||||
|
||||
/**
|
||||
* Create a child logger with a module tag.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* 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"
|
||||
);
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
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,13 +202,6 @@ 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)
|
||||
@@ -271,7 +264,6 @@ test("Batch handles and counts failures correctly", async () => {
|
||||
}
|
||||
} finally {
|
||||
stopBatchProcessor();
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -398,21 +390,6 @@ 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 = [
|
||||
@@ -472,10 +449,8 @@ 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,12 +27,11 @@ import path from "node:path";
|
||||
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-quota-"));
|
||||
|
||||
const { getCredentialRefreshExecutor } =
|
||||
await import("../../open-sse/executors/credential.ts");
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.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 getCredentialRefreshExecutor("codex");
|
||||
const exec = await getExecutor("codex");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
@@ -69,7 +68,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 getCredentialRefreshExecutor("cursor");
|
||||
const exec = await getExecutor("cursor");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
|
||||
@@ -15,7 +15,6 @@ 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 });
|
||||
@@ -55,9 +54,3 @@ 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,12 +1,5 @@
|
||||
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,
|
||||
@@ -14,90 +7,17 @@ const shutdownSignals = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
|
||||
// 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("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)
|
||||
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}).`
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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");
|
||||
});
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
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,
|
||||
@@ -117,88 +115,5 @@ test("shouldSwallowUncaught preserves crash semantics for genuine errors", () =>
|
||||
|
||||
test("installProcessCrashGuard does not throw on import and is idempotent", () => {
|
||||
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/);
|
||||
assert.doesNotThrow(() => installProcessCrashGuard(() => {}));
|
||||
});
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
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,8 +22,7 @@ process.env.DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-accesstoken-fallback-")
|
||||
);
|
||||
|
||||
const { getCredentialRefreshExecutor } =
|
||||
await import("../../open-sse/executors/credential.ts");
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts");
|
||||
|
||||
// `gemini` is a non-rotating (no rotation lock group), non-github OAuth provider,
|
||||
@@ -40,7 +39,7 @@ function geminiConnection() {
|
||||
}
|
||||
|
||||
test("falls back to the existing accessToken for a non-github provider when refreshCredentials returns null", async () => {
|
||||
const exec = await getCredentialRefreshExecutor("gemini");
|
||||
const exec = await getExecutor("gemini");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
exec.needsRefresh = () => true; // force the refresh attempt
|
||||
@@ -66,7 +65,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 getCredentialRefreshExecutor("gemini");
|
||||
const exec = await getExecutor("gemini");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
exec.needsRefresh = () => true;
|
||||
|
||||
@@ -18,8 +18,7 @@ import path from "node:path";
|
||||
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-force-refresh-imported-"));
|
||||
|
||||
const { getCredentialRefreshExecutor } =
|
||||
await import("../../open-sse/executors/credential.ts");
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts");
|
||||
|
||||
function importedCodexConnection() {
|
||||
@@ -34,7 +33,7 @@ function importedCodexConnection() {
|
||||
}
|
||||
|
||||
test("force re-mints an imported rotating account that needsRefresh would skip (#3019 reactive)", async () => {
|
||||
const exec = await getCredentialRefreshExecutor("codex");
|
||||
const exec = await getExecutor("codex");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
@@ -66,7 +65,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 getCredentialRefreshExecutor("codex");
|
||||
const exec = await getExecutor("codex");
|
||||
const origNeeds = exec.needsRefresh;
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
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,10 +15,7 @@ import {
|
||||
listStrategies,
|
||||
type RoutingContext,
|
||||
} from "../../open-sse/services/autoCombo/routerStrategy.ts";
|
||||
import {
|
||||
DEFAULT_WEIGHTS,
|
||||
type ProviderCandidate,
|
||||
} from "../../open-sse/services/autoCombo/scoring.ts";
|
||||
import type { ProviderCandidate } from "../../open-sse/services/autoCombo/scoring.ts";
|
||||
|
||||
function cand(p: Partial<ProviderCandidate> & { provider: string }): ProviderCandidate {
|
||||
return {
|
||||
@@ -36,32 +33,6 @@ 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 = [
|
||||
@@ -336,7 +307,6 @@ 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}'`);
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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 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.
|
||||
// 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.
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SOLVER = join(HERE, "../../open-sse/services/claudeTurnstileSolver.ts");
|
||||
|
||||
@@ -28,9 +28,10 @@ test("importing the real executor chain does not throw on an unsupported process
|
||||
Object.defineProperty(process, "platform", { value: "android", configurable: true });
|
||||
|
||||
try {
|
||||
// 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).
|
||||
// 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.
|
||||
await import("../../open-sse/executors/index.ts");
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", originalDescriptor);
|
||||
|
||||
@@ -162,13 +162,7 @@ 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" } } },
|
||||
],
|
||||
},
|
||||
@@ -255,7 +249,7 @@ test("Antigravity -> OpenAI lowers schema types recursively", () => {
|
||||
false
|
||||
);
|
||||
|
||||
assert.deepEqual((result.tools[0].function as Record<string, unknown>).parameters, {
|
||||
assert.deepEqual((result.tools[0].function as any).parameters, {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
@@ -304,17 +298,12 @@ test("Antigravity -> OpenAI strips enumDescriptions from tool schema (top-level
|
||||
false
|
||||
);
|
||||
|
||||
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>;
|
||||
const parameters = (result.tools[0].function as any).parameters;
|
||||
|
||||
// enumDescriptions must be removed at every level of the schema tree...
|
||||
assert.equal("enumDescriptions" in parameters, false);
|
||||
assert.equal("enumDescriptions" in (props.mode as Record<string, unknown>), false);
|
||||
assert.equal("enumDescriptions" in (tags.items as Record<string, unknown>), false);
|
||||
assert.equal("enumDescriptions" in parameters.properties.mode, false);
|
||||
assert.equal("enumDescriptions" in parameters.properties.tags.items, false);
|
||||
|
||||
// ...while leaving the rest of the schema (incl. enum values) intact.
|
||||
assert.deepEqual(parameters, {
|
||||
@@ -360,10 +349,7 @@ test("Antigravity -> OpenAI preserves the required array on Draft 2020-12 tool s
|
||||
false
|
||||
);
|
||||
|
||||
const params = (result.tools[0].function as Record<string, unknown>).parameters as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const params = (result.tools[0].function as any).parameters;
|
||||
// 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.
|
||||
@@ -399,104 +385,6 @@ test("Antigravity -> OpenAI drops required entries that no longer exist in prope
|
||||
false
|
||||
);
|
||||
|
||||
const params = (result.tools[0].function as Record<string, unknown>).parameters as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const params = (result.tools[0].function as any).parameters;
|
||||
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,70 +237,3 @@ 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}');
|
||||
});
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
/**
|
||||
* tests/unit/ui/orchestrationModel.test.ts
|
||||
* Run: node --import tsx/esm --test tests/unit/ui/orchestrationModel.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
ORCH_STATES,
|
||||
orchStateColor,
|
||||
} from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts";
|
||||
import { STATUS_HEX } from "../../../src/shared/constants/statusColors.ts";
|
||||
import { fromCloudAgent } from "../../../src/app/(dashboard)/dashboard/orchestration/model/fromCloudAgent.ts";
|
||||
import type { CloudAgentTask } from "../../../src/lib/cloudAgent/types.ts";
|
||||
import { fromA2A } from "../../../src/app/(dashboard)/dashboard/orchestration/model/fromA2A.ts";
|
||||
import type { A2ATask } from "../../../src/lib/a2a/taskManager.ts";
|
||||
import { fromConductor } from "../../../src/app/(dashboard)/dashboard/orchestration/model/fromConductor.ts";
|
||||
import type { FleetSnapshot } from "../../../src/lib/conductor/hubProxy.ts";
|
||||
import { mergeSnapshot } from "../../../src/app/(dashboard)/dashboard/orchestration/model/mergeSnapshot.ts";
|
||||
import {
|
||||
STALE_COMPLETED_MS,
|
||||
MAX_WORK_NODES,
|
||||
} from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts";
|
||||
|
||||
describe("orchestrationTypes", () => {
|
||||
it("covers all six states with a color each", () => {
|
||||
assert.equal(ORCH_STATES.length, 6);
|
||||
for (const s of ORCH_STATES) {
|
||||
assert.match(orchStateColor(s), /^#[0-9a-f]{6}$/i, s);
|
||||
}
|
||||
});
|
||||
it("waiting_approval maps to the new STATUS_HEX.approval violet", () => {
|
||||
assert.equal(orchStateColor("waiting_approval"), STATUS_HEX.approval);
|
||||
assert.equal(STATUS_HEX.approval, "#8b5cf6");
|
||||
});
|
||||
it("running maps to warning, succeeded to success, failed to error", () => {
|
||||
assert.equal(orchStateColor("running"), STATUS_HEX.warning);
|
||||
assert.equal(orchStateColor("succeeded"), STATUS_HEX.success);
|
||||
assert.equal(orchStateColor("failed"), STATUS_HEX.error);
|
||||
});
|
||||
});
|
||||
|
||||
function caTask(over: Partial<CloudAgentTask>): CloudAgentTask {
|
||||
return {
|
||||
id: "t1",
|
||||
providerId: "devin",
|
||||
status: "running",
|
||||
prompt: "Fix the flaky test in CI",
|
||||
source: { repoName: "acme/app", repoUrl: "https://github.com/acme/app" },
|
||||
options: {},
|
||||
activities: [],
|
||||
createdAt: "2026-08-30T10:00:00Z",
|
||||
updatedAt: "2026-08-30T10:05:00Z",
|
||||
...over,
|
||||
} as CloudAgentTask;
|
||||
}
|
||||
|
||||
describe("fromCloudAgent", () => {
|
||||
it("maps every status to the unified OrchState", () => {
|
||||
const cases: Array<[CloudAgentTask["status"], string]> = [
|
||||
["queued", "queued"],
|
||||
["running", "running"],
|
||||
["awaiting_approval", "waiting_approval"],
|
||||
["completed", "succeeded"],
|
||||
["failed", "failed"],
|
||||
["cancelled", "cancelled"],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
const { nodes } = fromCloudAgent([caTask({ status: input })]);
|
||||
const work = nodes.find((n) => n.kind === "work");
|
||||
assert.equal(work?.state, expected, input);
|
||||
}
|
||||
});
|
||||
it("unknown status becomes failed with the raw value in sublabel", () => {
|
||||
const { nodes } = fromCloudAgent([caTask({ status: "exploded" as CloudAgentTask["status"] })]);
|
||||
const work = nodes.find((n) => n.kind === "work");
|
||||
assert.equal(work?.state, "failed");
|
||||
assert.match(work?.sublabel ?? "", /exploded/);
|
||||
});
|
||||
it("running task with activities gets one ActivityNode; completed does not", () => {
|
||||
const running = caTask({
|
||||
activities: [
|
||||
{ id: "a1", type: "command", content: "npm test", timestamp: "2026-08-30T10:04:00Z" },
|
||||
],
|
||||
});
|
||||
const done = caTask({ id: "t2", status: "completed", activities: running.activities });
|
||||
const { nodes, edges } = fromCloudAgent([running, done]);
|
||||
const acts = nodes.filter((n) => n.kind === "activity");
|
||||
assert.equal(acts.length, 1);
|
||||
assert.equal(acts[0].id, "cloud-agent:t1:activity");
|
||||
assert.ok(
|
||||
edges.some(
|
||||
(e) => e.from === "cloud-agent:t1" && e.to === "cloud-agent:t1:activity" && e.active
|
||||
)
|
||||
);
|
||||
});
|
||||
it("emits a SourceNode with per-state counts and owns-edges from it", () => {
|
||||
const { nodes, edges } = fromCloudAgent([caTask({}), caTask({ id: "t2", status: "failed" })]);
|
||||
const src = nodes.find((n) => n.id === "source:cloud-agent");
|
||||
assert.equal(src?.counts?.running, 1);
|
||||
assert.equal(src?.counts?.failed, 1);
|
||||
assert.ok(
|
||||
edges.some(
|
||||
(e) => e.from === "source:cloud-agent" && e.to === "cloud-agent:t1" && e.kind === "owns"
|
||||
)
|
||||
);
|
||||
});
|
||||
it("empty input emits nothing", () => {
|
||||
const out = fromCloudAgent([]);
|
||||
assert.equal(out.nodes.length, 0);
|
||||
assert.equal(out.edges.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
function a2aTask(over: Partial<A2ATask>): A2ATask {
|
||||
return {
|
||||
id: "a1",
|
||||
skill: "smart-routing",
|
||||
state: "working",
|
||||
input: { skill: "smart-routing", messages: [{ role: "user", content: "route this well" }] },
|
||||
artifacts: [],
|
||||
events: [{ timestamp: "2026-08-30T10:00:00Z", state: "submitted" }],
|
||||
metadata: {},
|
||||
createdAt: "2026-08-30T10:00:00Z",
|
||||
updatedAt: "2026-08-30T10:01:00Z",
|
||||
expiresAt: "2026-08-30T10:05:00Z",
|
||||
...over,
|
||||
} as A2ATask;
|
||||
}
|
||||
|
||||
describe("fromA2A", () => {
|
||||
it("maps the five A2A states", () => {
|
||||
const cases: Array<[A2ATask["state"], string]> = [
|
||||
["submitted", "queued"],
|
||||
["working", "running"],
|
||||
["completed", "succeeded"],
|
||||
["failed", "failed"],
|
||||
["cancelled", "cancelled"],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
const { nodes } = fromA2A([a2aTask({ state: input })]);
|
||||
assert.equal(nodes.find((n) => n.kind === "work")?.state, expected, input);
|
||||
}
|
||||
});
|
||||
it("work node id is a2a:<id>, label is the skill", () => {
|
||||
const { nodes } = fromA2A([a2aTask({})]);
|
||||
const w = nodes.find((n) => n.kind === "work");
|
||||
assert.equal(w?.id, "a2a:a1");
|
||||
assert.equal(w?.label, "smart-routing");
|
||||
});
|
||||
it("empty input emits nothing", () => {
|
||||
assert.equal(fromA2A([]).nodes.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
const baseSnap: FleetSnapshot = {
|
||||
offline: false,
|
||||
runners: [{ id: "r1", name: "runner-one", clis: ["claude"], online: true, draining: false }],
|
||||
tasks: [
|
||||
{
|
||||
id: "ct1",
|
||||
status: "running",
|
||||
mode: "auto",
|
||||
repo: "acme/app",
|
||||
runner: "r1",
|
||||
summary: "Refactor auth",
|
||||
branch: null,
|
||||
error: null,
|
||||
updated_at: "2026-08-30T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("fromConductor", () => {
|
||||
it("online runner with a running task → running WorkNode + ActivityNode for the task", () => {
|
||||
const { nodes, edges } = fromConductor(baseSnap);
|
||||
const runner = nodes.find((n) => n.id === "conductor:runner:r1");
|
||||
assert.equal(runner?.state, "running");
|
||||
const act = nodes.find((n) => n.id === "conductor:task:ct1");
|
||||
assert.equal(act?.kind, "activity");
|
||||
assert.ok(edges.some((e) => e.from === "conductor:runner:r1" && e.to === "conductor:task:ct1"));
|
||||
});
|
||||
it("queued task without runner hangs directly under the source as a work node", () => {
|
||||
const snap: FleetSnapshot = {
|
||||
...baseSnap,
|
||||
runners: [],
|
||||
tasks: [{ ...baseSnap.tasks[0], id: "ct2", status: "queued", runner: null }],
|
||||
};
|
||||
const { nodes, edges } = fromConductor(snap);
|
||||
const w = nodes.find((n) => n.id === "conductor:task:ct2");
|
||||
assert.equal(w?.kind, "work");
|
||||
assert.equal(w?.state, "queued");
|
||||
assert.ok(edges.some((e) => e.from === "source:conductor" && e.to === "conductor:task:ct2"));
|
||||
});
|
||||
it("offline snapshot emits only nothing (hook marks the source offline separately)", () => {
|
||||
const out = fromConductor({ offline: true, runners: [], tasks: [] });
|
||||
assert.equal(out.nodes.length, 0);
|
||||
});
|
||||
it("unknown hub status maps to failed with the raw value in sublabel", () => {
|
||||
const snap: FleetSnapshot = {
|
||||
...baseSnap,
|
||||
runners: [],
|
||||
tasks: [{ ...baseSnap.tasks[0], id: "ct3", status: "vaporized", runner: null }],
|
||||
};
|
||||
const w = fromConductor(snap).nodes.find((n) => n.id === "conductor:task:ct3");
|
||||
assert.equal(w?.state, "failed");
|
||||
assert.match(w?.sublabel ?? "", /vaporized/);
|
||||
});
|
||||
it("running task whose runner is not present in snap.runners is emitted as a work node, not swallowed", () => {
|
||||
const snap: FleetSnapshot = {
|
||||
offline: false,
|
||||
runners: [],
|
||||
tasks: [
|
||||
{
|
||||
...baseSnap.tasks[0],
|
||||
id: "ct-orphan",
|
||||
status: "running",
|
||||
runner: "ghost-runner",
|
||||
},
|
||||
],
|
||||
};
|
||||
const { nodes, edges } = fromConductor(snap);
|
||||
const w = nodes.find((n) => n.id === "conductor:task:ct-orphan");
|
||||
assert.equal(w?.kind, "work");
|
||||
assert.equal(w?.state, "running");
|
||||
assert.ok(
|
||||
edges.some((e) => e.from === "source:conductor" && e.to === "conductor:task:ct-orphan")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const OK_SOURCES = [
|
||||
{ source: "cloud-agent" as const, ok: true },
|
||||
{ source: "a2a" as const, ok: true },
|
||||
{ source: "conductor" as const, ok: true },
|
||||
];
|
||||
const NOW = Date.parse("2026-08-30T12:00:00Z");
|
||||
const empty = { nodes: [], edges: [] };
|
||||
|
||||
describe("mergeSnapshot", () => {
|
||||
it("adds the orchestrator root and root→source edges", () => {
|
||||
const snap = mergeSnapshot(
|
||||
{ cloudAgent: fromCloudAgent([caTask({})]), a2a: empty, conductor: empty },
|
||||
OK_SOURCES,
|
||||
{ now: NOW }
|
||||
);
|
||||
assert.ok(snap.nodes.some((n) => n.id === "orchestrator"));
|
||||
assert.ok(snap.edges.some((e) => e.from === "orchestrator" && e.to === "source:cloud-agent"));
|
||||
});
|
||||
it("dedupes a Conductor-mirrored A2A task into one node with a mirror edge", () => {
|
||||
const a2a = fromA2A([
|
||||
a2aTask({ id: "am1", skill: "conductor", metadata: { conductor: { task_id: "ct1" } } }),
|
||||
]);
|
||||
const conductor = fromConductor(baseSnap); // contains conductor:task:ct1 as activity of r1
|
||||
const snap = mergeSnapshot({ cloudAgent: empty, a2a, conductor }, OK_SOURCES, { now: NOW });
|
||||
assert.ok(
|
||||
!snap.nodes.some((n) => n.id === "a2a:am1"),
|
||||
"mirrored A2A work node must be dropped"
|
||||
);
|
||||
const mirrorEdge = snap.edges.find((e) => e.kind === "mirror");
|
||||
assert.equal(mirrorEdge?.to, "source:a2a");
|
||||
});
|
||||
it("drops terminal work older than STALE_COMPLETED_MS unless showCompleted", () => {
|
||||
const old = caTask({
|
||||
id: "old",
|
||||
status: "completed",
|
||||
completedAt: new Date(NOW - STALE_COMPLETED_MS - 1000).toISOString(),
|
||||
});
|
||||
const parts = { cloudAgent: fromCloudAgent([old]), a2a: empty, conductor: empty };
|
||||
assert.ok(
|
||||
!mergeSnapshot(parts, OK_SOURCES, { now: NOW }).nodes.some((n) => n.id === "cloud-agent:old")
|
||||
);
|
||||
assert.ok(
|
||||
mergeSnapshot(parts, OK_SOURCES, { now: NOW, showCompleted: true }).nodes.some(
|
||||
(n) => n.id === "cloud-agent:old"
|
||||
)
|
||||
);
|
||||
});
|
||||
it("caps work nodes at MAX_WORK_NODES with a per-source overflow node", () => {
|
||||
const many = Array.from({ length: MAX_WORK_NODES + 10 }, (_, i) =>
|
||||
caTask({ id: `m${i}`, updatedAt: new Date(NOW - i * 1000).toISOString() })
|
||||
);
|
||||
const snap = mergeSnapshot(
|
||||
{ cloudAgent: fromCloudAgent(many), a2a: empty, conductor: empty },
|
||||
OK_SOURCES,
|
||||
{ now: NOW }
|
||||
);
|
||||
const works = snap.nodes.filter((n) => n.kind === "work");
|
||||
assert.ok(works.length <= MAX_WORK_NODES, `got ${works.length}`);
|
||||
const overflow = snap.nodes.find((n) => n.id === "overflow:cloud-agent");
|
||||
assert.ok(overflow, "overflow node expected");
|
||||
});
|
||||
it("keeps SourceStatus[] verbatim on the snapshot", () => {
|
||||
const src = [{ source: "conductor" as const, ok: false, offline: true }];
|
||||
const snap = mergeSnapshot({ cloudAgent: empty, a2a: empty, conductor: empty }, src, {
|
||||
now: NOW,
|
||||
});
|
||||
assert.deepEqual(snap.sources, src);
|
||||
});
|
||||
it("does not mutate input node objects when deduping (honors the Pure contract)", () => {
|
||||
const a2a = fromA2A([
|
||||
a2aTask({ id: "am2", skill: "conductor", metadata: { conductor: { task_id: "ct1" } } }),
|
||||
]);
|
||||
const conductor = fromConductor(baseSnap); // contains conductor:task:ct1 as activity of r1
|
||||
const originalNode = conductor.nodes.find((n) => n.id === "conductor:task:ct1");
|
||||
assert.ok(originalNode, "conductor:task:ct1 must exist in the source part");
|
||||
const snap = mergeSnapshot({ cloudAgent: empty, a2a, conductor }, OK_SOURCES, { now: NOW });
|
||||
assert.equal(
|
||||
"mirrorOf" in (originalNode as object),
|
||||
false,
|
||||
"the original conductor input node must not be mutated"
|
||||
);
|
||||
const mergedNode = snap.nodes.find((n) => n.id === "conductor:task:ct1");
|
||||
assert.equal(mergedNode?.mirrorOf, "a2a:am2");
|
||||
});
|
||||
it("materializes an offline placeholder source node for a source reporting offline:true even when ok:true", () => {
|
||||
const src = [{ source: "conductor" as const, ok: true, offline: true }];
|
||||
const snap = mergeSnapshot({ cloudAgent: empty, a2a: empty, conductor: empty }, src, {
|
||||
now: NOW,
|
||||
});
|
||||
const node = snap.nodes.find((n) => n.id === "source:conductor");
|
||||
assert.ok(node, "offline placeholder source:conductor node expected");
|
||||
assert.equal(node?.sublabel, "offline");
|
||||
assert.ok(
|
||||
snap.edges.some(
|
||||
(e) => e.from === "orchestrator" && e.to === "source:conductor" && e.kind === "owns"
|
||||
)
|
||||
);
|
||||
});
|
||||
it("overflow node carries droppedByState with the per-state counts of dropped work nodes", () => {
|
||||
const many = Array.from({ length: MAX_WORK_NODES + 5 }, (_, i) =>
|
||||
caTask({
|
||||
id: `ov${i}`,
|
||||
status: i < MAX_WORK_NODES ? "running" : "failed",
|
||||
updatedAt: new Date(NOW - i * 1000).toISOString(),
|
||||
})
|
||||
);
|
||||
const snap = mergeSnapshot(
|
||||
{ cloudAgent: fromCloudAgent(many), a2a: empty, conductor: empty },
|
||||
OK_SOURCES,
|
||||
{ now: NOW }
|
||||
);
|
||||
const overflow = snap.nodes.find((n) => n.id === "overflow:cloud-agent");
|
||||
assert.ok(overflow, "overflow node expected");
|
||||
assert.deepEqual(overflow?.droppedByState, { failed: 5 });
|
||||
});
|
||||
it("drops a stale terminal Conductor task older than STALE_COMPLETED_MS unless showCompleted", () => {
|
||||
const staleSnap: FleetSnapshot = {
|
||||
...baseSnap,
|
||||
runners: [],
|
||||
tasks: [
|
||||
{
|
||||
...baseSnap.tasks[0],
|
||||
id: "ctOld",
|
||||
status: "completed",
|
||||
runner: null,
|
||||
updated_at: new Date(NOW - STALE_COMPLETED_MS - 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const parts = { cloudAgent: empty, a2a: empty, conductor: fromConductor(staleSnap) };
|
||||
assert.ok(
|
||||
!mergeSnapshot(parts, OK_SOURCES, { now: NOW }).nodes.some(
|
||||
(n) => n.id === "conductor:task:ctOld"
|
||||
)
|
||||
);
|
||||
assert.ok(
|
||||
mergeSnapshot(parts, OK_SOURCES, { now: NOW, showCompleted: true }).nodes.some(
|
||||
(n) => n.id === "conductor:task:ctOld"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
/** Run: node --import tsx/esm --test tests/unit/ui/orchestrationToFlow.test.ts */
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { orchestrationToFlow } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts";
|
||||
import type { OrchSnapshot } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts";
|
||||
|
||||
const snap: OrchSnapshot = {
|
||||
nodes: [
|
||||
{ id: "orchestrator", kind: "orchestrator", label: "OmniRoute" },
|
||||
{ id: "source:a2a", kind: "source", source: "a2a", label: "A2A" },
|
||||
{ id: "a2a:t1", kind: "work", source: "a2a", state: "running", label: "smart-routing" },
|
||||
{ id: "a2a:t2", kind: "work", source: "a2a", state: "failed", label: "cost-analysis" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", from: "orchestrator", to: "source:a2a", kind: "owns", active: false },
|
||||
{ id: "e2", from: "source:a2a", to: "a2a:t1", kind: "owns", active: true },
|
||||
{ id: "e3", from: "source:a2a", to: "a2a:t2", kind: "owns", active: false },
|
||||
],
|
||||
sources: [],
|
||||
generatedAt: "2026-08-30T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("orchestrationToFlow", () => {
|
||||
it("puts each kind on its own Y layer and is deterministic", () => {
|
||||
const a = orchestrationToFlow(snap);
|
||||
const b = orchestrationToFlow(snap);
|
||||
assert.deepEqual(
|
||||
a.nodes.map((n) => n.position),
|
||||
b.nodes.map((n) => n.position)
|
||||
);
|
||||
const ys = new Map(a.nodes.map((n) => [n.id, n.position.y]));
|
||||
assert.equal(ys.get("orchestrator"), 0);
|
||||
assert.equal(ys.get("source:a2a"), 150);
|
||||
assert.equal(ys.get("a2a:t1"), 320);
|
||||
});
|
||||
it("active edge is animated; edge to failed work is red", () => {
|
||||
const { edges } = orchestrationToFlow(snap);
|
||||
assert.equal(edges.find((e) => e.id === "e2")?.animated, true);
|
||||
const failedEdge = edges.find((e) => e.id === "e3");
|
||||
assert.equal((failedEdge?.style as { stroke?: string })?.stroke, "#ef4444");
|
||||
});
|
||||
it("fitKey only tracks the set of work ids", () => {
|
||||
const k1 = orchestrationToFlow(snap).fitKey;
|
||||
const stateChanged = {
|
||||
...snap,
|
||||
nodes: snap.nodes.map((n) => (n.id === "a2a:t1" ? { ...n, state: "succeeded" as const } : n)),
|
||||
};
|
||||
assert.equal(orchestrationToFlow(stateChanged).fitKey, k1);
|
||||
const nodeRemoved = {
|
||||
...snap,
|
||||
nodes: snap.nodes.filter((n) => n.id !== "a2a:t2"),
|
||||
edges: snap.edges.filter((e) => e.to !== "a2a:t2"),
|
||||
};
|
||||
assert.notEqual(orchestrationToFlow(nodeRemoved).fitKey, k1);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
/** Run: node --import tsx/esm --test tests/unit/ui/overviewProjection.test.ts */
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { overviewProjection } from "../../../src/app/(dashboard)/dashboard/orchestration/model/overviewProjection.ts";
|
||||
import type { OrchSnapshot } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts";
|
||||
|
||||
const snap: OrchSnapshot = {
|
||||
nodes: [
|
||||
{ id: "orchestrator", kind: "orchestrator", label: "OmniRoute" },
|
||||
{ id: "cloud-agent:1", kind: "work", source: "cloud-agent", state: "running", label: "a" },
|
||||
{
|
||||
id: "cloud-agent:2",
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state: "waiting_approval",
|
||||
label: "b",
|
||||
},
|
||||
{
|
||||
id: "a2a:3",
|
||||
kind: "work",
|
||||
source: "a2a",
|
||||
state: "failed",
|
||||
label: "c",
|
||||
updatedAt: "2026-08-30T11:00:00Z",
|
||||
},
|
||||
{ id: "a2a:3:activity", kind: "activity", source: "a2a", state: "running", label: "noise" },
|
||||
],
|
||||
edges: [],
|
||||
sources: [],
|
||||
generatedAt: "2026-08-30T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("overviewProjection", () => {
|
||||
it("counts only work nodes and adds comboActive to running", () => {
|
||||
const { counts } = overviewProjection(snap, 4);
|
||||
assert.equal(counts.running, 1 + 4);
|
||||
assert.equal(counts.waiting_approval, 1);
|
||||
assert.equal(counts.failed, 1);
|
||||
assert.equal(counts.queued, 0);
|
||||
});
|
||||
it("folds terminals into the done column", () => {
|
||||
const { columns } = overviewProjection(snap, 0);
|
||||
assert.equal(columns.done.length, 1);
|
||||
assert.equal(columns.done[0].id, "a2a:3");
|
||||
assert.equal(columns.running.length, 1);
|
||||
});
|
||||
it("folds an overflow node's droppedByState into counts but not into columns", () => {
|
||||
const snapWithOverflow: OrchSnapshot = {
|
||||
...snap,
|
||||
nodes: [
|
||||
...snap.nodes,
|
||||
{
|
||||
id: "overflow:cloud-agent",
|
||||
kind: "overflow",
|
||||
source: "cloud-agent",
|
||||
label: "+7 more",
|
||||
droppedByState: { running: 5, failed: 2 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const { counts, columns } = overviewProjection(snapWithOverflow, 0);
|
||||
assert.equal(counts.running, 1 + 5);
|
||||
assert.equal(counts.failed, 1 + 2);
|
||||
assert.equal(columns.running.length, 1);
|
||||
assert.equal(columns.done.length, 1);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Capture the onEvent handler the hook registers on the requests channel.
|
||||
let capturedOnEvent: ((p: { channel: string }) => void) | null = null;
|
||||
vi.mock("@/hooks/useLiveDashboard", () => ({
|
||||
useLiveDashboard: (opts: { onEvent?: (p: { channel: string }) => void }) => {
|
||||
capturedOnEvent = opts.onEvent ?? null;
|
||||
return { connection: { isConnected: true }, events: [] };
|
||||
},
|
||||
}));
|
||||
|
||||
import { useOrchestrationSnapshot } from "@/app/(dashboard)/dashboard/orchestration/hooks/useOrchestrationSnapshot";
|
||||
|
||||
function HookProbe({
|
||||
onRender,
|
||||
}: {
|
||||
onRender: (v: ReturnType<typeof useOrchestrationSnapshot>) => void;
|
||||
}) {
|
||||
onRender(useOrchestrationSnapshot());
|
||||
return null;
|
||||
}
|
||||
|
||||
const okJson = (body: unknown) =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve(body) } as Response);
|
||||
|
||||
describe("useOrchestrationSnapshot", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("polls the three endpoints and builds a snapshot; a failed source keeps the last good data", async () => {
|
||||
let latest: ReturnType<typeof useOrchestrationSnapshot> | null = null;
|
||||
const task = {
|
||||
id: "t1",
|
||||
providerId: "devin",
|
||||
status: "running",
|
||||
prompt: "p",
|
||||
source: { repoName: "r", repoUrl: "https://x" },
|
||||
options: {},
|
||||
activities: [],
|
||||
createdAt: "2026-08-30T10:00:00Z",
|
||||
updatedAt: "2026-08-30T10:00:00Z",
|
||||
};
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.startsWith("/api/v1/agents/tasks")) return okJson({ data: [task] });
|
||||
if (url.startsWith("/api/a2a/tasks"))
|
||||
return okJson({ tasks: [], total: 0, limit: 200, offset: 0 });
|
||||
return okJson({ offline: false, runners: [], tasks: [] });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
onRender={(v) => {
|
||||
latest = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
expect(latest!.snapshot.nodes.some((n) => n.id === "cloud-agent:t1")).toBe(true);
|
||||
|
||||
// Second tick: cloud agent fails — node must survive from the last good photo, source marked stale.
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.startsWith("/api/v1/agents/tasks")) return Promise.reject(new Error("boom"));
|
||||
if (url.startsWith("/api/a2a/tasks"))
|
||||
return okJson({ tasks: [], total: 0, limit: 200, offset: 0 });
|
||||
return okJson({ offline: false, runners: [], tasks: [] });
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5_100);
|
||||
});
|
||||
expect(latest!.snapshot.nodes.some((n) => n.id === "cloud-agent:t1")).toBe(true);
|
||||
const st = latest!.snapshot.sources.find((s) => s.source === "cloud-agent");
|
||||
expect(st?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("a requests-channel WS event triggers a debounced immediate refetch", async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.startsWith("/api/v1/agents/tasks")) return okJson({ data: [] });
|
||||
if (url.startsWith("/api/a2a/tasks"))
|
||||
return okJson({ tasks: [], total: 0, limit: 200, offset: 0 });
|
||||
return okJson({ offline: false, runners: [], tasks: [] });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await act(async () => {
|
||||
root.render(<HookProbe onRender={() => {}} />);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
const callsAfterMount = fetchMock.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
capturedOnEvent?.({ channel: "requests" });
|
||||
capturedOnEvent?.({ channel: "requests" });
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_100);
|
||||
});
|
||||
// Two burst events → exactly ONE extra round of 3 fetches (debounce), not two.
|
||||
expect(fetchMock.mock.calls.length).toBe(callsAfterMount + 3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user