diff --git a/changelog.d/fixes/9970-credential-health-search-provider-exclusion.md b/changelog.d/fixes/9970-credential-health-search-provider-exclusion.md new file mode 100644 index 0000000000..04aef65204 --- /dev/null +++ b/changelog.d/fixes/9970-credential-health-search-provider-exclusion.md @@ -0,0 +1 @@ +- fix(sse): exclude search providers from credential-health scheduler sweep to stop burning billed API queries (#9970) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index fbaa8357b8..3b037282e7 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -146,7 +146,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | | `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | | `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | -| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. | +| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. Search providers (`SEARCH_VALIDATOR_CONFIGS` in `src/lib/providers/validation/searchProviders.ts`, e.g. `tavily-search`) are always excluded from the sweep — their "validation" is a real billed upstream query, so they are never health-checked on a timer (#9970). | | `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | | `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. **Do not use for `omniroute serve`** — use `OMNIROUTE_SERVER_HOST` instead (POSIX shells auto-set `HOSTNAME` to the machine name; `.env` cannot override it). | | `OMNIROUTE_SERVER_HOST` | `0.0.0.0` | `bin/cli/commands/serve.mjs` | Bind address for `omniroute serve`. Avoids collision with the POSIX shell `HOSTNAME` variable (always set to the machine name by bash/zsh). Falls back to `0.0.0.0` when unset. (#6194) | diff --git a/src/lib/credentialHealth/scheduler.ts b/src/lib/credentialHealth/scheduler.ts index fa4d78614d..6207a26549 100644 --- a/src/lib/credentialHealth/scheduler.ts +++ b/src/lib/credentialHealth/scheduler.ts @@ -25,6 +25,7 @@ import { } from "@/lib/credentialHealth/cache"; import { emit } from "@/lib/events/eventBus"; import { isAutomatedTestProcess } from "@/shared/utils/testProcess"; +import { SEARCH_VALIDATOR_CONFIGS } from "@/lib/providers/validation/searchProviders"; // ── Config ──────────────────────────────────────────────────────────────── @@ -230,7 +231,13 @@ export async function sweep(): Promise { try { const raw = await getProviderConnections({ isActive: true }); connections = (Array.isArray(raw) ? raw : []).filter( - (conn: any) => conn && conn.id && (conn.authType === "apikey" || conn.authType === "oauth") + (conn: any) => + conn && + conn.id && + (conn.authType === "apikey" || conn.authType === "oauth") && + // #9970: search-provider "validation" fires a REAL billed upstream + // query (e.g. POST api.tavily.com/search) — never sweep these. + !(conn.provider in SEARCH_VALIDATOR_CONFIGS) ) as Array<{ id: string; provider: string; diff --git a/tests/unit/credential-health-search-providers.test.ts b/tests/unit/credential-health-search-providers.test.ts new file mode 100644 index 0000000000..2d73da1bdb --- /dev/null +++ b/tests/unit/credential-health-search-providers.test.ts @@ -0,0 +1,96 @@ +/** + * Regression test for #9970 — credential-health scheduler burns real billed + * API queries for search providers. + * + * Search-provider "validation" (SEARCH_VALIDATOR_CONFIGS, e.g. tavily-search) + * issues a real upstream query (POST api.tavily.com/search) rather than a + * cheap auth probe. The scheduler's periodic sweep() must exclude connections + * whose provider id is registered in SEARCH_VALIDATOR_CONFIGS so it never + * fires a billed query on a timer. + * + * Mirrors the source-inspection style of + * tests/unit/credential-health-active-connections-9180.test.ts. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const schedulerSource = fs.readFileSync( + new URL("../../src/lib/credentialHealth/scheduler.ts", import.meta.url), + "utf8" +); + +const searchProvidersSource = fs.readFileSync( + new URL("../../src/lib/providers/validation/searchProviders.ts", import.meta.url), + "utf8" +); + +function getSweepConnectionSelection(): string { + const start = schedulerSource.indexOf("export async function sweep(): Promise"); + assert.notEqual(start, -1, "credential-health sweep must exist"); + + const end = schedulerSource.indexOf("\n if (connections.length === 0) return;", start); + assert.notEqual(end, -1, "credential-health connection-selection block must exist"); + + return schedulerSource.slice(start, end); +} + +test("#9970 scheduler imports SEARCH_VALIDATOR_CONFIGS to classify billed-query providers", () => { + assert.match( + schedulerSource, + /import\s*\{\s*SEARCH_VALIDATOR_CONFIGS\s*\}\s*from\s*"@\/lib\/providers\/validation\/searchProviders"/, + "scheduler.ts must import SEARCH_VALIDATOR_CONFIGS from the search-provider validators module" + ); +}); + +test("#9970 sweep() excludes search providers from the connection-selection filter", () => { + const selection = getSweepConnectionSelection(); + + assert.match( + selection, + /SEARCH_VALIDATOR_CONFIGS/, + "sweep()'s connection-selection block must reference SEARCH_VALIDATOR_CONFIGS to exclude search providers" + ); + + assert.match( + selection, + /!\(conn\.provider in SEARCH_VALIDATOR_CONFIGS\)/, + "sweep() must filter out connections whose provider id is a registered search-validator provider" + ); +}); + +test("#9970 sweep() still keeps API-key + OAuth eligibility intact (no regression on #9180)", () => { + const selection = getSweepConnectionSelection(); + + assert.match( + selection, + /getProviderConnections\(\{\s*isActive:\s*true\s*\}\)/, + "the scheduler must still request only active provider connections" + ); + + assert.match( + selection, + /conn\.authType === "apikey"/, + "API-key connections must remain eligible" + ); + + assert.match(selection, /conn\.authType === "oauth"/, "OAuth connections must remain eligible"); +}); + +test("#9970 trust anchor: SEARCH_VALIDATOR_CONFIGS providers target real billed upstream endpoints", () => { + // Sanity-check the assumption driving the fix: the search validators really + // do fire live upstream queries (not just an auth ping), so excluding them + // from the periodic sweep is the correct trade-off. + assert.match( + searchProvidersSource, + /api\.tavily\.com\/search/, + "tavily-search validator must target the real Tavily search endpoint" + ); + + assert.match( + searchProvidersSource, + /export const SEARCH_VALIDATOR_CONFIGS/, + "SEARCH_VALIDATOR_CONFIGS must be exported so the scheduler can reference it" + ); +});