feat(kimi-coding): add Kimi Coding plan quota display (#279)

- Add getKimiUsage() with X-Msh-* device headers for Kimi API
- Parse weekly quota + rate-limit breakdown from /v1/usages endpoint
- Fix X-Msh-* headers in refreshKimiCodingToken (was missing)
- Wire kimi-coding into getUsageForProvider switch
- Add quota capability flag to providers.ts
- Remove console.warn/error (production-safe silent returns)

Co-authored-by: nyatoru <nyarutoru0002@outlook.co.th>
This commit is contained in:
diegosouzapw
2026-03-10 09:04:03 -03:00
5 changed files with 231 additions and 13 deletions
+14
View File
@@ -137,6 +137,7 @@ export async function refreshClineToken(refreshToken, log) {
/**
* Specialized refresh for Kimi Coding OAuth tokens.
* Uses custom X-Msh-* headers required by Kimi OAuth API.
*/
export async function refreshKimiCodingToken(refreshToken, log) {
const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl;
@@ -145,6 +146,13 @@ export async function refreshKimiCodingToken(refreshToken, log) {
return null;
}
// Generate device info for headers (same as OAuth flow)
const deviceId = "kimi-refresh-" + Date.now();
const platform = "omniroute";
const version = "2.1.2";
const deviceModel =
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
try {
const params = new URLSearchParams({
grant_type: "refresh_token",
@@ -157,6 +165,10 @@ export async function refreshKimiCodingToken(refreshToken, log) {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"X-Msh-Platform": platform,
"X-Msh-Version": version,
"X-Msh-Device-Model": deviceModel,
"X-Msh-Device-Id": deviceId,
},
body: params,
});
@@ -181,6 +193,8 @@ export async function refreshKimiCodingToken(refreshToken, log) {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
tokenType: tokens.token_type,
scope: tokens.scope,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kimi Coding token: ${error.message}`);
+178
View File
@@ -38,6 +38,13 @@ const CLAUDE_CONFIG = {
apiVersion: "2023-06-01",
};
// Kimi Coding API config
const KIMI_CONFIG = {
baseUrl: "https://api.kimi.com/coding/v1",
usageUrl: "https://api.kimi.com/coding/v1/usages",
apiVersion: "2023-06-01",
};
type JsonRecord = Record<string, unknown>;
type UsageQuota = {
used: number;
@@ -89,6 +96,8 @@ export async function getUsageForProvider(connection) {
return await getCodexUsage(accessToken, providerSpecificData);
case "kiro":
return await getKiroUsage(accessToken, providerSpecificData);
case "kimi-coding":
return await getKimiUsage(accessToken);
case "qwen":
return await getQwenUsage(accessToken, providerSpecificData);
case "iflow":
@@ -779,6 +788,175 @@ async function getKiroUsage(accessToken, providerSpecificData) {
}
}
/**
* Map Kimi membership level to display name
* LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto,
* LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace
*/
function getKimiPlanName(level) {
if (!level) return "";
const levelMap = {
LEVEL_BASIC: "Moderato",
LEVEL_INTERMEDIATE: "Allegretto",
LEVEL_ADVANCED: "Allegro",
LEVEL_STANDARD: "Vivace",
};
return levelMap[level] || level.replace("LEVEL_", "").toLowerCase();
}
/**
* Kimi Coding Usage - Fetch quota from Kimi API
* Uses the official /v1/usages endpoint with custom X-Msh-* headers
*/
async function getKimiUsage(accessToken) {
// Generate device info for headers (same as OAuth flow)
const deviceId = "kimi-usage-" + Date.now();
const platform = "omniroute";
const version = "2.1.2";
const deviceModel =
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
try {
const response = await fetch(KIMI_CONFIG.usageUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"X-Msh-Platform": platform,
"X-Msh-Version": version,
"X-Msh-Device-Model": deviceModel,
"X-Msh-Device-Id": deviceId,
},
});
const responseText = await response.text();
if (!response.ok) {
return {
plan: "Kimi Coding",
message: `Kimi Coding connected. API Error ${response.status}: ${responseText.slice(0, 100)}`,
};
}
let data;
try {
data = JSON.parse(responseText);
} catch {
return {
plan: "Kimi Coding",
message: "Kimi Coding connected. Invalid JSON response from API.",
};
}
const quotas: Record<string, UsageQuota> = {};
const dataObj = toRecord(data);
// Parse Kimi usage response format
// Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] }
const usageObj = toRecord(dataObj.usage);
// Check for Kimi's actual usage fields (strings, not numbers)
const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0);
const usageUsed = toNumber(usageObj.used || usageObj.Used, 0);
const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0);
const usageResetTime =
usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt;
if (usageLimit > 0) {
const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0;
quotas["Weekly"] = {
used: usageUsed,
total: usageLimit,
remaining: usageRemaining,
remainingPercentage: percentRemaining,
resetAt: parseResetTime(usageResetTime),
unlimited: false,
};
}
// Also parse limits array for rate limits
const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : [];
for (let i = 0; i < limitsArray.length; i++) {
const limitItem = toRecord(limitsArray[i]);
const window = toRecord(limitItem.window);
const detail = toRecord(limitItem.detail);
const limit = toNumber(detail.limit || detail.Limit, 0);
const remaining = toNumber(detail.remaining || detail.Remaining, 0);
const resetTime = detail.resetTime || detail.reset_at || detail.resetAt;
if (limit > 0) {
quotas["Ratelimit"] = {
used: limit - remaining,
total: limit,
remaining,
remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0,
resetAt: parseResetTime(resetTime),
unlimited: false,
};
}
}
// Check for quota windows (Claude-like format with utilization) as fallback
const hasUtilization = (window: JsonRecord) =>
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
const createQuotaObject = (window: JsonRecord) => {
const remaining = safePercentage(window.utilization) as number;
const used = 100 - remaining;
return {
used,
total: 100,
remaining,
resetAt: parseResetTime(window.resets_at),
remainingPercentage: remaining,
unlimited: false,
};
};
if (hasUtilization(dataObj.five_hour)) {
quotas["session (5h)"] = createQuotaObject(dataObj.five_hour);
}
if (hasUtilization(dataObj.seven_day)) {
quotas["weekly (7d)"] = createQuotaObject(dataObj.seven_day);
}
// Check for model-specific quotas
for (const [key, value] of Object.entries(dataObj)) {
const valueRecord = toRecord(value);
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) {
const modelName = key.replace("seven_day_", "");
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord);
}
}
if (Object.keys(quotas).length > 0) {
const membershipLevel = dataObj.user?.membership?.level;
const planName = getKimiPlanName(membershipLevel);
return {
plan: planName || "Kimi Coding",
quotas,
};
}
// No quota data in response
const membershipLevel = dataObj.user?.membership?.level;
const planName = getKimiPlanName(membershipLevel);
return {
plan: planName || "Kimi Coding",
message: "Kimi Coding connected. Usage tracked per request.",
};
} catch (error) {
return {
message: `Kimi Coding connected. Unable to fetch usage: ${(error as Error).message}`,
};
}
}
/**
* Qwen Usage
*/
@@ -20,6 +20,7 @@ const PROVIDER_CONFIG = {
kiro: { label: "Kiro AI", color: "#FF6B35" },
codex: { label: "OpenAI Codex", color: "#10A37F" },
claude: { label: "Claude Code", color: "#D97757" },
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
};
const TIER_FILTERS = [
@@ -236,7 +237,7 @@ export default function ProviderLimits() {
);
const sortedConnections = useMemo(() => {
const priority = { antigravity: 1, github: 2, codex: 3, claude: 4, kiro: 5 };
const priority = { antigravity: 1, github: 2, codex: 3, claude: 4, kiro: 5, "kimi-coding": 6 };
return [...filteredConnections].sort(
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)
);
+29 -11
View File
@@ -1,4 +1,26 @@
import { KIMI_CODING_CONFIG } from "../constants/oauth";
import { randomUUID } from "node:crypto";
import { hostname } from "node:os";
// Generate device ID (persistent per installation)
const DEVICE_ID = randomUUID();
const PLATFORM = "omniroute";
const VERSION = "2.1.2";
const DEVICE_NAME = hostname();
const DEVICE_MODEL = `${process.platform} ${process.arch}`;
// Custom headers required by Kimi OAuth
function getKimiOAuthHeaders() {
return {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"X-Msh-Platform": PLATFORM,
"X-Msh-Version": VERSION,
"X-Msh-Device-Name": DEVICE_NAME,
"X-Msh-Device-Model": DEVICE_MODEL,
"X-Msh-Device-Id": DEVICE_ID,
};
}
export const kimiCoding = {
config: KIMI_CODING_CONFIG,
@@ -6,10 +28,7 @@ export const kimiCoding = {
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
headers: getKimiOAuthHeaders(),
body: new URLSearchParams({
client_id: config.clientId,
}),
@@ -24,10 +43,10 @@ export const kimiCoding = {
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri || `https://www.kimi.com/code/authorize_device`,
verification_uri: data.verification_uri || `https://auth.kimi.com/activate`,
verification_uri_complete:
data.verification_uri_complete ||
`https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`,
`https://auth.kimi.com/activate?user_code=${data.user_code}`,
expires_in: data.expires_in,
interval: data.interval || 5,
};
@@ -35,14 +54,11 @@ export const kimiCoding = {
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
headers: getKimiOAuthHeaders(),
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
@@ -63,5 +79,7 @@ export const kimiCoding = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
tokenType: tokens.token_type,
scope: tokens.scope,
}),
};
+8 -1
View File
@@ -400,7 +400,14 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
}, {});
// Providers that support usage/quota API
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex", "claude"];
export const USAGE_SUPPORTED_PROVIDERS = [
"antigravity",
"kiro",
"github",
"codex",
"claude",
"kimi-coding",
];
// ── Zod validation at module load (Phase 7.2) ──
import { validateProviders } from "../validation/providerSchema";