- `getPoolsByGroup(groupId)` in quotaPools.ts: SELECT all pools for a group,
exposed as public API and re-exported from localDb.ts.
- `resolveQuotaKeyScope`: pool → group → all pools in group expansion.
The returned `poolSlugs` field now holds GROUP slugs (quotaGroupSlug of the
group name) instead of individual pool-name slugs, one per distinct group.
Group slug included only when the group has ≥1 valid connection (group-level
anyValidConnection gate). Deduplication: a key in 2 pools of the same group
expands once.
- `filterModelsToQuotaPools` (quotaCombos.ts): already matches by
`parsed.groupSlug` ∈ poolSlugs — no logic change needed, just test alignment.
- quota-key-resolve.test.ts: updated 4 tests that asserted pool-name slugs
(testpoola2, poolmix, etc.) to assert the group slug ("groupdemo") — this is
alignment to the B5 group semantics, not masking; the assertions were correct
for the old pool-slug behavior and are now correct for the new group-slug
behavior.
- quota-catalog-filter.test.ts: updated fixtures from old quotaShared-* format
to qtSd/<groupSlug>/... (naming changed in B3, test was already broken before B5).
- resolvePoolForSync now resolves groupName via getGroupName(pool.groupId),
falling back to pool.name when the group record is missing.
- Both quotaModelName calls in syncQuotaCombos use groupName instead of
pool.name, so combos are named qtSd/<groupSlug>/<provider>/<model>.
- Prune is now scoped to group+provider: syncing pool A (openrouter) never
deletes pool B (baidu) combos that share the same group.
- removeQuotaCombosForPool likewise scoped to group+provider.
- Updated quota-combos-sync, quota-combo-balancing, quota-combo-cli-providers
tests for the new group-slug naming.
- New tests/unit/quota-combo-groups.test.ts: G1–G4 cover two-pool same-group
naming, provider-scoped prune isolation, default-group fallback, and stale
prune within the same group+provider.
Replace the old quotaShared-<poolSlug>-<provider>/<model> format with
qtSd/<groupSlug>/<provider>/<model>. Adds quotaGroupSlug() as the canonical
helper; keeps quotaPoolSlug() as a delegating alias so existing callers
(quotaKey.ts, quotaCombos.ts) compile without changes. parseQuotaModelName
now returns { groupSlug, provider, model }. Updates quotaCombos.ts,
apiKeyPolicy.ts, and all tests that referenced the old field name or the
old hardcoded prefix literal.
Add src/lib/db/quotaGroups.ts with createGroup/getGroup/getGroupName/
listGroups/renameGroup/deleteGroup; deleteGroup guards against non-empty
groups (throws /pools/i) and protects the 'group-demo' seed. Re-export
all functions + QuotaGroup type from localDb.ts. 15 unit tests in
tests/unit/quota-groups-crud.test.ts all passing.
Introduces first-class quota Group entity: new quota_groups table,
quota_pools.group_id column, GroupDemo seed, backfill of existing
pools, and groupId in QuotaPool/PoolCreate/PoolUpdate with a
group-demo default. Migration runner gains an isSchemaAlreadyApplied
guard for version 087.
Replace the saturation-signal approximation (globalUsedPercent × effectiveLimit,
which is always 0 for requests/tokens/usd) with store.poolConsumedTotal() so
the pool actually saturates and enforceQuotaShare returns "block" when the pool
total hits the effective limit for countable dimensions.
"percent" dimensions continue to use the upstream saturation signal, which is
the authoritative measure for provider-reported utilisation windows.
Add poolConsumedTotal(poolId, dim) to the QuotaStore interface and implement
it in both SqliteQuotaStore and RedisQuotaStore so the enforce path can read
the real pool-wide consumption (sum across all keys) rather than the per-key
saturation signal, which is 0 for countable units and therefore never blocks.
- db/quotaConsumption.ts: add sumPoolDimension() — single SQL COALESCE(SUM)
for curr and prev buckets across all api_key_id rows for a dimensionKey.
- localDb.ts: re-export sumPoolDimension.
- quota/types.ts: add poolConsumedTotal to QuotaStore interface (with JSDoc).
- sqliteQuotaStore.ts: implement using sumPoolDimension + existing
slidingWindowEffective helper — one consistent sliding-window read.
- redisQuotaStore.ts: implement by fetching pool allocations from SQLite (F2),
then issuing a single MGET for all (key, curr-bucket) and (key, prev-bucket)
Redis keys, summing raw values, and applying the sliding-window formula once.
- tests/unit/quota-store-pool-total.test.ts: 4 tests (sum, pool isolation,
unit isolation, peek-no-regression) all passing.
getQuotaStore() is async; enforce.ts used it without await, so store was a Promise
and store.peek/store.consume threw 'not a function' → enforceQuotaShare failed open
on every request and recordConsumption never wrote. Production quota was a silent
no-op (unit tests passed because they inject a sync mock store). Await it + guard.
Guard PUT and DELETE on /api/combos/[id] to return 409 when the target combo name
starts with QUOTA_MODEL_PREFIX; filter isHidden combos from the Combos page state
so quota-managed combos never appear as editable rows there.
deletePool now runs a SQLite json_each UPDATE inside the existing
transaction to remove the pool's id from every api_key's allowed_quotas
JSON array, preventing stale pool references after deletion.
Tests: tests/unit/quota-pool-delete-prune.test.ts (7 scenarios).
Runtime (enforce.ts): compute effectiveWeight = 100/N for each key when the
pool's total weight is 0, so pools with all-zero weights (newly created via
UI) are usable immediately without a re-save. Original non-zero weights are
unchanged.
UI (PoolWizard, EditAllocationsModal): addKey now recomputes all weights to
an equal split after adding a key, so saved pools store equal weights and
never persist all-zero allocations.
Tests: tests/unit/quota-equal-split.test.ts (7 scenarios).
getProviderModelIds read PROVIDER_MODELS, which is empty for CLI/OAuth providers
(codex, kimi, claude, …) — so pools built from those providers produced ZERO
quotaShared-* combos and their quota keys saw no models. Read the provider REGISTRY
instead (the same source /v1/models uses), which covers all providers.
Adds a read-only AccountQuotaRow component that fetches cached quota data
from GET /api/usage/provider-limits (same endpoint as ProviderLimits.tsx)
and renders a compact per-connection quota summary (% remaining + reset
countdown) inside each PoolCard, below the dimensions section and before
the BurnRateChart. Fail-softs to "—" on error / empty / loading state.
A pool with N same-type connections now has an effective budget of
perAccountLimit × N per dimension. Only the limit fed to fair-share
scales — consumption (pool-keyed shared bucket) is unchanged.
Replace the (connId × modelId) upsert loop in syncQuotaCombos with a
model-grouped build: one combo per model with ALL connections as steps
and strategy "fill-first", fixing the collision where a second same-provider
connection overwrote the first (last upsert won, only one account ever used).
- Add assertSingleProvider() helper in quotaPools.ts: queries provider_connections
with DISTINCT provider WHERE id IN (...), throws if >1 provider detected.
- Call guard early in createPool (when connectionIds.length > 1) and updatePool
(when input.connectionIds.length > 1), before any DB writes.
- Add lockedProvider useMemo in PoolWizard.tsx: derived from first selected
connection; availableConnections filtered to same provider once locked.
- Show wizardSingleProviderNote i18n hint in step 1 when lockedProvider is set.
- Add wizardSingleProviderNote to en.json + pt-BR.json (parity).
- New test: tests/unit/quota-pool-single-provider.test.ts (4 cases).
- Update tests/unit/quota-multiprovider.test.ts: all D2 tests now use two
same-provider connections (both PROVIDER_A/openrouter) — the tests were
exercising connection-plumbing (scope, enforce membership, combo fan-out),
not mixed-provider behavior per se; updated to remain valid under Task 3 rule.
GET /api/quota/pools/[id]/usage returns { usage: snapshot }, but usePoolUsage
stored the whole wrapper, leaving usage.dimensions undefined. StackedAllocationBar
then dereferenced usage.dimensions[dimensionIndex] (undefined[0]) and crashed the
entire quota-share page for any pool that has allocations. Unwrap data.usage in the
hook + defensively guard usage.dimensions?.[i] / dim.perKey ?? [] in the bar.
server-ws.mjs imports ./peer-stamp.mjs but the npm-pack path didn't include it:
prepublish.ts didn't copy it and pack-artifact-policy pruned it (not in the
app/ allowlist). Without it the WS wrapper throws ERR_MODULE_NOT_FOUND on boot
and the peer-IP stamp (authz LOCAL_ONLY locality) never runs. Copy it in
prepublish + allowlist + mark required so a regression fails the pack.
Satisfies the env/docs contract check (the per-process peer-stamp secret is
referenced in code; the auto-loader generates it, so it's documented as an
optional/auto var alongside OMNIROUTE_WS_BRIDGE_SECRET).
D2 moved poolSlug collection outside the connection loop, so a pool whose only
connection was deleted still leaked its slug into resolveQuotaKeyScope — that
slug has no quotaShared-* models behind it. Gate the slug on >=1 valid member
connection (restores Phase-A2 behavior for multi-connection pools).
Docker runs the Next standalone build (run-standalone.mjs), which now prefers
server-ws.mjs — but build-next-isolated.mjs only copied run-standalone.mjs, not
the WS wrapper or its deps, so Docker fell back to bare server.js with no peer
stamp (LOCAL_ONLY routes 403, CLI token broken — fail-closed but unusable).
Co-locate all three at the standalone root so the peer stamp runs in Docker too.
(The npm package path already ships them via prepublish.ts.)
Adversarial review of the peer-IP fix surfaced: (1) CRITICAL — cliTokenAuth.ts
still derived loopback from new URL(request.url).hostname (the same spoofable
Host class), letting a remote caller with a stolen CLI token reach management
APIs via Host: 127.0.0.1; now it trusts the middleware-stamped locality verdict
(AUTHZ_HEADER_PEER_LOCALITY, a client-stripped trusted header). (2) HIGH —
isLoopbackHost mangled bare IPv6 (::1, ::ffff:127.0.0.1) via split(":")[0],
a fail-closed DoS on IPv6 deploys. (3) HIGH — the Docker entrypoint ran bare
server.js (no peer stamp); run-standalone.mjs now prefers server-ws.mjs.
The middleware runtime exposes no socket, so a prior fix derived LOCAL_ONLY
locality from the Host header — letting a remote caller send Host: 127.0.0.1
and reach spawn-capable routes (RCE class). The custom Node servers now stamp
the real socket.remoteAddress into a token-signed internal header; the policy
trusts only a stamp whose token matches this process's secret, and fails closed
otherwise. Preserves the owner-authorized loopback + private-LAN access without
trusting any client-controlled header.
- PoolCreateSchema: add optional connectionIds[] + .refine() that enforces primary membership
- PoolWizard: replace single-select dropdown with checkbox multi-select; first checked = primary (badge); step-2 adds helper note for additional connections; step-3 preview grouped by provider with +N more; POST body sends both connectionId and connectionIds
- PoolCard: optional providers[] prop renders a row of ProviderIcon (up to 3 + badge) instead of a single icon when pool has multiple connections
- i18n: 4 new keys added to both en.json and pt-BR.json (wizardConnectionsLabel, wizardPrimaryBadge, wizardAdditionalConnectionsNote, wizardPreviewMoreModels) — parity maintained (23 wizard keys each)
- Tests: quota-pool-wizard-multi.test.ts (21 tests) covering schema accept/reject, structural wizard assertions, and i18n parity
- quotaKey.ts (resolveQuotaKeyScope): iterate pool.connectionIds (fall back to
[connectionId] for un-backfilled rows); each connection contributes its own
connId + provider to the scope. poolSlugs logic unchanged (one slug per pool).
- quotaCombos.ts (syncQuotaCombos): replace single-connection resolvePoolProvider
with resolvePoolForSync that returns all connectionIds; iterate each connId to
build the desiredNames union across all providers; upsert combos pinned to the
CORRECT per-connection connId; prune against the full union so only truly stale
combos (no longer produced by any current connection) are deleted.
- enforce.ts (enforceQuotaShare + recordConsumption): both pool-matching loops
changed from equality (p.connectionId === input.connectionId) to membership
(p.connectionIds.includes) with fallback for un-backfilled rows. Fail-open
(B16) and pool-level dimension key semantics are preserved unchanged.
- quotaPools.ts: no logic change needed — connectionIds already flows through
getPool (D1); syncQuotaCombosGuarded passes poolId and syncQuotaCombos
resolves the full QuotaPool internally.
- tests/unit/quota-multiprovider.test.ts: 6 new tests covering D2 (scope,
enforce primary/secondary membership, combos 2-provider create + prune).
All 22 tests pass (14 new + 8 existing enforce + pool-connections suites).
Adds migration 086 to create the `quota_pool_connections` join table with a backfill
that seeds every existing pool's single connection_id as its first member. Updates
QuotaPool type with `connectionIds: string[]`, wires createPool/updatePool/deletePool
to maintain the join table transactionally, and keeps `connection_id` as the primary
back-compat column synced to `connectionIds[0]`.
Keys with non-empty allowedQuotas may only use models whose provider belongs
to their pools' provider set; anything outside → 403 QUOTA_ONLY.
Normal allowedModels/allowedCombos checks are bypassed for quota-exclusive keys.
Introduces src/lib/quota/quotaKey.ts with resolveQuotaKeyScope(), a
pure async helper that maps an API key's allowedQuotas pool-ID list to
the concrete connectionIds and provider slugs it is permitted to use.
Covers empty/null/undefined input, missing pools, orphaned connectionIds,
and multi-pool deduplication. No behaviour change to existing code paths.
The authz pipeline runs in the Next middleware runtime (proxy.ts -> runAuthzPipeline)
where ctx.request is a NextRequest with no .socket/.ip. requestPeerAddress therefore
returned null, so isLoopbackRequest was ALWAYS false and every LOCAL_ONLY path 403'd
even from loopback (Services/MCP/Traffic-Inspector were unusable). Read the Host
header instead — exactly what isLoopbackHost/isPrivateLanHost were built to parse —
which restores loopback and, combined with isPrivateLanHost, enables the
owner-authorized private-LAN access. Spawn-capable endpoints still require
manage-scope auth after this gate.
Services + Traffic-Inspector (LOCAL_ONLY, spawn-capable) returned 403 when the
dashboard was reached via the LAN IP (192.168.0.x) instead of loopback. Add
isPrivateLanHost (RFC1918 IPv4 + IPv6 ULA/link-local) and widen ONLY the
local-only PATH gate to accept private-LAN socket peer IPs — based on the real
socket peer address (not the spoofable Host header), so public-internet clients
present public IPs and stay blocked. The CLI-token gate stays strictly loopback;
paths remain LOCAL_ONLY-classified (Hard Rules 15/17 unchanged). Enforcement-layer
carve-out for a LAN-deployed instance, authorized by the operator.
The pool usage snapshot can come back without a dimensions array (e.g. when the
plan resolves to empty for catalog-only providers). PoolCard.computeStatus and
hasDimensions read usage.dimensions.length directly, crashing the whole page
("Cannot read properties of undefined (reading 'length')"). Normalize to [] in
PoolCard and in usePoolsUsageAggregate (dimensions/perKey).
- Capture full search results (title/url/snippet) per provider, not just URLs.
- Render one column per selected provider: metrics header + result list
(title link, snippet, url), horizontal scroll for N providers.
- Mark results whose URL appears across providers with a star (overlap).
- Remove the 4-provider cap (MAX_PROVIDERS); add Select all / Clear; compare
every configured provider. Raise max_results 5 -> 10.
- Chat (StudioConfigPane): add Provider + Model selects reusing the translator
hooks; order Endpoint -> Provider -> Model; ConfigState gains optional provider.
- Compare (CompareTab): add a user-prompt input and include it in the request
body; throttle per-column stream updates via requestAnimationFrame to stop the
UI freeze (was setColumns per chunk x N columns with an empty user message).
- Build (BuildTab + build/BuildWizard): redesign as a guided 3-step wizard
(What to test -> Configure -> Run) reusing ToolsBuilder/StructuredOutputEditor;
all run/tool-call handlers preserved.
- i18n: playground.build.* (18 keys) in en + pt-BR.