Improve self-service provider quota visibility (#2931)

Integrated into release/v3.8.8
This commit is contained in:
guanbear
2026-05-31 08:18:50 +08:00
committed by GitHub
parent ec7233042c
commit e51ab949fa
11 changed files with 603 additions and 107 deletions

View File

@@ -61,36 +61,51 @@ Feature: Self-service API key usage and account quota visibility
Then the response status should be 200
And the response should not include shared account quota details
Scenario: Shared Codex account quota is visible with explicit permission
Scenario: Shared provider account quotas are visible with explicit permission
Given an API key named "team-a" has the scope "self:usage"
And "team-a" has the scope "self:account-quota"
And "team-a" is restricted to a Codex connection and a Claude connection
And Codex reports a session quota with 1 percent used
And Claude reports a daily quota with 35 percent used
When "team-a" calls GET "/api/v1/me/status" with its Bearer token
Then the response status should be 200
And the response accountQuotas should contain 2 entries
And the first response accountQuotas entry provider should be "codex"
And the first response accountQuotas entry quotas.session.remainingPercentage should be 99
And the second response accountQuotas entry provider should be "claude"
And the second response accountQuotas entry quotas.daily.remainingPercentage should be 65
Scenario: A single allowed provider also keeps the compatibility accountQuota field
Given an API key named "team-a" has the scope "self:usage"
And "team-a" has the scope "self:account-quota"
And "team-a" is restricted to exactly one Codex connection
And Codex reports a session quota with 1 percent used
And Codex reports a weekly quota with 97 percent used
When "team-a" calls GET "/api/v1/me/status" with its Bearer token
Then the response status should be 200
And the response accountQuotas should contain 1 entry
And the response accountQuota.provider should be "codex"
And the response accountQuota.shared should be true
And the response accountQuota.quotas.session.remainingPercentage should be 99
And the response accountQuota.quotas.weekly.remainingPercentage should be 3
Scenario: Account quota is not guessed for multi-connection keys
Given an API key named "team-a" has the scope "self:usage"
And "team-a" has the scope "self:account-quota"
And "team-a" is allowed to use two provider connections
When "team-a" calls GET "/api/v1/me/status" with its Bearer token
Then the response status should be 200
And the response accountQuota.available should be false
And the response accountQuota.reason should be "ambiguous_connection"
Scenario: Account quota is not guessed for unrestricted connection keys
Scenario: Unrestricted keys can see all active provider account quotas
Given an API key named "team-a" has the scope "self:usage"
And "team-a" has the scope "self:account-quota"
And "team-a" has no explicit allowed connection restrictions
And OmniRoute has active Codex and Cursor provider connections with quota data
When "team-a" calls GET "/api/v1/me/status" with its Bearer token
Then the response status should be 200
And the response accountQuota.available should be false
And the response accountQuota.reason should be "ambiguous_connection"
And the response accountQuotas should contain the Codex account quota
And the response accountQuotas should contain the Cursor account quota
Scenario: Provider connection lookup failures do not hide own usage
Given an API key named "team-a" has the scope "self:usage"
And "team-a" has the scope "self:account-quota"
And "team-a" is restricted to a Codex connection and another provider connection
And OmniRoute cannot resolve the other provider connection metadata
When "team-a" calls GET "/api/v1/me/status" with its Bearer token
Then the response status should be 200
And the response should still include own cost and token usage
And the unresolved response accountQuotas entry should have available false
And the unresolved response accountQuotas entry reason should be "connection_lookup_failed"
Scenario: Existing budget endpoint stays management-only
Given an API key named "team-a" has the scope "self:usage"

View File

@@ -21,7 +21,7 @@ In scope:
- New `GET /api/v1/me/status` endpoint authenticated by normal Bearer API key.
- New self-service API key scopes: `self:usage` and `self:account-quota`.
- Per-key cost and token aggregation for the calling key.
- Optional normalized provider account quota for unambiguous single-connection keys.
- Optional normalized provider account quotas for all provider-limit connections the key may use.
- API Manager create/edit controls for visibility scopes.
- Reuse the existing budget configuration surface for USD limits.
- i18n message keys for all new dashboard text.
@@ -46,7 +46,7 @@ The new scopes must not grant management access. Only `manage` and `admin` remai
- Scope editing in the current dashboard can collapse scopes to only management access; implementation must preserve unrelated scopes.
- Shared account quota can reveal account exhaustion; it must remain disabled by default.
- Multi-connection and unrestricted-connection keys are ambiguous; first implementation should decline account quota rather than guessing.
- Unrestricted keys can use all active provider connections, so account quota visibility enumerates all active provider-limit connections when explicitly permitted.
- Backfill must be idempotent so upgrades do not repeatedly rewrite API keys or re-enable a permission an operator later disabled.
- New UI text can regress non-English dashboards if translation keys are not added consistently.
- The current scope validation cap is 16 entries; adding self-service scopes may require raising that cap.

View File

@@ -119,30 +119,48 @@ The self-service endpoint SHALL include shared account quota only when the authe
- WHEN it calls the self-service endpoint
- THEN the response SHALL NOT include shared account quota details
#### Scenario: Codex quota shown with explicit permission
#### Scenario: Allowed provider quotas shown with explicit permission
- GIVEN a valid API key has `self:account-quota`
- AND it is restricted to exactly one Codex connection
- AND Codex quota data is available
- AND it is allowed to use Codex and Claude provider-limit connections
- AND quota data is available for both connections
- WHEN it calls the self-service endpoint
- THEN the response SHALL include normalized `session` and `weekly` quota windows
- THEN the response SHALL include an `accountQuotas` entry for each allowed provider-limit connection
- AND each window SHALL include used percentage, remaining percentage, and reset timestamp when known
#### Scenario: Multiple connections are ambiguous
#### Scenario: Single connection compatibility field
- GIVEN a valid API key has `self:account-quota`
- AND it is allowed to use more than one connection
- AND it is allowed to use exactly one provider-limit connection
- WHEN it calls the self-service endpoint
- THEN `accountQuota.available` SHALL be `false`
- AND `accountQuota.reason` SHALL be `ambiguous_connection`
- THEN the response SHALL include exactly one `accountQuotas` entry
- AND the response SHALL also include `accountQuota` with the same entry for backwards compatibility
#### Scenario: Unrestricted connections are ambiguous
#### Scenario: Unrestricted connections include active provider-limit connections
- GIVEN a valid API key has `self:account-quota`
- AND its `allowedConnections` list is empty, meaning all connections are allowed
- WHEN it calls the self-service endpoint
- THEN `accountQuota.available` SHALL be `false`
- AND `accountQuota.reason` SHALL be `ambiguous_connection`
- THEN the response SHALL include `accountQuotas` entries for active provider-limit connections
#### Scenario: Per-connection quota failure is isolated
- GIVEN a valid API key has `self:account-quota`
- AND it is allowed to use two provider-limit connections
- AND one provider quota fetch fails
- WHEN it calls the self-service endpoint
- THEN the successful provider SHALL remain in `accountQuotas`
- AND the failed provider SHALL be represented with `available: false` and `reason: "fetch_failed"`
#### Scenario: Provider connection lookup failure is isolated
- GIVEN a valid API key has `self:account-quota`
- AND it is explicitly allowed to use two provider-limit connections
- AND one provider connection lookup fails before quota fetching
- WHEN it calls the self-service endpoint
- THEN the successful provider SHALL remain in `accountQuotas`
- AND the unresolved connection SHALL be represented with `available: false` and `reason: "connection_lookup_failed"`
- AND the response SHALL still include the key's own cost and token usage
### Requirement: Dashboard configuration

View File

@@ -18,9 +18,10 @@
## 3. Account Quota
- [ ] Resolve account quota only when the key has `self:account-quota`.
- [ ] Use exactly one explicit allowed connection; treat unrestricted or multiple connections as ambiguous.
- [ ] Normalize Codex quota windows to `session` and `weekly`.
- [ ] Add tests for no scope, one connection, multiple connections, unsupported provider, and fetch failure.
- [ ] Enumerate all explicit allowed connections, or all active connections when `allowedConnections` is empty.
- [ ] Normalize quota windows for every provider-limit connection that returns quota data.
- [ ] Preserve the legacy `accountQuota` field when exactly one quota entry is returned.
- [ ] Add tests for no scope, one connection, multiple connections, unrestricted connections, unsupported provider, and fetch failure.
## 4. API Endpoint

View File

@@ -4,7 +4,7 @@
Operators often share one upstream coding account, such as Codex, across multiple OmniRoute API keys. OmniRoute already records per-key usage and supports per-key USD budgets, but a normal client API key cannot query its own spend or token totals. The existing usage APIs are management endpoints, so exposing them to each API key would disclose other keys, account metadata, and operational settings.
Operators also need a way to decide whether a key may see the shared upstream account quota. For Codex this includes the short session window and weekly window fetched from ChatGPT usage APIs. That quota is account-level state, not key-level state, so it should not be visible by default.
Operators also need a way to decide whether a key may see shared upstream account quotas. For Codex this includes the short session window and weekly window fetched from ChatGPT usage APIs, and other subscription providers can expose their own normalized provider-limit windows. That quota is account-level state, not key-level state, so it should not be visible by default.
The goal is to add a small self-service status API and matching dashboard controls so a delegated API key can see:
@@ -29,7 +29,7 @@ Relevant current implementation:
- `/api/v1/*` routes are public from the route classifier perspective, but individual handlers still validate Bearer API keys.
- Per-key USD budgets already exist through `domain_budgets`, `domain_cost_history`, `getCostSummary(apiKeyId)`, and `checkBudget(apiKeyId)`.
- Token usage is already recorded per key in `usage_history.api_key_id` with input, output, cache read, cache creation, and reasoning token columns.
- Provider quota data is fetched through `src/lib/usage/providerLimits.ts` and Codex quota support in `open-sse/services/codexQuotaFetcher.ts` / `open-sse/services/usage.ts`.
- Provider quota data is fetched through `src/lib/usage/providerLimits.ts` and provider usage support in `open-sse/services/usage.ts`.
- The API Manager UI currently has a management-access toggle on create/edit and sends `scopes: ["manage"]` or `[]`; the edit modal must be changed before adding more scope types so it does not discard unrelated scopes.
## Goals
@@ -101,16 +101,45 @@ The response contains only the caller's own API key identity, budget usage, toke
"totalTokens": 1067000
}
},
"accountQuotas": [
{
"provider": "codex",
"connectionId": "conn_123",
"shared": true,
"plan": "ChatGPT Plus",
"quotas": {
"session": {
"remainingPercentage": 99,
"usedPercentage": 1,
"resetAt": "2026-05-29T18:11:44.000Z"
},
"weekly": {
"remainingPercentage": 3,
"usedPercentage": 97,
"resetAt": "2026-05-31T01:23:38.000Z"
}
}
},
{
"provider": "claude",
"connectionId": "conn_456",
"shared": true,
"plan": "Claude Max",
"quotas": {
"daily": {
"remainingPercentage": 65,
"usedPercentage": 35,
"resetAt": "2026-05-30T00:00:00.000Z"
}
}
}
],
"accountQuota": {
"provider": "codex",
"connectionId": "conn_123",
"shared": true,
"plan": "ChatGPT Plus",
"quotas": {
"session": {
"remainingPercentage": 99,
"usedPercentage": 1,
"resetAt": "2026-05-29T18:11:44.000Z"
},
"weekly": {
"remainingPercentage": 3,
"usedPercentage": 97,
@@ -121,25 +150,53 @@ The response contains only the caller's own API key identity, budget usage, toke
}
```
`accountQuota` is omitted unless the key has the account quota scope. If the scope is present but the connection cannot be resolved safely, return:
`accountQuotas` is omitted unless the key has the account quota scope. `accountQuota` is retained as a compatibility alias only when exactly one account quota entry is returned. If a specific allowed connection cannot fetch quota data, include a per-connection unavailable entry:
```json
{
"accountQuotas": [
{
"provider": "cursor",
"connectionId": "conn_789",
"shared": true,
"available": false,
"reason": "fetch_failed"
}
],
"accountQuota": {
"provider": "cursor",
"connectionId": "conn_789",
"shared": true,
"available": false,
"reason": "ambiguous_connection"
"reason": "fetch_failed"
}
}
```
Use stable reason strings: `not_supported`, `ambiguous_connection`, `no_allowed_connection`, `not_available`, and `fetch_failed`.
If explicit connection metadata lookup fails before the provider is known, return the unresolved connection as unavailable without failing the whole status response:
```json
{
"accountQuotas": [
{
"provider": "unknown",
"connectionId": "conn_789",
"shared": true,
"available": false,
"reason": "connection_lookup_failed"
}
]
}
```
Use stable reason strings: `not_supported`, `not_available`, `fetch_failed`, and `connection_lookup_failed`.
## Scopes
Add self-service scopes that do not grant management access:
- `self:usage`: allows a key to query its own spend, budget percent, and token totals.
- `self:account-quota`: allows a key to see shared upstream account quota for its resolved connection.
- `self:account-quota`: allows a key to see shared upstream account quotas for provider-limit connections it may use.
`self:usage` should be enabled by default for newly created ordinary API keys. The UI should show it checked by default and persist the scope when the control is enabled. For backwards compatibility, the implementation should backfill `self:usage` onto existing ordinary keys during migration or first startup after upgrade. After that compatibility step, absence of `self:usage` means own-usage visibility is disabled and the self-service endpoint returns `403`.
@@ -181,19 +238,21 @@ WHERE api_key_id = ?
Account quota is shared provider state. The self-service endpoint may include it only when:
- The API key has `self:account-quota`.
- A single provider connection can be resolved without ambiguity.
- The provider supports quota fetching.
- Provider connections can be resolved from the key's allowed connection policy.
- The providers support quota fetching through the provider limits path.
Connection resolution must follow the source semantics for `allowedConnections`: an empty array means unrestricted access to all connections, not "no connections".
- If exactly one explicit allowed connection exists and it resolves to a quota-supported provider, use that connection.
- If `allowedConnections` is empty, treat the connection scope as ambiguous and return `available: false` with `ambiguous_connection`. This avoids exposing shared quota for a broad/unrestricted key.
- If explicit allowed connection ids are present but none resolve, return `available: false` with `no_allowed_connection`.
- If multiple explicit allowed connections exist, return `available: false` with `ambiguous_connection`.
- If explicit allowed connection ids are present, fetch quota data for those active provider-limit connections.
- If `allowedConnections` is empty, fetch quota data for all active provider-limit connections because the key may use all of them.
- If an allowed connection is inactive, missing, or unsupported, skip it or return a per-connection `not_supported` entry when the connection identity is known.
- If explicit connection lookup fails, keep the rest of the response and return that connection as `available: false` with `connection_lookup_failed`.
- If unrestricted connection listing fails, keep the rest of the response and return an empty `accountQuotas` array because no allowed connection identities can be resolved.
- If a provider quota fetch fails, keep the rest of the response and return that connection as `available: false` with `fetch_failed`.
This conservative rule avoids accidentally exposing quota for an account the key may not actually use. A later change can add an explicitly authorized `?connectionId=` flow if there is demand for multi-connection keys.
This rule matches routing permissions: the endpoint exposes only account quotas for connections the key is allowed to use, and only when the operator explicitly grants `self:account-quota`.
For Codex, reuse the existing provider limits / Codex quota path. Normalize Codex windows to `session` and `weekly` and return used/remaining percentages plus reset timestamps. Do not return raw upstream payloads.
Reuse the existing provider limits path. Normalize every returned quota window to used/remaining percentages plus reset timestamps. Do not return raw upstream payloads.
## Dashboard UX
@@ -218,7 +277,7 @@ Usage display:
- In the key list or details panel, show USD used, active USD limit, and used percent when a budget exists.
- Show token totals in a compact details view.
- Show shared account quota only for keys with `self:account-quota`, clearly labeled as shared account quota, not per-key quota.
- Show shared account quotas only for keys with `self:account-quota`, clearly labeled as shared account quota, not per-key quota.
- When no USD budget is configured, show usage normally and render the limit, remaining amount, and percent as unset/not configured rather than `0%`.
## Internationalization
@@ -287,7 +346,7 @@ Never include:
- Upstream access tokens or refresh tokens.
- Provider account email unless that email is already visible to this key through another client API.
- Other keys' spend, token totals, names, or budgets.
- Raw ChatGPT/Codex usage payloads.
- Raw upstream usage payloads.
Account quota should be treated as sensitive because it lets delegated users infer shared account exhaustion. The default remains off.
@@ -297,7 +356,7 @@ Account quota should be treated as sensitive because it lets delegated users inf
- Valid key without `self:usage`: `403`.
- Budget missing: `200` with null limit and percent fields.
- Usage aggregation failure: `500` with generic message; log server-side details.
- Quota fetch unsupported or unavailable: `200` with `accountQuota.available: false`.
- Quota fetch unsupported or unavailable: `200` with per-connection unavailable entries in `accountQuotas`.
- Quota fetch auth failure: do not leak provider auth details; return `not_available` or `fetch_failed` and log details server-side.
## Testing
@@ -310,9 +369,11 @@ Add focused tests:
- A normal key with `self:usage` can query its own cost and token totals without `manage`.
- The endpoint never accepts an `apiKeyId` override.
- Key A cannot see Key B usage.
- A key without account quota scope does not receive `accountQuota`.
- A key with account quota scope and one allowed Codex connection receives normalized session and weekly quota.
- Unrestricted or multiple allowed connections return `ambiguous_connection`.
- A key without account quota scope does not receive `accountQuotas`.
- A key with account quota scope and one allowed Codex connection receives normalized session and weekly quota plus the compatibility `accountQuota` field.
- A key with account quota scope and multiple allowed provider-limit connections receives multiple `accountQuotas` entries.
- A key with account quota scope and unrestricted connection access receives all active provider-limit connection quotas.
- A failed provider quota fetch returns an unavailable entry without hiding successful provider quota entries.
- Create UI defaults own usage on and shared quota off.
- Edit UI preserves unrelated scopes.
- UI renders the no-budget state as not configured, with usage and token totals still visible.

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo, useCallback, memo } from "react";
import { useState, useEffect, useMemo, useCallback, useId, useRef, memo } from "react";
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useTranslations } from "next-intl";
@@ -130,6 +130,8 @@ type ProviderGroup = [provider: string, models: Model[]];
export default function ApiManagerPageClient() {
const t = useTranslations("apiManager");
const tc = useTranslations("common");
const newKeyNameInputId = useId();
const createKeyFormRef = useRef<HTMLDivElement | null>(null);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [allModels, setAllModels] = useState<Model[]>([]);
const [allCombos, setAllCombos] = useState<ComboOption[]>([]);
@@ -159,6 +161,17 @@ export default function ApiManagerPageClient() {
const { copied, copy } = useCopyToClipboard();
const scrollCreateKeyFormToTop = useCallback(() => {
const scrollContainer = createKeyFormRef.current?.parentElement;
if (scrollContainer instanceof HTMLElement) {
scrollContainer.scrollTop = 0;
}
const input = document.getElementById(newKeyNameInputId);
input?.scrollIntoView({ block: "nearest", inline: "nearest" });
input?.focus({ preventScroll: true });
}, [newKeyNameInputId]);
useEffect(() => {
fetchData();
fetchModels();
@@ -174,6 +187,16 @@ export default function ApiManagerPageClient() {
writeActiveOnlyPreference(activeOnly);
}, [activeOnly]);
useEffect(() => {
if (!showAddModal || !nameError) return;
const timeout = window.setTimeout(() => {
scrollCreateKeyFormToTop();
}, 0);
return () => window.clearTimeout(timeout);
}, [showAddModal, nameError, scrollCreateKeyFormToTop]);
const fetchModels = async () => {
try {
const res = await fetch("/v1/models");
@@ -346,6 +369,7 @@ export default function ApiManagerPageClient() {
// Validate raw input first, then sanitize
const validation = validateKeyName(newKeyName, t);
if (!validation.valid) {
scrollCreateKeyFormToTop();
setNameError(validation.error || t("invalidKeyName"));
return;
}
@@ -1011,6 +1035,7 @@ export default function ApiManagerPageClient() {
<Modal
isOpen={showAddModal}
title={t("createKey")}
bodyClassName="p-6 max-h-[calc(100vh-150px)] overflow-y-auto"
onClose={() => {
setShowAddModal(false);
setNewKeyName("");
@@ -1021,12 +1046,13 @@ export default function ApiManagerPageClient() {
setCreateError(null);
}}
>
<div className="flex flex-col gap-4">
<div ref={createKeyFormRef} className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium text-text-main mb-1.5 block">
{t("keyName")}
</label>
<Input
id={newKeyNameInputId}
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value);
@@ -1909,11 +1935,10 @@ const PermissionsModal = memo(function PermissionsModal({
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("managementAccess")}</p>
<p className="text-xs text-text-muted">
Allow this API key to manage OmniRoute configuration.
</p>
<p className="text-xs text-text-muted">{t("managementAccessDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={manageEnabled}
onClick={() => setManageEnabled((prev) => !prev)}
@@ -1934,6 +1959,7 @@ const PermissionsModal = memo(function PermissionsModal({
<p className="text-xs text-text-muted">{t("selfServiceVisibilityDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={selfUsageEnabled}
onClick={() =>
@@ -1953,6 +1979,7 @@ const PermissionsModal = memo(function PermissionsModal({
</button>
<p className="text-xs text-text-muted">{t("ownUsageVisibilityDesc")}</p>
<button
type="button"
role="switch"
aria-checked={selfAccountQuotaEnabled}
disabled={!selfUsageEnabled}

View File

@@ -2,8 +2,10 @@ import {
hasSelfAccountQuotaScope,
hasSelfUsageScope,
} from "@/shared/constants/selfServiceScopes";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
type JsonRecord = Record<string, unknown>;
type DateLike = number | string | Date | null | undefined;
interface ApiKeySelfServiceMetadata {
id: string;
@@ -26,9 +28,9 @@ interface CostSummaryLike {
totalCostPeriod: number;
activeLimitUsd: number;
resetInterval: string | null;
budgetResetAt: number | null;
periodStartAt: number | null;
nextResetAt: number | null;
budgetResetAt: DateLike;
periodStartAt: DateLike;
nextResetAt: DateLike;
warningThreshold: number | null;
}
@@ -36,6 +38,7 @@ type GetCostSummaryFn = (apiKeyId: string) => CostSummaryLike;
type CheckBudgetFn = (apiKeyId: string) => unknown;
type GetDbInstanceFn = () => DbLike;
type GetProviderConnectionByIdFn = (connectionId: string) => Promise<unknown>;
type GetProviderConnectionsFn = (filters?: Record<string, unknown>) => Promise<unknown[]>;
type FetchAndPersistProviderLimitsFn = (
connectionId: string,
source: "manual"
@@ -47,6 +50,7 @@ interface ApiKeySelfServiceDeps {
checkBudget?: CheckBudgetFn;
getDbInstance?: GetDbInstanceFn;
getProviderConnectionById?: GetProviderConnectionByIdFn;
getProviderConnections?: GetProviderConnectionsFn;
fetchAndPersistProviderLimits?: FetchAndPersistProviderLimitsFn;
}
@@ -59,6 +63,12 @@ interface TokenTotals {
totalTokens: number;
}
interface AccountQuotaConnection {
id: string;
provider: string;
lookupFailed?: boolean;
}
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "bigint") return Number(value);
@@ -74,17 +84,30 @@ function roundNumber(value: number, precision = 6): number {
return Number(value.toFixed(precision));
}
function isoOrNull(value: number | string | null | undefined): string | null {
function dateMsOrNull(value: DateLike): number | null {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return new Date(value).toISOString();
return value;
}
if (value instanceof Date) {
const parsed = value.getTime();
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
if (typeof value === "string" && value.trim()) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return null;
}
function isoOrNull(value: DateLike): string | null {
const timestamp = dateMsOrNull(value);
return timestamp === null ? null : new Date(timestamp).toISOString();
}
function withDateFallback(value: DateLike, fallback: number): DateLike {
return isoOrNull(value) === null ? fallback : value;
}
function getCurrentMonthWindow(now: number) {
const date = new Date(now);
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0);
@@ -96,10 +119,10 @@ function buildCostStatus(summary: CostSummaryLike, now: number) {
const hasBudget = !!summary.budget && toNumber(summary.activeLimitUsd) > 0;
const fallbackWindow = getCurrentMonthWindow(now);
const periodStartAt = hasBudget
? toNumber(summary.periodStartAt, fallbackWindow.periodStartAt)
? withDateFallback(summary.periodStartAt, fallbackWindow.periodStartAt)
: fallbackWindow.periodStartAt;
const resetAt = hasBudget
? toNumber(summary.nextResetAt ?? summary.budgetResetAt, fallbackWindow.resetAt)
? withDateFallback(summary.nextResetAt ?? summary.budgetResetAt, fallbackWindow.resetAt)
: fallbackWindow.resetAt;
const usedUsd = hasBudget
? roundNumber(toNumber(summary.totalCostPeriod))
@@ -177,66 +200,170 @@ function quotaWindow(value: unknown) {
remainingPercentage: Number.isFinite(remainingPercentage)
? roundNumber(remainingPercentage, 2)
: roundNumber(100 - usedPercentage, 2),
resetAt: isoOrNull(record.resetAt as string | number | null | undefined),
resetAt: isoOrNull(record.resetAt as DateLike),
};
}
async function resolveAccountQuota(metadata: ApiKeySelfServiceMetadata, deps: RequiredDeps) {
if (!hasSelfAccountQuotaScope(metadata.scopes)) return undefined;
function normalizePlan(value: unknown): unknown {
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "boolean") return value;
return undefined;
}
function isSupportedProvider(provider: string): boolean {
return USAGE_SUPPORTED_PROVIDERS.includes(provider as (typeof USAGE_SUPPORTED_PROVIDERS)[number]);
}
function getConnectionIdentity(value: unknown): { id: string; provider: string } | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as JsonRecord;
if (record.isActive === false) return null;
const id = typeof record.id === "string" ? record.id : "";
const provider = typeof record.provider === "string" ? record.provider : "";
if (!id || !provider) return null;
return { id, provider };
}
async function listAccountQuotaConnections(
metadata: ApiKeySelfServiceMetadata,
deps: RequiredDeps
) {
const allowedConnections = Array.isArray(metadata.allowedConnections)
? metadata.allowedConnections
: [];
if (allowedConnections.length !== 1) {
return unavailableAccountQuota("ambiguous_connection");
const rawConnections =
allowedConnections.length > 0
? await Promise.all(
allowedConnections.map(async (id) => {
try {
return await deps.getProviderConnectionById(id);
} catch {
return { id, provider: "unknown", lookupFailed: true };
}
})
)
: await deps.getProviderConnections({ isActive: true }).catch(() => []);
const connections: AccountQuotaConnection[] = [];
const seen = new Set<string>();
for (const rawConnection of rawConnections) {
if (
rawConnection &&
typeof rawConnection === "object" &&
!Array.isArray(rawConnection) &&
(rawConnection as JsonRecord).lookupFailed === true
) {
const record = rawConnection as JsonRecord;
const id = typeof record.id === "string" ? record.id : "";
const provider = typeof record.provider === "string" ? record.provider : "unknown";
if (!id || seen.has(id)) continue;
seen.add(id);
connections.push({ id, provider, lookupFailed: true });
continue;
}
const connection = getConnectionIdentity(rawConnection);
if (!connection || seen.has(connection.id)) continue;
seen.add(connection.id);
connections.push(connection);
}
const connection = (await deps.getProviderConnectionById(allowedConnections[0])) as
| JsonRecord
| null;
if (!connection) {
return unavailableAccountQuota("no_allowed_connection");
return connections;
}
function normalizeQuotaWindows(quotas: JsonRecord | null) {
if (!quotas) return null;
const normalized: Record<string, ReturnType<typeof quotaWindow>> = {};
for (const [key, value] of Object.entries(quotas)) {
const window = quotaWindow(value);
if (window) normalized[key] = window;
}
const provider = typeof connection.provider === "string" ? connection.provider : "";
if (provider !== "codex") {
return unavailableAccountQuota("not_supported");
return Object.keys(normalized).length > 0 ? normalized : null;
}
async function resolveConnectionAccountQuota(
connection: AccountQuotaConnection,
deps: RequiredDeps
) {
if (connection.lookupFailed) {
return {
provider: connection.provider,
connectionId: connection.id,
shared: true,
...unavailableAccountQuota("connection_lookup_failed"),
};
}
if (!isSupportedProvider(connection.provider)) {
return {
provider: connection.provider,
connectionId: connection.id,
shared: true,
...unavailableAccountQuota("not_supported"),
};
}
try {
const result = await deps.fetchAndPersistProviderLimits(allowedConnections[0], "manual");
const result = await deps.fetchAndPersistProviderLimits(connection.id, "manual");
const usage = result.usage as JsonRecord;
const quotas =
usage.quotas && typeof usage.quotas === "object" && !Array.isArray(usage.quotas)
? (usage.quotas as JsonRecord)
: null;
if (!quotas) return unavailableAccountQuota("not_available");
const normalizedQuotas = normalizeQuotaWindows(quotas);
const plan = normalizePlan(usage.plan);
const session = quotaWindow(quotas.session);
const weekly = quotaWindow(quotas.weekly);
if (!session && !weekly) return unavailableAccountQuota("not_available");
if (!normalizedQuotas && plan === undefined) {
return {
provider: connection.provider,
connectionId: connection.id,
shared: true,
...unavailableAccountQuota("not_available"),
};
}
return {
provider,
connectionId: allowedConnections[0],
provider: connection.provider,
connectionId: connection.id,
shared: true,
quotas: {
...(session && { session }),
...(weekly && { weekly }),
},
...(plan !== undefined && { plan }),
...(normalizedQuotas && { quotas: normalizedQuotas }),
};
} catch {
return unavailableAccountQuota("fetch_failed");
return {
provider: connection.provider,
connectionId: connection.id,
shared: true,
...unavailableAccountQuota("fetch_failed"),
};
}
}
async function resolveAccountQuotas(metadata: ApiKeySelfServiceMetadata, deps: RequiredDeps) {
if (!hasSelfAccountQuotaScope(metadata.scopes)) return undefined;
const connections = await listAccountQuotaConnections(metadata, deps);
return Promise.all(
connections.map((connection) => resolveConnectionAccountQuota(connection, deps))
);
}
type RequiredDeps = Required<ApiKeySelfServiceDeps>;
async function normalizeDeps(deps: ApiKeySelfServiceDeps): Promise<RequiredDeps> {
const costRules =
deps.getCostSummary && deps.checkBudget ? null : await import("@/domain/costRules");
const dbCore = deps.getDbInstance ? null : await import("@/lib/db/core");
const localDb = deps.getProviderConnectionById ? null : await import("@/lib/localDb");
const localDb =
deps.getProviderConnectionById && deps.getProviderConnections
? null
: await import("@/lib/localDb");
const providerLimits = deps.fetchAndPersistProviderLimits
? null
: await import("@/lib/usage/providerLimits");
@@ -247,6 +374,7 @@ async function normalizeDeps(deps: ApiKeySelfServiceDeps): Promise<RequiredDeps>
checkBudget: deps.checkBudget ?? costRules!.checkBudget,
getDbInstance: deps.getDbInstance ?? dbCore!.getDbInstance,
getProviderConnectionById: deps.getProviderConnectionById ?? localDb!.getProviderConnectionById,
getProviderConnections: deps.getProviderConnections ?? localDb!.getProviderConnections,
fetchAndPersistProviderLimits:
deps.fetchAndPersistProviderLimits ?? providerLimits!.fetchAndPersistProviderLimits,
};
@@ -270,7 +398,9 @@ export async function buildApiKeySelfServiceStatus(
metadata.id,
cost.periodStartAt ?? new Date(getCurrentMonthWindow(resolvedDeps.now()).periodStartAt).toISOString()
);
const accountQuota = await resolveAccountQuota(metadata, resolvedDeps);
const accountQuotas = await resolveAccountQuotas(metadata, resolvedDeps);
const accountQuota =
accountQuotas && accountQuotas.length === 1 ? accountQuotas[0] : undefined;
return {
apiKey: {
@@ -284,6 +414,7 @@ export async function buildApiKeySelfServiceStatus(
...tokens,
},
},
...(accountQuotas !== undefined && { accountQuotas }),
...(accountQuota !== undefined && { accountQuota }),
};
}

View File

@@ -137,7 +137,6 @@ test.describe("Combo Unification", () => {
test("combos page exposes strategy tabs and intelligent panel", async ({ page }) => {
await gotoDashboardRoute(page, "/dashboard/combos?filter=intelligent");
await page.waitForLoadState("networkidle");
await expect(
page
@@ -159,7 +158,6 @@ test.describe("Combo Unification", () => {
test("sidebar no longer shows auto combo entry", async ({ page }) => {
await gotoDashboardRoute(page, "/dashboard/combos");
await page.waitForLoadState("networkidle");
const sidebar = page.locator("aside, nav").first();
await expect(sidebar.getByText("Combos", { exact: true })).toBeVisible();
@@ -168,7 +166,6 @@ test.describe("Combo Unification", () => {
test("builder shows intelligent step when auto strategy is selected", async ({ page }) => {
await gotoDashboardRoute(page, "/dashboard/combos");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /create combo/i }).click();
await page.getByLabel(/combo name/i).waitFor({ state: "visible" });

View File

@@ -91,6 +91,7 @@ function makeDeps(overrides: Record<string, unknown> = {}) {
}),
}),
getProviderConnectionById: async () => null,
getProviderConnections: async () => [],
fetchAndPersistProviderLimits: async () => {
throw new Error("unexpected quota fetch");
},
@@ -157,24 +158,228 @@ test("self-service status reports USD budget percentage using the budget period"
assert.equal(status.usage.cost.resetAt, "2026-06-01T00:00:00.000Z");
});
test("self-service status treats unrestricted account quota connection access as ambiguous", async () => {
test("self-service status preserves ISO and Date budget timestamps", async () => {
const metadata = {
id: "key-budget-date",
name: "budgeted date",
scopes: [SELF_USAGE_SCOPE],
allowedConnections: [],
};
const { deps, dbParams } = makeDeps({
getCostSummary: () => ({
budget: { resetInterval: "weekly" },
totalCostMonth: 99,
totalCostPeriod: 15,
activeLimitUsd: 60,
resetInterval: "weekly",
resetTime: "00:00",
budgetResetAt: null,
lastBudgetResetAt: null,
periodStartAt: "2026-05-18T00:00:00.000Z",
nextResetAt: new Date("2026-05-25T00:00:00.000Z"),
warningThreshold: 0.8,
}),
});
const status = await buildApiKeySelfServiceStatus(metadata, deps);
assert.equal(status.usage.cost.periodStartAt, "2026-05-18T00:00:00.000Z");
assert.equal(status.usage.cost.resetAt, "2026-05-25T00:00:00.000Z");
assert.equal(dbParams[0][1], "2026-05-18T00:00:00.000Z");
});
test("self-service status reports all explicitly allowed provider account quotas", async () => {
const metadata = {
id: "key-multi",
name: "multi",
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
allowedConnections: ["conn-codex", "conn-claude"],
};
const fetches: string[] = [];
const { deps } = makeDeps({
getProviderConnectionById: async (connectionId: string) => ({
id: connectionId,
provider: connectionId === "conn-codex" ? "codex" : "claude",
isActive: true,
}),
fetchAndPersistProviderLimits: async (connectionId: string) => {
fetches.push(connectionId);
if (connectionId === "conn-codex") {
return {
connection: { id: connectionId, provider: "codex" },
usage: {
plan: "ChatGPT Plus",
quotas: {
session: { used: 1, remaining: 99, resetAt: "2026-05-29T18:11:44.000Z" },
},
},
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
};
}
return {
connection: { id: connectionId, provider: "claude" },
usage: {
plan: "Claude Max",
quotas: {
daily: { usedPercentage: 35, remainingPercentage: 65, resetAt: "2026-05-30T00:00:00.000Z" },
},
},
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
};
},
});
const status = await buildApiKeySelfServiceStatus(metadata, deps);
assert.deepEqual(fetches, ["conn-codex", "conn-claude"]);
assert.deepEqual(
status.accountQuotas.map((quota: { connectionId: string }) => quota.connectionId),
["conn-codex", "conn-claude"]
);
assert.equal(status.accountQuotas[0].provider, "codex");
assert.equal(status.accountQuotas[0].plan, "ChatGPT Plus");
assert.equal(status.accountQuotas[0].quotas.session.remainingPercentage, 99);
assert.equal(status.accountQuotas[1].provider, "claude");
assert.equal(status.accountQuotas[1].plan, "Claude Max");
assert.equal(status.accountQuotas[1].quotas.daily.usedPercentage, 35);
});
test("self-service status reports all active provider account quotas for unrestricted keys", async () => {
const metadata = {
id: "key-unrestricted",
name: "unrestricted",
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
allowedConnections: [],
};
const { deps } = makeDeps();
const { deps } = makeDeps({
getProviderConnections: async () => [
{ id: "conn-codex", provider: "codex", isActive: true },
{ id: "conn-cursor", provider: "cursor", isActive: true },
{ id: "conn-disabled", provider: "claude", isActive: false },
],
fetchAndPersistProviderLimits: async (connectionId: string) => ({
connection: { id: connectionId, provider: connectionId === "conn-codex" ? "codex" : "cursor" },
usage: {
plan: connectionId === "conn-codex" ? "ChatGPT Plus" : "Cursor Pro",
quotas: {
monthly: { used: 25, remaining: 75, resetAt: "2026-06-01T00:00:00.000Z" },
},
},
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
}),
});
const status = await buildApiKeySelfServiceStatus(metadata, deps);
assert.deepEqual(status.accountQuota, {
assert.deepEqual(
status.accountQuotas.map((quota: { connectionId: string }) => quota.connectionId),
["conn-codex", "conn-cursor"]
);
assert.equal(status.accountQuotas[0].quotas.monthly.remainingPercentage, 75);
assert.equal(status.accountQuotas[1].plan, "Cursor Pro");
});
test("self-service status isolates provider account quota fetch failures per connection", async () => {
const metadata = {
id: "key-partial",
name: "partial",
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
allowedConnections: ["conn-codex", "conn-cursor"],
};
const { deps } = makeDeps({
getProviderConnectionById: async (connectionId: string) => ({
id: connectionId,
provider: connectionId === "conn-codex" ? "codex" : "cursor",
isActive: true,
}),
fetchAndPersistProviderLimits: async (connectionId: string) => {
if (connectionId === "conn-cursor") throw new Error("upstream unavailable");
return {
connection: { id: connectionId, provider: "codex" },
usage: {
quotas: {
weekly: { used: 40, remaining: 60, resetAt: "2026-06-01T00:00:00.000Z" },
},
},
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
};
},
});
const status = await buildApiKeySelfServiceStatus(metadata, deps);
assert.equal(status.accountQuotas[0].connectionId, "conn-codex");
assert.equal(status.accountQuotas[0].quotas.weekly.remainingPercentage, 60);
assert.deepEqual(status.accountQuotas[1], {
provider: "cursor",
connectionId: "conn-cursor",
shared: true,
available: false,
reason: "ambiguous_connection",
reason: "fetch_failed",
});
});
test("self-service status normalizes Codex account quota only for one explicit connection", async () => {
test("self-service status isolates explicit provider connection lookup failures", async () => {
const metadata = {
id: "key-lookup-partial",
name: "lookup partial",
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
allowedConnections: ["conn-codex", "conn-missing"],
};
const { deps } = makeDeps({
getProviderConnectionById: async (connectionId: string) => {
if (connectionId === "conn-missing") throw new Error("database unavailable");
return {
id: connectionId,
provider: "codex",
isActive: true,
};
},
fetchAndPersistProviderLimits: async (connectionId: string) => ({
connection: { id: connectionId, provider: "codex" },
usage: {
quotas: {
weekly: { used: 40, remaining: 60, resetAt: "2026-06-01T00:00:00.000Z" },
},
},
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
}),
});
const status = await buildApiKeySelfServiceStatus(metadata, deps);
assert.equal(status.accountQuotas[0].connectionId, "conn-codex");
assert.equal(status.accountQuotas[0].quotas.weekly.remainingPercentage, 60);
assert.deepEqual(status.accountQuotas[1], {
provider: "unknown",
connectionId: "conn-missing",
shared: true,
available: false,
reason: "connection_lookup_failed",
});
});
test("self-service status keeps usage visible when unrestricted provider lookup fails", async () => {
const metadata = {
id: "key-lookup-failed",
name: "lookup failed",
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
allowedConnections: [],
};
const { deps } = makeDeps({
getProviderConnections: async () => {
throw new Error("database unavailable");
},
});
const status = await buildApiKeySelfServiceStatus(metadata, deps);
assert.equal(status.usage.cost.usedUsd, 12.34);
assert.deepEqual(status.accountQuotas, []);
assert.equal("accountQuota" in status, false);
});
test("self-service status normalizes Codex account quota for one explicit connection", async () => {
const metadata = {
id: "key-codex",
name: "codex",
@@ -200,6 +405,8 @@ test("self-service status normalizes Codex account quota only for one explicit c
const status = await buildApiKeySelfServiceStatus(metadata, deps);
assert.equal(status.accountQuotas.length, 1);
assert.deepEqual(status.accountQuotas[0], status.accountQuota);
assert.deepEqual(status.accountQuota, {
provider: "codex",
connectionId: "conn-codex",

View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const pagePath = path.join(
repoRoot,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx"
);
function readApiManagerPage() {
return fs.readFileSync(pagePath, "utf8");
}
test("permissions modal uses i18n for management access description", () => {
const source = readApiManagerPage();
const managementBlock = source.slice(
source.indexOf("{/* Management Access */}", source.indexOf("const PermissionsModal")),
source.indexOf("{/* Self-service Visibility */}", source.indexOf("const PermissionsModal"))
);
assert.match(managementBlock, /\{t\("managementAccessDesc"\)\}/);
assert.doesNotMatch(managementBlock, /Allow this API key to manage OmniRoute configuration\./);
});
test("permissions modal switch buttons declare button type", () => {
const source = readApiManagerPage();
const modalStart = source.indexOf("const PermissionsModal");
const visibilityStart = source.indexOf("{/* Self-service Visibility */}", modalStart);
const visibilityEnd = source.indexOf("{/* Selected Models Summary", visibilityStart);
const selfServiceBlock = source.slice(visibilityStart, visibilityEnd);
const switchButtonCount = (selfServiceBlock.match(/role="switch"/g) ?? []).length;
const typedSwitchButtonCount = (selfServiceBlock.match(/<button\s+type="button"\s+role="switch"/g) ?? [])
.length;
assert.equal(switchButtonCount, 2);
assert.equal(typedSwitchButtonCount, 2);
});

View File

@@ -426,8 +426,7 @@ test("chatCore times out upstream execution before provider response headers", a
() =>
Object.values(getPendingRequests().details[connectionId] || {}).find(
(detail: any) => detail?.providerRequest
),
150
)
)) as any;
assert.equal(pendingDetail?.providerRequest?.model, "gpt-4o-mini");
assert.deepEqual(pendingDetail?.providerRequest?.messages, body.messages);