diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 33831395..868db885 100644 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -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}`); diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index e432e812..e0d3a8bd 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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; 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 = {}; + 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 */ diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index cba84c90..451044a2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -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) ); diff --git a/src/lib/oauth/providers/kimi-coding.ts b/src/lib/oauth/providers/kimi-coding.ts index 03a48722..6dd59aca 100644 --- a/src/lib/oauth/providers/kimi-coding.ts +++ b/src/lib/oauth/providers/kimi-coding.ts @@ -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, }), }; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 7681c726..d6910a3a 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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";