opencode-swap 0.4.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.
- opencode_swap/__init__.py +3 -0
- opencode_swap/__main__.py +4 -0
- opencode_swap/atomic.py +95 -0
- opencode_swap/backup.py +128 -0
- opencode_swap/cli.py +799 -0
- opencode_swap/exceptions.py +60 -0
- opencode_swap/locking.py +70 -0
- opencode_swap/macos_keychain.py +136 -0
- opencode_swap/models.py +190 -0
- opencode_swap/oauth_jwt.py +66 -0
- opencode_swap/oauth_refresh.py +156 -0
- opencode_swap/opencode_auth.py +54 -0
- opencode_swap/paths.py +83 -0
- opencode_swap/process_detection.py +29 -0
- opencode_swap/providers/__init__.py +26 -0
- opencode_swap/providers/api.py +50 -0
- opencode_swap/providers/base.py +91 -0
- opencode_swap/providers/common.py +144 -0
- opencode_swap/providers/github_copilot.py +46 -0
- opencode_swap/providers/openai.py +186 -0
- opencode_swap/providers/poe.py +64 -0
- opencode_swap/providers/xai.py +72 -0
- opencode_swap/providers/zai.py +31 -0
- opencode_swap/sealed.py +83 -0
- opencode_swap/store.py +551 -0
- opencode_swap/switcher.py +952 -0
- opencode_swap/transfer.py +149 -0
- opencode_swap/usage.py +304 -0
- opencode_swap-0.4.0.dist-info/METADATA +307 -0
- opencode_swap-0.4.0.dist-info/RECORD +33 -0
- opencode_swap-0.4.0.dist-info/WHEEL +4 -0
- opencode_swap-0.4.0.dist-info/entry_points.txt +3 -0
- opencode_swap-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Password-encrypted portable account archive format.
|
|
2
|
+
|
|
3
|
+
Credential JSON exists only in memory and inside a standard WinZip AES-256
|
|
4
|
+
member. pyzipper owns cipher and KDF details; this module only defines the
|
|
5
|
+
versioned manifest carried by that archive.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import cast
|
|
15
|
+
|
|
16
|
+
import pyzipper # type: ignore[import-untyped]
|
|
17
|
+
|
|
18
|
+
from opencode_swap.atomic import atomic_write_bytes_exclusive
|
|
19
|
+
from opencode_swap.exceptions import RegistryError, TransferError
|
|
20
|
+
from opencode_swap.models import AccountMeta, JsonObject
|
|
21
|
+
|
|
22
|
+
FORMAT_NAME = "opencode-swap-accounts"
|
|
23
|
+
FORMAT_VERSION = 2
|
|
24
|
+
MEMBER_NAME = "accounts.json"
|
|
25
|
+
MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
|
|
26
|
+
MAX_MANIFEST_BYTES = 10 * 1024 * 1024
|
|
27
|
+
MAX_ACCOUNTS = 1000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TransferEntry:
|
|
32
|
+
meta: AccountMeta
|
|
33
|
+
record: JsonObject
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _password_bytes(password: str) -> bytes:
|
|
37
|
+
if not password:
|
|
38
|
+
raise TransferError("archive password cannot be empty")
|
|
39
|
+
return password.encode("utf-8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_archive(entries: list[TransferEntry], password: str) -> bytes:
|
|
43
|
+
if len(entries) > MAX_ACCOUNTS:
|
|
44
|
+
raise TransferError("account export contains too many accounts")
|
|
45
|
+
manifest = {
|
|
46
|
+
"format": FORMAT_NAME,
|
|
47
|
+
"version": FORMAT_VERSION,
|
|
48
|
+
"accounts": [{"name": entry.meta.name, "meta": entry.meta.to_dict(), "record": entry.record} for entry in entries],
|
|
49
|
+
}
|
|
50
|
+
payload = json.dumps(manifest, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
51
|
+
if len(payload) > MAX_MANIFEST_BYTES:
|
|
52
|
+
raise TransferError("account export is too large")
|
|
53
|
+
|
|
54
|
+
output = io.BytesIO()
|
|
55
|
+
with pyzipper.AESZipFile(output, "w", compression=pyzipper.ZIP_DEFLATED) as archive:
|
|
56
|
+
archive.setpassword(_password_bytes(password))
|
|
57
|
+
archive.setencryption(pyzipper.WZ_AES, nbits=256)
|
|
58
|
+
archive.writestr(MEMBER_NAME, payload)
|
|
59
|
+
encrypted = output.getvalue()
|
|
60
|
+
if len(encrypted) > MAX_ARCHIVE_BYTES:
|
|
61
|
+
raise TransferError("account export is too large")
|
|
62
|
+
return encrypted
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write_archive(path: Path, entries: list[TransferEntry], password: str) -> None:
|
|
66
|
+
try:
|
|
67
|
+
atomic_write_bytes_exclusive(path, create_archive(entries, password), mode=0o600)
|
|
68
|
+
except FileExistsError as exc:
|
|
69
|
+
raise TransferError(f"refusing to overwrite existing export file: {path}") from exc
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise TransferError(f"could not write export file: {path} ({exc})") from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _unique_object(pairs: list[tuple[str, object]]) -> JsonObject:
|
|
75
|
+
result: JsonObject = {}
|
|
76
|
+
for key, value in pairs:
|
|
77
|
+
if key in result:
|
|
78
|
+
raise TransferError("account archive contains duplicate JSON fields")
|
|
79
|
+
result[key] = value
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _decode_manifest(payload: bytes) -> list[TransferEntry]:
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(payload, object_pairs_hook=_unique_object)
|
|
86
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
87
|
+
raise TransferError("account archive manifest is not valid JSON") from exc
|
|
88
|
+
if not isinstance(data, dict) or set(data) != {"format", "version", "accounts"}:
|
|
89
|
+
raise TransferError("account archive has an unsupported structure")
|
|
90
|
+
if data["format"] != FORMAT_NAME or type(data["version"]) is not int or data["version"] not in (1, FORMAT_VERSION):
|
|
91
|
+
raise TransferError("account archive has an unsupported format version")
|
|
92
|
+
accounts = data["accounts"]
|
|
93
|
+
if not isinstance(accounts, list) or len(accounts) > MAX_ACCOUNTS:
|
|
94
|
+
raise TransferError("account archive has an invalid account list")
|
|
95
|
+
|
|
96
|
+
entries: list[TransferEntry] = []
|
|
97
|
+
names: set[tuple[str, str]] = set()
|
|
98
|
+
for item in accounts:
|
|
99
|
+
if not isinstance(item, dict) or set(item) != {"name", "meta", "record"}:
|
|
100
|
+
raise TransferError("account archive contains an invalid account entry")
|
|
101
|
+
name = item["name"]
|
|
102
|
+
record = item["record"]
|
|
103
|
+
if not isinstance(name, str) or not isinstance(record, dict):
|
|
104
|
+
raise TransferError("account archive contains an invalid account entry")
|
|
105
|
+
try:
|
|
106
|
+
meta = AccountMeta.from_dict(name, item["meta"])
|
|
107
|
+
except RegistryError as exc:
|
|
108
|
+
raise TransferError("account archive contains invalid account metadata") from exc
|
|
109
|
+
account_key = (meta.provider, name)
|
|
110
|
+
if account_key in names:
|
|
111
|
+
raise TransferError("account archive contains duplicate account names")
|
|
112
|
+
if data["version"] == 1 and any(existing_name == name for _provider, existing_name in names):
|
|
113
|
+
raise TransferError("version 1 account archive contains duplicate account names")
|
|
114
|
+
names.add(account_key)
|
|
115
|
+
entries.append(TransferEntry(meta=meta, record=cast(JsonObject, record)))
|
|
116
|
+
return entries
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_archive(path: Path, password: str) -> list[TransferEntry]:
|
|
120
|
+
try:
|
|
121
|
+
if path.stat().st_size > MAX_ARCHIVE_BYTES:
|
|
122
|
+
raise TransferError("account archive is too large")
|
|
123
|
+
encrypted = path.read_bytes()
|
|
124
|
+
if len(encrypted) > MAX_ARCHIVE_BYTES:
|
|
125
|
+
raise TransferError("account archive is too large")
|
|
126
|
+
except FileNotFoundError as exc:
|
|
127
|
+
raise TransferError(f"account archive does not exist: {path}") from exc
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise TransferError(f"could not read account archive: {path}") from exc
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
with pyzipper.AESZipFile(io.BytesIO(encrypted), "r") as archive:
|
|
133
|
+
if archive.namelist() != [MEMBER_NAME]:
|
|
134
|
+
raise TransferError("account archive has an unsupported structure")
|
|
135
|
+
info = archive.getinfo(MEMBER_NAME)
|
|
136
|
+
if getattr(info, "wz_aes_strength", None) != 3:
|
|
137
|
+
raise TransferError("account archive is not AES-256 encrypted")
|
|
138
|
+
if info.file_size > MAX_MANIFEST_BYTES:
|
|
139
|
+
raise TransferError("account archive manifest is too large")
|
|
140
|
+
archive.setpassword(_password_bytes(password))
|
|
141
|
+
with archive.open(MEMBER_NAME) as member:
|
|
142
|
+
payload = member.read(MAX_MANIFEST_BYTES + 1)
|
|
143
|
+
except TransferError:
|
|
144
|
+
raise
|
|
145
|
+
except (OSError, RuntimeError, ValueError, pyzipper.BadZipFile) as exc:
|
|
146
|
+
raise TransferError("could not decrypt account archive; password may be incorrect or archive may be corrupt") from exc
|
|
147
|
+
if len(payload) > MAX_MANIFEST_BYTES:
|
|
148
|
+
raise TransferError("account archive manifest is too large")
|
|
149
|
+
return _decode_manifest(payload)
|
opencode_swap/usage.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Live provider usage lookup, for `opencode-swap list --usage`.
|
|
2
|
+
|
|
3
|
+
Two providers are supported, each against an endpoint its own first-party
|
|
4
|
+
client already uses:
|
|
5
|
+
|
|
6
|
+
**OpenAI/ChatGPT** (OAuth accounts). Verified against opencode-balancer's
|
|
7
|
+
implementation (src/core/usage/providers/openai.ts), not guessed: GET
|
|
8
|
+
https://chatgpt.com/backend-api/wham/usage with the account's own access
|
|
9
|
+
token as Bearer auth (plus a ChatGPT-Account-Id header when the account id
|
|
10
|
+
is known). The response's `rate_limit` dict carries one or more rate-limit
|
|
11
|
+
windows -- OpenAI added a 5-hour window alongside the pre-existing 7-day one
|
|
12
|
+
in 2026-08, each shaped the same: `used_percent`, `reset_at`,
|
|
13
|
+
`limit_window_seconds`.
|
|
14
|
+
|
|
15
|
+
Third-party clients of this same endpoint (surveyed on GitHub while adding
|
|
16
|
+
the 5h window here) do NOT agree on which key holds which window --
|
|
17
|
+
`primary_window`/`secondary_window` is observed both ways round, classified
|
|
18
|
+
only by `limit_window_seconds` (18000 = 5h, 604800 = 7d). So this module
|
|
19
|
+
never keys into `rate_limit` by name: it walks every value under
|
|
20
|
+
`rate_limit`, keeps whatever parses as a window (dict with a usable
|
|
21
|
+
`used_percent`), and sorts what it finds by `limit_window_seconds`. That
|
|
22
|
+
also means it survives OpenAI going back to one window, adding a third, or
|
|
23
|
+
renaming the keys again, with no code change here -- and it naturally skips
|
|
24
|
+
sibling blocks like `code_review_rate_limit` (a separate quota some clients
|
|
25
|
+
also parse) since those aren't inside `rate_limit`.
|
|
26
|
+
|
|
27
|
+
**Z.AI GLM Coding Plan** (`zai-coding-plan` API-key accounts). GET
|
|
28
|
+
https://api.z.ai/api/monitor/usage/quota/limit with the account's own API
|
|
29
|
+
key as Bearer auth -- the undocumented endpoint z.ai's own subscription UI
|
|
30
|
+
calls. `data.limits[]` carries the quota windows; each `CREDIT_LIMIT` entry
|
|
31
|
+
(older responses: `TOKENS_LIMIT`, and the kind under a `name` key rather than
|
|
32
|
+
`type` -- openusage honors both) is a percentage window whose length is
|
|
33
|
+
`number * unit`, where `unit` is z.ai's period enum -- 3=hour, 4=day,
|
|
34
|
+
5=month, 6=week, read from z.ai's frontend source and cross-checked against
|
|
35
|
+
openusage's ZAIUsageMapper.swift (`classifyTokenWindow`). So the 5h/7d
|
|
36
|
+
windows are derived from the payload, not hardcoded: an unfamiliar `unit`
|
|
37
|
+
just yields a window with no duration label, same as an unfamiliar OpenAI
|
|
38
|
+
window length. `TIME_LIMIT` entries are a monthly web-search *count*, not a
|
|
39
|
+
rate-limit window, and are skipped. `data.level` is the plan tier.
|
|
40
|
+
|
|
41
|
+
No caching, no polling, no persistence, and never called unless the caller
|
|
42
|
+
explicitly opts in -- every other opencode-swap command is intentionally
|
|
43
|
+
local/offline-only. `oauth_refresh.py` (a standalone OAuth token refresh,
|
|
44
|
+
triggered from inside `Switcher.fetch_usage`/`refresh_account` for a saved
|
|
45
|
+
account OpenCode doesn't currently have live) is the only other exception;
|
|
46
|
+
both are opt-in on purpose, one from `--usage`, the other from the explicit
|
|
47
|
+
`refresh` command.
|
|
48
|
+
|
|
49
|
+
The CLI itself only calls this with `--usage`. The bundled OpenCode TUI
|
|
50
|
+
plugin (integrations/opencode-tui-plugin) is a different caller with its own
|
|
51
|
+
default: it opts in automatically and polls every 60 seconds for the active
|
|
52
|
+
managed account, unless its own `usage` option is set to `false`. See that
|
|
53
|
+
plugin's README "Network access" section for what that sends where.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import http.client
|
|
59
|
+
import json
|
|
60
|
+
import math
|
|
61
|
+
import urllib.error
|
|
62
|
+
import urllib.request
|
|
63
|
+
from dataclasses import dataclass
|
|
64
|
+
from typing import TypeGuard
|
|
65
|
+
|
|
66
|
+
CHATGPT_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
|
|
67
|
+
ZAI_USAGE_URL = "https://api.z.ai/api/monitor/usage/quota/limit"
|
|
68
|
+
_TIMEOUT = 5.0
|
|
69
|
+
_USER_AGENT = "opencode-swap"
|
|
70
|
+
_MAX_RESET_AT_MILLIS = 253_402_300_000_000 # 9999-12-31, safely datetime-compatible
|
|
71
|
+
|
|
72
|
+
_PLAN_NAMES = {
|
|
73
|
+
"enterprise": "ChatGPT Enterprise",
|
|
74
|
+
"plus": "ChatGPT Plus",
|
|
75
|
+
"pro": "ChatGPT Pro",
|
|
76
|
+
"team": "ChatGPT Team",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# z.ai's `unit` period enum -> seconds. From z.ai's frontend source, matched
|
|
80
|
+
# against openusage's ZAIUsageMapper.swift. Unknown units are left unlabeled
|
|
81
|
+
# rather than guessed (see module docstring).
|
|
82
|
+
_ZAI_UNIT_SECONDS = {3: 3600, 4: 86_400, 5: 2_592_000, 6: 604_800}
|
|
83
|
+
_ZAI_PERCENTAGE_TYPES = {"CREDIT_LIMIT", "TOKENS_LIMIT"}
|
|
84
|
+
_ZAI_PLAN_NAMES = {
|
|
85
|
+
"lite": "GLM Coding Lite",
|
|
86
|
+
"pro": "GLM Coding Pro",
|
|
87
|
+
"max": "GLM Coding Max",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class UsageWindow:
|
|
93
|
+
used_percent: float | None
|
|
94
|
+
reset_at: float | None # epoch ms
|
|
95
|
+
window_seconds: float | None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class UsageSnapshot:
|
|
100
|
+
available: bool
|
|
101
|
+
plan_name: str | None = None
|
|
102
|
+
windows: tuple[UsageWindow, ...] = ()
|
|
103
|
+
message: str = ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _plan_name(plan_type: object) -> str | None:
|
|
107
|
+
if not isinstance(plan_type, str) or not plan_type.strip():
|
|
108
|
+
return None
|
|
109
|
+
key = plan_type.strip().lower()
|
|
110
|
+
return _PLAN_NAMES.get(key)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _zai_plan_name(level: object) -> str | None:
|
|
114
|
+
if not isinstance(level, str) or not level.strip():
|
|
115
|
+
return None
|
|
116
|
+
return _ZAI_PLAN_NAMES.get(level.strip().lower())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _finite_number(value: object) -> TypeGuard[int | float]:
|
|
120
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
121
|
+
return False
|
|
122
|
+
return not isinstance(value, float) or math.isfinite(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _used_percent(value: object) -> float | None:
|
|
126
|
+
"""A `used_percent`/`percentage` value coerced to a 0-100 float, or None
|
|
127
|
+
when it isn't a finite number in range -- the signal both providers use
|
|
128
|
+
to tell an actual quota window from an unrelated sibling entry."""
|
|
129
|
+
if not _finite_number(value) or not 0 <= value <= 100:
|
|
130
|
+
return None
|
|
131
|
+
return float(value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _reset_at_millis(value: object) -> float | None:
|
|
135
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0:
|
|
136
|
+
return None
|
|
137
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
138
|
+
return None
|
|
139
|
+
if value > _MAX_RESET_AT_MILLIS:
|
|
140
|
+
return None
|
|
141
|
+
# opencode-balancer's own heuristic: values already in the millisecond
|
|
142
|
+
# range are passed through, smaller ones are assumed to be seconds.
|
|
143
|
+
millis = float(value) if value > 1_000_000_000_000 else float(value) * 1000
|
|
144
|
+
return millis if millis <= _MAX_RESET_AT_MILLIS else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _fetch_json(url: str, headers: dict[str, str]) -> tuple[object | None, str]:
|
|
148
|
+
"""GET `url` and parse JSON. Returns `(body, "ok")` on success, or
|
|
149
|
+
`(None, message)` on any failure -- never raises, and the message never
|
|
150
|
+
contains a header value (so a Bearer token can't leak into output)."""
|
|
151
|
+
try:
|
|
152
|
+
request = urllib.request.Request(url, headers=headers)
|
|
153
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
|
|
154
|
+
return json.loads(response.read()), "ok"
|
|
155
|
+
except urllib.error.HTTPError as exc:
|
|
156
|
+
return None, f"HTTP {exc.code}"
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
return None, "invalid JSON in response"
|
|
159
|
+
except (urllib.error.URLError, TimeoutError, OSError, UnicodeDecodeError) as exc:
|
|
160
|
+
return None, str(exc)
|
|
161
|
+
except http.client.HTTPException:
|
|
162
|
+
# e.g. IncompleteRead from response.read() on a truncated body --
|
|
163
|
+
# not an OSError, so it would otherwise escape. Fixed message.
|
|
164
|
+
return None, "HTTP protocol error"
|
|
165
|
+
except ValueError:
|
|
166
|
+
# http.client raises ValueError for a malformed header value, and the
|
|
167
|
+
# exception message embeds that value verbatim -- which here is the
|
|
168
|
+
# Bearer credential. Never stringify it. (JSONDecodeError and
|
|
169
|
+
# UnicodeDecodeError are ValueError subclasses handled above.)
|
|
170
|
+
return None, "malformed request"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_window(value: object) -> UsageWindow | None:
|
|
174
|
+
"""Parse one candidate rate-limit window. Returns None when `value` isn't
|
|
175
|
+
dict-shaped or has no usable `used_percent` -- the two signals used to
|
|
176
|
+
tell an actual window apart from an unrelated `rate_limit` entry (see
|
|
177
|
+
module docstring on key-agnostic discovery)."""
|
|
178
|
+
if not isinstance(value, dict):
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
used_percent = _used_percent(value.get("used_percent"))
|
|
182
|
+
if used_percent is None:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
window_seconds = value.get("limit_window_seconds")
|
|
186
|
+
valid_window = _finite_number(window_seconds) and window_seconds > 0
|
|
187
|
+
return UsageWindow(
|
|
188
|
+
used_percent=used_percent,
|
|
189
|
+
reset_at=_reset_at_millis(value.get("reset_at")),
|
|
190
|
+
window_seconds=window_seconds if valid_window else None,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _sort_key(item: tuple[str, UsageWindow]) -> tuple[float, str]:
|
|
195
|
+
key, window = item
|
|
196
|
+
# Windows with no usable duration sort last (inf); ties (including two
|
|
197
|
+
# unknown-duration windows) break on key name so output is deterministic.
|
|
198
|
+
duration = window.window_seconds if window.window_seconds is not None else math.inf
|
|
199
|
+
return (duration, key)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def fetch_openai_oauth_usage(access_token: str, account_id: str | None) -> UsageSnapshot:
|
|
203
|
+
"""Fetch live ChatGPT usage for an OAuth account. Never raises — network
|
|
204
|
+
failures, timeouts, and unexpected response shapes all come back as an
|
|
205
|
+
`available=False` snapshot instead."""
|
|
206
|
+
headers = {"Authorization": f"Bearer {access_token}", "User-Agent": _USER_AGENT}
|
|
207
|
+
if account_id:
|
|
208
|
+
headers["ChatGPT-Account-Id"] = account_id
|
|
209
|
+
|
|
210
|
+
body, message = _fetch_json(CHATGPT_USAGE_URL, headers)
|
|
211
|
+
if body is None:
|
|
212
|
+
return UsageSnapshot(available=False, message=message)
|
|
213
|
+
if not isinstance(body, dict):
|
|
214
|
+
return UsageSnapshot(available=False, message="unexpected response shape")
|
|
215
|
+
|
|
216
|
+
rate_limit = body.get("rate_limit")
|
|
217
|
+
candidates = rate_limit.items() if isinstance(rate_limit, dict) else []
|
|
218
|
+
parsed = [(key, window) for key, value in candidates if (window := _parse_window(value)) is not None]
|
|
219
|
+
windows = tuple(window for _key, window in sorted(parsed, key=_sort_key))
|
|
220
|
+
|
|
221
|
+
return UsageSnapshot(
|
|
222
|
+
available=True,
|
|
223
|
+
plan_name=_plan_name(body.get("plan_type")),
|
|
224
|
+
windows=windows,
|
|
225
|
+
message="ok",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _ZaiResponseError(Exception):
|
|
230
|
+
"""Internal: a recognized z.ai quota record that can't be parsed. Caught
|
|
231
|
+
inside `fetch_zai_usage` and turned into an unavailable snapshot -- never
|
|
232
|
+
escapes the module."""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _zai_window(entry: object) -> tuple[float, UsageWindow] | None:
|
|
236
|
+
"""Parse one `data.limits[]` percentage entry into `(sort_seconds,
|
|
237
|
+
window)`. Returns None when `entry` isn't a recognized percentage-quota
|
|
238
|
+
entry at all. Raises `_ZaiResponseError` when it *is* one (by `type` or
|
|
239
|
+
the legacy `name` key) but its `percentage` can't be read -- a broken
|
|
240
|
+
quota record makes the whole snapshot untrustworthy rather than silently
|
|
241
|
+
short.
|
|
242
|
+
|
|
243
|
+
An unfamiliar `unit` is not an error: the window is kept with no
|
|
244
|
+
duration label (see module docstring), same as an unfamiliar OpenAI
|
|
245
|
+
window length."""
|
|
246
|
+
if not isinstance(entry, dict):
|
|
247
|
+
return None
|
|
248
|
+
kind = entry.get("type") or entry.get("name") # openusage honors both keys
|
|
249
|
+
if kind not in _ZAI_PERCENTAGE_TYPES:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
used_percent = _used_percent(entry.get("percentage"))
|
|
253
|
+
if used_percent is None:
|
|
254
|
+
raise _ZaiResponseError
|
|
255
|
+
|
|
256
|
+
unit, number = entry.get("unit"), entry.get("number")
|
|
257
|
+
window_seconds: float | None = None
|
|
258
|
+
if isinstance(unit, int) and not isinstance(unit, bool) and unit in _ZAI_UNIT_SECONDS and _finite_number(number) and number > 0:
|
|
259
|
+
window_seconds = _ZAI_UNIT_SECONDS[unit] * float(number)
|
|
260
|
+
|
|
261
|
+
window = UsageWindow(
|
|
262
|
+
used_percent=used_percent,
|
|
263
|
+
reset_at=_reset_at_millis(entry.get("nextResetTime")),
|
|
264
|
+
window_seconds=window_seconds,
|
|
265
|
+
)
|
|
266
|
+
return (window_seconds if window_seconds is not None else math.inf, window)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def fetch_zai_usage(api_key: str) -> UsageSnapshot:
|
|
270
|
+
"""Fetch live Z.AI GLM Coding Plan quota for an API-key account. Never
|
|
271
|
+
raises -- network failures, an inactive coding plan, and unexpected
|
|
272
|
+
response shapes all come back as an `available=False` snapshot."""
|
|
273
|
+
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json", "User-Agent": _USER_AGENT}
|
|
274
|
+
|
|
275
|
+
body, message = _fetch_json(ZAI_USAGE_URL, headers)
|
|
276
|
+
if body is None:
|
|
277
|
+
return UsageSnapshot(available=False, message=message)
|
|
278
|
+
if not isinstance(body, dict):
|
|
279
|
+
return UsageSnapshot(available=False, message="unexpected response shape")
|
|
280
|
+
|
|
281
|
+
# z.ai answers 2xx with `success: false` for real errors, not just the
|
|
282
|
+
# no-coding-plan case (observed: "service unavailable"). Every one of
|
|
283
|
+
# those is a failed lookup, not an empty-but-valid snapshot. The message
|
|
284
|
+
# is fixed text, never the server's `msg` -- that string is not trusted
|
|
285
|
+
# to be credential-free.
|
|
286
|
+
if body.get("success") is False:
|
|
287
|
+
note = body.get("msg")
|
|
288
|
+
plan_gone = isinstance(note, str) and "coding plan" in note.lower()
|
|
289
|
+
return UsageSnapshot(available=False, message="no active GLM coding plan" if plan_gone else "z.ai rejected the request")
|
|
290
|
+
|
|
291
|
+
data = body.get("data")
|
|
292
|
+
try:
|
|
293
|
+
limits = data.get("limits") if isinstance(data, dict) else None
|
|
294
|
+
if limits is None: # openusage also tolerates the limits array at the root
|
|
295
|
+
limits = body.get("limits")
|
|
296
|
+
if not isinstance(limits, list):
|
|
297
|
+
raise _ZaiResponseError
|
|
298
|
+
parsed = [result for entry in limits if (result := _zai_window(entry)) is not None]
|
|
299
|
+
except _ZaiResponseError:
|
|
300
|
+
return UsageSnapshot(available=False, message="unexpected response shape")
|
|
301
|
+
windows = tuple(window for _seconds, window in sorted(parsed, key=lambda item: item[0]))
|
|
302
|
+
level = data.get("level") if isinstance(data, dict) else None
|
|
303
|
+
|
|
304
|
+
return UsageSnapshot(available=True, plan_name=_zai_plan_name(level), windows=windows, message="ok")
|