Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 45 additions & 16 deletions web/src/components/add-to-lite.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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');
Expand Down Expand Up @@ -70,6 +93,7 @@ export function AddToLite({ apiKey, aiGateway, ownerMayUseGateway, children }: A
<Button
variant="outline"
className="hidden md:inline-flex"
aria-describedby={withLimits ? limitsId : undefined}
onClick={() => {
openAppLink(link);
setOpened(true);
Expand All @@ -93,22 +117,27 @@ export function AddToLite({ apiKey, aiGateway, ownerMayUseGateway, children }: A
{reason}
</p>
) : (
opened && (
<p className="hidden text-xs text-muted-foreground md:block">
{noResponse}
{/* A space after "?", none after the full-width "?". */}
{/[\x21-\x7e]$/.test(noResponse) ? ' ' : ''}
<a
href={liteDownloadUrl(i18n.language)}
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline underline-offset-4"
>
{t('lite.installFirst', { version: LITE_MIN_VERSION })}
</a>
withLimits && (
<p id={limitsId} className="hidden text-xs text-muted-foreground md:block">
{t('lite.limitsIncluded', { version: LITE_LIMITS_MIN_VERSION })}
</p>
)
)}
{!reason && opened && (
<p className="hidden text-xs text-muted-foreground md:block">
{noResponse}
{/* A space after "?", none after the full-width "?". */}
{/[\x21-\x7e]$/.test(noResponse) ? ' ' : ''}
<a
href={liteDownloadUrl(i18n.language)}
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline underline-offset-4"
>
{t('lite.installFirst', { version: minVersion })}
</a>
</p>
)}
</div>
);
}
12 changes: 12 additions & 0 deletions web/src/components/limits/key-limits-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Items<Omit<KeyRule, 'current'>>>(`/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;
Expand Down
3 changes: 2 additions & 1 deletion web/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion web/src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 132 additions & 1 deletion web/src/lib/lite-link.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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> = {}): 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')
Expand Down
Loading
Loading