mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-18 21:25:40 +00:00
* feat(oauth): parse boosterWallet extra usage from /usages * feat(oauth): expose extraUsage on AuthManagedUsageResult * feat(kimi-code): render Extra Usage section in /usage panel * fix(kimi-code): address Task 3 review feedback for extra usage section * feat(kimi-code): render Extra Usage section in /status panel * feat(kimi-code): wire extraUsage into /usage and /status commands * chore(extra-usage): address final review findings for fuel pack feature - Update changeset to cover both kimi-code and kimi-code-sdk packages - Add parser clamp tests and toolkit null-case test - Replace 'as never' casts in usage-panel tests - Wrap long import line in status-panel * chore: temporarily log /usages raw response for debugging * fix(oauth): accept BOOSTER balance type and drop debug log * fix(oauth): drop reset hint from Extra Usage and revert periodEnd parsing * fix(oauth): treat missing amountLeft as zero extra usage and drop debug log * revert: keep missing amountLeft defaulting to 0 (fully used) * feat(extra-usage): show monthly cap usage bar and balance in /usage and /status * fix(extra-usage): show balance and unlimited marker when no monthly cap * fix(extra-usage): show monthly used with unlimited marker and balance * fix(extra-usage): label Used and use English Unlimited * feat(extra-usage): render balance, monthly used and monthly limit as labeled rows * fix(extra-usage): move Balance row to the bottom * fix(extra-usage): format currency values with two decimals for column alignment * fix(extra-usage): right-align currency values so numbers line up * fix(extra-usage): align currency symbol and decimal point in usage rows
252 lines
8.4 KiB
TypeScript
252 lines
8.4 KiB
TypeScript
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
|
|
import {
|
|
fetchManagedUsage,
|
|
formatDuration,
|
|
formatResetTime,
|
|
isManagedKimiCode,
|
|
parseManagedUsagePayload,
|
|
} from '../src/managed-usage';
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('isManagedKimiCode', () => {
|
|
it('matches only the kimi-code managed provider', () => {
|
|
expect(isManagedKimiCode('managed:kimi-code')).toBe(true);
|
|
expect(isManagedKimiCode('managed:moonshot-ai')).toBe(false);
|
|
expect(isManagedKimiCode('openai')).toBe(false);
|
|
expect(isManagedKimiCode('')).toBe(false);
|
|
expect(isManagedKimiCode(null)).toBe(false);
|
|
expect(isManagedKimiCode()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseManagedUsagePayload', () => {
|
|
it('returns empty when payload is not an object', () => {
|
|
expect(parseManagedUsagePayload(null)).toEqual({ summary: null, limits: [], extraUsage: null });
|
|
expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [], extraUsage: null });
|
|
});
|
|
|
|
it('extracts a summary from the `usage` object', () => {
|
|
const parsed = parseManagedUsagePayload({
|
|
usage: { used: 40, limit: 1000, name: 'Weekly limit' },
|
|
});
|
|
expect(parsed.summary).toEqual({
|
|
label: 'Weekly limit',
|
|
used: 40,
|
|
limit: 1000,
|
|
});
|
|
expect(parsed.limits).toEqual([]);
|
|
});
|
|
|
|
it('falls back to remaining=limit-used when used is absent', () => {
|
|
const parsed = parseManagedUsagePayload({ usage: { remaining: 200, limit: 1000 } });
|
|
expect(parsed.summary).toEqual({ label: 'Weekly limit', used: 800, limit: 1000 });
|
|
});
|
|
|
|
it('labels limits from window duration when no name is given', () => {
|
|
const parsed = parseManagedUsagePayload({
|
|
limits: [
|
|
{ detail: { used: 1, limit: 100 }, window: { duration: 300, timeUnit: 'MINUTE' } },
|
|
{ detail: { used: 2, limit: 50 }, window: { duration: 24, timeUnit: 'HOUR' } },
|
|
],
|
|
});
|
|
expect(parsed.limits.map((l) => l.label)).toEqual(['5h limit', '24h limit']);
|
|
});
|
|
|
|
it('prefers explicit item.name over window duration label', () => {
|
|
const parsed = parseManagedUsagePayload({
|
|
limits: [
|
|
{
|
|
name: 'Daily cap',
|
|
detail: { used: 5, limit: 100 },
|
|
window: { duration: 1440, timeUnit: 'MINUTE' },
|
|
},
|
|
],
|
|
});
|
|
expect(parsed.limits[0]!.label).toBe('Daily cap');
|
|
});
|
|
|
|
it('surfaces reset hints from resetAt timestamps', () => {
|
|
const future = new Date(Date.now() + 3600_000).toISOString();
|
|
const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, resetAt: future } });
|
|
expect(parsed.summary?.resetHint).toMatch(/resets in/);
|
|
});
|
|
|
|
it('extracts extra usage from boosterWallet.balance', () => {
|
|
const parsed = parseManagedUsagePayload({
|
|
usage: { used: 40, limit: 1000, name: 'Weekly limit' },
|
|
boosterWallet: {
|
|
id: 'wallet_1',
|
|
balance: {
|
|
type: 'BOOSTER',
|
|
amount: '20000000000',
|
|
amountLeft: '10000000000',
|
|
unit: 'UNIT_CURRENCY',
|
|
},
|
|
monthlyChargeLimitEnabled: true,
|
|
monthlyChargeLimit: { currency: 'USD', priceInCents: '20000' },
|
|
monthlyUsed: { currency: 'USD', priceInCents: '5000' },
|
|
},
|
|
});
|
|
expect(parsed.extraUsage).toEqual({
|
|
balanceCents: 10000,
|
|
totalCents: 20000,
|
|
monthlyChargeLimitEnabled: true,
|
|
monthlyChargeLimitCents: 20000,
|
|
monthlyUsedCents: 5000,
|
|
currency: 'USD',
|
|
});
|
|
});
|
|
|
|
it('treats missing amountLeft as zero balance', () => {
|
|
const parsed = parseManagedUsagePayload({
|
|
usage: { used: 1, limit: 10 },
|
|
boosterWallet: { balance: { type: 'BOOSTER', amount: '20000000000' } },
|
|
});
|
|
expect(parsed.extraUsage).toMatchObject({ totalCents: 20000, balanceCents: 0 });
|
|
});
|
|
|
|
it('defaults monthly limit fields when absent', () => {
|
|
const parsed = parseManagedUsagePayload({
|
|
usage: { used: 1, limit: 10 },
|
|
boosterWallet: {
|
|
balance: { type: 'BOOSTER', amount: '20000000000', amountLeft: '20000000000' },
|
|
},
|
|
});
|
|
expect(parsed.extraUsage).toEqual({
|
|
balanceCents: 20000,
|
|
totalCents: 20000,
|
|
monthlyChargeLimitEnabled: false,
|
|
monthlyChargeLimitCents: 0,
|
|
monthlyUsedCents: 0,
|
|
currency: 'USD',
|
|
});
|
|
});
|
|
|
|
it('returns null extra usage when boosterWallet is missing or invalid', () => {
|
|
expect(parseManagedUsagePayload({ usage: { used: 1, limit: 10 } }).extraUsage).toBeNull();
|
|
expect(
|
|
parseManagedUsagePayload({
|
|
usage: { used: 1, limit: 10 },
|
|
boosterWallet: { balance: { type: 'OTHER', amount: '100', amountLeft: '50' } },
|
|
}).extraUsage,
|
|
).toBeNull();
|
|
expect(
|
|
parseManagedUsagePayload({
|
|
usage: { used: 1, limit: 10 },
|
|
boosterWallet: { balance: { type: 'BOOSTER', amount: '0', amountLeft: '0' } },
|
|
}).extraUsage,
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('fetchManagedUsage', () => {
|
|
it('sends only Authorization and Accept headers', async () => {
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ usage: { used: 1, limit: 10 } }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(fetchManagedUsage('https://api.example/usages', 'access-token')).resolves.toEqual({
|
|
kind: 'ok',
|
|
parsed: {
|
|
summary: { label: 'Weekly limit', used: 1, limit: 10 },
|
|
limits: [],
|
|
extraUsage: null,
|
|
},
|
|
});
|
|
|
|
const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][];
|
|
const init = calls[0]?.[1] ?? {};
|
|
const headers = new Headers((init.headers ?? {}) as Record<string, string>);
|
|
expect(headers.get('authorization')).toBe('Bearer access-token');
|
|
expect(headers.get('accept')).toBe('application/json');
|
|
expect(headers.get('user-agent')).toBeNull();
|
|
expect(headers.get('x-msh-platform')).toBeNull();
|
|
});
|
|
|
|
it('surfaces JSON API error messages with status', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ message: 'usage quota unavailable' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await fetchManagedUsage('https://api.example/usages', 'access-token');
|
|
|
|
expect(result.kind).toBe('error');
|
|
if (result.kind !== 'error') return;
|
|
expect(result.status).toBe(401);
|
|
expect(result.message).toBe('usage quota unavailable');
|
|
});
|
|
|
|
it('surfaces nested JSON API error messages', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ error: { message: 'usage endpoint moved' } }), {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await fetchManagedUsage('https://api.example/usages', 'access-token');
|
|
|
|
expect(result.kind).toBe('error');
|
|
if (result.kind !== 'error') return;
|
|
expect(result.status).toBe(404);
|
|
expect(result.message).toBe('usage endpoint moved');
|
|
});
|
|
|
|
it('falls back to local usage hints when the API error body is empty', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
|
|
|
|
const result = await fetchManagedUsage('https://api.example/usages', 'access-token');
|
|
|
|
expect(result.kind).toBe('error');
|
|
if (result.kind !== 'error') return;
|
|
expect(result.status).toBe(404);
|
|
expect(result.message).toBe('Usage endpoint not available. Try Kimi For Coding.');
|
|
});
|
|
});
|
|
|
|
describe('formatDuration', () => {
|
|
it('formats days/hours/minutes', () => {
|
|
expect(formatDuration(0)).toBe('0s');
|
|
expect(formatDuration(45)).toBe('45s');
|
|
expect(formatDuration(90)).toBe('1m');
|
|
expect(formatDuration(3600)).toBe('1h');
|
|
expect(formatDuration(3661)).toBe('1h 1m');
|
|
expect(formatDuration(86_400 + 7200 + 600)).toBe('1d 2h 10m');
|
|
});
|
|
});
|
|
|
|
describe('formatResetTime', () => {
|
|
it('returns "reset" for past timestamps', () => {
|
|
const past = new Date(Date.now() - 5000).toISOString();
|
|
expect(formatResetTime(past)).toBe('reset');
|
|
});
|
|
|
|
it('returns "resets in X" for future timestamps', () => {
|
|
const future = new Date(Date.now() + 3600_000).toISOString();
|
|
expect(formatResetTime(future)).toMatch(/^resets in /);
|
|
});
|
|
|
|
it('falls back when parsing fails', () => {
|
|
expect(formatResetTime('not-a-date')).toBe('resets at not-a-date');
|
|
});
|
|
});
|