Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/web-usage-limits-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

web: Add a usage limits panel showing context size, managed weekly and rolling rate limits, and booster balance. Click the context ring in the composer toolbar to open it.
59 changes: 58 additions & 1 deletion apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { traceKeyEvent } from '../../debug/trace';
import type {
AppConfig,
AppGoal,
AppManagedUsage,
AppMessage,
AppMessageRole,
AppModel,
AppProvider,
AppUsageRow,
ProviderRefreshResult,
AppSession,
AppSkill,
Expand All @@ -35,7 +37,8 @@ import type {
QuestionResponse,
} from '../types';
import { createAgentProjector } from './agentEventProjector';
import { DaemonHttpClient } from './http';
import { DaemonHttpClient, SERVER_AUTH_UNAUTHORIZED_CODE } from './http';
import { DaemonApiError } from '../errors';
import {
toAppApprovalRequest,
toAppConfig,
Expand Down Expand Up @@ -83,6 +86,8 @@ import type {
WireSessionSnapshot,
WireWorkspace,
WireLogoutResult,
WireManagedUsage,
WireUsageRow,
} from './wire';
import { DaemonEventSocket } from './ws';

Expand Down Expand Up @@ -1359,6 +1364,58 @@ export class DaemonKimiWebApi implements KimiWebApi {
return { loggedOut: data.logged_out };
}

async getManagedUsage(): Promise<AppManagedUsage> {
let data: WireManagedUsage;
try {
data = await this.http.get<WireManagedUsage>('/oauth/usage');
} catch (error) {
// Degraded results keep the panel renderable: 404 = old backend without
// the route; server-auth 401 = re-login required; anything else is a
// plain fetch failure. Server-auth 401 also trips the global auth gate
// via the http layer, so the panel copy is mostly a fallback there.
if (error instanceof DaemonApiError) {
if (error.code === 404) {
return { kind: 'error', code: 'route_unavailable', message: error.message };
}
if (error.code === SERVER_AUTH_UNAUTHORIZED_CODE || error.code === 401) {
return { kind: 'error', code: 'unauthenticated', message: error.message };
}
}
return {
kind: 'error',
code: 'unavailable',
message: error instanceof Error ? error.message : String(error),
};
}
if (data.kind === 'error') {
return { kind: 'error', code: data.code, message: data.message };
}
const row = (r: WireUsageRow): AppUsageRow => ({
label: r.label,
used: r.used,
limit: r.limit,
resetHint: r.reset_hint,
resetAt: r.reset_at,
windowSeconds: r.window_seconds,
});
return {
kind: 'ok',
summary: data.summary === null ? null : row(data.summary),
limits: data.limits.map(row),
extraUsage:
data.extra_usage === null
? null
: {
balanceCents: data.extra_usage.balance_cents,
totalCents: data.extra_usage.total_cents,
monthlyChargeLimitEnabled: data.extra_usage.monthly_charge_limit_enabled,
monthlyChargeLimitCents: data.extra_usage.monthly_charge_limit_cents,
monthlyUsedCents: data.extra_usage.monthly_used_cents,
currency: data.extra_usage.currency,
},
};
}

// -------------------------------------------------------------------------
// File upload
// -------------------------------------------------------------------------
Expand Down
6 changes: 4 additions & 2 deletions apps/kimi-web/src/api/daemon/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,8 +518,10 @@ export class DaemonHttpClient {
// Unwrap: code 0 = success; allowed non-zero = return data; else throw
if (envelope.code !== 0 && !allowCodes.includes(envelope.code)) {
throw new DaemonApiError({
code: envelope.code,
msg: envelope.msg,
// Non-envelope error bodies (e.g. the router's plain 404 JSON) carry no
// `code`; fall back to the HTTP status so callers can branch on it.
code: envelope.code ?? response.status,
msg: envelope.msg ?? response.statusText,
requestId: envelope.request_id,
details: envelope.details,
timestamp: Date.now(),
Expand Down
31 changes: 30 additions & 1 deletion apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export interface WireConfig {
}

// ---------------------------------------------------------------------------
// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout)
// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout, GET /api/v1/oauth/usage)
// ---------------------------------------------------------------------------

export interface WireManagedProvider {
Expand Down Expand Up @@ -482,6 +482,35 @@ export interface WireLogoutResult {
logged_out: boolean;
}

// GET /oauth/usage — managed-platform quotas (weekly summary, rolling 5h
// limits, booster wallet). Discriminated by `kind`.
export interface WireUsageRow {
label: string;
used: number;
limit: number;
reset_hint?: string;
reset_at?: string;
window_seconds?: number;
}

export interface WireBoosterWallet {
balance_cents: number;
total_cents: number;
monthly_charge_limit_enabled: boolean;
monthly_charge_limit_cents: number;
monthly_used_cents: number;
currency: string;
}

export type WireManagedUsage =
| {
kind: 'ok';
summary: WireUsageRow | null;
limits: WireUsageRow[];
extra_usage: WireBoosterWallet | null;
}
| { kind: 'error'; code: 'unauthenticated' | 'unavailable'; message: string };

// ---------------------------------------------------------------------------
// File upload wire DTOs
// ---------------------------------------------------------------------------
Expand Down
40 changes: 40 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,8 +801,48 @@ export interface KimiWebApi {
} | null>;
cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>;
logout(): Promise<{ loggedOut: boolean }>;
/** Managed-account usage quotas (5h / weekly / booster wallet) — GET /oauth/usage. */
getManagedUsage(): Promise<AppManagedUsage>;
}

/** Managed-platform quota row (weekly summary, rolling 5h limit, …). */
export interface AppUsageRow {
label: string;
used: number;
limit: number;
resetHint?: string;
/** Raw ISO reset timestamp — preferred over `resetHint` for localized display. */
resetAt?: string;
/** Rolling window length in seconds — preferred over `label` for localized display. */
windowSeconds?: number;
}

/** Booster wallet — pay-as-you-go balance beyond the subscription quota. */
export interface AppBoosterWallet {
balanceCents: number;
totalCents: number;
monthlyChargeLimitEnabled: boolean;
monthlyChargeLimitCents: number;
monthlyUsedCents: number;
currency: string;
}

/** Stable failure reason for a usage fetch — the UI binds localized copy to
* the code instead of rendering the raw upstream `message`.
* `unauthenticated` / `unavailable` come from the server; `route_unavailable`
* is synthesized by the client when the backend predates the route (404). */
export type AppManagedUsageErrorCode = 'unauthenticated' | 'unavailable' | 'route_unavailable';

/** Result of `getManagedUsage()`, mirroring the wire discriminated union. */
export type AppManagedUsage =
| {
kind: 'ok';
summary: AppUsageRow | null;
limits: AppUsageRow[];
extraUsage: AppBoosterWallet | null;
}
| { kind: 'error'; code: AppManagedUsageErrorCode; message: string };

/** Result of `startOAuthLogin()`, mirroring the wire discriminated union. */
export type OAuthLoginStartResult =
| {
Expand Down
59 changes: 21 additions & 38 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useI18n } from 'vue-i18n';
import SlashMenu from './SlashMenu.vue';
import MentionMenu from './MentionMenu.vue';
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
import { formatTokens } from '../../lib/formatTokens';
import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types';
Expand All @@ -29,9 +28,9 @@ import Spinner from '../ui/Spinner.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
import ContextRing from '../ui/ContextRing.vue';
import Tooltip from '../ui/Tooltip.vue';
import AttachmentChip from './AttachmentChip.vue';
import ContextUsagePanel from './ContextUsagePanel.vue';

// ---------------------------------------------------------------------------
// Props & emits
Expand Down Expand Up @@ -560,12 +559,14 @@ const hasUpload = computed(() => !!props.uploadImage);
const dropdownOpen = ref(false);
const permDropdownOpen = ref(false);
const toolbarRef = ref<HTMLElement | null>(null);
const usagePanelRef = ref<{ close: () => void } | null>(null);

function toggleDropdown(): void {
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
permDropdownOpen.value = false;
closeModes();
usagePanelRef.value?.close();
document.addEventListener('click', onDocClick, true);
} else {
document.removeEventListener('click', onDocClick, true);
Expand All @@ -584,6 +585,7 @@ function togglePermDropdown(): void {
if (permDropdownOpen.value) {
dropdownOpen.value = false;
closeModes();
usagePanelRef.value?.close();
document.addEventListener('click', onDocClick, true);
} else {
document.removeEventListener('click', onDocClick, true);
Expand Down Expand Up @@ -618,14 +620,15 @@ const pct = computed(() => {
return Math.min(100, Math.max(0, Math.ceil(((props.status?.ctxUsed ?? 0) / max) * 100)));
});

const ctxTooltip = computed(() => {
const used = formatTokens(props.status?.ctxUsed ?? 0);
const max = formatTokens(props.status?.ctxMax ?? 0);
return t('status.ctxTooltip', { used, max, pct: pct.value });
});

const showCompact = computed(() => pct.value >= 80);

// Keep the toolbar menus mutually exclusive when the usage panel opens.
function onUsagePanelOpen(): void {
closeDropdown();
closePermDropdown();
closeModes();
}

// Thinking toggle
// Identity is the model id — display/model names can collide across providers.
const currentModel = computed(() =>
Expand Down Expand Up @@ -703,6 +706,7 @@ function toggleModes(): void {
// Keep the toolbar menus mutually exclusive so they never overlap.
closeDropdown();
closePermDropdown();
usagePanelRef.value?.close();
const r = modesRef.value?.getBoundingClientRect();
if (r) {
modesMenuStyle.value = {
Expand Down Expand Up @@ -1086,21 +1090,15 @@ function selectModel(modelId: string): void {
<!-- Compact chip when context is high -->
<button v-if="showCompact" class="compact-chip" @click.stop="emit('compact')">/compact</button>

<!-- Context meter — circular ring only; the full usage (used/max/pct)
lives in the tooltip. The ring is aria-hidden, so the trigger
exposes those numbers via aria-label; focusable so keyboard and
switch-control users reach the same tooltip hover users see. -->
<Tooltip :text="ctxTooltip">
<span
v-if="status && !hideContext"
class="ctx-group"
role="img"
tabindex="0"
:aria-label="ctxTooltip"
>
<ContextRing :pct="pct" />
</span>
</Tooltip>
<!-- Context meter + usage limits — click the ring to open the panel.
The trigger tooltip keeps the full used/max/pct numbers on hover. -->
<ContextUsagePanel
v-if="status && !hideContext"
ref="usagePanelRef"
:status="status"
:provider="currentModel?.provider"
@open="onUsagePanelOpen"
/>

<!-- Model pill — click to open quick-switch dropdown -->
<span
Expand Down Expand Up @@ -1590,21 +1588,6 @@ function selectModel(modelId: string): void {
color: var(--color-danger);
}

/* Context group — circular ring. Focusable for keyboard / switch access to its
aria-label and tooltip (see template), so it needs a focus ring. */
.ctx-group {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
padding: 2px 4px;
border-radius: var(--radius-xs);
}
.ctx-group:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}

/* Model pill */
.model-pill {
display: inline-flex;
Expand Down
Loading