feat(routing): read-only auto/* candidate transparency + per-API-key exclusions (#7819) (#7839)

Level 1: GET /v1/auto-combo/{channel}/candidates lists an auto/* channel's
candidate pool with live reachability (provider circuit breaker via
getStatus()/canExecute(), connection cooldown, model lockout).

Level 2: per-API-key candidate exclusions, persisted in a new
auto_candidate_overrides table and enforced at the virtualFactory.ts
candidate-pool chokepoint via a pure, fail-open filter — mirrors the #7622/
#7646 precedent exactly (zero touches to the frozen combo.ts god-file).

Levels 3 (weights/ordering) and 4 (policy pin) are deferred to a follow-up
issue, as is the dashboard UI (Step 4) and its i18n strings.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-20 10:09:01 -03:00
committed by GitHub
parent 6770a57131
commit 51b118c2d3
16 changed files with 753 additions and 5 deletions

View File

@@ -0,0 +1 @@
- feat(routing): add a read-only `GET /v1/auto-combo/{channel}/candidates` endpoint listing an `auto/*` channel's candidate pool with live reachability (provider circuit breaker, connection cooldown, model lockout), plus a per-API-key candidate exclusion list enforced in the `auto/*` candidate-pool builder — users with no overrides configured see byte-identical routing to today (#7819).

View File

@@ -296,7 +296,8 @@
"src/lib/tokenHealthCheck.ts": 832,
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.",
"_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.",
"src/lib/localDb.ts": 807,
"_rebaseline_2026_07_20_7819_autocandidateoverrides_reexport": "PR for #7819 (Level 1+2: read-only auto/* candidate transparency + per-API-key exclusions) own growth: localDb.ts 807->808 (+1). Adds a single `export * from \"./db/autoCandidateOverrides\"` barrel re-export (hard rule #2 — no logic) for the new DB module backing per-apiKey candidate exclusions. Irreducible for a re-export list; frozen so it can only shrink.",
"src/lib/localDb.ts": 808,
"_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable)."
},
"testCap": 800,

View File

@@ -76,6 +76,34 @@ model: "auto/cheap" # cheapest per token
-**Multi-account aware:** Each provider connection becomes a separate candidate
-**No DB writes:** Virtual combo exists only for the request, zero persistence overhead
### Per-key candidate control (#7819, Level 1+2)
`GET /v1/auto-combo/{channel}/candidates` (`{channel}` = the suffix after `auto/`, or
the literal `auto` for the base channel) is a **read-only** endpoint that lists an
`auto/*` channel's current candidate pool decorated with live reachability, reusing
the existing resilience reads (never raw breaker `state`):
- provider circuit breaker — `getCircuitBreaker(provider).getStatus()` / `.canExecute()`
- connection cooldown — `rateLimitedUntil` / `testStatus` on the resolved
`provider_connections` row
- model lockout — `isModelLocked(provider, connectionId, model)`
Each candidate also carries this API key's `excluded` flag. Exclusions are stored
per-API-key (`auto_candidate_overrides` table, migration `128`) — OmniRoute is
single-tenant with no `users` table, so `apiKeyId` is the closest real per-caller
identity — and enforced at the candidate-pool chokepoint in
`open-sse/services/autoCombo/virtualFactory.ts` via the pure, unit-tested
`filterExcludedCandidates()` (`open-sse/services/autoCombo/candidateOverrides.ts`).
The filter is **fail-open**: an unset apiKeyId/channel or a DB lookup failure both
leave the pool unfiltered, so an operator with no overrides configured sees routing
byte-identical to before this feature.
**Deferred to a follow-up issue:** per-candidate weights + explicit ordering (Level 3
— feeds into the existing weighted/priority strategy paths) and pinning a specific
`combo.ts` strategy per `auto/*` channel (Level 4). See the #7819 plan for the open
question on whether overrides should stay per-API-key or become global given the
single-tenant model.
**Behind the scenes:**
```txt

View File

@@ -0,0 +1,146 @@
/**
* #7819 (Level 1) — read-only candidate pool + reachability listing for an
* `auto/*` channel.
*
* Builds the SAME candidate pool `virtualFactory.createVirtualAutoCombo` uses
* for routing (via `createBuiltinAutoCombo`, unfiltered by any per-key
* exclusion so the operator can see — and toggle — excluded candidates), then
* decorates each candidate with live reachability derived from the existing
* resilience reads (CLAUDE.md "Resilience Runtime State"):
* - provider circuit breaker: `getCircuitBreaker(provider).getStatus()` /
* `.canExecute()` — NEVER raw `state`, so an expired breaker (lazy
* recovery) doesn't show as permanently open.
* - connection cooldown: `rateLimitedUntil` / `testStatus` on the resolved
* provider_connections row (no-auth synthetic connections have no row —
* treated as always reachable on this axis).
* - model lockout: `isModelLocked(provider, connectionId, model)`.
*/
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import { getCircuitBreaker } from "@/shared/utils/circuitBreaker";
import { isModelLocked } from "@omniroute/open-sse/services/accountFallback.ts";
import { getProviderConnectionById } from "@/lib/db/providers";
import { getExcludedConnectionIds } from "@/lib/db/autoCandidateOverrides";
export interface AutoComboCandidateView {
provider: string;
connectionId: string;
model: string;
modelStr: string;
excluded: boolean;
reachable: boolean;
breakerState: string;
connectionCooldown: boolean;
modelLocked: boolean;
}
export interface AutoComboCandidatesResult {
channel: string;
candidates: AutoComboCandidateView[];
}
function hasFutureRateLimit(value: unknown): boolean {
if (value === null || value === undefined || value === "") return false;
const time = new Date(String(value)).getTime();
return Number.isFinite(time) && time > Date.now();
}
async function decorateCandidate(candidate: {
provider: string;
connectionId: string;
model: string;
modelStr: string;
}): Promise<AutoComboCandidateView> {
const breaker = getCircuitBreaker(candidate.provider);
const breakerStatus = breaker.getStatus();
const breakerReachable = breaker.canExecute();
let connectionCooldown = false;
if (candidate.connectionId && candidate.connectionId !== "noauth") {
try {
const connection = await getProviderConnectionById(candidate.connectionId);
connectionCooldown =
hasFutureRateLimit((connection as Record<string, unknown> | null)?.rateLimitedUntil) ||
(connection as Record<string, unknown> | null)?.testStatus === "unavailable";
} catch {
// Fail-open: an unresolved connection lookup should not mark a
// candidate unreachable — the panel is read-only transparency, not the
// dispatch path.
connectionCooldown = false;
}
}
const modelLocked = isModelLocked(candidate.provider, candidate.connectionId, candidate.model);
return {
provider: candidate.provider,
connectionId: candidate.connectionId,
model: candidate.model,
modelStr: candidate.modelStr,
excluded: false,
reachable: breakerReachable && !connectionCooldown && !modelLocked,
breakerState: String(breakerStatus.state),
connectionCooldown,
modelLocked,
};
}
/**
* Builds the candidate pool for `channel` (the suffix after "auto/", or the
* literal "auto" for the base channel) and decorates it with reachability +
* this API key's exclusion state. Read-only — never mutates routing state.
*/
export async function getAutoComboCandidates(
channel: string,
apiKeyId: string | null
): Promise<AutoComboCandidatesResult> {
const modelStr = channel === "auto" ? "auto" : `auto/${channel}`;
// The bare "auto" channel (no variant/spec overlay) is handled directly by
// virtualFactory — createBuiltinAutoCombo() only recognizes `auto/<suffix>`
// ids (matches classifyAutoModel()'s special-casing of the literal "auto"
// model string in src/sse/handlers/autoRouting.ts).
let virtualCombo;
if (channel === "auto") {
const { createVirtualAutoCombo } = await import(
"@omniroute/open-sse/services/autoCombo/virtualFactory.ts"
);
virtualCombo = await createVirtualAutoCombo(undefined);
} else {
const { createBuiltinAutoCombo } = await import(
"@omniroute/open-sse/services/autoCombo/builtinCatalog.ts"
);
virtualCombo = await createBuiltinAutoCombo(modelStr, channel);
}
const excludedConnectionIds = apiKeyId
? await getExcludedConnectionIds(apiKeyId, modelStr).catch(() => new Set<string>())
: new Set<string>();
const models: Array<{ providerId: string; connectionId: string; model: string }> =
Array.isArray(virtualCombo?.models) ? virtualCombo.models : [];
const candidates = await Promise.all(
models.map(async (candidate) => {
const decorated = await decorateCandidate({
provider: candidate.providerId,
connectionId: candidate.connectionId,
model: candidate.model,
modelStr: candidate.model,
});
return { ...decorated, excluded: excludedConnectionIds.has(candidate.connectionId) };
})
);
return { channel: modelStr, candidates };
}
/** Thrown by `getAutoComboCandidates` (via `createBuiltinAutoCombo`) when the
* channel is not a recognized built-in `auto/*` id — mapped to a 404 by the
* route handler. */
export function isUnknownAutoChannelError(err: unknown): boolean {
return err instanceof Error && err.message.startsWith("Unknown built-in auto combo");
}
export function buildCandidatesErrorBody(statusCode: number, message: string) {
return buildErrorBody(statusCode, message);
}

View File

@@ -0,0 +1,28 @@
/**
* #7819 (Level 2) — per-API-key candidate exclusions for `auto/*` channels.
*
* Pure, dependency-light filter kept separate from `virtualFactory.ts` so it
* is unit-testable in isolation, mirroring `paidModelFilter.ts` in this same
* directory (`filterPaidOnlyCandidates`). Fail-open by design: an empty
* exclusion set is the identity function (near-zero overhead on the
* unconfigured hot path), and any caller-side lookup failure should pass the
* candidate pool through unfiltered rather than break routing.
*/
interface OverridableCandidate {
connectionId: string;
}
/**
* Return the candidate pool with excluded connection IDs removed. Returns
* the SAME array reference (identity) when there is nothing to filter, so
* callers can cheaply detect "unchanged" the same way
* `filterPaidOnlyCandidates` does.
*/
export function filterExcludedCandidates<T extends OverridableCandidate>(
pool: T[],
excludedConnectionIds: Set<string>
): T[] {
if (!excludedConnectionIds || excludedConnectionIds.size === 0) return pool;
return pool.filter((candidate) => !excludedConnectionIds.has(candidate.connectionId));
}

View File

@@ -20,6 +20,8 @@ import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
import { getHiddenModelsByProvider } from "@/models";
import { filterPaidOnlyCandidates } from "./paidModelFilter";
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
import { filterExcludedCandidates } from "./candidateOverrides";
import { getExcludedConnectionIds } from "@/lib/db/autoCandidateOverrides";
/** #4235 Phase B: optional category/tier overlay for `auto/<category>:<tier>` combos.
* #6453: optional `family` overlay for `auto/<family>` combos (e.g. `auto/glm`) —
@@ -277,7 +279,9 @@ export function computeAdvertisedLimits(candidates: Array<{ provider: string; mo
export async function createVirtualAutoCombo(
variant: AutoVariant | undefined,
spec?: AutoComboSpec
spec?: AutoComboSpec,
apiKeyId?: string,
autoChannel?: string
): Promise<VirtualAutoCombo> {
const [connections, disabledNoAuthConnections, settings] = await Promise.all([
getCachedProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
@@ -362,6 +366,24 @@ export async function createVirtualAutoCombo(
candidatePool.push(...paidFilteredPool);
}
// #7819 (Level 2): per-API-key candidate exclusions. Fail-open — an absent
// apiKeyId/autoChannel (every caller before #7819) or a DB lookup failure
// both leave the pool untouched, so default (unconfigured) routing stays
// byte-identical to pre-#7819 behavior.
let excludedConnectionIds: Set<string> = new Set();
if (apiKeyId && autoChannel) {
try {
excludedConnectionIds = await getExcludedConnectionIds(apiKeyId, autoChannel);
} catch (err) {
log.warn("AUTO", "Failed to load auto-candidate overrides; routing unfiltered", { err });
}
}
const overrideFilteredPool = filterExcludedCandidates(candidatePool, excludedConnectionIds);
if (overrideFilteredPool !== candidatePool) {
candidatePool.length = 0;
candidatePool.push(...overrideFilteredPool);
}
if (candidatePool.length === 0) {
log.warn("AUTO", "No connected providers with valid credentials for virtual auto-combo");
const emptyPool: string[] = [];

View File

@@ -0,0 +1,60 @@
/**
* GET /api/v1/auto-combo/[channel]/candidates — #7819 Level 1: read-only
* candidate pool + live reachability for one `auto/*` channel, decorated
* with this API key's exclusion state (#7819 Level 2).
*
* `channel` is the suffix after "auto/" (e.g. "best-coding", "coding:free",
* "glm") or the literal "auto" for the base channel.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
import {
getAutoComboCandidates,
isUnknownAutoChannelError,
} from "@omniroute/open-sse/handlers/autoComboCandidates.ts";
const channelParamSchema = z
.string()
.min(1)
.max(128)
.regex(/^[a-zA-Z0-9:_-]+$/, "channel must be a simple auto/* suffix");
export async function OPTIONS() {
return handleCorsOptions();
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ channel: string }> }
) {
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
const { channel: rawChannel } = await params;
const parsedChannel = channelParamSchema.safeParse(rawChannel);
if (!parsedChannel.success) {
return NextResponse.json(buildErrorBody(400, "Invalid auto channel"), {
status: 400,
headers: CORS_HEADERS,
});
}
try {
const result = await getAutoComboCandidates(parsedChannel.data, scope.apiKeyId);
return NextResponse.json(result, { headers: CORS_HEADERS });
} catch (err) {
if (isUnknownAutoChannelError(err)) {
return NextResponse.json(buildErrorBody(404, "Unknown auto channel"), {
status: 404,
headers: CORS_HEADERS,
});
}
return NextResponse.json(
buildErrorBody(500, err instanceof Error ? err.message : "Failed to list candidates"),
{ status: 500, headers: CORS_HEADERS }
);
}
}

View File

@@ -0,0 +1,117 @@
/**
* db/autoCandidateOverrides.ts — Per-API-key candidate exclusions for `auto/*`
* channels (#7819, Level 2 of the "per-user candidate control" feature).
*
* OmniRoute is single-tenant (no `users` table, no `user_id`/`userId` column
* anywhere under `src/lib/db/`) — `apiKeyId` is the closest real per-caller
* identity this app has, so overrides are keyed by (apiKeyId, autoChannel,
* connectionId) rather than "per user". See the Open Question in the #7819
* plan for the follow-up decision on whether this should instead be global.
*
* Mirrors the relational style of `reasoningRoutingRules.ts` / `apiKeyGroups.ts`
* rather than the key_value JSON-blob pattern — exclusions are a simple
* row-per-candidate set, which is simpler to index and reason about than a
* JSON array column.
*/
import { randomUUID } from "node:crypto";
import { getDbInstance } from "./core";
export interface AutoCandidateOverride {
id: string;
apiKeyId: string;
autoChannel: string;
connectionId: string;
excluded: boolean;
createdAt: string;
}
type OverrideRow = {
id: string;
api_key_id: string;
auto_channel: string;
connection_id: string;
excluded: number;
created_at: string;
};
function rowToOverride(row: OverrideRow): AutoCandidateOverride {
return {
id: row.id,
apiKeyId: row.api_key_id,
autoChannel: row.auto_channel,
connectionId: row.connection_id,
excluded: row.excluded === 1,
createdAt: row.created_at,
};
}
/**
* Returns the set of connection IDs excluded by this API key for this auto
* channel. Empty set when no overrides exist (the default, unconfigured
* path) — callers should treat an empty set as "no filtering needed" rather
* than iterating a lookup for every candidate.
*/
export async function getExcludedConnectionIds(
apiKeyId: string,
autoChannel: string
): Promise<Set<string>> {
if (!apiKeyId || !autoChannel) return new Set();
const db = getDbInstance();
const rows = db
.prepare(
`SELECT connection_id FROM auto_candidate_overrides
WHERE api_key_id = ? AND auto_channel = ? AND excluded = 1`
)
.all(apiKeyId, autoChannel) as Array<{ connection_id: string }>;
return new Set(rows.map((row) => row.connection_id));
}
/**
* Sets (or clears) the excluded flag for one candidate connection, scoped to
* one API key + auto channel. Idempotent — re-setting the same value is a
* no-op write via UPSERT.
*/
export async function setExcluded(
apiKeyId: string,
autoChannel: string,
connectionId: string,
excluded: boolean
): Promise<AutoCandidateOverride> {
const db = getDbInstance();
const id = randomUUID();
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO auto_candidate_overrides
(id, api_key_id, auto_channel, connection_id, excluded, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(api_key_id, auto_channel, connection_id)
DO UPDATE SET excluded = excluded.excluded`
).run(id, apiKeyId, autoChannel, connectionId, excluded ? 1 : 0, createdAt);
const row = db
.prepare(
`SELECT id, api_key_id, auto_channel, connection_id, excluded, created_at
FROM auto_candidate_overrides
WHERE api_key_id = ? AND auto_channel = ? AND connection_id = ?`
)
.get(apiKeyId, autoChannel, connectionId) as OverrideRow;
return rowToOverride(row);
}
/** Lists all override rows for one API key + auto channel (excluded and not). */
export async function listOverrides(
apiKeyId: string,
autoChannel: string
): Promise<AutoCandidateOverride[]> {
if (!apiKeyId || !autoChannel) return [];
const db = getDbInstance();
const rows = db
.prepare(
`SELECT id, api_key_id, auto_channel, connection_id, excluded, created_at
FROM auto_candidate_overrides
WHERE api_key_id = ? AND auto_channel = ?
ORDER BY created_at ASC`
)
.all(apiKeyId, autoChannel) as OverrideRow[];
return rows.map(rowToOverride);
}

View File

@@ -0,0 +1,16 @@
-- #7819 (Level 2) — per-API-key candidate exclusions for `auto/*` channels.
-- OmniRoute is single-tenant (no `users` table); `api_key_id` is the closest
-- real per-caller identity this app has. One row per excluded candidate
-- connection for a given API key + auto channel.
CREATE TABLE IF NOT EXISTS auto_candidate_overrides (
id TEXT PRIMARY KEY,
api_key_id TEXT NOT NULL,
auto_channel TEXT NOT NULL,
connection_id TEXT NOT NULL,
excluded INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(api_key_id, auto_channel, connection_id)
);
CREATE INDEX IF NOT EXISTS idx_auto_candidate_overrides_key_channel
ON auto_candidate_overrides(api_key_id, auto_channel);

View File

@@ -315,6 +315,7 @@ export type { BatchItemCheckpoint, BatchRecord } from "./db/batches";
export type { ModelComboMapping } from "./db/modelComboMappings";
export * from "./db/reasoningRoutingRules";
export * from "./db/autoCandidateOverrides";
export {
// Webhooks
getWebhooks,

View File

@@ -105,7 +105,8 @@ export async function resolveAutoRoutingState(model: string): Promise<AutoRoutin
export async function createVirtualAutoCombo(
state: AutoRoutingState,
combo: any
combo: any,
apiKeyId?: string
): Promise<any | Response> {
if (!state.isAutoRouting || combo !== null) return combo;
if (!state.recognizedBuiltInAuto) {
@@ -118,7 +119,10 @@ export async function createVirtualAutoCombo(
try {
const { createVirtualAutoCombo: createVirtual } =
await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts");
const virtualCombo = await createVirtual(state.variant, state.spec);
// #7819 (Level 2): scope candidate exclusions to this API key + the
// requested auto channel (e.g. "auto/best-coding"). Omitted for any
// caller that doesn't pass apiKeyId — routing stays unfiltered.
const virtualCombo = await createVirtual(state.variant, state.spec, apiKeyId, state.model);
virtualCombo.name = state.model;
virtualCombo.id = state.model;
log.info(

View File

@@ -627,7 +627,7 @@ export async function handleChat(
}
}
const virtualCombo = await createVirtualAutoCombo(autoRouting, combo);
const virtualCombo = await createVirtualAutoCombo(autoRouting, combo, apiKeyInfo?.id);
if (virtualCombo instanceof Response) return virtualCombo;
combo = virtualCombo;
if (combo) {

View File

@@ -0,0 +1,56 @@
/**
* #7819 (Level 1) — GET /api/v1/auto-combo/[channel]/candidates
* Run: node --import tsx/esm --test tests/unit/api/auto-combo-candidates-route-7819.test.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-7819-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../../src/lib/db/core.ts");
const routeModule = await import(
"../../../src/app/api/v1/auto-combo/[channel]/candidates/route.ts"
);
function makeRequest(channel: string) {
return new Request(`http://localhost/api/v1/auto-combo/${encodeURIComponent(channel)}/candidates`);
}
async function callGET(channel: string) {
return routeModule.GET(makeRequest(channel), { params: Promise.resolve({ channel }) });
}
test.beforeEach(() => {
core.resetDbInstance();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#7819: GET /candidates for the base 'auto' channel returns 200 with a candidates array", async () => {
const res = await callGET("auto");
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.channel, "auto");
assert.ok(Array.isArray(body.candidates));
});
test("#7819: GET /candidates rejects an invalid channel path segment (400, sanitized body)", async () => {
const res = await callGET("../../etc/passwd");
assert.equal(res.status, 400);
const body = await res.json();
assert.ok(!body.error.message.includes("at /"), "error body must never leak a stack trace");
});
test("#7819: GET /candidates 404s for an unrecognized built-in auto channel", async () => {
const res = await callGET("totally-not-a-real-channel");
assert.equal(res.status, 404);
const body = await res.json();
assert.ok(!body.error.message.includes("at /"), "error body must never leak a stack trace");
});

View File

@@ -0,0 +1,85 @@
/**
* #7819 (Level 2) — `src/lib/db/autoCandidateOverrides.ts` round-trip: a
* per-API-key exclusion set for one `auto/*` channel is created, listed,
* toggled, and read back correctly, scoped strictly to (apiKeyId, autoChannel).
*/
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-7819-overrides-db-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const overridesDb = await import("../../src/lib/db/autoCandidateOverrides.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});
test("#7819: with no overrides configured, getExcludedConnectionIds returns an empty set", async () => {
const excluded = await overridesDb.getExcludedConnectionIds("key-1", "auto/best-coding");
assert.equal(excluded.size, 0);
});
test("#7819: setExcluded(true) persists and getExcludedConnectionIds reflects it", async () => {
await overridesDb.setExcluded("key-1", "auto/best-coding", "conn-a", true);
const excluded = await overridesDb.getExcludedConnectionIds("key-1", "auto/best-coding");
assert.ok(excluded.has("conn-a"), "conn-a must be excluded after setExcluded(true)");
});
test("#7819: setExcluded(false) clears a previously excluded connection (UPSERT toggle)", async () => {
await overridesDb.setExcluded("key-1", "auto/best-coding", "conn-a", true);
await overridesDb.setExcluded("key-1", "auto/best-coding", "conn-a", false);
const excluded = await overridesDb.getExcludedConnectionIds("key-1", "auto/best-coding");
assert.ok(!excluded.has("conn-a"), "conn-a must no longer be excluded after re-toggling");
});
test("#7819: exclusions are scoped per API key — key-2 is unaffected by key-1's exclusion", async () => {
await overridesDb.setExcluded("key-1", "auto/best-coding", "conn-a", true);
const excludedForOtherKey = await overridesDb.getExcludedConnectionIds(
"key-2",
"auto/best-coding"
);
assert.equal(excludedForOtherKey.size, 0);
});
test("#7819: exclusions are scoped per auto channel — a different channel is unaffected", async () => {
await overridesDb.setExcluded("key-1", "auto/best-coding", "conn-a", true);
const excludedForOtherChannel = await overridesDb.getExcludedConnectionIds(
"key-1",
"auto/best-fast"
);
assert.equal(excludedForOtherChannel.size, 0);
});
test("#7819: listOverrides returns every row for a key+channel, excluded and not", async () => {
await overridesDb.setExcluded("key-1", "auto/best-coding", "conn-a", true);
await overridesDb.setExcluded("key-1", "auto/best-coding", "conn-b", false);
const rows = await overridesDb.listOverrides("key-1", "auto/best-coding");
assert.equal(rows.length, 2);
const byConnection = new Map(rows.map((row) => [row.connectionId, row]));
assert.equal(byConnection.get("conn-a")?.excluded, true);
assert.equal(byConnection.get("conn-b")?.excluded, false);
});

View File

@@ -0,0 +1,143 @@
/**
* #7819 (Level 2) — mandatory regression guard (CLAUDE.md hard rule #18 /
* acceptance criterion "Users with no overrides see behavior byte-identical
* to today"): the `auto/*` candidate pool built by
* `virtualFactory.createVirtualAutoCombo` must be UNCHANGED when
* (a) the caller doesn't pass apiKeyId/autoChannel at all (every caller
* that existed before #7819 — builtinCatalog.ts, app/api/combos/auto),
* (b) the caller DOES pass apiKeyId/autoChannel but no override row exists.
*
* A separate test proves the filter actually excludes a configured candidate
* — the behavior the byte-identical guard above must NOT mask.
*/
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-7819-regression-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
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 overridesDb = await import("../../src/lib/db/autoCandidateOverrides.ts");
const virtualFactory = await import("../../open-sse/services/autoCombo/virtualFactory.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});
async function seedTwoGlmConnections() {
const first = await providersDb.createProviderConnection({
provider: "glm",
authType: "apikey",
name: "GLM Account 1",
apiKey: "glm-test-key-1",
});
const second = await providersDb.createProviderConnection({
provider: "glm",
authType: "apikey",
name: "GLM Account 2",
apiKey: "glm-test-key-2",
});
return { first, second };
}
function connectionIds(combo: { models: Array<{ connectionId: string }> }) {
return combo.models.map((m) => m.connectionId).sort();
}
test("#7819 regression: pool is IDENTICAL whether or not apiKeyId/autoChannel are passed, with no overrides configured", async () => {
const { first, second } = await seedTwoGlmConnections();
const legacyCombo = await virtualFactory.createVirtualAutoCombo(undefined);
const scopedComboNoOverrides = await virtualFactory.createVirtualAutoCombo(
undefined,
undefined,
"key-1",
"auto"
);
assert.deepEqual(
connectionIds(legacyCombo),
connectionIds(scopedComboNoOverrides),
"candidate pool must be byte-identical when no overrides exist for the key+channel"
);
assert.ok(connectionIds(legacyCombo).includes(first.id));
assert.ok(connectionIds(legacyCombo).includes(second.id));
});
test("#7819: an exclusion configured for one connection removes ONLY that connection from the pool", async () => {
const { first, second } = await seedTwoGlmConnections();
await overridesDb.setExcluded("key-1", "auto", first.id, true);
const scopedCombo = await virtualFactory.createVirtualAutoCombo(
undefined,
undefined,
"key-1",
"auto"
);
const ids = connectionIds(scopedCombo);
assert.ok(!ids.includes(first.id), `excluded connection ${first.id} must be absent`);
assert.ok(ids.includes(second.id), `non-excluded connection ${second.id} must remain`);
});
test("#7819: an exclusion for key-1 does NOT affect key-2's pool for the same channel", async () => {
const { first, second } = await seedTwoGlmConnections();
await overridesDb.setExcluded("key-1", "auto", first.id, true);
const otherKeyCombo = await virtualFactory.createVirtualAutoCombo(
undefined,
undefined,
"key-2",
"auto"
);
const ids = connectionIds(otherKeyCombo);
assert.ok(ids.includes(first.id), "key-2 must still see the connection key-1 excluded");
assert.ok(ids.includes(second.id));
});
test("#7819: fail-open — a DB lookup failure never breaks routing (pool falls back unfiltered)", async () => {
const { first, second } = await seedTwoGlmConnections();
// Force getExcludedConnectionIds() to throw by dropping the table out from
// under it — proves the try/catch in virtualFactory.ts actually fires and
// the candidate pool is still returned unfiltered, not a 500 or an empty
// pool. (Not the "empty override set" no-op path — that's covered above.)
const db = core.getDbInstance();
db.exec("DROP TABLE auto_candidate_overrides");
const combo = await virtualFactory.createVirtualAutoCombo(
undefined,
undefined,
"key-1",
"auto"
);
const ids = connectionIds(combo);
assert.ok(ids.includes(first.id));
assert.ok(ids.includes(second.id));
});

View File

@@ -0,0 +1,40 @@
/**
* #7819 (Level 2) — pure `filterExcludedCandidates` unit tests
* (`open-sse/services/autoCombo/candidateOverrides.ts`). No DB, no network —
* mirrors the style of `paidModelFilter.ts`'s own tests.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { filterExcludedCandidates } from "../../open-sse/services/autoCombo/candidateOverrides.ts";
const pool = [
{ connectionId: "conn-a", model: "gpt" },
{ connectionId: "conn-b", model: "claude" },
{ connectionId: "conn-c", model: "gemini" },
];
test("#7819: empty exclusion set returns the SAME array reference (identity, no-op path)", () => {
const result = filterExcludedCandidates(pool, new Set());
assert.strictEqual(result, pool);
});
test("#7819: excluding one connection removes only that candidate", () => {
const result = filterExcludedCandidates(pool, new Set(["conn-b"]));
assert.deepEqual(
result.map((c) => c.connectionId),
["conn-a", "conn-c"]
);
});
test("#7819: excluding an unknown connection id leaves the pool unchanged in content", () => {
const result = filterExcludedCandidates(pool, new Set(["conn-does-not-exist"]));
assert.deepEqual(
result.map((c) => c.connectionId),
["conn-a", "conn-b", "conn-c"]
);
});
test("#7819: excluding every candidate yields an empty pool (caller's empty-pool path handles it)", () => {
const result = filterExcludedCandidates(pool, new Set(["conn-a", "conn-b", "conn-c"]));
assert.equal(result.length, 0);
});