multi-codex 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multi_codex/__init__.py +3 -0
- multi_codex/__main__.py +5 -0
- multi_codex/accounts.py +245 -0
- multi_codex/actions.py +76 -0
- multi_codex/apps.py +74 -0
- multi_codex/binding.py +66 -0
- multi_codex/cli.py +994 -0
- multi_codex/completion.py +254 -0
- multi_codex/config.py +327 -0
- multi_codex/doctor.py +244 -0
- multi_codex/fsutil.py +250 -0
- multi_codex/identity.py +171 -0
- multi_codex/launcher.py +151 -0
- multi_codex/lock.py +47 -0
- multi_codex/migrate.py +672 -0
- multi_codex/platform.py +184 -0
- multi_codex/shared.py +127 -0
- multi_codex/switch.py +286 -0
- multi_codex/usage.py +475 -0
- multi_codex-0.7.0.dist-info/METADATA +452 -0
- multi_codex-0.7.0.dist-info/RECORD +25 -0
- multi_codex-0.7.0.dist-info/WHEEL +5 -0
- multi_codex-0.7.0.dist-info/entry_points.txt +2 -0
- multi_codex-0.7.0.dist-info/licenses/LICENSE +21 -0
- multi_codex-0.7.0.dist-info/top_level.txt +1 -0
multi_codex/usage.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""额度读取:默认读本地会话记录里最近一次的快照,`--live` 时经官方 codex app-server 联网读取。
|
|
2
|
+
|
|
3
|
+
两条通道的权威性不同:
|
|
4
|
+
- 本地快照是 Codex 上次运行时记下的,不联网,可能已经过时(窗口可能已经重置);
|
|
5
|
+
- 实时结果由官方客户端按它自己的协议向后端查询,我们不接触令牌,也不直接调用后端接口。
|
|
6
|
+
所以 `--live` 失败时如实报错,不悄悄退回本地快照(feature-account-insight §6)。
|
|
7
|
+
|
|
8
|
+
数据格式以 openai/codex 源码为准(基线 6b4daafd),出处见 feature-account-insight §1。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import calendar
|
|
12
|
+
import glob
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import queue
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
|
|
26
|
+
SOURCE_LOCAL = "local"
|
|
27
|
+
SOURCE_LIVE = "live"
|
|
28
|
+
|
|
29
|
+
# 本地扫描上限:按修改时间从新到旧最多看这么多个会话文件,读到第一个含快照的文件就停。
|
|
30
|
+
MAX_SCAN_FILES = 20
|
|
31
|
+
# 反向分块读取的块大小,单位字节。
|
|
32
|
+
CHUNK_BYTES = 64 * 1024
|
|
33
|
+
# serde 输出的紧凑 JSON 中,非 null 的额度字段一定以这个片段出现;先按字节过滤,避免逐行解析整个文件。
|
|
34
|
+
_RATE_LIMITS_MARK = b'"rate_limits":{'
|
|
35
|
+
DEFAULT_LIMIT_ID = "codex"
|
|
36
|
+
|
|
37
|
+
DEFAULT_LIVE_TIMEOUT_SEC = 30.0
|
|
38
|
+
# 正常拿到响应后,关闭 stdin 等 app-server 自行退出的时间,单位秒。
|
|
39
|
+
_GRACEFUL_EXIT_SEC = 5.0
|
|
40
|
+
# 对进程组发 SIGTERM 后,等多久再发 SIGKILL,单位秒。
|
|
41
|
+
_TERM_GRACE_SEC = 2.0
|
|
42
|
+
_STDERR_TAIL_LINES = 20
|
|
43
|
+
_MESSAGE_LIMIT = 300
|
|
44
|
+
|
|
45
|
+
# 未知方法在旧版本里是 -32601;当前版本反序列化失败统一返回 -32600,信息里带 serde 的 unknown variant。
|
|
46
|
+
_METHOD_NOT_FOUND = -32601
|
|
47
|
+
_INVALID_REQUEST = -32600
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Window(NamedTuple):
|
|
51
|
+
kind: str # primary / secondary
|
|
52
|
+
used_percent: float # 0-100
|
|
53
|
+
window_minutes: Optional[int] # 窗口长度,单位分钟
|
|
54
|
+
resets_at: Optional[int] # 窗口重置时刻,epoch 秒
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Limit(NamedTuple):
|
|
58
|
+
limit_id: str
|
|
59
|
+
plan_type: Optional[str]
|
|
60
|
+
windows: List[Window]
|
|
61
|
+
credits: Optional[dict]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class UsageResult(NamedTuple):
|
|
65
|
+
name: str
|
|
66
|
+
source: str
|
|
67
|
+
ok: bool
|
|
68
|
+
error: Optional[str]
|
|
69
|
+
snapshot_time: Optional[float] # 本地:快照所在行的时间;实时:查询时刻。epoch 秒
|
|
70
|
+
sessions_shared: bool
|
|
71
|
+
limits: List[Limit]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LiveError(Exception):
|
|
75
|
+
"""实时查询失败;信息会直接显示给用户,不含令牌(app-server 的响应与 stderr 都不含令牌)。"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---- 快照解析(本地与实时共用) ----
|
|
79
|
+
|
|
80
|
+
def _pick(data: dict, *keys: str) -> Any:
|
|
81
|
+
for key in keys:
|
|
82
|
+
if key in data:
|
|
83
|
+
return data[key]
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_window(kind: str, raw: object) -> Optional[Window]:
|
|
88
|
+
if not isinstance(raw, dict):
|
|
89
|
+
return None
|
|
90
|
+
# 本地记录与 0.48.0 的 app-server 用蛇形命名,当前 app-server 用驼峰命名,两种都接受。
|
|
91
|
+
used = _pick(raw, "used_percent", "usedPercent")
|
|
92
|
+
if isinstance(used, bool) or not isinstance(used, (int, float)):
|
|
93
|
+
return None
|
|
94
|
+
minutes = _pick(raw, "window_minutes", "windowDurationMins")
|
|
95
|
+
resets = _pick(raw, "resets_at", "resetsAt")
|
|
96
|
+
return Window(kind, float(used),
|
|
97
|
+
minutes if isinstance(minutes, int) and not isinstance(minutes, bool) else None,
|
|
98
|
+
resets if isinstance(resets, int) and not isinstance(resets, bool) else None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_limit(raw: dict, limit_id: Optional[str] = None) -> Limit:
|
|
102
|
+
windows = [window for window in (_parse_window("primary", raw.get("primary")),
|
|
103
|
+
_parse_window("secondary", raw.get("secondary"))) if window]
|
|
104
|
+
plan = _pick(raw, "plan_type", "planType")
|
|
105
|
+
credits = raw.get("credits")
|
|
106
|
+
return Limit(limit_id or _pick(raw, "limit_id", "limitId") or DEFAULT_LIMIT_ID,
|
|
107
|
+
plan if isinstance(plan, str) else None, windows,
|
|
108
|
+
credits if isinstance(credits, dict) else None)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---- 本地快照 ----
|
|
112
|
+
|
|
113
|
+
def _candidate_files(account_dir: str) -> List[str]:
|
|
114
|
+
patterns = [os.path.join(account_dir, "sessions", "*", "*", "*", "rollout-*.jsonl"),
|
|
115
|
+
os.path.join(account_dir, "archived_sessions", "rollout-*.jsonl")]
|
|
116
|
+
stamped = []
|
|
117
|
+
for pattern in patterns:
|
|
118
|
+
for path in glob.glob(pattern):
|
|
119
|
+
try:
|
|
120
|
+
stamped.append((os.stat(path).st_mtime, path))
|
|
121
|
+
except OSError:
|
|
122
|
+
continue
|
|
123
|
+
stamped.sort(reverse=True)
|
|
124
|
+
return [path for _, path in stamped[:MAX_SCAN_FILES]]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _reverse_lines(path: str) -> Iterator[bytes]:
|
|
128
|
+
"""从文件末尾向前逐行产出(不含行尾的 \n 和 \r)。会话文件可能很大,不整个读进内存。"""
|
|
129
|
+
with open(path, "rb") as handle:
|
|
130
|
+
handle.seek(0, os.SEEK_END)
|
|
131
|
+
position = handle.tell()
|
|
132
|
+
pending = b""
|
|
133
|
+
while position > 0:
|
|
134
|
+
step = min(CHUNK_BYTES, position)
|
|
135
|
+
position -= step
|
|
136
|
+
handle.seek(position)
|
|
137
|
+
pending = handle.read(step) + pending
|
|
138
|
+
lines = pending.split(b"\n")
|
|
139
|
+
# 第一段可能是被块边界截断的半行,留到下一轮与更前面的内容拼起来。
|
|
140
|
+
pending = lines[0]
|
|
141
|
+
for line in reversed(lines[1:]):
|
|
142
|
+
line = line.rstrip(b"\r")
|
|
143
|
+
if line:
|
|
144
|
+
yield line
|
|
145
|
+
pending = pending.rstrip(b"\r")
|
|
146
|
+
if pending:
|
|
147
|
+
yield pending
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_timestamp(value: object) -> Optional[float]:
|
|
151
|
+
# 记录格式为 YYYY-MM-DDTHH:MM:SS.mmmZ(UTC);Python 3.8 的 fromisoformat 不接受末尾的 Z。
|
|
152
|
+
if not isinstance(value, str):
|
|
153
|
+
return None
|
|
154
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
|
155
|
+
try:
|
|
156
|
+
return float(calendar.timegm(time.strptime(value, fmt)))
|
|
157
|
+
except ValueError:
|
|
158
|
+
continue
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _snapshot_in_file(path: str) -> Tuple[Optional[float], List[Limit]]:
|
|
163
|
+
"""在一个文件里按 limit_id 各取最新的一条快照;返回 (最新一条的时间, 快照列表)。"""
|
|
164
|
+
found: Dict[str, Limit] = {}
|
|
165
|
+
newest: Optional[float] = None
|
|
166
|
+
try:
|
|
167
|
+
for line in _reverse_lines(path):
|
|
168
|
+
if _RATE_LIMITS_MARK not in line:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
record = json.loads(line.decode("utf-8"))
|
|
172
|
+
except (ValueError, UnicodeError):
|
|
173
|
+
continue
|
|
174
|
+
if not isinstance(record, dict):
|
|
175
|
+
continue
|
|
176
|
+
payload = record.get("payload")
|
|
177
|
+
if record.get("type") != "event_msg" or not isinstance(payload, dict) \
|
|
178
|
+
or payload.get("type") != "token_count" or not isinstance(payload.get("rate_limits"), dict):
|
|
179
|
+
continue
|
|
180
|
+
limit = parse_limit(payload["rate_limits"])
|
|
181
|
+
if limit.limit_id in found:
|
|
182
|
+
continue
|
|
183
|
+
found[limit.limit_id] = limit
|
|
184
|
+
if newest is None:
|
|
185
|
+
newest = _parse_timestamp(record.get("timestamp"))
|
|
186
|
+
if newest is None:
|
|
187
|
+
newest = os.stat(path).st_mtime
|
|
188
|
+
except OSError:
|
|
189
|
+
return None, []
|
|
190
|
+
return newest, [found[key] for key in sorted(found)]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def local_snapshot(name: str, account_dir: str) -> UsageResult:
|
|
194
|
+
"""读一个账号最近一次的本地额度快照。没有任何快照时 ok 仍为 True、limits 为空(“暂无数据”)。"""
|
|
195
|
+
shared = os.path.islink(os.path.join(account_dir, "sessions"))
|
|
196
|
+
for path in _candidate_files(account_dir):
|
|
197
|
+
snapshot_time, limits = _snapshot_in_file(path)
|
|
198
|
+
if limits:
|
|
199
|
+
return UsageResult(name, SOURCE_LOCAL, True, None, snapshot_time, shared, limits)
|
|
200
|
+
return UsageResult(name, SOURCE_LOCAL, True, None, None, shared, [])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---- 实时额度 ----
|
|
204
|
+
|
|
205
|
+
class _AppServer(object):
|
|
206
|
+
"""一次性的 app-server 会话:启动、按行收发 JSON、在任何路径上回收整个进程组。
|
|
207
|
+
|
|
208
|
+
必须用新的会话(进程组)启动:npm 安装的 codex 是 node 包装器,再拉起真正的程序,
|
|
209
|
+
包装器只转发 SIGINT / SIGTERM / SIGHUP(上游 codex-cli/bin/codex.js:241,259-271)。
|
|
210
|
+
只对包装器发 SIGKILL,真正的 app-server 会变成孤儿,只能等它自己 45 秒的看门狗退出。
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(self, launcher: str, timeout_sec: float) -> None:
|
|
214
|
+
self.deadline = time.monotonic() + timeout_sec
|
|
215
|
+
self.timeout_sec = timeout_sec
|
|
216
|
+
self.stderr_file = tempfile.TemporaryFile()
|
|
217
|
+
try:
|
|
218
|
+
self.proc = subprocess.Popen([launcher, "app-server"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
219
|
+
stderr=self.stderr_file, start_new_session=True)
|
|
220
|
+
except OSError:
|
|
221
|
+
# 构造失败时调用方拿不到对象、不会调用 close(),临时文件在这里关掉。
|
|
222
|
+
self.stderr_file.close()
|
|
223
|
+
raise
|
|
224
|
+
self.lines: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
225
|
+
self.reader = threading.Thread(target=self._read_stdout, daemon=True)
|
|
226
|
+
self.reader.start()
|
|
227
|
+
|
|
228
|
+
def _read_stdout(self) -> None:
|
|
229
|
+
try:
|
|
230
|
+
for raw in self.proc.stdout:
|
|
231
|
+
self.lines.put(raw)
|
|
232
|
+
except (OSError, ValueError):
|
|
233
|
+
pass
|
|
234
|
+
self.lines.put(None)
|
|
235
|
+
|
|
236
|
+
def send(self, message: dict) -> None:
|
|
237
|
+
# 协议不是真正的 JSON-RPC 2.0:不带 "jsonrpc" 字段(上游 app-server-protocol/src/rpc.rs:1-2)。
|
|
238
|
+
try:
|
|
239
|
+
self.proc.stdin.write((json.dumps(message) + "\n").encode("utf-8"))
|
|
240
|
+
self.proc.stdin.flush()
|
|
241
|
+
except (BrokenPipeError, OSError):
|
|
242
|
+
raise LiveError(self._exited_message())
|
|
243
|
+
|
|
244
|
+
def response(self, request_id: int) -> dict:
|
|
245
|
+
"""等待指定 id 的响应;通知和服务端发来的请求都跳过。"""
|
|
246
|
+
while True:
|
|
247
|
+
remaining = self.deadline - time.monotonic()
|
|
248
|
+
if remaining <= 0:
|
|
249
|
+
raise LiveError("no response from app-server within {:g}s".format(self.timeout_sec))
|
|
250
|
+
try:
|
|
251
|
+
raw = self.lines.get(timeout=remaining)
|
|
252
|
+
except queue.Empty:
|
|
253
|
+
raise LiveError("no response from app-server within {:g}s".format(self.timeout_sec))
|
|
254
|
+
if raw is None:
|
|
255
|
+
raise LiveError(self._exited_message())
|
|
256
|
+
try:
|
|
257
|
+
message = json.loads(raw.decode("utf-8"))
|
|
258
|
+
except (ValueError, UnicodeError):
|
|
259
|
+
raise LiveError("invalid response from app-server: {}".format(
|
|
260
|
+
raw.decode("utf-8", "replace").strip()[:_MESSAGE_LIMIT]))
|
|
261
|
+
# 服务端发给客户端的请求也带 id,只有带 result / error 且不带 method 的才是响应。
|
|
262
|
+
if isinstance(message, dict) and message.get("id") == request_id and "method" not in message \
|
|
263
|
+
and ("result" in message or "error" in message):
|
|
264
|
+
return message
|
|
265
|
+
|
|
266
|
+
def _exited_message(self) -> str:
|
|
267
|
+
code = self.proc.poll()
|
|
268
|
+
text = "app-server exited before responding" + (" (exit code {})".format(code) if code is not None else "")
|
|
269
|
+
tail = self.stderr_tail()
|
|
270
|
+
return text + (": " + tail if tail else "")
|
|
271
|
+
|
|
272
|
+
def stderr_tail(self) -> str:
|
|
273
|
+
try:
|
|
274
|
+
self.stderr_file.seek(0)
|
|
275
|
+
lines = self.stderr_file.read().decode("utf-8", "replace").strip().splitlines()
|
|
276
|
+
except OSError:
|
|
277
|
+
return ""
|
|
278
|
+
return " | ".join(line.strip() for line in lines[-_STDERR_TAIL_LINES:] if line.strip())[:_MESSAGE_LIMIT * 2]
|
|
279
|
+
|
|
280
|
+
def close(self, graceful: bool) -> None:
|
|
281
|
+
"""回收子进程。正常结束时先关 stdin 让它自行退出;超时或出错时直接结束整个进程组。"""
|
|
282
|
+
try:
|
|
283
|
+
if graceful:
|
|
284
|
+
self._close_stdin()
|
|
285
|
+
try:
|
|
286
|
+
self.proc.wait(timeout=_GRACEFUL_EXIT_SEC)
|
|
287
|
+
except subprocess.TimeoutExpired:
|
|
288
|
+
graceful = False
|
|
289
|
+
if not graceful:
|
|
290
|
+
self._signal_group(signal.SIGTERM)
|
|
291
|
+
self._close_stdin()
|
|
292
|
+
give_up_at = time.monotonic() + _TERM_GRACE_SEC
|
|
293
|
+
while time.monotonic() < give_up_at and self._group_alive():
|
|
294
|
+
time.sleep(0.05)
|
|
295
|
+
# 包装器退出不代表它拉起的子进程也退出了;无论如何对整个进程组补一次 SIGKILL。
|
|
296
|
+
self._signal_group(signal.SIGKILL)
|
|
297
|
+
try:
|
|
298
|
+
self.proc.wait(timeout=_GRACEFUL_EXIT_SEC)
|
|
299
|
+
except subprocess.TimeoutExpired:
|
|
300
|
+
# 只有进程组成员换了 uid、SIGKILL 被拒时才会走到这里;不能让 usage 因此挂住。
|
|
301
|
+
pass
|
|
302
|
+
finally:
|
|
303
|
+
for stream in (self.proc.stdout, self.stderr_file):
|
|
304
|
+
try:
|
|
305
|
+
stream.close()
|
|
306
|
+
except OSError:
|
|
307
|
+
pass
|
|
308
|
+
self.reader.join(timeout=1)
|
|
309
|
+
|
|
310
|
+
def _close_stdin(self) -> None:
|
|
311
|
+
try:
|
|
312
|
+
self.proc.stdin.close()
|
|
313
|
+
except OSError:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
def _signal_group(self, sig: int) -> None:
|
|
317
|
+
try:
|
|
318
|
+
os.killpg(self.proc.pid, sig)
|
|
319
|
+
except (ProcessLookupError, PermissionError):
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
def _group_alive(self) -> bool:
|
|
323
|
+
self.proc.poll() # 回收已退出的组长,否则僵尸进程会让进程组看起来仍然存在
|
|
324
|
+
try:
|
|
325
|
+
os.killpg(self.proc.pid, 0)
|
|
326
|
+
except ProcessLookupError:
|
|
327
|
+
return False
|
|
328
|
+
except PermissionError:
|
|
329
|
+
return True
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _rpc_error(message: dict, what: str) -> Optional[LiveError]:
|
|
334
|
+
error = message.get("error")
|
|
335
|
+
if error is None:
|
|
336
|
+
return None
|
|
337
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
338
|
+
text = str(error.get("message", "")) if isinstance(error, dict) else str(error)
|
|
339
|
+
if code == _METHOD_NOT_FOUND or (code == _INVALID_REQUEST and "unknown variant" in text):
|
|
340
|
+
return LiveError("{} is not supported by this Codex version; Codex 0.48.0 or newer is required".format(what))
|
|
341
|
+
return LiveError("app-server error {}: {}".format(code, text[:_MESSAGE_LIMIT]))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def live_snapshot(name: str, launcher: str, timeout_sec: float) -> UsageResult:
|
|
345
|
+
"""通过该账号的启动命令运行 `codex app-server`,调用 account/rateLimits/read。
|
|
346
|
+
|
|
347
|
+
走启动命令是为了沿用该账号的 CODEX_HOME 和代理设置。查询会联网;Codex 可能顺带刷新令牌并写回
|
|
348
|
+
该账号自己的凭据存储,这与用户自己启动一次 Codex 相同,不会写到其它账号。
|
|
349
|
+
"""
|
|
350
|
+
server = None
|
|
351
|
+
graceful = False
|
|
352
|
+
try:
|
|
353
|
+
try:
|
|
354
|
+
server = _AppServer(launcher, timeout_sec)
|
|
355
|
+
except OSError as exc:
|
|
356
|
+
raise LiveError("cannot start {} app-server: {}".format(launcher, exc))
|
|
357
|
+
server.send({"id": 1, "method": "initialize",
|
|
358
|
+
"params": {"clientInfo": {"name": "multi-codex", "title": "multi-codex",
|
|
359
|
+
"version": __version__}}})
|
|
360
|
+
failure = _rpc_error(server.response(1), "initialize")
|
|
361
|
+
if failure:
|
|
362
|
+
raise failure
|
|
363
|
+
server.send({"method": "initialized"})
|
|
364
|
+
server.send({"id": 2, "method": "account/rateLimits/read"})
|
|
365
|
+
reply = server.response(2)
|
|
366
|
+
failure = _rpc_error(reply, "account/rateLimits/read")
|
|
367
|
+
if failure:
|
|
368
|
+
raise failure
|
|
369
|
+
result = reply.get("result")
|
|
370
|
+
limits = _limits_from_result(result if isinstance(result, dict) else {})
|
|
371
|
+
graceful = True
|
|
372
|
+
return UsageResult(name, SOURCE_LIVE, True, None, time.time(), False, limits)
|
|
373
|
+
except LiveError as exc:
|
|
374
|
+
return UsageResult(name, SOURCE_LIVE, False, str(exc), None, False, [])
|
|
375
|
+
finally:
|
|
376
|
+
if server is not None:
|
|
377
|
+
server.close(graceful)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _limits_from_result(result: dict) -> List[Limit]:
|
|
381
|
+
by_id = result.get("rateLimitsByLimitId")
|
|
382
|
+
if isinstance(by_id, dict) and by_id:
|
|
383
|
+
# HashMap 序列化后的顺序不稳定,按名字排序保证输出可比较。
|
|
384
|
+
return [parse_limit(by_id[key], key) for key in sorted(by_id) if isinstance(by_id[key], dict)]
|
|
385
|
+
snapshot = result.get("rateLimits")
|
|
386
|
+
if not isinstance(snapshot, dict):
|
|
387
|
+
raise LiveError("unexpected response from account/rateLimits/read: no rateLimits field")
|
|
388
|
+
return [parse_limit(snapshot)]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---- 输出 ----
|
|
392
|
+
|
|
393
|
+
def _window_label(minutes: Optional[int]) -> str:
|
|
394
|
+
if minutes is None:
|
|
395
|
+
return "?"
|
|
396
|
+
if minutes < 1440:
|
|
397
|
+
return "{:g}h".format(minutes / 60.0)
|
|
398
|
+
return "{:g}d".format(round(minutes / 1440.0, 1))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _human_duration(seconds: float) -> str:
|
|
402
|
+
seconds = int(max(0, seconds))
|
|
403
|
+
days, rest = divmod(seconds, 86400)
|
|
404
|
+
hours, rest = divmod(rest, 3600)
|
|
405
|
+
minutes = rest // 60
|
|
406
|
+
if days:
|
|
407
|
+
return "{}d{}h".format(days, hours) if hours and days < 3 else "{}d".format(days)
|
|
408
|
+
if hours:
|
|
409
|
+
return "{}h{}m".format(hours, minutes) if minutes else "{}h".format(hours)
|
|
410
|
+
return "{}m".format(minutes)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _local_time(epoch: float, now: float) -> str:
|
|
414
|
+
moment = datetime.fromtimestamp(epoch)
|
|
415
|
+
if moment.date() == datetime.fromtimestamp(now).date():
|
|
416
|
+
return moment.strftime("%H:%M")
|
|
417
|
+
return moment.strftime("%m-%d %H:%M")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def format_result(result: UsageResult, now: float) -> List[str]:
|
|
421
|
+
"""给人看的输出;格式不承诺稳定,脚本请用 --json。"""
|
|
422
|
+
if not result.ok:
|
|
423
|
+
return ["{} error: {}".format(result.name, result.error)]
|
|
424
|
+
parts = []
|
|
425
|
+
plan = next((limit.plan_type for limit in result.limits if limit.plan_type), None)
|
|
426
|
+
if plan:
|
|
427
|
+
parts.append(plan)
|
|
428
|
+
if result.source == SOURCE_LIVE:
|
|
429
|
+
parts.append("live")
|
|
430
|
+
elif result.snapshot_time is not None:
|
|
431
|
+
parts.append("snapshot {}, {} ago".format(datetime.fromtimestamp(result.snapshot_time).strftime(
|
|
432
|
+
"%Y-%m-%d %H:%M"), _human_duration(now - result.snapshot_time)))
|
|
433
|
+
lines = ["{} ({})".format(result.name, ", ".join(parts)) if parts else result.name]
|
|
434
|
+
if result.sessions_shared:
|
|
435
|
+
lines.append(" note: sessions is a link shared with other accounts; this usage may belong to another account")
|
|
436
|
+
if not result.limits:
|
|
437
|
+
lines.append(" no usage data in local session logs")
|
|
438
|
+
return lines
|
|
439
|
+
width = max(len(limit.limit_id) for limit in result.limits)
|
|
440
|
+
for limit in result.limits:
|
|
441
|
+
if not limit.windows:
|
|
442
|
+
lines.append(" {} (no window data)".format(limit.limit_id.ljust(width)))
|
|
443
|
+
for index, window in enumerate(limit.windows):
|
|
444
|
+
label = limit.limit_id if index == 0 else ""
|
|
445
|
+
head = " {} {:>4}".format(label.ljust(width), _window_label(window.window_minutes))
|
|
446
|
+
if window.resets_at is not None and window.resets_at <= now and result.source == SOURCE_LOCAL:
|
|
447
|
+
# 快照之后窗口已经重置,旧百分比不再代表当前用量。
|
|
448
|
+
lines.append("{} reset since snapshot".format(head))
|
|
449
|
+
continue
|
|
450
|
+
line = "{} {:>3.0f}% used".format(head, window.used_percent)
|
|
451
|
+
if window.resets_at is not None:
|
|
452
|
+
line += " resets {} (in {})".format(_local_time(window.resets_at, now),
|
|
453
|
+
_human_duration(window.resets_at - now))
|
|
454
|
+
lines.append(line)
|
|
455
|
+
return lines
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def to_json(result: UsageResult) -> dict:
|
|
459
|
+
snapshot_time = None
|
|
460
|
+
if result.snapshot_time is not None:
|
|
461
|
+
snapshot_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(result.snapshot_time))
|
|
462
|
+
return {
|
|
463
|
+
"name": result.name,
|
|
464
|
+
"source": result.source,
|
|
465
|
+
"ok": result.ok,
|
|
466
|
+
"error": result.error,
|
|
467
|
+
"snapshot_time": snapshot_time,
|
|
468
|
+
"sessions_shared": result.sessions_shared,
|
|
469
|
+
"limits": [{
|
|
470
|
+
"limit_id": limit.limit_id,
|
|
471
|
+
"plan_type": limit.plan_type,
|
|
472
|
+
"windows": [window._asdict() for window in limit.windows],
|
|
473
|
+
"credits": limit.credits,
|
|
474
|
+
} for limit in result.limits],
|
|
475
|
+
}
|