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:
@@ -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}`);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user