From 46de00559b7a135c69f5fe229571267b0d028e68 Mon Sep 17 00:00:00 2001
From: fylorn <249551762+fylorn@users.noreply.github.com>
Date: Tue, 20 Oct 2026 21:20:41 +0800
Subject: [PATCH] feat(web): carry a key's request limits in the Add to
ThinkWatch Lite link
The rotated-key dialog reads the key's own rate-limit rules and puts the
ones Lite can hold exactly into the link's `limits` parameter: enabled,
unexpired AI-gateway request rules over whole minutes up to 60 or whole
hours up to 24 become Lite's rolling minute/hour windows. Token rules are
left out (the gateway counts weighted tokens, Lite plain ones), as are
sliding 7-day rules, budgets, MCP rules and the owner's limits. Duplicate
windows keep the smaller count; at most 16 windows, the lowest rates.
A link with limits needs Lite 2026.10.26, which the dialog states. When
the rules cannot be read the link is built without them, as before.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 18 +++
web/src/components/add-to-lite.tsx | 61 +++++---
web/src/components/limits/key-limits-query.ts | 12 ++
web/src/i18n/en.json | 3 +-
web/src/i18n/zh.json | 3 +-
web/src/lib/lite-link.test.ts | 133 +++++++++++++++++-
web/src/lib/lite-link.ts | 73 +++++++++-
web/src/routes/api-key-dialogs.tsx | 8 ++
web/src/routes/api-keys.test.tsx | 102 ++++++++++++++
9 files changed, 392 insertions(+), 21 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fdad7da..8f8b8dc8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,24 @@ target.
## [Unreleased]
+### Added
+
+- **The rotated-key dialog's Add to ThinkWatch Lite link carries the key's
+ request limits.** The key's own enabled, unexpired AI-gateway rules that
+ count requests over a window of whole minutes up to 60, or whole hours
+ up to 24 (the console's 1 minute, 5 minutes, 1 hour, 5 hours and 24
+ hours among them), go into the link's `limits` parameter as Lite's
+ rolling windows of the same length; of two rules on one window the
+ smaller count goes, and at most 16 windows, those allowing the fewest
+ requests per second. Token rules stay out, since they count weighted
+ tokens and Lite counts plain ones; so do sliding 7-day rules (Lite's
+ week is a calendar week), budgets, MCP rules and the owner's limits.
+ A link with limits needs ThinkWatch Lite 2026.10.26 or later, which the
+ dialog states under the button. Reading the rules needs
+ `rate_limits:read` in a scope covering the key; without it, or when the
+ rules cannot be read, the link is built without limits as before. A new
+ key has no limits of its own yet, so its link carries none.
+
## [3.8.3] — 2026-10-20
Tool-call inspection in streamed answers now decides only on arguments
diff --git a/web/src/components/add-to-lite.tsx b/web/src/components/add-to-lite.tsx
index a5a31171..33a96a33 100644
--- a/web/src/components/add-to-lite.tsx
+++ b/web/src/components/add-to-lite.tsx
@@ -1,15 +1,27 @@
import { useId, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { MonitorDown } from 'lucide-react';
+import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { useGatewayUrl } from '@/hooks/use-gateway-url';
import { hasPermission } from '@/lib/api';
-import { LITE_MIN_VERSION, liteDownloadUrl, liteImportLink } from '@/lib/lite-link';
+import { keyRulesQuery } from '@/components/limits/key-limits-query';
+import {
+ LITE_LIMITS_MIN_VERSION,
+ LITE_MIN_VERSION,
+ liteDownloadUrl,
+ liteImportLink,
+ liteLimits,
+} from '@/lib/lite-link';
import { openAppLink } from '@/lib/open-app-link';
interface AddToLiteProps {
/// The plaintext key, as shown on this screen.
apiKey: string;
+ /// The key's id, where the screen has it: the link then carries the
+ /// key's own request limits, read with `rate_limits:read`. Without the
+ /// id, the permission, or a readable answer, the link goes without them.
+ keyId?: string;
/// Whether the key may call the AI gateway (`ai_gateway` in its surfaces).
aiGateway: boolean;
/// Whether the key's owner holds `ai_gateway:use`, without which the
@@ -28,15 +40,26 @@ interface AddToLiteProps {
/// key on it — with an error page. Lite is a desktop app: below the `md`
/// breakpoint (where the console's mobile layout starts) only the Copy
/// button shows.
-export function AddToLite({ apiKey, aiGateway, ownerMayUseGateway, children }: AddToLiteProps) {
+export function AddToLite({ apiKey, keyId, aiGateway, ownerMayUseGateway, children }: AddToLiteProps) {
const { t, i18n } = useTranslation();
const { baseUrl, known, failed } = useGatewayUrl();
const [opened, setOpened] = useState(false);
const reasonId = useId();
+ const limitsId = useId();
const mayUseGateway = ownerMayUseGateway ?? hasPermission('ai_gateway:use');
- const built = known ? liteImportLink(baseUrl, apiKey) : undefined;
+ // Until the rules come (and when they cannot be read) the link goes
+ // without limits rather than holding the button back.
+ const rules = useQuery({
+ ...keyRulesQuery(keyId ?? ''),
+ enabled: !!keyId && aiGateway && hasPermission('rate_limits:read'),
+ retry: false,
+ });
+ const limits = rules.data ? liteLimits(rules.data) : [];
+ const built = known ? liteImportLink(baseUrl, apiKey, limits) : undefined;
const link = built && 'link' in built ? built.link : undefined;
+ const withLimits = !!link && limits.length > 0;
+ const minVersion = withLimits ? LITE_LIMITS_MIN_VERSION : LITE_MIN_VERSION;
let reason: string | undefined;
if (!aiGateway) {
reason = t('lite.reasonNoAiGateway');
@@ -70,6 +93,7 @@ export function AddToLite({ apiKey, aiGateway, ownerMayUseGateway, children }: A
{
openAppLink(link);
setOpened(true);
@@ -93,22 +117,27 @@ export function AddToLite({ apiKey, aiGateway, ownerMayUseGateway, children }: A
{reason}
) : (
- opened && (
-
- {noResponse}
- {/* A space after "?", none after the full-width "?". */}
- {/[\x21-\x7e]$/.test(noResponse) ? ' ' : ''}
-
- {t('lite.installFirst', { version: LITE_MIN_VERSION })}
-
+ withLimits && (
+
+ {t('lite.limitsIncluded', { version: LITE_LIMITS_MIN_VERSION })}
)
)}
+ {!reason && opened && (
+
+ {noResponse}
+ {/* A space after "?", none after the full-width "?". */}
+ {/[\x21-\x7e]$/.test(noResponse) ? ' ' : ''}
+
+ {t('lite.installFirst', { version: minVersion })}
+
+
+ )}
);
}
diff --git a/web/src/components/limits/key-limits-query.ts b/web/src/components/limits/key-limits-query.ts
index 74a04c01..752916b0 100644
--- a/web/src/components/limits/key-limits-query.ts
+++ b/web/src/components/limits/key-limits-query.ts
@@ -76,6 +76,18 @@ export function keyLimitsQuery(keyId: string) {
};
}
+/** The key's own rate-limit rules alone, for the "Add to ThinkWatch Lite"
+ * link. Under the same query key as [`keyLimitsQuery`], so an edit there
+ * refreshes it. */
+export function keyRulesQuery(keyId: string) {
+ return {
+ queryKey: [...keyLimitsQueryKey(keyId), 'rules'] as const,
+ queryFn: async ({ signal }: { signal: AbortSignal }) =>
+ (await api>>(`/api/admin/limits/api_key/${keyId}/rules`, { signal }))
+ .items,
+ };
+}
+
/** The caller holds `rate_limits:read`, but not in a scope covering this key. */
export function isForbidden(error: unknown): boolean {
return (error as { status?: number } | null)?.status === 403;
diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json
index 6c93da2b..b49de707 100644
--- a/web/src/i18n/en.json
+++ b/web/src/i18n/en.json
@@ -2115,7 +2115,8 @@
"reasonBadUrl": "The gateway address {{url}} cannot be added to ThinkWatch Lite.",
"reasonBadKey": "This API key cannot be added to ThinkWatch Lite.",
"noResponse": "Nothing happened?",
- "installFirst": "Install ThinkWatch Lite ({{version}} or later) first"
+ "installFirst": "Install ThinkWatch Lite ({{version}} or later) first",
+ "limitsIncluded": "The link includes this key's request-count limits and opens in ThinkWatch Lite {{version}} or later."
},
"notFound": {
"title": "404",
diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json
index 8cbd5555..272d5000 100644
--- a/web/src/i18n/zh.json
+++ b/web/src/i18n/zh.json
@@ -2115,7 +2115,8 @@
"reasonBadUrl": "网关地址 {{url}} 无法添加到 ThinkWatch Lite。",
"reasonBadKey": "这把 API 密钥无法添加到 ThinkWatch Lite。",
"noResponse": "没有反应?",
- "installFirst": "先安装 ThinkWatch Lite({{version}} 或更新版本)"
+ "installFirst": "先安装 ThinkWatch Lite({{version}} 或更新版本)",
+ "limitsIncluded": "链接附带此密钥的请求数限额,需 ThinkWatch Lite {{version}} 或更新版本打开。"
},
"notFound": {
"title": "404",
diff --git a/web/src/lib/lite-link.test.ts b/web/src/lib/lite-link.test.ts
index dc67e902..a0cd3081 100644
--- a/web/src/lib/lite-link.test.ts
+++ b/web/src/lib/lite-link.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { liteDownloadUrl, liteImportLink } from './lite-link'
+import { liteDownloadUrl, liteImportLink, liteLimits, type KeyRateRule } from './lite-link'
const KEY = 'tw-0123456789abcdef'
@@ -104,6 +104,137 @@ describe('liteImportLink', () => {
})
})
+describe('liteImportLink with limits', () => {
+ it('adds limits as one encoded value, `|` as %7C and `;` as %3B', () => {
+ expect(
+ liteImportLink('https://gateway.example.com', KEY, [
+ 'minute|requests|60',
+ 'hour|requests|500|5',
+ ]),
+ ).toEqual({
+ link:
+ 'thinkwatch://import?service=thinkwatch&name=thinkwatch' +
+ '&url=https%3A%2F%2Fgateway.example.com&key=tw-0123456789abcdef' +
+ '&limits=minute%7Crequests%7C60%3Bhour%7Crequests%7C500%7C5',
+ })
+ })
+
+ it('leaves the parameter out when there are no limits', () => {
+ expect(liteImportLink('https://gateway.example.com', KEY, [])).toEqual(
+ liteImportLink('https://gateway.example.com', KEY),
+ )
+ expect(liteImportLink('https://gateway.example.com', KEY)).not.toHaveProperty(
+ 'link',
+ expect.stringContaining('limits='),
+ )
+ })
+})
+
+const NOW = Date.parse('2026-10-20T12:00:00Z')
+
+const rule = (overrides: Partial = {}): KeyRateRule => ({
+ surface: 'ai_gateway',
+ metric: 'requests',
+ window_secs: 60,
+ max_count: 100,
+ enabled: true,
+ expires_at: null,
+ ...overrides,
+})
+
+describe('liteLimits', () => {
+ it("maps each of the console's windows that Lite has", () => {
+ const cases: [number, string][] = [
+ [60, 'minute|requests|7'],
+ [300, 'minute|requests|7|5'],
+ [3600, 'hour|requests|7'],
+ [18000, 'hour|requests|7|5'],
+ [86400, 'hour|requests|7|24'],
+ ]
+ for (const [window_secs, entry] of cases) {
+ expect(liteLimits([rule({ window_secs, max_count: 7 })], NOW), String(window_secs)).toEqual([
+ entry,
+ ])
+ }
+ })
+
+ it('maps any other window of whole minutes up to 60 or whole hours up to 24', () => {
+ expect(liteLimits([rule({ window_secs: 120 })], NOW)).toEqual(['minute|requests|100|2'])
+ expect(liteLimits([rule({ window_secs: 1800 })], NOW)).toEqual(['minute|requests|100|30'])
+ expect(liteLimits([rule({ window_secs: 7200 })], NOW)).toEqual(['hour|requests|100|2'])
+ expect(liteLimits([rule({ window_secs: 43200 })], NOW)).toEqual(['hour|requests|100|12'])
+ })
+
+ it('leaves out windows Lite has no rolling counterpart for', () => {
+ for (const window_secs of [604800, 30, 90, 5400, 90000, 172800, 0, -60, 60.5]) {
+ expect(liteLimits([rule({ window_secs })], NOW), String(window_secs)).toEqual([])
+ }
+ })
+
+ it('leaves out token rules, which count weighted tokens', () => {
+ expect(liteLimits([rule({ metric: 'tokens', max_count: 100000 })], NOW)).toEqual([])
+ })
+
+ it('takes only enabled, unexpired AI-gateway rules', () => {
+ expect(
+ liteLimits(
+ [
+ rule({ enabled: false, max_count: 1 }),
+ rule({ surface: 'mcp_gateway', max_count: 2 }),
+ rule({ expires_at: '2026-10-20T11:59:59Z', max_count: 3 }),
+ rule({ expires_at: '2026-10-20T12:00:00Z', max_count: 4 }),
+ rule({ window_secs: 3600, expires_at: '2026-10-21T00:00:00Z', max_count: 5 }),
+ ],
+ NOW,
+ ),
+ ).toEqual(['hour|requests|5'])
+ })
+
+ it('leaves out counts Lite does not take', () => {
+ expect(liteLimits([rule({ max_count: 0 })], NOW)).toEqual([])
+ expect(liteLimits([rule({ max_count: 1.5 })], NOW)).toEqual([])
+ expect(liteLimits([rule({ max_count: 1_000_000_000_000_000 })], NOW)).toEqual([])
+ expect(liteLimits([rule({ max_count: 999_999_999_999_999 })], NOW)).toEqual([
+ 'minute|requests|999999999999999',
+ ])
+ })
+
+ it('keeps the smaller count of two rules on one window', () => {
+ expect(
+ liteLimits([rule({ max_count: 50 }), rule({ max_count: 20 }), rule({ max_count: 80 })], NOW),
+ ).toEqual(['minute|requests|20'])
+ })
+
+ it('orders entries by window, shortest first', () => {
+ expect(
+ liteLimits(
+ [
+ rule({ window_secs: 86400, max_count: 5000 }),
+ rule({ window_secs: 60, max_count: 10 }),
+ rule({ window_secs: 3600, max_count: 300 }),
+ ],
+ NOW,
+ ),
+ ).toEqual(['minute|requests|10', 'hour|requests|300', 'hour|requests|5000|24'])
+ })
+
+ it('keeps the 16 windows with the lowest rate when there are more', () => {
+ // 20 windows of 1–20 minutes; the n-minute one allows 10 + n requests,
+ // so the rate falls as n grows and the longest 16 are the tightest.
+ const rules = Array.from({ length: 20 }, (_, i) =>
+ rule({ window_secs: 60 * (i + 1), max_count: 11 + i }),
+ )
+ const out = liteLimits(rules, NOW)
+ expect(out).toHaveLength(16)
+ expect(out[0]).toBe('minute|requests|15|5')
+ expect(out[15]).toBe('minute|requests|30|20')
+ })
+
+ it('gives nothing when no rule maps', () => {
+ expect(liteLimits([], NOW)).toEqual([])
+ })
+})
+
describe('liteDownloadUrl', () => {
it('follows the console language', () => {
expect(liteDownloadUrl('zh')).toBe('https://thinkwat.ch/zh-CN/lite')
diff --git a/web/src/lib/lite-link.ts b/web/src/lib/lite-link.ts
index a3a45511..022fd6ea 100644
--- a/web/src/lib/lite-link.ts
+++ b/web/src/lib/lite-link.ts
@@ -11,6 +11,10 @@
/// The first ThinkWatch Lite release that takes `service=thinkwatch`.
export const LITE_MIN_VERSION = '2026.10.16';
+/// The first ThinkWatch Lite release that takes `limits`. An older one
+/// refuses a link that carries it, so a link without limits leaves it out.
+export const LITE_LIMITS_MIN_VERSION = '2026.10.26';
+
/// The upstream name the link gives; Lite asks for another when it is
/// taken.
const UPSTREAM_NAME = 'thinkwatch';
@@ -62,8 +66,9 @@ export type LiteLinkProblem =
export type LiteLink = { link: string } | { problem: LiteLinkProblem };
/// The import link for the gateway at `baseUrl` (its root: no path, no
-/// trailing slash) with `key`, or why Lite would refuse one.
-export function liteImportLink(baseUrl: string, key: string): LiteLink {
+/// trailing slash) with `key` and, when there are any, the key's `limits`
+/// (from [`liteLimits`]), or why Lite would refuse one.
+export function liteImportLink(baseUrl: string, key: string, limits: string[] = []): LiteLink {
let url: URL;
try {
url = new URL(baseUrl);
@@ -89,12 +94,76 @@ export function liteImportLink(baseUrl: string, key: string): LiteLink {
['name', UPSTREAM_NAME],
['url', baseUrl],
['key', key],
+ ...(limits.length > 0 ? [['limits', limits.join(';')]] : []),
]
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
return { link: `thinkwatch://import?${params}` };
}
+/// A rate-limit rule of an API key, as the limits endpoints list it.
+export interface KeyRateRule {
+ surface: 'ai_gateway' | 'mcp_gateway';
+ metric: 'requests' | 'tokens';
+ window_secs: number;
+ max_count: number;
+ enabled: boolean;
+ expires_at?: string | null;
+}
+
+/// Lite's limit on a request or token count (fifteen digits).
+const MAX_LITE_COUNT = 999_999_999_999_999;
+/// The most limits one link carries.
+export const MAX_LITE_LIMITS = 16;
+
+/// The key's own rate-limit rules as entries of Lite's `limits` parameter
+/// (`per|measure|max|every`), the ones that mean the same limit there:
+///
+/// - Only enabled, unexpired rules on the AI gateway: Lite is an AI
+/// client's upstream, and MCP tools do not go through it.
+/// - Only request counts. A token rule here counts weighted tokens (each
+/// model's input and output weights, cache reads at a fraction of the
+/// input weight and cache writes at a premium), while Lite counts plain
+/// tokens; no `cache_reads` setting makes the two the same number.
+/// - A sliding window of whole minutes up to 60 becomes Lite's rolling
+/// `minute` window, one of whole hours up to 24 its rolling `hour`
+/// window (an hour, or 24, is written in hours). Any other window, a
+/// sliding 7 days among them, has no counterpart: Lite's days, weeks
+/// and months are calendar periods, a different limit.
+/// - Two rules on one window keep the smaller count. Of more than 16
+/// windows the 16 that allow the fewest requests per second stay: a
+/// looser rate is the likelier to be implied by a tighter one.
+///
+/// The entries come ordered by window, shortest first; none means the
+/// link carries no `limits`.
+export function liteLimits(rules: KeyRateRule[], now: number = Date.now()): string[] {
+ const kept = new Map();
+ for (const r of rules) {
+ if (!r.enabled || r.surface !== 'ai_gateway' || r.metric !== 'requests') continue;
+ if (r.expires_at && !(Date.parse(r.expires_at) > now)) continue;
+ if (!Number.isInteger(r.max_count) || r.max_count < 1 || r.max_count > MAX_LITE_COUNT) continue;
+ if (liteWindow(r.window_secs) === undefined) continue;
+ const before = kept.get(r.window_secs);
+ if (before === undefined || r.max_count < before) kept.set(r.window_secs, r.max_count);
+ }
+ return [...kept]
+ .sort(([wa, ma], [wb, mb]) => ma / wa - mb / wb || wa - wb)
+ .slice(0, MAX_LITE_LIMITS)
+ .sort(([wa], [wb]) => wa - wb)
+ .map(([secs, max]) => {
+ const { per, every } = liteWindow(secs)!;
+ return every === 1 ? `${per}|requests|${max}` : `${per}|requests|${max}|${every}`;
+ });
+}
+
+/// Lite's rolling window of `secs` seconds, if it has one.
+function liteWindow(secs: number): { per: 'minute' | 'hour'; every: number } | undefined {
+ if (!Number.isInteger(secs) || secs <= 0) return undefined;
+ if (secs % 3600 === 0 && secs / 3600 <= 24) return { per: 'hour', every: secs / 3600 };
+ if (secs % 60 === 0 && secs / 60 <= 60) return { per: 'minute', every: secs / 60 };
+ return undefined;
+}
+
/// Where ThinkWatch Lite is downloaded, in the console's language.
export function liteDownloadUrl(language: string): string {
return language.startsWith('zh') ? 'https://thinkwat.ch/zh-CN/lite' : 'https://thinkwat.ch/lite';
diff --git a/web/src/routes/api-key-dialogs.tsx b/web/src/routes/api-key-dialogs.tsx
index 1c273a4b..6c3bb4e8 100644
--- a/web/src/routes/api-key-dialogs.tsx
+++ b/web/src/routes/api-key-dialogs.tsx
@@ -230,6 +230,8 @@ export function CreateApiKeyDialog({
{createdKey}
+ {/* A new key starts a lineage of its own, with no limits yet:
+ its link carries none. */}
{copied ? : }
@@ -696,6 +698,7 @@ export function RotateApiKeyDialog({
const { t } = useTranslation();
const [rotatedNewKey, setRotatedNewKey] = useState(null);
+ const [rotatedNewId, setRotatedNewId] = useState();
const [rotateSubmitting, setRotateSubmitting] = useState(false);
const [rotateError, setRotateError] = useState('');
const [rotateCopied, setRotateCopied] = useState(false);
@@ -703,6 +706,7 @@ export function RotateApiKeyDialog({
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setRotatedNewKey(null);
+ setRotatedNewId(undefined);
setRotateError('');
setRotateCopied(false);
}
@@ -716,6 +720,7 @@ export function RotateApiKeyDialog({
try {
const res = await apiPost(`/api/keys/${apiKey.id}/rotate`, {});
setRotatedNewKey(res.key);
+ setRotatedNewId(res.id);
onSuccess();
} catch (err) {
setRotateError(err instanceof Error ? err.message : t('common.error'));
@@ -748,6 +753,9 @@ export function RotateApiKeyDialog({
diff --git a/web/src/routes/api-keys.test.tsx b/web/src/routes/api-keys.test.tsx
index 3fe6e88d..475d64ad 100644
--- a/web/src/routes/api-keys.test.tsx
+++ b/web/src/routes/api-keys.test.tsx
@@ -294,4 +294,106 @@ describe('ApiKeysPage', () => {
// The settings form is there as before.
expect(within(screen.getByRole('dialog')).getByText('Gateway access')).toBeInTheDocument()
})
+
+ async function rotateAndOpenLite() {
+ mockApiPost.mockResolvedValue({
+ id: 'key-2',
+ key: 'tw-rotated-key',
+ name: 'test-key',
+ key_prefix: 'tw_rota',
+ })
+ const user = userEvent.setup()
+ renderWithQueryClient( )
+ await user.click(await screen.findByRole('button', { name: 'Rotate' }))
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Rotate' }))
+ const add = await screen.findByRole('button', { name: 'Add to ThinkWatch Lite' })
+ await waitFor(() => expect(add).toBeEnabled())
+ return { user, add }
+ }
+
+ const LINK =
+ 'thinkwatch://import?service=thinkwatch&name=thinkwatch' +
+ '&url=https%3A%2F%2Fgateway.example.com&key=tw-rotated-key'
+
+ it("carries the rotated key's request limits to ThinkWatch Lite", async () => {
+ mockKeysFetch([makeKey()], (url) =>
+ url.endsWith('/rules')
+ ? {
+ items: [
+ { id: 'r1', surface: 'ai_gateway', metric: 'requests', window_secs: 60, max_count: 30, enabled: true },
+ { id: 'r2', surface: 'ai_gateway', metric: 'requests', window_secs: 18000, max_count: 600, enabled: true },
+ // Left out: weighted tokens, a sliding week, an MCP rule.
+ { id: 'r3', surface: 'ai_gateway', metric: 'tokens', window_secs: 60, max_count: 9000, enabled: true },
+ { id: 'r4', surface: 'ai_gateway', metric: 'requests', window_secs: 604800, max_count: 9000, enabled: true },
+ { id: 'r5', surface: 'mcp_gateway', metric: 'requests', window_secs: 60, max_count: 5, enabled: true },
+ ],
+ }
+ : { items: [] },
+ )
+ const { user, add } = await rotateAndOpenLite()
+
+ await waitFor(() =>
+ expect(add).toHaveAccessibleDescription(
+ "The link includes this key's request-count limits and opens in ThinkWatch Lite 2026.10.26 or later.",
+ ),
+ )
+ expect(mockApi).toHaveBeenCalledWith('/api/admin/limits/api_key/key-2/rules', expect.anything())
+ await user.click(add)
+ const frame = document.querySelector('iframe')
+ expect(frame).toHaveAttribute(
+ 'src',
+ LINK + '&limits=minute%7Crequests%7C30%3Bhour%7Crequests%7C600%7C5',
+ )
+ expect(screen.getByRole('link', { name: 'Install ThinkWatch Lite (2026.10.26 or later) first' }))
+ .toBeInTheDocument()
+ frame?.remove()
+ })
+
+ it('builds the link without limits when none of the rotated key maps', async () => {
+ mockKeysFetch([makeKey()])
+ const { user, add } = await rotateAndOpenLite()
+
+ await waitFor(() =>
+ expect(mockApi).toHaveBeenCalledWith('/api/admin/limits/api_key/key-2/rules', expect.anything()),
+ )
+ expect(add).not.toHaveAccessibleDescription()
+ await user.click(add)
+ const frame = document.querySelector('iframe')
+ expect(frame).toHaveAttribute('src', LINK)
+ expect(screen.getByRole('link', { name: 'Install ThinkWatch Lite (2026.10.16 or later) first' }))
+ .toBeInTheDocument()
+ frame?.remove()
+ })
+
+ it("builds the link without limits when the key's limits cannot be read", async () => {
+ mockKeysFetch([makeKey()], () => {
+ throw Object.assign(new Error('Forbidden'), { status: 403 })
+ })
+ const { user, add } = await rotateAndOpenLite()
+
+ await waitFor(() =>
+ expect(mockApi).toHaveBeenCalledWith('/api/admin/limits/api_key/key-2/rules', expect.anything()),
+ )
+ expect(add).toBeEnabled()
+ expect(add).not.toHaveAccessibleDescription()
+ await user.click(add)
+ const frame = document.querySelector('iframe')
+ expect(frame).toHaveAttribute('src', LINK)
+ frame?.remove()
+ })
+
+ it('reads no limits for the link without rate_limits:read', async () => {
+ mockHasPermission.mockImplementation((perm) => perm !== 'rate_limits:read')
+ mockKeysFetch([makeKey()])
+ const { user, add } = await rotateAndOpenLite()
+
+ await user.click(add)
+ const frame = document.querySelector('iframe')
+ expect(frame).toHaveAttribute('src', LINK)
+ expect(mockApi).not.toHaveBeenCalledWith(
+ expect.stringContaining('/api/admin/limits/'),
+ expect.anything(),
+ )
+ frame?.remove()
+ })
})