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.
@@ -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")