mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution!
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
/**
|
|
* usage/kimi.ts — Kimi Coding (kimi-coding / kimi-coding-apikey) usage fetcher + helpers.
|
|
*
|
|
* Extracted from services/usage.ts (god-file decomposition): the Kimi family — the coding
|
|
* API config, membership-level → display-name mapping, and the getKimiUsage fetcher that
|
|
* probes the official /v1/usages endpoint. Depends only on the sibling scalar/quota leaves
|
|
* plus safePercentage — no host coupling — so it lives as a co-located provider leaf.
|
|
* usage.ts imports getKimiUsage (dispatcher). Behavior-preserving move.
|
|
*/
|
|
|
|
import { safePercentage } from "@/shared/utils/formatting";
|
|
import {
|
|
KIMI_CODE_ADDITIONAL_CREDITS_URL,
|
|
type KimiBillingStatus,
|
|
} from "@/shared/utils/kimiBilling";
|
|
import {
|
|
buildKimiCodeIdentityHeaders,
|
|
getKimiCodeCliUserAgent,
|
|
} from "../../config/providers/registry/kimi/coding/runtime.ts";
|
|
import { toRecord, toNumber } from "./scalars.ts";
|
|
import { createQuotaFromUsage, type UsageQuota, parseResetTime } from "./quota.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
// 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",
|
|
};
|
|
|
|
const KIMI_BOOSTER_FIXED_POINT_PER_CENT = 1_000_000;
|
|
|
|
function toInteger(value: unknown): number | null {
|
|
const parsed = toNumber(value, Number.NaN);
|
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
|
}
|
|
|
|
function fixedPointToCents(value: number): number {
|
|
const cents = value / KIMI_BOOSTER_FIXED_POINT_PER_CENT;
|
|
if (cents > 0 && cents < 1) return 1;
|
|
return Math.round(cents);
|
|
}
|
|
|
|
function parseKimiMoney(value: unknown): { cents: number; currency: string } | null {
|
|
const money = toRecord(value);
|
|
const cents = toInteger(money.priceInCents);
|
|
const currency = money.currency;
|
|
if (
|
|
cents === null ||
|
|
cents < 0 ||
|
|
typeof currency !== "string" ||
|
|
!/^[A-Za-z]{3}$/.test(currency)
|
|
) {
|
|
return null;
|
|
}
|
|
return { cents, currency: currency.toUpperCase() };
|
|
}
|
|
|
|
function parseKimiExtraUsageStatus(value: unknown): KimiBillingStatus["extraUsageStatus"] {
|
|
switch (value) {
|
|
case "STATUS_ACTIVE":
|
|
return "enabled";
|
|
case "STATUS_DISABLED":
|
|
return "disabled";
|
|
case "STATUS_FROZEN":
|
|
return "frozen";
|
|
default:
|
|
return "unavailable";
|
|
}
|
|
}
|
|
|
|
function parseKimiBoosterWallet(value: unknown): KimiBillingStatus | null {
|
|
const wallet = toRecord(value);
|
|
const balance = toRecord(wallet.balance);
|
|
if (balance.type !== "BOOSTER") return null;
|
|
|
|
const amount = toInteger(balance.amount);
|
|
const amountLeft = toInteger(balance.amountLeft);
|
|
const monthlyLimit = parseKimiMoney(wallet.monthlyChargeLimit);
|
|
const monthlyUsed = parseKimiMoney(wallet.monthlyUsed);
|
|
const autoRefillCharge = parseKimiMoney(wallet.autoRefillCharge);
|
|
const autoRefillThreshold = parseKimiMoney(wallet.autoRefillThreshold);
|
|
const extraUsageStatus = parseKimiExtraUsageStatus(wallet.status);
|
|
const hasWalletEvidence =
|
|
(amount !== null && amount > 0) ||
|
|
amountLeft !== null ||
|
|
monthlyLimit !== null ||
|
|
monthlyUsed !== null ||
|
|
extraUsageStatus !== "unavailable";
|
|
if (!hasWalletEvidence) return null;
|
|
|
|
const currency =
|
|
monthlyLimit?.currency ??
|
|
monthlyUsed?.currency ??
|
|
autoRefillCharge?.currency ??
|
|
autoRefillThreshold?.currency ??
|
|
"USD";
|
|
|
|
return {
|
|
currency,
|
|
// Proto JSON omits numeric zero values. Production therefore returns a
|
|
// BOOSTER balance record without amount/amountLeft when the preserved
|
|
// balance is exactly zero; treat that as an explicit zero, not unknown.
|
|
extraCreditsMinorUnits:
|
|
amountLeft === null || amountLeft < 0 ? 0 : fixedPointToCents(amountLeft),
|
|
monthlyUsedMinorUnits: monthlyUsed?.cents ?? 0,
|
|
monthlyLimitEnabled: wallet.monthlyChargeLimitEnabled === true,
|
|
monthlyLimitMinorUnits: monthlyLimit?.cents ?? 0,
|
|
extraUsageStatus,
|
|
additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL,
|
|
};
|
|
}
|
|
|
|
function buildKimiBillingStatus(value: unknown): KimiBillingStatus {
|
|
return (
|
|
parseKimiBoosterWallet(value) ?? {
|
|
currency: "USD",
|
|
extraUsageStatus: "unavailable",
|
|
additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL,
|
|
}
|
|
);
|
|
}
|
|
|
|
function optionalNumber(value: unknown): number | null {
|
|
if (typeof value !== "number" && typeof value !== "string") return null;
|
|
const parsed = toNumber(value, Number.NaN);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function createKimiCountQuota(value: unknown): UsageQuota | null {
|
|
const detail = toRecord(value);
|
|
const limit = optionalNumber(detail.limit ?? detail.Limit);
|
|
if (limit === null || limit <= 0) return null;
|
|
|
|
const reportedUsed = optionalNumber(detail.used ?? detail.Used);
|
|
const reportedRemaining = optionalNumber(detail.remaining ?? detail.Remaining);
|
|
const used = reportedUsed ?? (reportedRemaining === null ? 0 : limit - reportedRemaining);
|
|
return createQuotaFromUsage(used, limit, detail.resetTime ?? detail.reset_at ?? detail.resetAt);
|
|
}
|
|
|
|
type KimiWindowLabel = { key: string; displayName: string };
|
|
|
|
function normalizeKimiWindow(value: unknown, fallbackIndex: number): KimiWindowLabel {
|
|
const window = toRecord(value);
|
|
const duration = optionalNumber(window.duration);
|
|
const timeUnit = window.timeUnit;
|
|
|
|
if (duration !== null && duration > 0) {
|
|
if (timeUnit === "TIME_UNIT_MINUTE" && duration % 60 === 0) {
|
|
const hours = duration / 60;
|
|
return { key: `${hours}h`, displayName: `Code · ${hours}h` };
|
|
}
|
|
if (timeUnit === "TIME_UNIT_HOUR") {
|
|
return { key: `${duration}h`, displayName: `Code · ${duration}h` };
|
|
}
|
|
if (timeUnit === "TIME_UNIT_DAY") {
|
|
return { key: `${duration}d`, displayName: `Code · ${duration}d` };
|
|
}
|
|
if (timeUnit === "TIME_UNIT_WEEK") {
|
|
return { key: `${duration}w`, displayName: `Code · ${duration}w` };
|
|
}
|
|
if (timeUnit === "TIME_UNIT_MINUTE") {
|
|
return { key: `${duration}m`, displayName: `Code · ${duration}m` };
|
|
}
|
|
}
|
|
|
|
return { key: `limit_${fallbackIndex}`, displayName: `Code · Limit ${fallbackIndex}` };
|
|
}
|
|
|
|
/**
|
|
* Map Kimi membership level to display name
|
|
* LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto,
|
|
* LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace
|
|
*/
|
|
function getKimiPlanName(level: unknown): string {
|
|
if (!level) return "";
|
|
const normalizedLevel = String(level);
|
|
|
|
const levelMap = {
|
|
LEVEL_BASIC: "Moderato",
|
|
LEVEL_INTERMEDIATE: "Allegretto",
|
|
LEVEL_ADVANCED: "Allegro",
|
|
LEVEL_STANDARD: "Vivace",
|
|
};
|
|
|
|
return (
|
|
levelMap[normalizedLevel as keyof typeof levelMap] ||
|
|
normalizedLevel.replace("LEVEL_", "").toLowerCase()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Kimi Coding Usage - Fetch quota from Kimi API
|
|
* Uses the official /v1/usages endpoint with custom X-Msh-* headers
|
|
*/
|
|
export async function getKimiUsage(
|
|
accessToken?: string,
|
|
apiKey?: string,
|
|
providerSpecificData: JsonRecord = {}
|
|
) {
|
|
// API key auth takes precedence — Kimi's /usages endpoint accepts the same
|
|
// API key used for /messages (verified live: responds with
|
|
// authentication.method = METHOD_API_KEY). OAuth flow falls through to the
|
|
// Bearer + device-headers shape used by Kimi Coding OAuth.
|
|
const useApiKey = typeof apiKey === "string" && apiKey.length > 0;
|
|
|
|
const authHeaders: Record<string, string> = useApiKey
|
|
? { "x-api-key": apiKey as string }
|
|
: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
...buildKimiCodeIdentityHeaders(providerSpecificData),
|
|
"User-Agent": getKimiCodeCliUserAgent(),
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(KIMI_CONFIG.usageUrl, {
|
|
method: "GET",
|
|
headers: {
|
|
...authHeaders,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
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);
|
|
const billing = buildKimiBillingStatus(dataObj.boosterWallet);
|
|
|
|
// The managed Kimi Code API reports the Code 7-day quota in `usage`.
|
|
// The website's separate shared-membership total/Kimi split comes from a
|
|
// Web-session-only endpoint and cannot be read with a Coding OAuth token.
|
|
const weeklyQuota = createKimiCountQuota(dataObj.usage);
|
|
if (weeklyQuota) {
|
|
quotas.code_7d = { ...weeklyQuota, displayName: "Code · 7d" };
|
|
}
|
|
|
|
// Each limits[] item is an independent rolling window. Preserve all of
|
|
// them with deterministic window-derived keys instead of overwriting one
|
|
// generic `Ratelimit` row.
|
|
const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : [];
|
|
for (let i = 0; i < limitsArray.length; i++) {
|
|
const limitItem = toRecord(limitsArray[i]);
|
|
const quota = createKimiCountQuota(limitItem.detail);
|
|
if (!quota) continue;
|
|
|
|
const normalized = normalizeKimiWindow(limitItem.window, i + 1);
|
|
const baseKey = `code_${normalized.key}`;
|
|
let key = baseKey;
|
|
let suffix = 2;
|
|
while (key in quotas) key = `${baseKey}_${suffix++}`;
|
|
const reportedName =
|
|
typeof limitItem.name === "string" && limitItem.name.trim() ? limitItem.name.trim() : null;
|
|
const displayName = reportedName
|
|
? /^code\b/i.test(reportedName)
|
|
? reportedName
|
|
: `Code · ${reportedName}`
|
|
: normalized.displayName;
|
|
quotas[key] = { ...quota, displayName };
|
|
}
|
|
|
|
// 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(toRecord(dataObj.five_hour))) {
|
|
quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour));
|
|
}
|
|
|
|
if (hasUtilization(toRecord(dataObj.seven_day))) {
|
|
quotas["weekly (7d)"] = createQuotaObject(toRecord(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 userRecord = toRecord(dataObj.user);
|
|
const membershipLevel = toRecord(userRecord.membership).level;
|
|
const planName = getKimiPlanName(membershipLevel);
|
|
return {
|
|
plan: planName || "Kimi Coding",
|
|
quotas,
|
|
billing,
|
|
};
|
|
}
|
|
|
|
// No quota data in response
|
|
const userRecord = toRecord(dataObj.user);
|
|
const membershipLevel = toRecord(userRecord.membership).level;
|
|
const planName = getKimiPlanName(membershipLevel);
|
|
return {
|
|
plan: planName || "Kimi Coding",
|
|
message: "Kimi Coding connected. Usage tracked per request.",
|
|
billing,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
message: `Kimi Coding connected. Unable to fetch usage: ${(error as Error).message}`,
|
|
};
|
|
}
|
|
}
|