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