Coverage for src/kimi_code_usage/providers/kimi.py: 100%
122 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-14 14:57 +0800
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-14 14:57 +0800
1import aiohttp
2from datetime import datetime, timedelta
3from typing import Any, List, Mapping, Optional, Sequence, Tuple, cast
4from . import ProviderUsage
6def _to_int(v: Any) -> Optional[int]:
7 try:
8 return int(v)
9 except (TypeError, ValueError):
10 return None
12def _get_reset_info(data: Mapping[str, Any]) -> Optional[Tuple[str, str]]:
13 reset_at = data.get("resetTime") or data.get("reset_at") or data.get("reset_time")
14 if reset_at:
15 try:
16 if isinstance(reset_at, (int, float)):
17 dt = datetime.fromtimestamp(reset_at)
18 else:
19 dt = datetime.fromisoformat(reset_at.replace("Z", "+00:00")).astimezone()
21 now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
22 diff = dt - now
23 if diff.total_seconds() <= 0:
24 return dt.strftime("%m-%d %H:%M"), "0m"
25 days = diff.days
26 hours, rem = divmod(diff.seconds, 3600)
27 minutes, _ = divmod(rem, 60)
28 parts = []
29 if days > 0:
30 parts.append(f"{days}d")
31 if hours > 0:
32 parts.append(f"{hours}h")
33 parts.append(f"{minutes}m")
34 return dt.strftime("%m-%d %H:%M"), " ".join(parts)
35 except Exception:
36 pass
38 reset_in = _to_int(data.get("reset_in"))
39 if reset_in is not None:
40 dt = datetime.now() + timedelta(seconds=reset_in)
41 hours, rem = divmod(reset_in, 3600)
42 minutes, _ = divmod(rem, 60)
43 return dt.strftime("%m-%d %H:%M"), f"{hours}h {minutes}m"
44 return None
46def _limit_label(window: Mapping[str, Any], idx: int) -> str:
47 duration = _to_int(window.get("duration"))
48 time_unit = str(window.get("timeUnit") or window.get("time_unit") or "").upper()
49 if duration is not None:
50 if "MINUTE" in time_unit:
51 if duration >= 60 and duration % 60 == 0:
52 return f"{duration // 60}h Limit"
53 return f"{duration}m Limit"
54 if "HOUR" in time_unit:
55 return f"{duration}h Limit"
56 if "DAY" in time_unit:
57 return f"{duration}d Limit"
58 if "MONTH" in time_unit:
59 return f"{duration}mo Limit"
60 return f"{duration}s Limit"
61 return f"Limit #{idx + 1}"
63class KimiRow:
64 def __init__(self, label: str, used: int, limit: int, reset_at: Optional[str] = None, countdown: Optional[str] = None):
65 self.label = label
66 self.used = used
67 self.limit = limit
68 self.reset_at = reset_at
69 self.countdown = countdown
71def _to_usage_row(data: Mapping[str, Any], default_label: str) -> Optional[KimiRow]:
72 limit = _to_int(data.get("limit") or data.get("limit_amount"))
73 used = _to_int(data.get("used") or data.get("used_amount"))
74 if used is None:
75 remaining = _to_int(data.get("remaining"))
76 if remaining is not None and limit is not None:
77 used = limit - remaining
78 if used is None and limit is None:
79 return None
80 reset_info = _get_reset_info(data)
81 reset_at, countdown = reset_info if reset_info else (None, None)
82 return KimiRow(
83 label=str(data.get("name") or data.get("title") or data.get("model_name") or default_label),
84 used=used or 0,
85 limit=limit or 0,
86 reset_at=reset_at,
87 countdown=countdown,
88 )
90def _parse_usage_payload(payload: Mapping[str, Any]) -> Tuple[Optional[KimiRow], List[KimiRow]]:
91 summary = None
92 limits = []
94 data_list = payload.get("data")
95 if isinstance(data_list, Sequence):
96 for item in data_list:
97 if not isinstance(item, Mapping):
98 continue
99 label = "Weekly Usage" if item.get("model_name") == "all" else "Limit"
100 row = _to_usage_row(item, default_label=label)
101 if row:
102 if item.get("model_name") == "all":
103 summary = row
104 else:
105 limits.append(row)
106 else:
107 usage = payload.get("usage")
108 if isinstance(usage, Mapping):
109 summary = _to_usage_row(cast(Mapping, usage), default_label="Weekly Usage")
110 raw_limits = payload.get("limits")
111 if isinstance(raw_limits, Sequence):
112 for idx, item in enumerate(raw_limits):
113 if not isinstance(item, Mapping):
114 continue
115 detail = item.get("detail") if isinstance(item.get("detail"), Mapping) else item
116 window = item.get("window") if isinstance(item.get("window"), Mapping) else {}
117 row = _to_usage_row(detail, default_label=_limit_label(window, idx))
118 if row:
119 limits.append(row)
121 return summary, limits
123async def fetch_kimi_usage(api_key: str, base_url: str, management_key: Optional[str] = None) -> List[ProviderUsage]:
124 url = base_url.rstrip("/") + "/usages"
125 async with aiohttp.ClientSession() as session:
126 async with session.get(url, headers={"Authorization": f"Bearer {api_key}"}) as resp:
127 if resp.status != 200:
128 fallback_url = base_url.rstrip("/") + "/usage"
129 async with session.get(fallback_url, headers={"Authorization": f"Bearer {api_key}"}) as f_resp:
130 if f_resp.status != 200:
131 text = await f_resp.text()
132 raise Exception(f"API Error {f_resp.status}: {text}")
133 payload = await f_resp.json()
134 else:
135 payload = await resp.json()
137 summary, limits = _parse_usage_payload(payload)
138 rows = ([summary] if summary else []) + limits
140 res = []
141 for r in rows:
142 pct = (r.used / r.limit * 100) if r.limit > 0 else 0.0
143 rem = float(r.limit - r.used)
144 res.append(ProviderUsage(
145 provider="kimi",
146 label=r.label,
147 used=float(r.used),
148 limit=float(r.limit),
149 remaining=rem,
150 percent=pct,
151 reset_at=r.reset_at,
152 countdown=r.countdown,
153 unit="%"
154 ))
155 return res