mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
* feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos
`auto/*` combos currently bypass per-key authorization entirely. They are
virtual — synthesised in the catalog, never stored as combo rows — so
`resolveRequestedComboName()` returns null for them and
`isComboAllowedForKey()` fails open:
const comboName = await resolveRequestedComboName(modelStr);
if (!comboName) return { allowed: true, comboName: null };
`validateModelAccess()` then sets `requestedComboName = modelStr` for any
`auto/` id and returns before `isModelAllowedForKey()` runs, so
`allowedModels` and `blockedModels` are skipped for those ids too.
The effect is that `allowedCombos` does not constrain `auto/*`: a key
scoped to a single cheap lane can still send `auto/best-coding` and reach
every model on the gateway. `blockedModels: ["auto/*"]` only unadvertises
the ids — it cannot deny them.
Add an explicit per-key flag instead of tightening the fail-open, which
would silently revoke `auto/*` from every key whose `allowedCombos` lacks
an entry for it. `allow_auto_combos` is NOT NULL DEFAULT 1 and the row
parser treats anything but an explicit falsy value as allowed, so every
existing key keeps working and opting out is deliberate.
When set to false:
- `validateModelAccess()` rejects `auto/*` for that key;
- the catalog skips the `auto/*` synthesis loop for it, reusing the
existing `hideAuto` break so the key is not offered ids it cannot use.
Settable via PATCH /api/keys/[id]. The create path and the dashboard
toggle are deliberately left for a follow-up: the API Manager control
needs UI strings across all message catalogs, which does not belong in
the same change as the policy fix.
* feat(dashboard): add the Auto Combos toggle to API key permissions
Exposes the `allowAutoCombos` flag in the API Manager permissions modal so
the per-key gate can be managed from the dashboard rather than only over
the API.
The control mirrors the prompt-compression toggle: a small dedicated
component, a `role="switch"` button, and labels from the `settings`
message namespace.
Defaults to ON. State reads `apiKey?.allowAutoCombos !== false` — using
`!== false` rather than `=== true` so a key that predates the column, or
one that has never been configured, renders as enabled and matches the
`NOT NULL DEFAULT 1` column.
The field is threaded through all three positional lists (the save
handler signature, the modal prop type and the onSave call) plus the
PATCH payload, so no later argument shifts position.
UI strings are added to en.json and to vi.json. Vietnamese is translated
rather than left as a sync placeholder because
tests/unit/i18n-vi-completeness.test.ts asserts key parity with English
and bans `__MISSING__` markers in that locale. The remaining locales fall
back to English at runtime; `i18n:check-ui-coverage` still passes well
clear of its threshold. They are deliberately not mass-synced here: a
full `i18n:sync-ui` run also replicates ~844 unrelated pre-existing gaps
across all 50 catalogs, which does not belong in this change.
* feat(api): advertise the combo description in /v1/models
A combo's description is stored on its record and returned by
GET /api/combos, but the catalog row never carried it, so no client could
show it.
Claude Code's gateway model discovery reads exactly `id`, `display_name`
and `description` from each entry in the /v1/models `data` array and
renders the description in the /model picker — an entry without one reads
"From gateway" instead. Other OpenAI-compatible clients surface it too.
Emit it only when the combo actually has one, so rows for combos without
a description are byte-identical to before. The value is typeof-narrowed
and trimmed because ComboRecord is Record<string, unknown>, and
`comboMetadata` still spreads last so context and capability metadata
keep precedence.
`display_name` is deliberately not sent: a combo's id is already its
human-chosen name, and the field is only consulted when it differs from
the id.
Ref: https://code.claude.com/docs/en/llm-gateway-protocol.md#model-discovery
* fix(api): list a key's allowed combos in /v1/models
`allowedCombos` gates combos; `modelAccessMode`, `allowedModels` and
`blockedModels` gate provider models. The catalog consulted only the
latter, so a key with `modelAccessMode: "restricted"` and an empty
`allowedModels` received an empty catalog — zero rows — while every combo
in its `allowedCombos` dispatched normally. The catalog contradicted the
key.
Observed on a live gateway: a key with 24 entries in `allowedCombos` and
`restricted` + `allowedModels: []` returned {"object":"list","data":[]},
yet `claude-orchestrate` answered 200 on that same key.
Gate combo rows on `allowedCombos` instead of hiding them. Listing a
combo the key can already dispatch grants no new access, so this is a
consistency fix rather than a relaxation, and it needs no opt-in: the
rule is simply that a key's catalog shows what that key can use.
auto/* rows are exempt. They fail open at dispatch — they resolve to no
stored combo — and their synthesis is already gated by allowAutoCombos,
so gating them here would make the catalog stricter than dispatch.
The decision lives in a new exported helper, isComboNameAllowedForKey(),
which wraps the existing matchesComboAccessRule. An absent list means no
combo restriction, matching validateComboAccess, which skips the check
when allowedCombos is not an array; an empty list allows nothing.
Also advertise `display_name` on combo rows from an operator-set
`displayName` field. Claude Code uses it as the picker entry's name when
it differs from the id, which lets a combo carry a discovery-compatible
id and still read cleanly. It is never derived from the combo name — an
unset field advertises nothing.
* fix(api): accept displayName on the combo schemas
The previous commit advertises `display_name` in /v1/models from a
combo's `displayName`, but neither createComboSchema nor
updateComboSchema declared the field, so Zod stripped it from every
request body and the value could never be set. The endpoint would have
answered 200 and written nothing — the feature was unreachable.
This is the same silent no-op that made `blockedModels` unsettable on
API keys: a field plumbed through the route and the store, missing only
its schema declaration.
Declare it on both schemas and count it in updateComboSchema's "no valid
fields" guard, so a body carrying only `displayName` is a valid update
rather than being rejected as empty. Nullable on update so a label can be
cleared.
* feat(api): add per-key catalogScope to scope what /v1/models advertises
A key had no way to say which kinds of thing its catalog should list. It
always advertised whatever the key's model and combo policies permitted,
mixed together. A client that builds its model picker from /v1/models —
Claude Code's gateway discovery, for one — then sees provider models
alongside the curated combos it was meant to offer.
Add a three-way per-key setting: "all" (default), "combos", "models".
This is a listing preference, not an access control: narrowing it never
changes what the key may dispatch, which the model policy and
allowedCombos continue to decide. That is why it is an explicit setting
rather than implied behaviour — unlike gating combo rows on
allowedCombos, which was a correctness fix and needed no opt-in.
Defaults to "all" everywhere: the column, the parser, the metadata and
the UI state, so every existing key is unchanged. The parser widens to
"all" on an unrecognised value rather than narrowing, so a bad value can
never silently hide rows an operator expects to see.
The dashboard control is a segmented radio group beside the Auto Combos
toggle. UI strings are added to en.json and vi.json; the remaining
locales fall back to English, and vi is translated rather than left as a
sync placeholder because tests/unit/i18n-vi-completeness.test.ts asserts
key parity and bans markers there.
* fix(api): invalidate the model catalog on key visibility changes
updateApiKeyPermissions already advances the unified /v1/models catalog
generation for the fields that change what a key may dispatch, but the two
fields this branch introduces -- allowAutoCombos and catalogScope -- were
missing from that predicate. Both change what the catalog advertises, so a
PATCH toggling either one left the request-shaped catalog cache serving the
previous listing until its TTL expired, and the dashboard's API-key screen
could show a catalog that disagreed with the key it had just written.
Add the two fields to the existing predicate -- no new cache machinery. The
call still runs only after a successful write, so a no-op or failed update
does not invalidate, and unrelated metadata edits (isActive, rate limits)
still leave the catalog cached.
Observed on a live deployment before the fix: PATCH catalogScope="combos"
returned 200 and the column read back "combos", yet GET /v1/models kept
returning the previous mixed rows until a process restart, after which the
same key correctly returned combo-only rows.
* docs(changelog): add fragment for per-key allowAutoCombos and catalogScope
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* chore(quality): rebaseline the two ceilings this PR's own growth moved
src/app/api/v1/models/catalog.ts 2075 -> 2117 and src/lib/db/apiKeys.ts
1625 -> 1659. Measured on the clean tip first: catalog.ts sits at 2074 (under
its 2075 ceiling) and apiKeys.ts at 1620 (under 1625), so none of this is
inherited — it is the feature itself. Gating the built-in auto/* combos per key
means the permission field has to be read, validated and carried all the way to
the catalog filter, and each of those is an explicit call site rather than
something extractable without hiding the gate.
Covered by the PR's 25 tests. The other violations in this tree (chatHelpers.ts,
chatCore.ts, chatcore-translation-paths.test.ts) are inherited base-reds and were
left untouched.
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
177 lines
7.8 KiB
TypeScript
177 lines
7.8 KiB
TypeScript
/**
|
|
* Per-key control over the built-in `auto/*` combos.
|
|
*
|
|
* `auto/*` combos are virtual — they are synthesised in the catalog, not stored
|
|
* as rows — so `resolveRequestedComboName()` returns null for them and
|
|
* `isComboAllowedForKey()` FAILS OPEN (`src/shared/utils/apiKeyPolicy.ts`:
|
|
* `if (!comboName) return { allowed: true, comboName: null }`). Because
|
|
* `validateModelAccess()` then returns early on a resolved combo name, the
|
|
* `allowedModels` / `blockedModels` check is never reached for an `auto/*` id
|
|
* either.
|
|
*
|
|
* Net effect before this change: `auto/*` bypassed per-key authorisation
|
|
* completely. A key scoped via `allowedCombos` to a single cheap lane could
|
|
* still send `auto/best-coding` and reach every model on the gateway. Observed
|
|
* on a live gateway: a key whose `allowedCombos` held 24 named combos and no
|
|
* `auto` entry dispatched `auto/best-fast` successfully (HTTP 200).
|
|
*
|
|
* `blockedModels: ["auto/*"]` only hides the ids from `/v1/models`; it cannot
|
|
* deny them, for the early-return reason above.
|
|
*
|
|
* The fix is an explicit per-key flag, `allowAutoCombos`, defaulting to TRUE so
|
|
* every existing key keeps working. Setting it to false denies `auto/*` at
|
|
* dispatch and drops the ids from that key's catalog.
|
|
*
|
|
* Rules:
|
|
* R1 The column is declared with DEFAULT 1 (allowed) for legacy rows.
|
|
* R2 The row parser treats anything but an explicit falsy value as allowed.
|
|
* R3 The deny predicate fires only for auto/* ids on a key that opted out.
|
|
* R4 The PATCH schema preserves the flag and counts it as a real update.
|
|
* R5 The update route forwards it into the payload.
|
|
* R6 The catalog skips the auto/* synthesis loop for an opted-out key.
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const { API_KEY_COLUMN_FALLBACKS } = await import("../../src/lib/db/apiKeyColumnFallbacks.ts");
|
|
const { parseAllowAutoCombos } = await import("../../src/lib/db/apiKeys/rowParsers.ts");
|
|
const { isAutoComboDeniedForKey } = await import("../../src/shared/utils/apiKeyPolicy.ts");
|
|
const schemas = await import("../../src/shared/validation/schemas.ts");
|
|
|
|
function read(relativePath: string) {
|
|
return fs.readFileSync(path.join(process.cwd(), relativePath), "utf8");
|
|
}
|
|
|
|
test("R1: allow_auto_combos is declared NOT NULL DEFAULT 1 so legacy keys keep auto/*", () => {
|
|
const column = API_KEY_COLUMN_FALLBACKS.find(
|
|
(c: { name: string }) => c.name === "allow_auto_combos"
|
|
);
|
|
assert.ok(column, "api_keys must gain an allow_auto_combos column");
|
|
assert.match(
|
|
column.definition,
|
|
/NOT NULL DEFAULT 1/,
|
|
"default must be 1 — an existing key must not silently lose auto/* access"
|
|
);
|
|
});
|
|
|
|
test("R2: the row parser defaults to allowed and opts out only on an explicit falsy value", () => {
|
|
// Legacy rows predating the column, and rows that never set it.
|
|
assert.equal(parseAllowAutoCombos(undefined), true);
|
|
assert.equal(parseAllowAutoCombos(null), true);
|
|
assert.equal(parseAllowAutoCombos(1), true);
|
|
assert.equal(parseAllowAutoCombos("1"), true);
|
|
assert.equal(parseAllowAutoCombos(true), true);
|
|
// Explicit opt-out, in every shape SQLite / JSON round-trips produce.
|
|
assert.equal(parseAllowAutoCombos(0), false);
|
|
assert.equal(parseAllowAutoCombos("0"), false);
|
|
assert.equal(parseAllowAutoCombos(false), false);
|
|
});
|
|
|
|
test("R3: the deny predicate fires only for auto/* on a key that opted out", () => {
|
|
const optedOut = { allowAutoCombos: false };
|
|
const optedIn = { allowAutoCombos: true };
|
|
const legacy = {}; // flag absent entirely
|
|
|
|
assert.equal(isAutoComboDeniedForKey(optedOut, "auto/best-coding"), true);
|
|
assert.equal(isAutoComboDeniedForKey(optedOut, "auto/coding:fast"), true);
|
|
|
|
// Opted in, or never configured — never denied.
|
|
assert.equal(isAutoComboDeniedForKey(optedIn, "auto/best-coding"), false);
|
|
assert.equal(isAutoComboDeniedForKey(legacy, "auto/best-coding"), false);
|
|
assert.equal(isAutoComboDeniedForKey(undefined, "auto/best-coding"), false);
|
|
assert.equal(isAutoComboDeniedForKey(null, "auto/best-coding"), false);
|
|
|
|
// Never touches anything that is not an auto/* id, even when opted out.
|
|
assert.equal(isAutoComboDeniedForKey(optedOut, "claude-haiku"), false);
|
|
assert.equal(isAutoComboDeniedForKey(optedOut, "codex/gpt-5.6-sol-xhigh"), false);
|
|
assert.equal(isAutoComboDeniedForKey(optedOut, "qtSd/pool-1"), false);
|
|
// A combo whose name merely starts with the word "auto" is not an auto/* id.
|
|
assert.equal(isAutoComboDeniedForKey(optedOut, "auto-router"), false);
|
|
assert.equal(isAutoComboDeniedForKey(optedOut, ""), false);
|
|
});
|
|
|
|
test("R4: the PATCH schema preserves allowAutoCombos and counts it as a real update", () => {
|
|
const parsed = schemas.updateKeyPermissionsSchema.safeParse({ allowAutoCombos: false });
|
|
assert.equal(
|
|
parsed.success,
|
|
true,
|
|
"allowAutoCombos alone must be a valid update — the 'No valid fields' guard must count it"
|
|
);
|
|
if (!parsed.success) return;
|
|
assert.equal(parsed.data.allowAutoCombos, false, "the flag must survive parsing");
|
|
|
|
const on = schemas.updateKeyPermissionsSchema.safeParse({ allowAutoCombos: true });
|
|
assert.equal(on.success, true);
|
|
if (on.success) assert.equal(on.data.allowAutoCombos, true);
|
|
|
|
assert.equal(
|
|
schemas.updateKeyPermissionsSchema.safeParse({ allowAutoCombos: "no" }).success,
|
|
false,
|
|
"a non-boolean must be rejected"
|
|
);
|
|
});
|
|
|
|
test("R5: the update route forwards allowAutoCombos into the payload", () => {
|
|
const route = read("src/app/api/keys/[id]/route.ts");
|
|
assert.ok(
|
|
route.includes("if (allowAutoCombos !== undefined) payload.allowAutoCombos = allowAutoCombos"),
|
|
"PATCH /api/keys/[id] must forward allowAutoCombos to updateApiKeyPermissions"
|
|
);
|
|
});
|
|
|
|
test("R7: the API Manager wires the toggle and defaults it ON", () => {
|
|
const client = read("src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx");
|
|
|
|
assert.ok(
|
|
client.includes("ApiKeyAutoCombosToggle"),
|
|
"the permissions modal must render the auto-combos toggle"
|
|
);
|
|
assert.ok(
|
|
client.includes("apiKey?.allowAutoCombos !== false"),
|
|
"state must default ON via `!== false` — `=== true` would render a key that predates the field as disabled"
|
|
);
|
|
// Positional plumbing: the save handler signature, the onSave call and the
|
|
// PATCH payload must each carry the field, or later arguments shift by one.
|
|
assert.ok(
|
|
client.includes("allowAutoCombos: boolean,"),
|
|
"the save handler and modal prop signatures must declare it"
|
|
);
|
|
assert.match(
|
|
client,
|
|
/body: JSON\.stringify\(\{[\s\S]*?allowAutoCombos,[\s\S]*?\}\)/,
|
|
"the PATCH body must include allowAutoCombos"
|
|
);
|
|
});
|
|
|
|
test("R8: the toggle's UI strings exist in English and Vietnamese", () => {
|
|
// en.json is the source of truth; vi is the one locale whose completeness is
|
|
// asserted by tests/unit/i18n-vi-completeness.test.ts (it bans placeholders).
|
|
for (const locale of ["en", "vi"]) {
|
|
const messages = JSON.parse(read(`src/i18n/messages/${locale}.json`));
|
|
for (const key of ["autoCombosTitle", "autoCombosDesc"]) {
|
|
const value = messages?.settings?.[key];
|
|
assert.equal(typeof value, "string", `${locale}.json settings.${key} must exist`);
|
|
assert.ok(value.trim().length > 0, `${locale}.json settings.${key} must not be empty`);
|
|
assert.ok(
|
|
!/__(?:MISSING|TODO)__/i.test(value),
|
|
`${locale}.json settings.${key} must be translated, not a placeholder`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("R6: the catalog skips auto/* synthesis for a key that opted out", () => {
|
|
const catalog = read("src/app/api/v1/models/catalog.ts");
|
|
assert.ok(
|
|
catalog.includes("autoCombosDisallowedForKey"),
|
|
"catalog must compute a per-key auto/* suppression flag"
|
|
);
|
|
assert.ok(
|
|
/if \(hideAuto \|\| autoCombosDisallowedForKey\) break;/.test(catalog),
|
|
"the auto/* synthesis loop must break for an opted-out key, as it already does for hideAuto"
|
|
);
|
|
});
|