mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
perf(health): short-TTL cache for GET /api/monitoring/health (#6553)
Merged — thank you, @developerjillur! Short-TTL (1s) cache for the frequently-polled GET /api/monitoring/health, invalidated on DELETE (circuit-breaker reset). Integrated into release/v3.8.47.
This commit is contained in:
@@ -26,6 +26,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
|
||||
- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`.
|
||||
- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `<select>` to enable Send, asserts `sendBtn.disabled === false` before clicking, and asserts the streamed SSE delta (`"Hello!"`) actually reached the response editor. Root cause on the detector side: `check-test-masking.mjs`'s tautology subcheck only compares base-vs-HEAD counts within a PR's own diff (`headExtTaut > baseExtTaut`) and no-ops locally when `GITHUB_BASE_SHA`/`GITHUB_BASE_REF` are unset ("sem base ref — pulando") — so a tautology merged once, or checked with a bare local run, was invisible forever after. Added a new always-on, PR-independent absolute-floor scan (`scanBareTautologies` + `countBareTautologies`) over every git-tracked test file for the bare `expect(true).toBe(true)` / `assert.equal(1,1)` / `assert.strictEqual(1,1)` patterns specifically (deliberately excluding `assert.ok(true)`, which has ~15 pre-existing verified-legitimate try/catch-fallback uses repo-wide and stays governed by the lenient diff-only subcheck) — verified zero pre-existing hits repo-wide once this file was fixed, so the new floor is safe to enforce unconditionally. Regression guard: `tests/unit/check-test-masking.test.ts` (new `scanBareTautologies`/`countBareTautologies` cases) + `tests/unit/ui/playground-api-tab.test.tsx`. (thanks @chirag127)
|
||||
- **fix(oauth):** Codex/ChatGPT (and every other OAuth provider) connection stays stuck showing "Auth Failed" even after a genuinely successful token refresh ([#6352](https://github.com/diegosouzapw/OmniRoute/issues/6352)) — `updateProviderCredentials()` (the shared `onPersist` callback for the manual "Refresh token" route, the reactive per-request refresh in `chat.ts`, and the Codex/Claude auth-file importers) correctly reused the stored `refresh_token`, persisted the new `access_token`, and replaced a rotated `refresh_token`, but never cleared the stale `testStatus`/`lastError*`/`errorCode` fields left over from a prior expired/invalid refresh or upstream 401/403 — only the separate background health-check sweep did that clearing. A successful refresh now resets `testStatus` to `"active"` and clears `lastError`, `lastErrorAt`, `lastErrorType`, `lastErrorSource`, and `errorCode` (an explicit `testStatus` from the caller still wins). Regression guard: `tests/unit/codex-oauth-refresh-persist-6352.test.ts`.
|
||||
- **perf(health):** short-TTL (1s) cache for the frequently-polled `GET /api/monitoring/health` payload — rebuilding it every request (DB reads + status aggregation across 8 subsystems) was wasteful under rapid polling; the cache stays near-real-time and is invalidated immediately on `DELETE` (circuit-breaker reset) so a manual reset is reflected at once. Regression guard: `tests/integration/monitoring-health-cache.test.ts`. ([#6553](https://github.com/diegosouzapw/OmniRoute/pull/6553)) — see PR. (thanks @developerjillur)
|
||||
- **fix(resilience):** `headroom` combo routing did not always select the Codex account with the most free quota ([#6379](https://github.com/diegosouzapw/OmniRoute/issues/6379)) — `orderTargetsByHeadroom` (`open-sse/services/combo/quotaStrategies.ts`) already loaded the per-connection DB snapshot (with decrypted credentials) via `expandTargetsByQuotaAwareConnections`, but discarded it before calling `getSaturation`; for Codex, `fetchCodexSaturation` forwards straight to `fetchCodexQuota(connectionId, connection)`, which needs `connection` (or a prior `registerCodexConnection()` call, which never happens before headroom ranking runs) to read `accessToken` — so it returned `null` for every candidate, saturation failed open to `0` across the board, and ranking fell back to the original combo order regardless of actual free quota. `getSaturation()` and the headroom `SaturationFetcher` seam now accept and thread the loaded connection snapshot through to `fetchCodexQuota`. Kilo's dup flag vs #5903 was a false positive — that issue is about session-sticky reset-aware/least-used selection, not headroom's Codex saturation lookup. Regression guard: `tests/unit/headroom-codex-quota-snapshot-6379.test.ts`. (thanks @eidoog)
|
||||
- **fix(providers):** custom models a provider actually has are no longer dropped from the Free Provider Rankings when both **"Configured only"** and **"Available only"** filters are applied ([#6368](https://github.com/diegosouzapw/OmniRoute/issues/6368)), follow-up to #6150 — `freeProviderRankings.ts::getProviderModels()` only ever walked the static `open-sse/config/providerRegistry.ts` catalog, so a user-added custom model (e.g. a Puter `claude-fable-5` model saved as "Claude Fable 5") never entered the candidate model list the ranking scores against, and could never survive the #6150 configured/available filters even when actually configured and available. It now additively merges the provider's custom models (`db/models.ts::getCustomModels`) into that candidate list via a new pure, de-duping `mergeProviderModels()` helper, before scoring/filtering runs — catalog free/paid filtering elsewhere is untouched. Regression guard: `tests/unit/free-provider-rankings-custom-models-6368.test.ts`. (thanks @shabeer)
|
||||
- **fix(providers):** `cloudflare-ai` no longer silently drops image/non-text content parts ([#6390](https://github.com/diegosouzapw/OmniRoute/issues/6390)) — `transformRequest()`'s `flattenContent()` (added for #2539 to satisfy the Workers AI `/ai/v1/chat/completions` plain-string `content` requirement) mapped any non-text OpenAI content part (e.g. `image_url`) to `""` and joined the rest, so a request carrying an image quietly went out as text-only with the attachment gone and no error surfaced. It now throws a clear error on the first non-text part instead of dropping it silently, which the existing top-level `chatCore.ts` catch already routes through `buildErrorBody()`/`sanitizeErrorMessage()` (same pattern as `buildUrl()`'s missing-Account-ID error). Regression guard: `tests/unit/cloudflare-ai-image-parts-6390.test.ts`.
|
||||
|
||||
@@ -11,7 +11,20 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* Returns system info, provider health (circuit breakers),
|
||||
* rate limit status, and database stats.
|
||||
*/
|
||||
// §8.2 optimization: short-TTL cache for the health payload. Health is a
|
||||
// frequently-polled endpoint and rebuilding it every request (DB reads +
|
||||
// status aggregation across 8 subsystems) is wasteful under rapid polling. 1s
|
||||
// stays near-real-time for monitoring; the cache is invalidated on DELETE
|
||||
// (circuit-breaker reset) so a manual reset is reflected immediately.
|
||||
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
|
||||
const HEALTH_PAYLOAD_TTL_MS = 1000;
|
||||
|
||||
export async function GET() {
|
||||
const cachedNow = Date.now();
|
||||
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
|
||||
return NextResponse.json(healthPayloadCache.payload);
|
||||
}
|
||||
|
||||
const readHealthValue = <T>(label: string, reader: () => T, fallback: T): T => {
|
||||
try {
|
||||
return reader();
|
||||
@@ -158,6 +171,7 @@ export async function GET() {
|
||||
credentialHealth,
|
||||
});
|
||||
|
||||
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
@@ -196,6 +210,7 @@ export async function DELETE(request: Request) {
|
||||
const resetCount = before.length;
|
||||
|
||||
resetAllCircuitBreakers();
|
||||
healthPayloadCache = null; // a reset just happened — don't serve a stale GET snapshot
|
||||
|
||||
console.log(`[API] DELETE /api/monitoring/health — Reset ${resetCount} circuit breakers`);
|
||||
|
||||
|
||||
63
tests/integration/monitoring-health-cache.test.ts
Normal file
63
tests/integration/monitoring-health-cache.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Integration test for the short-TTL cache on GET /api/monitoring/health.
|
||||
*
|
||||
* Health is a frequently-polled endpoint; rebuilding it every request (DB reads
|
||||
* + status aggregation across subsystems) is wasteful under rapid polling. The
|
||||
* route caches the payload for HEALTH_PAYLOAD_TTL_MS (1s) and invalidates it on
|
||||
* DELETE (circuit-breaker reset). We assert the behavior via the payload's
|
||||
* `timestamp` field, which is stamped at build time: identical timestamp ⇒ the
|
||||
* cached payload was served; a fresh timestamp ⇒ it was rebuilt.
|
||||
*/
|
||||
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-health-cache-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
process.env.JWT_SECRET = "test-health-cache-secret";
|
||||
|
||||
await import("../../src/lib/db/core.ts");
|
||||
const { GET, DELETE } = await import("../../src/app/api/monitoring/health/route.ts");
|
||||
|
||||
async function healthTimestamp(): Promise<string> {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { timestamp?: string };
|
||||
assert.ok(body.timestamp, "health payload should carry a timestamp");
|
||||
return body.timestamp as string;
|
||||
}
|
||||
|
||||
test("GET within the TTL serves the cached payload (identical timestamp)", async () => {
|
||||
const t1 = await healthTimestamp();
|
||||
const t2 = await healthTimestamp();
|
||||
assert.equal(t2, t1, "a second GET within the TTL must return the cached payload");
|
||||
});
|
||||
|
||||
test("cache expires after the TTL — a fresh payload is built", async () => {
|
||||
const t1 = await healthTimestamp();
|
||||
await new Promise((r) => setTimeout(r, 1100)); // TTL is 1000ms
|
||||
const t2 = await healthTimestamp();
|
||||
assert.notEqual(t2, t1, "after the 1s TTL the payload must be rebuilt");
|
||||
});
|
||||
|
||||
test("DELETE (circuit-breaker reset) invalidates the cache immediately", async () => {
|
||||
const { SignJWT } = await import("jose");
|
||||
const authToken = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("30d")
|
||||
.sign(new TextEncoder().encode(process.env.JWT_SECRET as string));
|
||||
|
||||
const t1 = await healthTimestamp(); // populate cache
|
||||
const delRes = await DELETE(
|
||||
new Request("http://localhost/api/monitoring/health", {
|
||||
method: "DELETE",
|
||||
headers: { cookie: `auth_token=${authToken}` },
|
||||
}),
|
||||
);
|
||||
assert.ok(delRes.status < 400, `DELETE should succeed, got ${delRes.status}`);
|
||||
await new Promise((r) => setTimeout(r, 5)); // ensure the clock advances past ms precision
|
||||
const t2 = await healthTimestamp();
|
||||
assert.notEqual(t2, t1, "a GET right after DELETE must rebuild (cache invalidated)");
|
||||
});
|
||||
Reference in New Issue
Block a user