mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
6770a57131
commit
51b118c2d3
1
changelog.d/features/7819-auto-candidate-control.md
Normal file
1
changelog.d/features/7819-auto-candidate-control.md
Normal 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).
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
146
open-sse/handlers/autoComboCandidates.ts
Normal file
146
open-sse/handlers/autoComboCandidates.ts
Normal 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);
|
||||
}
|
||||
28
open-sse/services/autoCombo/candidateOverrides.ts
Normal file
28
open-sse/services/autoCombo/candidateOverrides.ts
Normal 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));
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
60
src/app/api/v1/auto-combo/[channel]/candidates/route.ts
Normal file
60
src/app/api/v1/auto-combo/[channel]/candidates/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
117
src/lib/db/autoCandidateOverrides.ts
Normal file
117
src/lib/db/autoCandidateOverrides.ts
Normal 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);
|
||||
}
|
||||
16
src/lib/db/migrations/128_auto_candidate_overrides.sql
Normal file
16
src/lib/db/migrations/128_auto_candidate_overrides.sql
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
56
tests/unit/api/auto-combo-candidates-route-7819.test.ts
Normal file
56
tests/unit/api/auto-combo-candidates-route-7819.test.ts
Normal 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");
|
||||
});
|
||||
85
tests/unit/auto-candidate-overrides-7819.test.ts
Normal file
85
tests/unit/auto-candidate-overrides-7819.test.ts
Normal 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);
|
||||
});
|
||||
143
tests/unit/auto-candidate-overrides-regression-7819.test.ts
Normal file
143
tests/unit/auto-candidate-overrides-regression-7819.test.ts
Normal 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));
|
||||
});
|
||||
40
tests/unit/auto-candidate-pool-filter-7819.test.ts
Normal file
40
tests/unit/auto-candidate-pool-filter-7819.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user