fix: compute Kimi quota usage from used or remaining field
This commit is contained in:
@@ -14,6 +14,20 @@ export const providerId = 'kimi-for-coding';
|
||||
export const providerName = 'Kimi for Coding';
|
||||
const aliases = ['kimi-for-coding', 'kimi'];
|
||||
|
||||
// Kimi's weekly `usage` block reports `used`; its rate-limit `limits[].detail`
|
||||
// blocks report `remaining` instead. Neither field is guaranteed present, so
|
||||
// derive usedPercent from whichever one the API actually returned.
|
||||
const computeUsedPercent = (total, used, remaining) => {
|
||||
if (!total) return null;
|
||||
if (used !== null) {
|
||||
return Math.max(0, Math.min(100, (used / total) * 100));
|
||||
}
|
||||
if (remaining !== null) {
|
||||
return Math.max(0, Math.min(100, 100 - (remaining / total) * 100));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
@@ -59,10 +73,9 @@ export const fetchQuota = async () => {
|
||||
const usage = payload?.usage ?? null;
|
||||
if (usage) {
|
||||
const limit = toNumber(usage.limit);
|
||||
const used = toNumber(usage.used);
|
||||
const remaining = toNumber(usage.remaining);
|
||||
const usedPercent = limit && remaining !== null
|
||||
? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100))
|
||||
: null;
|
||||
const usedPercent = computeUsedPercent(limit, used, remaining);
|
||||
windows.weekly = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds: null,
|
||||
@@ -78,10 +91,9 @@ export const fetchQuota = async () => {
|
||||
const windowSeconds = durationToSeconds(window?.duration, window?.timeUnit);
|
||||
const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel;
|
||||
const total = toNumber(detail?.limit);
|
||||
const used = toNumber(detail?.used);
|
||||
const remaining = toNumber(detail?.remaining);
|
||||
const usedPercent = total && remaining !== null
|
||||
? Math.max(0, Math.min(100, 100 - (remaining / total) * 100))
|
||||
: null;
|
||||
const usedPercent = computeUsedPercent(total, used, remaining);
|
||||
windows[label] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../opencode/auth.js', () => ({
|
||||
readAuthFile: () => ({ 'kimi-for-coding': { key: 'test-token' } }),
|
||||
}));
|
||||
|
||||
import { fetchQuota } from './kimi.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const mockResponse = (body, init = {}) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
...init,
|
||||
});
|
||||
|
||||
describe('Kimi for Coding quota provider', () => {
|
||||
it('computes weekly usedPercent from the used field (live API shape, no remaining field)', async () => {
|
||||
// Captured from GET https://api.kimi.com/coding/v1/usages — the weekly
|
||||
// `usage` block only ever includes `used`, never `remaining`.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
mockResponse({
|
||||
usage: { limit: '100', used: '100', resetTime: '2026-08-04T06:21:48.514003Z' },
|
||||
limits: [{
|
||||
window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' },
|
||||
detail: { limit: '100', remaining: '100', resetTime: '2026-08-03T07:21:48.514003Z' },
|
||||
}],
|
||||
}),
|
||||
));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.usage.windows.weekly.usedPercent).toBe(100);
|
||||
expect(result.usage.windows['Rate Limit (300m)'].usedPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to computing usedPercent from remaining when used is absent', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
mockResponse({
|
||||
usage: { limit: '2048', remaining: '512', resetTime: '2026-08-04T06:21:48.514003Z' },
|
||||
limits: [],
|
||||
}),
|
||||
));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.usage.windows.weekly.usedPercent).toBe(75);
|
||||
});
|
||||
|
||||
it('prefers used over remaining when both fields are present', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
mockResponse({
|
||||
usage: { limit: '100', used: '30', remaining: '999', resetTime: null },
|
||||
limits: [],
|
||||
}),
|
||||
));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.usage.windows.weekly.usedPercent).toBe(30);
|
||||
});
|
||||
|
||||
it('reports null usedPercent when neither used nor remaining is present', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
mockResponse({
|
||||
usage: { limit: '100', resetTime: null },
|
||||
limits: [],
|
||||
}),
|
||||
));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.usage.windows.weekly.usedPercent).toBeNull();
|
||||
});
|
||||
|
||||
it('reports not configured when no credentials are stored', async () => {
|
||||
vi.doMock('../../opencode/auth.js', () => ({ readAuthFile: () => ({}) }));
|
||||
vi.resetModules();
|
||||
const { fetchQuota: fetchQuotaFresh } = await import('./kimi.js');
|
||||
|
||||
const result = await fetchQuotaFresh();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.error).toBe('Not configured');
|
||||
|
||||
vi.doUnmock('../../opencode/auth.js');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('surfaces API errors with status', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({}),
|
||||
}));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe('API error: 401');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user