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/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
+ }