diff --git a/changelog.d/features/7819-auto-candidate-control.md b/changelog.d/features/7819-auto-candidate-control.md new file mode 100644 index 0000000000..c06853deeb --- /dev/null +++ b/changelog.d/features/7819-auto-candidate-control.md @@ -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). diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index d14fa8c549..0672067e33 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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, diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index 0130f5dd67..07ecf36f24 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -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 diff --git a/open-sse/handlers/autoComboCandidates.ts b/open-sse/handlers/autoComboCandidates.ts new file mode 100644 index 0000000000..40c3d9a174 --- /dev/null +++ b/open-sse/handlers/autoComboCandidates.ts @@ -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 { + 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 | null)?.rateLimitedUntil) || + (connection as Record | 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 { + const modelStr = channel === "auto" ? "auto" : `auto/${channel}`; + + // The bare "auto" channel (no variant/spec overlay) is handled directly by + // virtualFactory — createBuiltinAutoCombo() only recognizes `auto/` + // 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()) + : new Set(); + + 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); +} diff --git a/open-sse/services/autoCombo/candidateOverrides.ts b/open-sse/services/autoCombo/candidateOverrides.ts new file mode 100644 index 0000000000..9c66f6dc8d --- /dev/null +++ b/open-sse/services/autoCombo/candidateOverrides.ts @@ -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( + pool: T[], + excludedConnectionIds: Set +): T[] { + if (!excludedConnectionIds || excludedConnectionIds.size === 0) return pool; + return pool.filter((candidate) => !excludedConnectionIds.has(candidate.connectionId)); +} diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index a2638fd046..cdf8724373 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -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/:` combos. * #6453: optional `family` overlay for `auto/` 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 { const [connections, disabledNoAuthConnections, settings] = await Promise.all([ getCachedProviderConnections({ isActive: true }) as Promise, @@ -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 = 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[] = []; diff --git a/src/app/api/v1/auto-combo/[channel]/candidates/route.ts b/src/app/api/v1/auto-combo/[channel]/candidates/route.ts new file mode 100644 index 0000000000..7c6c6abe64 --- /dev/null +++ b/src/app/api/v1/auto-combo/[channel]/candidates/route.ts @@ -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 } + ); + } +} diff --git a/src/lib/db/autoCandidateOverrides.ts b/src/lib/db/autoCandidateOverrides.ts new file mode 100644 index 0000000000..4c3dcf9d6f --- /dev/null +++ b/src/lib/db/autoCandidateOverrides.ts @@ -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> { + 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 { + 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 { + 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); +} diff --git a/src/lib/db/migrations/128_auto_candidate_overrides.sql b/src/lib/db/migrations/128_auto_candidate_overrides.sql new file mode 100644 index 0000000000..c6262f2534 --- /dev/null +++ b/src/lib/db/migrations/128_auto_candidate_overrides.sql @@ -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); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 1ac96e5c2d..4ec1a7e18b 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -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, diff --git a/src/sse/handlers/autoRouting.ts b/src/sse/handlers/autoRouting.ts index 88a25db560..49987dcd08 100644 --- a/src/sse/handlers/autoRouting.ts +++ b/src/sse/handlers/autoRouting.ts @@ -105,7 +105,8 @@ export async function resolveAutoRoutingState(model: string): Promise { 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( diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 194da49dd8..c76346d319 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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) { diff --git a/tests/unit/api/auto-combo-candidates-route-7819.test.ts b/tests/unit/api/auto-combo-candidates-route-7819.test.ts new file mode 100644 index 0000000000..bc5c8df474 --- /dev/null +++ b/tests/unit/api/auto-combo-candidates-route-7819.test.ts @@ -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"); +}); diff --git a/tests/unit/auto-candidate-overrides-7819.test.ts b/tests/unit/auto-candidate-overrides-7819.test.ts new file mode 100644 index 0000000000..50b21f2839 --- /dev/null +++ b/tests/unit/auto-candidate-overrides-7819.test.ts @@ -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); +}); diff --git a/tests/unit/auto-candidate-overrides-regression-7819.test.ts b/tests/unit/auto-candidate-overrides-regression-7819.test.ts new file mode 100644 index 0000000000..2d36a69ccc --- /dev/null +++ b/tests/unit/auto-candidate-overrides-regression-7819.test.ts @@ -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)); +}); diff --git a/tests/unit/auto-candidate-pool-filter-7819.test.ts b/tests/unit/auto-candidate-pool-filter-7819.test.ts new file mode 100644 index 0000000000..7947055c35 --- /dev/null +++ b/tests/unit/auto-candidate-pool-filter-7819.test.ts @@ -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); +});