Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

### 🐛 Fixed

- **fix(dashboard): Logs auto-refresh now works even when the tab loads in the background** — the Logs page never auto-refreshed (only the manual Refresh button worked). The auto-refresh interval gated each tick on a visibility ref seeded once at mount and updated only by a `visibilitychange` event; when the tab mounted while the document reported `hidden` (background load, bfcache restore, embedded/Docker-proxied webviews) and no `visibilitychange` ever fired, the ref stayed `false` forever, so the interval ticked but never fetched. The tick now reads the live `document.visibilityState`, so polling self-heals as soon as the tab is visible — while still pausing when genuinely hidden. ([#3972](https://github.com/diegosouzapw/OmniRoute/issues/3972) — thanks @tjengbudi)
- **fix(providers): LLM7 (and BytePlus) now fetch the live `/models` catalog instead of a stale hardcoded list** — importing an LLM7 key surfaced only a small, outdated model list even though `GET https://api.llm7.io/v1/models` returns the full pro/standard catalog. Both providers carried a correct `modelsUrl` in the registry, but neither was classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route skipped the upstream probe and served the registry's 4 hardcoded entries (`source: "local_catalog"`). Added `llm7` and `byteplus` to `NAMED_OPENAI_STYLE_PROVIDERS` so the route probes `<baseUrl>/models` with the key and serves the live catalog, falling back to the local catalog only when the upstream fetch fails (so key import never breaks). ([#3976](https://github.com/diegosouzapw/OmniRoute/issues/3976) — thanks @FerLuisxd)
- **fix(resilience): respect connection cooldown stored as a numeric epoch (router kept hammering 429 accounts)** — the router kept dispatching to connections still inside their rate-limit cooldown, causing client timeouts and "connection cooldown isn't respected" reports. Root cause: `rate_limited_until` is a `TEXT` column, but the Antigravity full-quota path (`setConnectionRateLimitUntil`) persists a raw epoch **number**, which SQLite coerces to a numeric string like `"1781696905131.0"`. The account-selection predicate then did `new Date("1781696905131.0")` → `Invalid Date` → `NaN`, so `NaN > Date.now()` was false and the cooling connection was never skipped. The cooldown read predicates (`isAccountUnavailable`, `getEarliestRateLimitedUntil`, `filterAvailableAccounts`, `parseFutureDateMs`) now normalize numeric-epoch strings as well as ISO strings/Date/number via a shared `cooldownUntilMs()` helper — ISO behavior is unchanged. ([#3954](https://github.com/diegosouzapw/OmniRoute/issues/3954))
- **fix(compression/memory): stop memory + compression from poisoning the upstream prompt cache** — with compression and/or memory enabled, requests to caching providers (Anthropic-family) missed the prompt cache on every turn, multiplying cost. Two root causes: (1) memory injection prepended the retrieved memories — which **vary per user query** — at index 0 of the message array, shifting the entire cacheable prefix every turn; memory is now inserted just before the last user message when the request carries `cache_control` breakpoints, keeping the cacheable prefix (system prompt + prior turns) byte-stable. (2) the cache-aware `skipSystemPrompt` flag computed by `getCacheAwareStrategy()` was dropped by `selectCompressionStrategy()` (which can only return a mode), so the system prompt could still be compressed under caching; a new `resolveCacheAwareConfig()` now forces `preserveSystemPrompt` on for caching requests. ([#3936](https://github.com/diegosouzapw/OmniRoute/pull/3936), closes [#3890](https://github.com/diegosouzapw/OmniRoute/issues/3890) — thanks @xenstar / @diegosouzapw)
Expand Down
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"_rebaseline_2026_06_15_3929_vertex_media": "PR #3929 own growth: audioSpeech.ts 952->965 (+13) and videoGeneration.ts 1026->1078 (+52) = vertex/* media branches (Gemini TTS, Veo predictLongRunning poll) wired into the speech/video handlers; new logic lives in open-sse/executors/vertexMedia.ts (341, under cap). Cohesive media-provider feature.",
"_rebaseline_2026_06_15_3879_redact_thinking": "PR #3879 + #3921 reconcile: AddApiKeyModal.tsx 843->845 (+2 = merging #3879's CcCompatibleRequestDefaultsFields (context1m + opt-in redact-thinking toggle) into #3921's preset-input block in the cc-compatible settings group). Cohesive UI; not extractable.",
"_rebaseline_2026_06_15_3890_cache_preserve": "Issue #3890 own growth: chatCore.ts 5815->5823 (+8 = wire resolveCacheAwareConfig() into the compression apply step so the system prompt is never compressed in a caching context — honors the cache-aware skipSystemPrompt flag that selectCompressionStrategy could not carry). Cohesive cache-preservation guard at the existing compression chokepoint; not extractable.",
"_rebaseline_2026_06_16_3972_logs_autorefresh": "Issue #3972 own growth: RequestLoggerV2.tsx 1282->1287 (+5 = the auto-refresh interval now reads the live document.visibilityState each tick instead of a stale mount-time ref, plus a 3-line comment explaining the hidden-tab trap). Cohesive one-spot fix; structural shrink of this component tracked in #3501.",
"_rebaseline_2026_06_16_3976_llm7_byteplus_models": "Issue #3976 own growth: models/route.ts 2489->2494 (+5 = add llm7 + byteplus to NAMED_OPENAI_STYLE_PROVIDERS with an explanatory comment so the import route does a live <baseUrl>/models fetch instead of serving the stale hardcoded registry catalog). Structural shrink of this route tracked in #3789.",
"_rebaseline_2026_06_16_3954_cooldown_epoch": "Issue #3954 own growth: accountFallback.ts 1708->1727 (+19 = a shared cooldownUntilMs() normalizer + its use in isAccountUnavailable/getEarliestRateLimitedUntil/filterAvailableAccounts so a rate_limited_until persisted as a numeric-epoch string is honored, not parsed to NaN) and auth.ts 2216->2219 (+3 = parseFutureDateMs reuses cooldownUntilMs). Cohesive cooldown read-path hardening at the existing chokepoints; one helper, not extractable.",
"_rebaseline_2026_06_15_3938_perplexity_v218": "PR #3938 own growth: perplexity-web.ts 868->939 (+71 = rebuild buildPplxRequestBody to mirror the current www.perplexity.ai schematized request body — version 2.18, use_schematized_api + the full supported_block_use_cases list, dsl_query, shared requestId for frontend_uuid/client_search_results_cache_key, last_backend_uuid only on follow-ups — plus the x-perplexity-request-* / x-request-id headers replacing the stale X-App-ApiVersion pair that triggered HTTP 400). Cohesive upstream-schema sync in a single executor; not extractable.",
Expand Down Expand Up @@ -112,7 +113,7 @@
"src/lib/usage/providerLimits.ts": 949,
"src/lib/usage/usageHistory.ts": 854,
"src/shared/components/OAuthModal.tsx": 960,
"src/shared/components/RequestLoggerV2.tsx": 1282,
"src/shared/components/RequestLoggerV2.tsx": 1287,
"src/shared/components/analytics/charts.tsx": 1558,
"src/shared/constants/cliTools.ts": 875,
"src/shared/constants/pricing.ts": 1529,
Expand Down
7 changes: 6 additions & 1 deletion src/shared/components/RequestLoggerV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,12 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
if (intervalRef.current) clearInterval(intervalRef.current);
if (!selectedLog && shouldAutoRefresh(recording, limit, PAGE_SIZE)) {
intervalRef.current = setInterval(() => {
if (visibleRef.current) fetchLogs(false);
// #3972: read live visibility each tick, not a mount-time ref. A tab
// mounted while "hidden" with no later `visibilitychange` left the ref
// false forever, so auto-refresh never ran (only the manual button did).
if (typeof document === "undefined" || document.visibilityState === "visible") {
fetchLogs(false);
}
Comment on lines +289 to +291

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since this setInterval is registered inside a useEffect hook, it is guaranteed to execute only on the client side (browser environment) where document is always defined. Checking typeof document === "undefined" is redundant and can be safely removed to simplify the condition.

Suggested change
if (typeof document === "undefined" || document.visibilityState === "visible") {
fetchLogs(false);
}
if (document.visibilityState === "visible") {
fetchLogs(false);
}

}, refreshIntervalSec * 1000);
}
return () => {
Expand Down
144 changes: 144 additions & 0 deletions tests/unit/request-logger-autorefresh-visibility-3972.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// @vitest-environment jsdom
/**
* TDD regression for #3972: Logs page auto-refresh is broken — it never polls
* until the manual Refresh button is clicked.
*
* Root cause: the auto-refresh interval gated each tick on `visibleRef.current`,
* a ref seeded once at mount from `document.visibilityState` and only updated by
* a `visibilitychange` event. When the logs tab mounts while the document is
* reported "hidden" (background load, bfcache restore, embedded/proxied webviews)
* and no `visibilitychange` ever fires, the ref stays `false` forever — the
* interval ticks but never calls `fetchLogs`, so auto-refresh produces zero
* requests. The manual button (no gate) still works, matching the report.
*
* Fix: the tick reads the live `document.visibilityState` instead of the stale
* ref, so polling self-heals as soon as the tab is visible.
*
* This test mounts hidden, then flips visibility to "visible" WITHOUT dispatching
* a `visibilitychange` event, and asserts the 10s tick still polls.
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));

vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn(), refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/dashboard/logs",
}));

vi.mock("@/store/emailPrivacyStore", () => ({
default: () => ({ emailsVisible: true }),
}));

const RequestLoggerV2 = (await import("../../src/shared/components/RequestLoggerV2.tsx")).default;
const { DEFAULT_REFRESH_INTERVAL_SEC } = await import(
"../../src/shared/components/requestLoggerPreferences.ts"
);

function setVisibility(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
}

class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}

let callLogsRequests = 0;
let container: HTMLElement;
let root: Root;

beforeEach(() => {
callLogsRequests = 0;
localStorage.clear();
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/usage/call-logs")) {
callLogsRequests += 1;
return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/provider-nodes")) {
return Response.json({ nodes: [] });
}
if (url.startsWith("/api/logs/detail")) {
return Response.json({ enabled: false });
}
return Response.json({});
})
);
vi.useFakeTimers();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
setVisibility("visible");
});

describe("RequestLoggerV2 auto-refresh (#3972)", () => {
it("keeps polling on the interval when the tab becomes visible without a visibilitychange event", async () => {
// Mounts while the document reports "hidden" → resolveInitialVisibility() = false.
setVisibility("hidden");

await act(async () => {
root.render(<RequestLoggerV2 />);
});
// Settle the mount fetches (logs + provider-nodes + detail).
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});

const afterMount = callLogsRequests;
expect(afterMount).toBeGreaterThanOrEqual(1); // initial load fired

// Tab becomes visible, but NO `visibilitychange` event is dispatched — this is
// the trap: the old code's visibleRef would stay false forever.
setVisibility("visible");

// One auto-refresh interval tick (10s).
await act(async () => {
await vi.advanceTimersByTimeAsync(DEFAULT_REFRESH_INTERVAL_SEC * 1000);
});

expect(callLogsRequests).toBeGreaterThan(afterMount);
});

it("does not poll while the tab stays hidden (preserves the hidden-tab optimization)", async () => {
setVisibility("hidden");

await act(async () => {
root.render(<RequestLoggerV2 />);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const afterMount = callLogsRequests;

// Stays hidden across two ticks → must not poll.
await act(async () => {
await vi.advanceTimersByTimeAsync(DEFAULT_REFRESH_INTERVAL_SEC * 2000);
});

expect(callLogsRequests).toBe(afterMount);
});
});