mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 13:52:28 +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>
100 lines
4.6 KiB
TypeScript
100 lines
4.6 KiB
TypeScript
/**
|
|
* Combos advertise their own description in `GET /v1/models`.
|
|
*
|
|
* A combo's description is stored in its record and returned by
|
|
* `GET /api/combos`, but the catalog row built in
|
|
* `src/app/api/v1/models/catalog.ts` never copied it, so no client could see 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. See
|
|
* https://code.claude.com/docs/en/llm-gateway-protocol.md#model-discovery
|
|
*
|
|
* Rules:
|
|
* R1 The combo row emits `description` when the combo has one.
|
|
* R2 It is omitted entirely when the combo has none, rather than sent empty.
|
|
* R3 The value is read defensively — a non-string description cannot leak through.
|
|
* R4 `comboMetadata` still spreads last, so it keeps precedence over the literal.
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
// catalog.ts contains a NUL byte, so read it explicitly as UTF-8 text rather
|
|
// than relying on tools that sniff it as binary.
|
|
const catalog = fs.readFileSync(
|
|
path.join(process.cwd(), "src/app/api/v1/models/catalog.ts"),
|
|
"utf8"
|
|
);
|
|
|
|
test("R1/R2: the combo row emits description only when the combo has one", () => {
|
|
assert.ok(
|
|
catalog.includes("...(comboDescription ? { description: comboDescription } : {})"),
|
|
"the combo row must spread description conditionally, so a combo without one is unchanged"
|
|
);
|
|
});
|
|
|
|
test("R3: the description is narrowed to a trimmed string before use", () => {
|
|
assert.match(
|
|
catalog,
|
|
/const comboDescription\s*=\s*\n?\s*typeof combo\.description === "string" \? combo\.description\.trim\(\) : "";/,
|
|
"ComboRecord is Record<string, unknown>, so the value must be typeof-narrowed and trimmed — " +
|
|
"a non-string must collapse to the empty string and be omitted"
|
|
);
|
|
});
|
|
|
|
test("R5: an operator-set display_name is advertised, and omitted when unset", () => {
|
|
assert.ok(
|
|
catalog.includes("...(comboDisplayName ? { display_name: comboDisplayName } : {})"),
|
|
"display_name must be spread conditionally so combos without one are unchanged"
|
|
);
|
|
assert.match(
|
|
catalog,
|
|
/const comboDisplayName\s*=\s*\n?\s*typeof combo\.displayName === "string" \? combo\.displayName\.trim\(\) : "";/,
|
|
"display_name must come from an operator-set field, typeof-narrowed and trimmed — " +
|
|
"never derived heuristically from the combo name"
|
|
);
|
|
});
|
|
|
|
test("R6: the combo schemas accept displayName and count it as a real update", async () => {
|
|
const schemas = await import("../../src/shared/validation/schemas.ts");
|
|
|
|
// Without this the field is stripped by Zod and can never be set — the same
|
|
// silent no-op that made blockedModels unreachable through the API.
|
|
const upd = schemas.updateComboSchema.safeParse({ displayName: "Codex Sol" });
|
|
assert.equal(upd.success, true, "displayName alone must be a valid combo update");
|
|
if (upd.success) assert.equal(upd.data.displayName, "Codex Sol");
|
|
|
|
const created = schemas.createComboSchema.safeParse({
|
|
name: "claude-codex-sol",
|
|
displayName: "Codex Sol",
|
|
models: [{ kind: "model", model: "codex/gpt-5.6-sol-xhigh", providerId: "codex" }],
|
|
});
|
|
assert.equal(created.success, true, "create must accept displayName");
|
|
if (created.success) assert.equal(created.data.displayName, "Codex Sol");
|
|
|
|
// Clearing it must be expressible, and a non-string rejected.
|
|
assert.equal(schemas.updateComboSchema.safeParse({ displayName: null }).success, true);
|
|
assert.equal(schemas.updateComboSchema.safeParse({ displayName: 42 }).success, false);
|
|
});
|
|
|
|
test("R4: comboMetadata still spreads after the literal fields", () => {
|
|
// Bound the slice by the block itself rather than a byte count, so adding
|
|
// another field to the row cannot silently make this assertion vacuous.
|
|
const rowStart = catalog.indexOf("listedIds.add(combo.name);");
|
|
assert.ok(rowStart > -1, "combo row builder must exist");
|
|
const rowEnd = catalog.indexOf("maybeYieldCatalogBuild", rowStart);
|
|
assert.ok(rowEnd > rowStart, "combo row builder must be followed by the yield call");
|
|
const row = catalog.slice(rowStart, rowEnd);
|
|
const descIndex = row.indexOf("description: comboDescription");
|
|
const metaIndex = row.indexOf("...comboMetadata");
|
|
assert.ok(descIndex > -1 && metaIndex > -1, "both spreads must be present in the row");
|
|
assert.ok(
|
|
metaIndex > descIndex,
|
|
"comboMetadata must spread last so context/capability metadata keeps precedence"
|
|
);
|
|
});
|