diff --git a/README.md b/README.md index 2b2a8ac..c740b20 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,11 @@ The `video_generate` tool plays the generated clip inline: Only logged-in providers appear in the session model picker; the lists above refresh on login/logout. Vision-capable models declare `['text', 'image']` input modalities, and image content is translated to each provider's wire format. -Logged-in cards also show **subscription usage** — per rate-limit window (5-hour session, weekly, and per-model weekly where the plan has one) with the used percentage, a progress bar, and the reset time, plus a Refresh button. Codex usage comes from `chatgpt.com/backend-api/wham/usage` (also reports the plan), Claude usage from `api.anthropic.com/api/oauth/usage`, and Grok usage from the Grok Build CLI proxy's `cli-chat-proxy.grok.com/v1/billing` (the source of the CLI's `/usage` panel; reports the shared weekly pool and the subscription tier). Copilot exposes no usage endpoint, so its card shows no usage section. +Logged-in cards also show **subscription usage** — per rate-limit window (5-hour session, weekly, and per-model weekly where the plan has one) with the used percentage, a progress bar, and the reset time, plus a Refresh button. Codex usage comes from `chatgpt.com/backend-api/wham/usage` (also reports the plan), Claude usage from `api.anthropic.com/api/oauth/usage` (the plan comes from the stored subscription type, since that payload names no tier), and Grok usage from the Grok Build CLI proxy's `cli-chat-proxy.grok.com/v1/billing` (the source of the CLI's `/usage` panel; reports the shared weekly pool and the subscription tier). Copilot exposes no usage endpoint, so its card shows no usage section. + +Claude usage additionally rides a **meter in the composer**, left of the model selector and beside the shell's own context meter: a ring showing the limit that applies to the session's current model — a model with its own weekly limit shows that one, everything else the shared weekly pool — which turns amber at 75% and red at 95%. Hovering names the window and revalidates; clicking opens every reported limit with its reset time. It renders only while a Claude model is selected, so it never reports a limit the next turn will not spend. + +The meter is deliberately frugal with that endpoint, which is aggressively rate limited and shared with the settings page above: one cache serves every open session, an idle session issues no request at all, a running one asks every 3 minutes, and a refusal never clears the last good reading. A `Retry-After` on a 429 is honoured when the provider sends one. Also included, registered when the matching provider is enabled: diff --git a/src/auth/rpc.ts b/src/auth/rpc.ts index eb6794c..c243d71 100644 --- a/src/auth/rpc.ts +++ b/src/auth/rpc.ts @@ -11,7 +11,7 @@ import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { PROVIDER_IDS, type ProviderId } from './store.js' -import type { ProviderUsage } from '../providers/common.js' +import { OAuthEndpointError, type ProviderUsage } from '../providers/common.js' import type { ProxyConfigView, ProxyDraft, ProxyInput, ProxyTestResult } from '../http.js' /** The RPC channel this plugin registers on the host connection. */ @@ -137,7 +137,28 @@ function failure(error: unknown): RpcResult { // The issues array is zod-shaped upstream; this channel validates by hand. return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } } } - return { ok: false, error: { code: 'internal', message, details: {} } } + return { ok: false, error: { code: 'internal', message: withRetryAfter(error, message), details: {} } } +} + +/** + * Append the provider's retry delay to a failure message when it sent one. + * + * The delay belongs in structured data, but the `internal` RpcResult branch + * types `details` as an empty object upstream (`z.object({})` in + * dsh-host-apiproxy's rpc schema), so a field added here would be stripped in + * validation rather than reaching the page. The message is the only channel + * that survives, so the suffix is a stable, parseable one — a browser caller + * that backs off on a 429 can honour the interval the provider actually asked + * for instead of inventing its own. + * + * @param error - the thrown failure, which may carry a parsed `Retry-After`. + * @param message - the failure message as it stands. + * @returns the message, with ` (retry-after: s)` appended when known. + */ +function withRetryAfter(error: unknown, message: string): string { + if (!(error instanceof OAuthEndpointError) || error.retryAfterMs === undefined) return message + const seconds = Math.max(1, Math.round(error.retryAfterMs / 1000)) + return `${message} (retry-after: ${String(seconds)}s)` } function readProvider(payload: unknown): ProviderId { diff --git a/src/client/SubscriptionsSection.tsx b/src/client/SubscriptionsSection.tsx index f6f8229..652cbb6 100644 --- a/src/client/SubscriptionsSection.tsx +++ b/src/client/SubscriptionsSection.tsx @@ -193,11 +193,17 @@ const styles: Record = { display: 'flex', justifyContent: 'space-between', gap: 8, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)', }, + // Meter geometry follows the shell's own ContextMeter bar (4px, fully + // rounded, filled track, no border) so a progress bar contributed by a + // plugin reads as the same control the product draws elsewhere. usageTrack: { - height: 6, borderRadius: 3, overflow: 'hidden', - background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)', + height: 4, borderRadius: 999, overflow: 'hidden', + background: 'var(--dsw-alias-interactive-bg-hover)', + }, + usageFill: { + height: '100%', borderRadius: 999, + transition: 'width .3s ease, background-color .3s ease', }, - usageFill: { height: '100%', borderRadius: 3 }, manual: { marginTop: 4, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' }, manualRow: { display: 'flex', gap: 8, marginTop: 6 }, manualInput: { @@ -288,11 +294,17 @@ function usageWindowLabel(t: SubscriptionsSectionInjected['t'], window: UsageWin return window.scope !== undefined && window.scope !== '' ? `${base} · ${window.scope}` : base } -/** Bar fill color: success normally, warn from 80%, error from 95%. */ +/** + * Bar fill color. The steps follow the Claude Code app, which warns from 75% + * rather than 80%, so a plan this panel and that app both report reaches its + * warning shade at the same point. Below the warning step the meter is the + * neutral business tone rather than success green: a half-consumed limit is a + * reading, not an achievement, and green reads as the latter. + */ function usageBarColor(usedPercent: number): string { if (usedPercent >= 95) return 'var(--dsw-alias-state-error-primary)' - if (usedPercent >= 80) return 'var(--dsw-alias-state-warn-label)' - return 'var(--dsw-alias-state-success-primary)' + if (usedPercent >= 75) return 'var(--dsw-alias-state-warn-label)' + return 'var(--dsw-static-blue-450)' } /** One-line status text of the proxy config card. */ diff --git a/src/client/UsageMeter.tsx b/src/client/UsageMeter.tsx new file mode 100644 index 0000000..bbc6d4d --- /dev/null +++ b/src/client/UsageMeter.tsx @@ -0,0 +1,513 @@ +/** + * Composer usage meter: a progress ring in the input tool row + * (`conversation.input.right`) reporting the claude subscription limit that + * applies to the session's current model, with a click-open panel listing + * every limit the provider reported. + * + * It renders only while a claude model is selected, so it never shows a + * reading that does not describe the turn about to be sent. The ring geometry + * and the panel chrome follow the shell's own ContextMeter, which sits two + * seats to the right, so the pair reads as one family. + * + * Every color resolves through a `--dsw-*` design token and every user-visible + * string goes through the locale `t` of the 'settings.subscriptions' + * namespace, same as the settings section. + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { CSSProperties } from 'react' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { callSubscriptionsAuth } from './SubscriptionsSection.js' +import { en } from './locales.js' +import type { SubscriptionsKey } from './locales.js' +import type { ProviderUsage, UsageWindow } from '../providers/common.js' + +/** Ring geometry, matching the shell's ContextMeter: 14px box, 2px stroke. */ +const RADIUS = 5.5 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +/** ContextMeter's own hover delay. */ +const TOOLTIP_DELAY_MS = 200 + +/** + * Model-gate cadence. The session's current model is not on the conversation + * snapshot and no model-change event exists, so the gate asks `sessions.models` + * — a local host RPC with no upstream traffic, cheap enough to poll briskly. + */ +const GATE_POLL_MS = 700 + +/** + * A model switch is always a user gesture, so any pointer or Enter gesture + * additionally probes on this staircase. The poll is the safety net; the burst + * is what makes the ring appear and disappear with the click rather than a + * tick later. + */ +const GATE_BURST_MS: readonly number[] = [90, 260, 600, 1100] +const GATE_BURST_THROTTLE_MS = 350 + +/** + * Usage revalidation. The upstream endpoint is aggressively rate limited and + * shared with this plugin's own settings page, so the meter is deliberately + * frugal: an idle session issues nothing at all, a running one asks on this + * interval, and hovering the ring revalidates under the same floor. + */ +const USAGE_MIN_INTERVAL_MS = 180_000 +const USAGE_RUNNING_INTERVAL_MS = 180_000 + +/** First rate-limit refusal stands down this long; later ones double to the cap. */ +const BACKOFF_START_MS = 300_000 +const BACKOFF_MAX_MS = 600_000 + +/** A request that never settles must not wedge the store. */ +const REQUEST_TIMEOUT_MS = 15_000 + +/** Warning step, matching the Claude Code app; the error step follows at 95%. */ +const WARN_PERCENT = 75 +const DANGER_PERCENT = 95 + +/** What the meter renders from: the last good reading and when it landed. */ +export interface UsageMeterState { + usage: ProviderUsage | null + at: number +} + +/** + * The shared usage cache. The slot renders once per session, so a fetcher per + * component would multiply the request rate against the shared endpoint by the + * number of open sessions; every instance subscribes to one store instead. + */ +export interface UsageMeterStore { + get: () => UsageMeterState + subscribe: (fn: (state: UsageMeterState) => void) => () => void + /** Revalidate if — and only if — the floor and any active backoff allow it. */ + request: () => void +} + +/** Whether the session's current model is a claude one, and which. */ +export interface ModelGate { + visible: boolean + model: string | null +} + +/** Injected dependencies of {@link UsageMeter} (slot `inject`, session-bound). */ +export interface UsageMeterInjected { + /** Resolve the session's current model; rejects rather than reporting "hidden". */ + checkModel: () => Promise + /** The shared usage store. */ + store: UsageMeterStore +} + +/** + * Props delivered by the slot outlet: the framework session kit and InputZone + * owner share, the injected face, and the locale seat. + */ +export type UsageMeterProps = PropsRuntime<'conversation.input.right'> + & Partial + & Partial> + +/** English-dictionary fallback for a missing inject `t` (standalone renders). */ +function fallbackTranslate(key: SubscriptionsKey, params?: Record): string { + let text: string = en[key] + for (const [name, value] of Object.entries(params ?? {})) { + text = text.replaceAll(`{${name}}`, String(value)) + } + return text +} + +/** Recognise a rate-limit refusal from the flattened RPC failure message. */ +function isRateLimited(message: string): boolean { + return /\b429\b/.test(message) || /rate.?limit/i.test(message) +} + +/** + * The delay the provider asked for, when the node half appended one. The + * `internal` RpcResult branch types `details` as an empty object upstream, so + * the retry hint rides the message text (see `withRetryAfter` in auth/rpc.ts). + */ +function retryHintMs(message: string): number | undefined { + const match = /retry-after:\s*(\d+)s/i.exec(message) + if (match === null) return undefined + const seconds = Number(match[1]) + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined +} + +/** + * Build the shared usage store. + * + * Stale-while-revalidate throughout: the last successful reading is always + * what renders, and a failure — including a rate-limit refusal — never clears + * it. A refused request only schedules a later retry, so the meter degrades to + * slightly stale numbers rather than an empty or shouting panel. + * + * @param rpc - the connection's RPC caller. + * @returns the store; call `request()` to revalidate under the floor. + */ +export function createUsageStore(rpc: ConnectionHandle['rpc']): UsageMeterStore { + const listeners = new Set<(state: UsageMeterState) => void>() + let state: UsageMeterState = { usage: null, at: 0 } + let inflight = false + let blockedUntil = 0 + let backoffMs = 0 + + const publish = (next: UsageMeterState): void => { + state = next + for (const fn of [...listeners]) fn(state) + } + + const request = (): void => { + const now = Date.now() + if (inflight || now < blockedUntil || now - state.at < USAGE_MIN_INTERVAL_MS) return + inflight = true + let settled = false + const finish = (apply?: () => void): void => { + if (settled) return + settled = true + inflight = false + clearTimeout(watchdog) + apply?.() + } + const watchdog = setTimeout(() => { finish() }, REQUEST_TIMEOUT_MS) + void callSubscriptionsAuth(rpc, 'usage', { provider: 'claude' }).then( + (usage) => { + finish(() => { + backoffMs = 0 + blockedUntil = 0 + publish({ usage, at: Date.now() }) + }) + }, + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + finish(() => { + if (!isRateLimited(message)) return + const hint = retryHintMs(message) + backoffMs = hint ?? (backoffMs === 0 + ? BACKOFF_START_MS + : Math.min(backoffMs * 2, BACKOFF_MAX_MS)) + blockedUntil = Date.now() + backoffMs + }) + }, + ) + } + + return { + get: () => state, + subscribe(fn) { + listeners.add(fn) + return () => { listeners.delete(fn) } + }, + request, + } +} + +/** + * The `checkModel` half of the inject face: the host's current model selection. + * A failure throws rather than answering "hidden" — the caller keeps its last + * known state, so a transient RPC failure never blinks the meter away. + * + * `sessionId` is a plain string: slot and command contexts brand it through + * different dsh-session copies, and only the API-client boundary needs one. + */ +export function createModelChecker( + connection: ConnectionHandle, + sessionId: string, +): UsageMeterInjected['checkModel'] { + return async () => { + const { result } = await connection.api.sessions.models({ sessionId: sessionId as SessionId }) + if (!result.ok) throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`) + const current = result.value.current + if (current === null || current.provider !== 'claude') return { visible: false, model: null } + return { visible: true, model: current.model } + } +} + +/** + * The window the ring represents: on a model with its own weekly limit (Fable + * today) that limit, otherwise the shared weekly pool — the two readings a + * user is actually spending. Falls back to the most consumed window so the + * ring still means something on a plan shape this code has not seen. + */ +export function pickWindow(windows: readonly UsageWindow[], model: string | null): UsageWindow | null { + if (windows.length === 0) return null + const weekly = windows.filter(w => w.kind === 'weekly') + if (typeof model === 'string') { + const scoped = weekly.find(w => typeof w.scope === 'string' && w.scope.length > 0 + && model.toLowerCase().includes(w.scope.toLowerCase())) + if (scoped !== undefined) return scoped + } + const overall = weekly.find(w => w.scope === undefined || w.scope === '') + if (overall !== undefined) return overall + return weekly[0] ?? [...windows].sort((a, b) => b.usedPercent - a.usedPercent)[0] ?? null +} + +/** Clamp a reported percentage into the range the meter can draw. */ +function clamp(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +/** Ring and bar tint: neutral while healthy, warning from 75%, error from 95%. */ +function meterColor(percent: number): string { + if (percent >= DANGER_PERCENT) return 'var(--dsw-alias-state-error-primary)' + if (percent >= WARN_PERCENT) return 'var(--dsw-alias-state-warn-label)' + return 'var(--dsw-static-blue-450)' +} + +/** Localized label of one window: the kind, plus the model scope when named. */ +function windowLabel(t: NonNullable, window: UsageWindow): string { + const base = window.kind === 'session' + ? t('usageSession') + : window.kind === 'weekly' ? t('usageWeekly') : t('usageWindow') + const scope = window.scope !== undefined && window.scope !== '' ? window.scope : t('meterAllModels') + return window.kind === 'weekly' ? `${base} · ${scope}` : base +} + +/** + * Localized reset phrasing: a countdown while the window is close, and a + * weekday clock time beyond a day, where a countdown would be noise. + */ +function resetLabel( + t: NonNullable, + resetsAt: number | undefined, + now = Date.now(), +): string { + if (resetsAt === undefined) return '' + const diff = resetsAt - now + const HOUR = 3_600_000 + if (diff > 0 && diff < 24 * HOUR) { + const totalMinutes = Math.round(diff / 60_000) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + const duration = hours === 0 + ? t('meterMinutes', { count: minutes }) + : minutes === 0 + ? t('meterHours', { count: hours }) + : t('meterHoursMinutes', { hours, minutes }) + return t('meterResetsIn', { duration }) + } + const date = new Date(resetsAt) + return t('meterResetsAt', { + date: date.toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' }), + }) +} + +const styles = { + root: { display: 'inline-flex', position: 'relative' }, + trigger: { + width: 28, height: 28, padding: 0, border: 'none', borderRadius: 999, + background: 'transparent', cursor: 'pointer', flex: 'none', + display: 'grid', placeItems: 'center', + }, + track: { fill: 'none', stroke: 'var(--dsw-alias-border-l3)', strokeWidth: 2 }, + ring: { fill: 'none', strokeWidth: 2, strokeLinecap: 'round', transition: 'stroke-dasharray .3s ease, stroke .3s ease' }, + panel: { + position: 'absolute', right: 0, bottom: 'calc(100% + 8px)', zIndex: 100, + boxSizing: 'border-box', width: 320, maxWidth: 'calc(100vw - 32px)', padding: 12, + border: '1px solid var(--dsw-alias-border-inverted)', borderRadius: 12, + background: 'var(--dsw-specific-menu)', boxShadow: 'var(--dsw-shadow-lv3)', + color: 'var(--dsw-alias-label-secondary)', fontSize: 12, lineHeight: '20px', cursor: 'default', + }, + // Leading trim: a 12px glyph in a 20px line box carries 4px of half-leading, + // so without this the caption's ink sits 16px below the top edge while the + // last bar — a solid block with no leading — sits 12px above the bottom. + caption: { margin: '-4px 0 10px', color: 'var(--dsw-alias-label-tertiary)' }, + limit: { marginTop: 12 }, + line: { display: 'flex', alignItems: 'baseline', gap: 8 }, + name: { + minWidth: 0, color: 'var(--dsw-alias-label-primary)', fontWeight: 500, + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', + }, + reset: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap', flex: 'none' }, + percent: { + color: 'var(--dsw-alias-label-primary)', fontWeight: 500, + fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', flex: 'none', + }, + bar: { + height: 4, marginTop: 6, borderRadius: 999, overflow: 'hidden', + background: 'var(--dsw-alias-interactive-bg-hover)', + }, + barFill: { height: '100%', borderRadius: 999, transition: 'width .3s ease, background-color .3s ease' }, + empty: { color: 'var(--dsw-alias-label-tertiary)' }, +} satisfies Record + +/** + * The composer meter. Renders nothing until the model gate proves a claude + * model is selected; from then on the ring tracks {@link pickWindow} and the + * panel lists every reported limit. + */ +export function UsageMeter({ checkModel, store, useSession, t }: UsageMeterProps) { + const translate = t ?? fallbackTranslate + const running = useSession?.(snapshot => snapshot.running) === true + const [gate, setGate] = useState({ visible: false, model: null }) + const [usageState, setUsageState] = useState(() => store?.get() ?? { usage: null, at: 0 }) + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + // The inject face may be re-evaluated on re-render; the effects mount once + // and read through refs, so identity churn neither resets the timers nor + // multiplies in-flight requests. + const checkRef = useRef(checkModel) + checkRef.current = checkModel + const storeRef = useRef(store) + storeRef.current = store + + useEffect(() => { + const current = storeRef.current + if (current === undefined) return + setUsageState(current.get()) + return current.subscribe(setUsageState) + }, []) + + useEffect(() => { + if (checkRef.current === undefined) return + let cancelled = false + let inflight = false + let lastBurst = 0 + const burstTimers: ReturnType[] = [] + + const check = (): void => { + const resolve = checkRef.current + if (cancelled || inflight || resolve === undefined) return + inflight = true + void resolve().then( + (next) => { + if (cancelled) return + setGate(prev => (prev.visible === next.visible && prev.model === next.model ? prev : next)) + }, + () => { /* keep the last known gate; the next tick retries */ }, + ).finally(() => { inflight = false }) + } + + const clearBurst = (): void => { + while (burstTimers.length > 0) clearTimeout(burstTimers.pop()) + } + + const burst = (event: Event): void => { + // Enter commits the /model popup; other keys are ordinary typing. + if (event.type === 'keyup' && (event as KeyboardEvent).key !== 'Enter') return + const now = Date.now() + if (now - lastBurst < GATE_BURST_THROTTLE_MS) return + lastBurst = now + clearBurst() + for (const delay of GATE_BURST_MS) burstTimers.push(setTimeout(check, delay)) + } + + check() + const poll = setInterval(check, GATE_POLL_MS) + document.addEventListener('pointerdown', burst, true) + document.addEventListener('keyup', burst, true) + return () => { + cancelled = true + clearBurst() + clearInterval(poll) + document.removeEventListener('pointerdown', burst, true) + document.removeEventListener('keyup', burst, true) + } + }, []) + + // Revalidate only while a turn is running; an idle session issues nothing, + // which is what keeps the shared endpoint available to the settings page. + useEffect(() => { + if (!gate.visible || !running) return + const timer = setInterval(() => { storeRef.current?.request() }, USAGE_RUNNING_INTERVAL_MS) + return () => { clearInterval(timer) } + }, [running, gate.visible]) + + useEffect(() => { + if (!open) return + const onPointerDown = (event: PointerEvent): void => { + if (event.target instanceof Node && rootRef.current?.contains(event.target) === true) return + setOpen(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + useEffect(() => { + if (!gate.visible && open) setOpen(false) + }, [gate.visible, open]) + + const windows = usageState.usage?.windows ?? [] + const selected = pickWindow(windows, gate.model) + const percent = selected === null ? 0 : clamp(selected.usedPercent) + const color = meterColor(percent) + const label = selected === null + ? translate('usageTitle') + : `${windowLabel(translate, selected)} ${String(Math.round(percent))}%` + + /** + * Hovering is the revalidation trigger: pointing at the ring is the earliest + * honest signal of intent, so the panel opens onto an already-fresh reading + * instead of refetching underneath the user. The Tooltip resolves its label + * only while the bubble is visible, which makes it the natural hook. + */ + const resolveLabel = useCallback(() => { + storeRef.current?.request() + return label + }, [label]) + + if (!gate.visible) return null + + const plan = usageState.usage?.plan + const caption = plan === undefined || plan === '' + ? translate('meterCaption') + : translate('meterCaptionPlan', { plan }) + + return ( + + + + + {open && ( +
+

{caption}

+ {windows.length === 0 + ?
{translate('meterEmpty')}
+ : windows.map((window, index) => { + const used = clamp(window.usedPercent) + const reset = resetLabel(translate, window.resetsAt) + return ( +
+
+ {windowLabel(translate, window)} + {reset !== '' && {reset}} + + {`${String(Math.round(used))}%`} + +
+
+
+
+
+ ) + })} +
+ )} + + ) +} diff --git a/src/client/index.ts b/src/client/index.ts index 0437bac..e69810b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -21,6 +21,9 @@ import type { CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/clie // nodenext the .js specifier resolves to the .tsx source (see README note). import { SubscriptionsSection } from './SubscriptionsSection.js' import type { SubscriptionsSectionInjected } from './SubscriptionsSection.js' +import { installNavIcon } from './nav-icon.js' +import { UsageMeter, createModelChecker, createUsageStore } from './UsageMeter.js' +import type { UsageMeterInjected } from './UsageMeter.js' import { ImageGenerateToolview, createImageLoader } from './ImageGenerateToolview.js' import type { ImageGenerateToolviewInjected } from './ImageGenerateToolview.js' import { VideoGenerateToolview, createVideoLoader } from './VideoGenerateToolview.js' @@ -85,6 +88,10 @@ export function apply(ctx: ClientContext): void { label: () => t('nav'), inject: injected, }, SubscriptionsSection)) + // The shell picks nav glyphs by section id and gives every unknown id the + // settings gear; registration carries no icon field. Decorate our own cell + // instead, reading the same label thunk so the match follows the locale. + ctx.effect(() => installNavIcon(() => t('nav')), 'dsh-plugin-subscriptions: settings nav glyph') // The image_generate keyed toolview owns how image calls render inline; its // gallery bytes ride the same channel through the injected loader. The @@ -121,6 +128,22 @@ export function apply(ctx: ClientContext): void { }), }, SpeedSelect)) + // The claude usage meter sits in the same tool row, just left of the model + // selector and two seats from the shell's own context meter it mirrors. One + // store serves every session: the slot renders per session, and the usage + // endpoint is both rate limited and shared with the settings page above. + const usageStore = createUsageStore(connection.rpc) + ctx.slots.inject('conversation.input.right', () => ctx.slots.register({ + name: 'conversation.input.right', + id: 'claude-usage', + order: 10, + locale: NS, + inject: (sessionId: SessionId): UsageMeterInjected => ({ + checkModel: createModelChecker(connection, sessionId), + store: usageStore, + }), + }, UsageMeter)) + // The /fast slash command offers the same Standard/Fast choice as a popup. // `available` is synchronous and sees only the session id, so the command // stays listed everywhere; `options` throws the friendly gate when the diff --git a/src/client/locales.ts b/src/client/locales.ts index 15bf826..7c9d220 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -34,6 +34,15 @@ export const en = { usageWindow: 'Window', usageResets: 'resets {date}', usagePlan: 'Plan: {plan}', + meterCaption: 'Plan usage limits', + meterCaptionPlan: 'Plan usage limits · {plan}', + meterAllModels: 'all models', + meterEmpty: 'No usage data yet.', + meterResetsIn: 'Resets in {duration}', + meterResetsAt: 'Resets {date}', + meterHours: '{count} hr', + meterHoursMinutes: '{hours} hr {minutes} min', + meterMinutes: '{count} min', generating: 'Generating image…', image: 'image', viewImage: 'View image', @@ -120,6 +129,15 @@ export const zh = { usageWindow: '窗口', usageResets: '{date} 重置', usagePlan: '计划:{plan}', + meterCaption: '订阅用量上限', + meterCaptionPlan: '订阅用量上限 · {plan}', + meterAllModels: '全部模型', + meterEmpty: '暂无用量数据。', + meterResetsIn: '{duration}后重置', + meterResetsAt: '{date} 重置', + meterHours: '{count} 小时', + meterHoursMinutes: '{hours} 小时 {minutes} 分', + meterMinutes: '{count} 分', generating: '正在生成图片…', image: '图片', viewImage: '查看图片', diff --git a/src/client/nav-icon.ts b/src/client/nav-icon.ts new file mode 100644 index 0000000..51b6a59 --- /dev/null +++ b/src/client/nav-icon.ts @@ -0,0 +1,150 @@ +/** + * Settings-nav glyph for the Subscriptions section. + * + * The settings shell picks nav icons by section id — `models`, + * `agent-presets`, and `plugins` get their own, and every other id falls back + * to the settings gear (see `navIcon` in dsh-client-ui-settings-general). + * `settings.section` registration carries only `id`, `order`, and `label`, so + * a registrant has no way to supply a glyph through the API. + * + * Until the shell offers one, this decorates our nav cell after render: it + * finds the button by the label WE registered and rewrites the icon in place. + * Three properties keep that honest: + * + * - it mutates the existing `` rather than replacing React-managed nodes, + * so React's tree is never invalidated underneath it; + * - it matches on the live value of our own `label` thunk, so it follows the + * active locale automatically instead of hardcoding translated strings; + * - every failure path leaves the shell's own gear in place, and disposal + * restores it, so the worst outcome is the icon we started with. + * + * If the shell ever accepts an icon in the registration, delete this file and + * pass the glyph instead. + */ + +/** A credit-card glyph, in the 24×24 stroked geometry the shell's icons use. */ +const CARD_ICON_INNER = '' + +/** Marks an svg this module already rewrote, so re-renders are cheap to skip. */ +const PATCHED_FLAG = 'subscriptionsNavIcon' + +/** What one patched icon needs to be restored to its shipped state. */ +interface Restore { + readonly svg: SVGElement + readonly viewBox: string | null + readonly fill: string | null + readonly stroke: string | null + readonly strokeWidth: string | null + readonly innerHTML: string +} + +function queryAll(root: ParentNode | Document, selector: string): Element[] { + try { + return typeof root.querySelectorAll === 'function' ? [...root.querySelectorAll(selector)] : [] + } catch { + return [] + } +} + +/** + * Rewrite the nav icon of every settings dialog whose row carries our label. + * @param label - the section label as currently rendered (the `label` thunk's value). + * @param restores - accumulator recording what each patch replaced. + */ +function patchNavIcons(label: string, restores: Restore[]): void { + if (typeof document === 'undefined' || document.body === null) return + if (label.length === 0) return + for (const dialog of queryAll(document, '[role="dialog"]')) { + for (const button of queryAll(dialog, 'nav button')) { + const text = typeof button.textContent === 'string' ? button.textContent : '' + if (!text.includes(label)) continue + const svg = button.querySelector('svg') + if (svg === null) continue + const flags = (svg as SVGElement & { dataset?: DOMStringMap }).dataset + if (flags === undefined || flags[PATCHED_FLAG] === '1') continue + try { + restores.push({ + svg, + viewBox: svg.getAttribute('viewBox'), + fill: svg.getAttribute('fill'), + stroke: svg.getAttribute('stroke'), + strokeWidth: svg.getAttribute('stroke-width'), + innerHTML: svg.innerHTML, + }) + flags[PATCHED_FLAG] = '1' + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('fill', 'none') + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('stroke-width', '1.5') + svg.setAttribute('stroke-linecap', 'round') + svg.setAttribute('stroke-linejoin', 'round') + svg.innerHTML = CARD_ICON_INNER + } catch { + // Any failure leaves the shell's own gear rendered; nothing to report. + } + } + } +} + +/** + * Start decorating our settings-nav cell. + * + * The panel mounts and unmounts with the dialog and re-renders on locale + * change, so the patch runs under a body observer rather than once: a fresh + * React icon arrives without our marker and is rewritten on the next tick. + * + * @param label - reads the section label as currently rendered; called per pass + * so a locale switch is picked up without re-registering anything. + * @returns a disposer that stops observing and restores every icon it changed. + */ +export function installNavIcon(label: () => string): () => void { + const restores: Restore[] = [] + const run = (): void => { + try { + patchNavIcons(label(), restores) + } catch { + // A failing label thunk must not break the observer. + } + } + + run() + + let observer: MutationObserver | undefined + try { + if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' && document.body !== null) { + observer = new MutationObserver(run) + observer.observe(document.body, { childList: true, subtree: true }) + } + } catch { + observer = undefined + } + + return () => { + try { + observer?.disconnect() + } catch { + // Best-effort teardown. + } + for (const entry of restores) { + try { + const flags = (entry.svg as SVGElement & { dataset?: DOMStringMap }).dataset + if (flags !== undefined) delete flags[PATCHED_FLAG] + entry.innerHTML === '' ? entry.svg.replaceChildren() : (entry.svg.innerHTML = entry.innerHTML) + for (const [name, value] of [ + ['viewBox', entry.viewBox], + ['fill', entry.fill], + ['stroke', entry.stroke], + ['stroke-width', entry.strokeWidth], + ] as const) { + if (value === null) entry.svg.removeAttribute(name) + else entry.svg.setAttribute(name, value) + } + entry.svg.removeAttribute('stroke-linecap') + entry.svg.removeAttribute('stroke-linejoin') + } catch { + // The node may already be gone with the unmounted dialog. + } + } + restores.length = 0 + } +} diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 72b70aa..63c738d 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -285,11 +285,29 @@ function claudeLimitsWindows(value: unknown): UsageWindow[] { return windows } +/** + * Display name of the plan a stored `subscriptionType` names, e.g. `max` → + * `Max`. The usage endpoint reports no plan of its own (unlike codex, whose + * payload carries `plan_type`), so the session's own subscription type is the + * only tier this provider can disclose. An unknown value is title-cased + * rather than dropped, so a tier introduced later still shows up. + * @param subscriptionType - the value stored with the session, when present. + * @returns the display name, or undefined when the session names no tier. + */ +function claudePlanName(subscriptionType: string | undefined): string | undefined { + if (typeof subscriptionType !== 'string') return undefined + const trimmed = subscriptionType.trim() + if (trimmed.length === 0) return undefined + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1) +} + /** * Fetch the claude subscription usage from the OAuth usage endpoint (the * source of Claude Code's `/usage` screen). Newer responses carry a * structured `limits` array; older ones the flat `five_hour`/`seven_day*` * buckets — both shapes are read, the array winning when it has entries. + * The plan rides the stored session (see {@link claudePlanName}), because the + * payload itself names no tier. * @param session - the stored session (used as-is; never refreshed here). * @param fetchFn - fetch implementation (injectable for tests). * @param signal - caller cancellation from the RPC transport. @@ -313,8 +331,10 @@ export async function fetchClaudeUsage( }) if (!response.ok) throw await oauthEndpointError(response, 'claude usage') const payload = await response.json() as Record + const plan = claudePlanName(session.subscriptionType) + const planField = plan === undefined ? {} : { plan } const modern = claudeLimitsWindows(payload.limits) - if (modern.length > 0) return { supported: true, windows: modern } + if (modern.length > 0) return { supported: true, windows: modern, ...planField } const windows: UsageWindow[] = [] const legacy = [ claudeLegacyWindow(payload.five_hour, 'session'), @@ -325,7 +345,7 @@ export async function fetchClaudeUsage( for (const window of legacy) { if (window !== undefined) windows.push(window) } - return { supported: true, windows } + return { supported: true, windows, ...planField } } interface ClaudeModelCapabilities { diff --git a/src/providers/common.ts b/src/providers/common.ts index b8a2b6d..69a3c90 100644 --- a/src/providers/common.ts +++ b/src/providers/common.ts @@ -186,15 +186,44 @@ export class OAuthEndpointError extends Error { readonly status: number /** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */ readonly oauthCode: string | undefined + /** + * How long the provider asked the caller to wait, in milliseconds, when it + * sent a `Retry-After` header. Rate-limited endpoints (notably the claude + * usage lookup) answer 429 with one, and a caller that ignores it can only + * guess at a backoff — so it rides the error rather than being dropped with + * the rest of the response. + */ + readonly retryAfterMs: number | undefined - constructor(message: string, status: number, oauthCode?: string) { + constructor(message: string, status: number, oauthCode?: string, retryAfterMs?: number) { super(message) this.name = 'OAuthEndpointError' this.status = status this.oauthCode = oauthCode + this.retryAfterMs = retryAfterMs } } +/** + * Parse a `Retry-After` header. The header carries either a delay in seconds + * or an HTTP date; both forms resolve to a forward-looking delay, and a past + * date or unparsable value answers undefined rather than a negative wait. + * @param value - the raw header value, when the response carried one. + * @param now - clock override for tests. + * @returns the delay in milliseconds, when the header named a future one. + */ +export function retryAfterMs(value: string | null, now: number = Date.now()): number | undefined { + if (value === null) return undefined + const trimmed = value.trim() + if (trimmed.length === 0) return undefined + const seconds = Number(trimmed) + if (Number.isFinite(seconds)) return seconds > 0 ? seconds * 1000 : undefined + const at = Date.parse(trimmed) + if (!Number.isFinite(at)) return undefined + const delay = at - now + return delay > 0 ? delay : undefined +} + /** * Read an OAuth JSON error body into an {@link OAuthEndpointError}. * @param response - the failed token-endpoint response. @@ -214,7 +243,12 @@ export async function oauthEndpointError(response: Response, label: string): Pro const message = detail.length > 0 ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}` : `${label} token endpoint error (HTTP ${String(response.status)})` - return new OAuthEndpointError(message, response.status, oauthCode) + return new OAuthEndpointError( + message, + response.status, + oauthCode, + retryAfterMs(response.headers.get('retry-after')), + ) } /** A session fresh enough to serve a request without a refresh. */ diff --git a/test/usage.spec.ts b/test/usage.spec.ts index 0541c99..fb445af 100644 --- a/test/usage.spec.ts +++ b/test/usage.spec.ts @@ -21,6 +21,7 @@ const { fetchClaudeUsage } = await import('../src/providers/claude.js') const { fetchGrokUsage, grokTierName } = await import('../src/providers/grok.js') const plugin = await import('../src/index.js') +import { OAuthEndpointError, retryAfterMs } from '../src/providers/common.js' import type { FetchFn } from '../src/providers/common.js' import type { ClaudeSession, CodexSession, GrokSession } from '../src/auth/store.js' @@ -43,8 +44,11 @@ const grokSession: GrokSession = { tokenEndpoint: 'https://auth.x.ai/token', } -/** A fetch implementation answering one JSON payload; records the request. */ -function fakeFetch(payload: unknown, status = 200): { +/** + * A fetch implementation answering one JSON payload; records the request. + * @param responseHeaders - headers to answer with (e.g. `retry-after` on a 429). + */ +function fakeFetch(payload: unknown, status = 200, responseHeaders?: Record): { fetchFn: FetchFn requests: { url: string; headers: Record }[] } { @@ -53,7 +57,10 @@ function fakeFetch(payload: unknown, status = 200): { const headers: Record = {} new Headers(init?.headers).forEach((value, key) => { headers[key] = value }) requests.push({ url: String(url), headers }) - return Promise.resolve(new Response(JSON.stringify(payload), { status })) + return Promise.resolve(new Response(JSON.stringify(payload), { + status, + ...responseHeaders === undefined ? {} : { headers: responseHeaders }, + })) }) as FetchFn return { fetchFn, requests } } @@ -181,6 +188,56 @@ test('fetchClaudeUsage prefers the modern limits array when present', async () = ]) }) +test('fetchClaudeUsage reports the plan from the stored subscription type', async () => { + // The usage payload names no tier (unlike codex's plan_type), so the plan + // rides the session; both response shapes must carry it. + const modern = fakeFetch({ + limits: [{ kind: 'weekly_all', percent: 40, resets_at: '2026-04-14T16:59:59Z' }], + }) + const withPlan = { ...claudeSession, subscriptionType: 'max' } + assert.equal((await fetchClaudeUsage(withPlan, modern.fetchFn)).plan, 'Max') + + const legacy = fakeFetch({ five_hour: { utilization: 6, resets_at: null } }) + assert.equal((await fetchClaudeUsage(withPlan, legacy.fetchFn)).plan, 'Max') +}) + +test('fetchClaudeUsage omits the plan when the session names none', async () => { + const { fetchFn } = fakeFetch({ + limits: [{ kind: 'weekly_all', percent: 40, resets_at: '2026-04-14T16:59:59Z' }], + }) + const usage = await fetchClaudeUsage(claudeSession, fetchFn) + assert.equal('plan' in usage, false) + const blank = await fetchClaudeUsage({ ...claudeSession, subscriptionType: ' ' }, fakeFetch({ + limits: [{ kind: 'weekly_all', percent: 40 }], + }).fetchFn) + assert.equal('plan' in blank, false) +}) + +test('retryAfterMs reads both header forms and refuses past deadlines', () => { + const now = 1_700_000_000_000 + assert.equal(retryAfterMs('120'), 120_000) + assert.equal(retryAfterMs('0'), undefined) + assert.equal(retryAfterMs(null), undefined) + assert.equal(retryAfterMs(' '), undefined) + assert.equal(retryAfterMs('tomorrow'), undefined) + assert.equal(retryAfterMs(new Date(now + 60_000).toUTCString(), now), 60_000) + assert.equal(retryAfterMs(new Date(now - 60_000).toUTCString(), now), undefined) +}) + +test('a rate-limited usage lookup surfaces the provider retry delay', async () => { + // The `internal` RpcResult branch types `details` as an empty object + // upstream, so the delay rides the message; a caller backing off on 429 can + // then honour the interval the provider actually asked for. + const { fetchFn } = fakeFetch({ error: 'rate_limited' }, 429, { 'retry-after': '300' }) + const error = await fetchClaudeUsage(claudeSession, fetchFn).then( + () => undefined, + (thrown: unknown) => thrown, + ) + assert.ok(error instanceof OAuthEndpointError) + assert.equal(error.status, 429) + assert.equal(error.retryAfterMs, 300_000) +}) + test('fetchGrokUsage maps the credits-config shape (weekly percent + reset)', async () => { const { fetchFn, requests } = fakeFetch({ config: {