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
opencode_swap/store.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Secure per-account secret storage, and the non-secret account registry.
|
|
2
|
+
|
|
3
|
+
SecretStore: macOS Keychain or private file routing.
|
|
4
|
+
|
|
5
|
+
- macOS: an *envelope* scheme (sealed.py). A 32-byte AES-256-GCM data key
|
|
6
|
+
lives in the Keychain (via ``/usr/bin/security`` — macos_keychain.py,
|
|
7
|
+
pinned binary so creator == reader across interpreter upgrades, ported
|
|
8
|
+
from claude-swap); the credential itself is ciphertext in a v3 file
|
|
9
|
+
under 0600/0700. This exists because ``security -i``'s stdin transport
|
|
10
|
+
truncates at ~4095 chars (see macos_keychain.py) and a hex-encoded
|
|
11
|
+
OpenAI OAuth record is well over that — the data key is a fixed 64 hex
|
|
12
|
+
chars regardless of credential size, so it always fits.
|
|
13
|
+
- Linux: 0600 files under a 0700 directory, matching OpenCode's own
|
|
14
|
+
filesystem trust boundary without Secret Service unlock prompts or D-Bus
|
|
15
|
+
availability requirements. Plain base64 (v2 format below), same as
|
|
16
|
+
macOS's genuine-outage fallback.
|
|
17
|
+
- v2 file backend (both the Linux path and the macOS outage fallback) is
|
|
18
|
+
obfuscation (base64), not encryption — same as claude-swap's ``.enc``
|
|
19
|
+
files — protected by 0600 file / 0700 dir perms, matching (not
|
|
20
|
+
exceeding) OpenCode's own auth.json trust boundary. A small pre-v3
|
|
21
|
+
credential (e.g. an API key short enough for ``security -i``) may also
|
|
22
|
+
still be a bare value directly in a Keychain item with no file at all;
|
|
23
|
+
reads fall back that far as a last resort.
|
|
24
|
+
|
|
25
|
+
Read/write ordering, ported from claude-swap's credentials.py and
|
|
26
|
+
extended for the v3 envelope format:
|
|
27
|
+
|
|
28
|
+
- **v3-wins-on-read**: on macOS, a sealed v3 file for a key is always
|
|
29
|
+
authoritative over any v2/legacy file — unlike v2, whose presence merely
|
|
30
|
+
means "possibly fresher than the OS backend," a v3 file's Keychain data
|
|
31
|
+
key is the only thing that can ever decrypt it, so it cannot go stale
|
|
32
|
+
the way a plain fallback copy can.
|
|
33
|
+
- **File-wins-on-read** (v2/legacy, no v3 present): a fallback file is
|
|
34
|
+
authoritative — it may be fresher than a stale or currently-unreachable
|
|
35
|
+
OS-backend copy (written during a prior fallback episode).
|
|
36
|
+
- **Reconcile-on-write**: after a successful v3 (or, off macOS, v2) write,
|
|
37
|
+
any stale older-format file for that key is deleted, best-effort.
|
|
38
|
+
- **Sticky fallback**: once an OS-backend operation fails during this
|
|
39
|
+
process, this SecretStore instance pins itself to the file backend for
|
|
40
|
+
the rest of its life — a single CLI invocation never flip-flops between
|
|
41
|
+
backends mid-operation.
|
|
42
|
+
|
|
43
|
+
Registry: registry.json — non-secret account metadata (name, provider,
|
|
44
|
+
type, account id, email, which account is active). Never holds tokens.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import base64
|
|
50
|
+
import binascii
|
|
51
|
+
import enum
|
|
52
|
+
import hashlib
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import stat
|
|
56
|
+
from contextlib import suppress
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
from opencode_swap import macos_keychain, sealed
|
|
60
|
+
from opencode_swap.atomic import atomic_write_bytes, atomic_write_json, atomic_write_json_exclusive
|
|
61
|
+
from opencode_swap.exceptions import RegistryError, SecretStoreError
|
|
62
|
+
from opencode_swap.models import AccountKey, AccountMeta, JsonObject, Platform, normalize_account_name, normalize_provider_id
|
|
63
|
+
|
|
64
|
+
SERVICE_NAME = "opencode-swap"
|
|
65
|
+
REGISTRY_VERSION = 2
|
|
66
|
+
REGISTRY_V1_BACKUP = "registry.v1.json.bak"
|
|
67
|
+
_LEGACY_KEY_PREFIX = "openai:"
|
|
68
|
+
_FALLBACK_NAME_MAX = 255
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RecordLocation(enum.Enum):
|
|
72
|
+
"""Where a key's credential currently lives, for `doctor` reporting."""
|
|
73
|
+
|
|
74
|
+
SEALED = "sealed" # v3 envelope, or a small pre-v3 value straight in the Keychain
|
|
75
|
+
FILE_FALLBACK = "file_fallback" # v2/legacy: recoverable base64 on disk
|
|
76
|
+
MISSING = "missing"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe_filename(key: str) -> str:
|
|
80
|
+
return f"v2-{hashlib.sha256(key.encode('utf-8')).hexdigest()}.enc"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sealed_filename(key: str) -> str:
|
|
84
|
+
return f"v3-{hashlib.sha256(key.encode('utf-8')).hexdigest()}.enc"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _legacy_filename(key: str) -> str:
|
|
88
|
+
return key.replace(":", "_").replace("/", "_") + ".enc"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SecretStore:
|
|
92
|
+
"""String key-value secret store. Keys are opaque; callers own their format."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, secrets_dir: Path, platform: Platform | None = None):
|
|
95
|
+
self._dir = secrets_dir
|
|
96
|
+
self._platform = platform or Platform.detect()
|
|
97
|
+
self._use_file_backend = self._platform is not Platform.MACOS
|
|
98
|
+
self._backend_errors: tuple[type[BaseException], ...] = ()
|
|
99
|
+
if self._platform is Platform.MACOS:
|
|
100
|
+
self._backend_errors = macos_keychain.KEYCHAIN_ERRORS
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def backend_name(self) -> str:
|
|
104
|
+
if self._use_file_backend:
|
|
105
|
+
return "file"
|
|
106
|
+
return "keychain"
|
|
107
|
+
|
|
108
|
+
def _pin_file_backend(self) -> None:
|
|
109
|
+
self._use_file_backend = True
|
|
110
|
+
|
|
111
|
+
def _put_os(self, key: str, value: str) -> None:
|
|
112
|
+
macos_keychain.set_password(SERVICE_NAME, key, value)
|
|
113
|
+
|
|
114
|
+
def _get_os(self, key: str) -> str | None:
|
|
115
|
+
return macos_keychain.get_password(SERVICE_NAME, key)
|
|
116
|
+
|
|
117
|
+
def _delete_os(self, key: str) -> None:
|
|
118
|
+
macos_keychain.delete_password(SERVICE_NAME, key)
|
|
119
|
+
|
|
120
|
+
def _file_path(self, key: str) -> Path:
|
|
121
|
+
return self._dir / _safe_filename(key)
|
|
122
|
+
|
|
123
|
+
def _sealed_file_path(self, key: str) -> Path:
|
|
124
|
+
return self._dir / _sealed_filename(key)
|
|
125
|
+
|
|
126
|
+
def _legacy_file_path(self, key: str) -> Path:
|
|
127
|
+
return self._dir / _legacy_filename(key)
|
|
128
|
+
|
|
129
|
+
def _has_legacy_fallback(self, key: str) -> bool:
|
|
130
|
+
# v1 only supported normalized OpenAI account names. Extra separators
|
|
131
|
+
# could alias another legacy file, and overlong names cannot be
|
|
132
|
+
# probed on common filesystems even though v2's digest handles them.
|
|
133
|
+
if not key.startswith(_LEGACY_KEY_PREFIX):
|
|
134
|
+
return False
|
|
135
|
+
name = key.removeprefix(_LEGACY_KEY_PREFIX)
|
|
136
|
+
try:
|
|
137
|
+
if normalize_account_name(name) != name:
|
|
138
|
+
return False
|
|
139
|
+
except ValueError:
|
|
140
|
+
return False
|
|
141
|
+
name_max_path = self._dir
|
|
142
|
+
while not name_max_path.exists() and name_max_path != name_max_path.parent:
|
|
143
|
+
name_max_path = name_max_path.parent
|
|
144
|
+
try:
|
|
145
|
+
name_max = os.pathconf(name_max_path, "PC_NAME_MAX")
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
name_max = _FALLBACK_NAME_MAX
|
|
148
|
+
return len(_legacy_filename(key).encode("utf-8")) <= name_max
|
|
149
|
+
|
|
150
|
+
def _put_file(self, key: str, value: str) -> None:
|
|
151
|
+
encoded = base64.b64encode(value.encode("utf-8"))
|
|
152
|
+
# atomic_write_bytes creates missing parents with the process umask;
|
|
153
|
+
# tighten our private directory before publishing any secret into it.
|
|
154
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
self._dir.chmod(0o700)
|
|
156
|
+
atomic_write_bytes(self._file_path(key), encoded, mode=0o600)
|
|
157
|
+
if self._has_legacy_fallback(key):
|
|
158
|
+
# v2 now wins every read, so a failed legacy cleanup cannot make
|
|
159
|
+
# a stale credential authoritative again.
|
|
160
|
+
with suppress(OSError):
|
|
161
|
+
self._legacy_file_path(key).unlink(missing_ok=True)
|
|
162
|
+
|
|
163
|
+
def _get_file(self, key: str) -> str | None:
|
|
164
|
+
try:
|
|
165
|
+
encoded = self._file_path(key).read_bytes()
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
if not self._has_legacy_fallback(key):
|
|
168
|
+
return None
|
|
169
|
+
try:
|
|
170
|
+
encoded = self._legacy_file_path(key).read_bytes()
|
|
171
|
+
except FileNotFoundError:
|
|
172
|
+
return None
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise SecretStoreError("could not read stored file credential") from exc
|
|
175
|
+
return self._decode_file_value(encoded)
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
raise SecretStoreError("could not read stored file credential") from exc
|
|
178
|
+
return self._decode_file_value(encoded)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _decode_file_value(encoded: bytes) -> str:
|
|
182
|
+
try:
|
|
183
|
+
return base64.b64decode(encoded, validate=True).decode("utf-8")
|
|
184
|
+
except (binascii.Error, UnicodeDecodeError) as exc:
|
|
185
|
+
raise SecretStoreError("stored file credential is corrupt") from exc
|
|
186
|
+
|
|
187
|
+
def _delete_file(self, key: str) -> None:
|
|
188
|
+
self._sealed_file_path(key).unlink(missing_ok=True)
|
|
189
|
+
self._file_path(key).unlink(missing_ok=True)
|
|
190
|
+
if self._has_legacy_fallback(key):
|
|
191
|
+
self._legacy_file_path(key).unlink(missing_ok=True)
|
|
192
|
+
|
|
193
|
+
def _get_sealed(self, key: str) -> str | None:
|
|
194
|
+
"""Read+decrypt the v3 record for `key`. None if there is no v3
|
|
195
|
+
file for it (an older-format file or a direct Keychain value may
|
|
196
|
+
still exist — callers fall back to those). `self._backend_errors`
|
|
197
|
+
propagate for a genuine Keychain outage; SecretStoreError for a
|
|
198
|
+
corrupt or tampered blob."""
|
|
199
|
+
try:
|
|
200
|
+
blob = self._sealed_file_path(key).read_bytes()
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
return None
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
raise SecretStoreError("could not read stored file credential") from exc
|
|
205
|
+
data_key_hex = self._get_os(key)
|
|
206
|
+
if data_key_hex is None:
|
|
207
|
+
# Orphaned: the file survived a crash but its data key never
|
|
208
|
+
# committed, or the key was deleted independently. Unreadable
|
|
209
|
+
# either way -- GC it and let an older-format fallback win.
|
|
210
|
+
with suppress(OSError):
|
|
211
|
+
self._sealed_file_path(key).unlink(missing_ok=True)
|
|
212
|
+
return None
|
|
213
|
+
return sealed.unseal(key, data_key_hex, blob)
|
|
214
|
+
|
|
215
|
+
def _put_sealed(self, key: str, value: str) -> None:
|
|
216
|
+
"""Encrypt `value` under `key`'s existing Keychain data key, or a
|
|
217
|
+
freshly minted one if this is the first write for `key`. Reusing
|
|
218
|
+
the existing key on update (rather than rotating per write) keeps
|
|
219
|
+
every update to a single atomic file write -- exactly the crash
|
|
220
|
+
atomicity `atomic_write_bytes` already gives v2 -- instead of two
|
|
221
|
+
independent writes (Keychain + file) that could tear on a crash.
|
|
222
|
+
GCM's security only needs a fresh nonce per encryption under a
|
|
223
|
+
reused key, which `sealed.seal` already generates every call."""
|
|
224
|
+
existing_key = self._get_os(key) if self._sealed_file_path(key).exists() else None
|
|
225
|
+
data_key_hex = existing_key if existing_key is not None else sealed.new_data_key()
|
|
226
|
+
blob = sealed.seal(key, data_key_hex, value)
|
|
227
|
+
if existing_key is None:
|
|
228
|
+
self._put_os(key, data_key_hex)
|
|
229
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
self._dir.chmod(0o700)
|
|
231
|
+
atomic_write_bytes(self._sealed_file_path(key), blob, mode=0o600)
|
|
232
|
+
# v3 always wins subsequent reads, so cleaning up a stale
|
|
233
|
+
# older-format copy is pure best-effort -- unlike the old v2
|
|
234
|
+
# reconcile, there's no need to republish a fallback if this fails.
|
|
235
|
+
with suppress(OSError):
|
|
236
|
+
self._file_path(key).unlink(missing_ok=True)
|
|
237
|
+
if self._has_legacy_fallback(key):
|
|
238
|
+
self._legacy_file_path(key).unlink(missing_ok=True)
|
|
239
|
+
|
|
240
|
+
def get(self, key: str) -> str | None:
|
|
241
|
+
if not self._use_file_backend:
|
|
242
|
+
try:
|
|
243
|
+
sealed_value = self._get_sealed(key)
|
|
244
|
+
except self._backend_errors:
|
|
245
|
+
self._pin_file_backend()
|
|
246
|
+
return None
|
|
247
|
+
if sealed_value is not None:
|
|
248
|
+
return sealed_value
|
|
249
|
+
file_value = self._get_file(key)
|
|
250
|
+
if file_value is not None:
|
|
251
|
+
return file_value
|
|
252
|
+
if self._use_file_backend:
|
|
253
|
+
return None
|
|
254
|
+
try:
|
|
255
|
+
return self._get_os(key)
|
|
256
|
+
except self._backend_errors:
|
|
257
|
+
self._pin_file_backend()
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def get_confirmed(self, key: str) -> str | None:
|
|
261
|
+
"""Read `key`, refusing to report absence when OS state is unavailable."""
|
|
262
|
+
if not self._use_file_backend:
|
|
263
|
+
try:
|
|
264
|
+
sealed_value = self._get_sealed(key)
|
|
265
|
+
except self._backend_errors as exc:
|
|
266
|
+
self._pin_file_backend()
|
|
267
|
+
raise SecretStoreError("could not confirm credential absence from OS credential store") from exc
|
|
268
|
+
if sealed_value is not None:
|
|
269
|
+
return sealed_value
|
|
270
|
+
file_value = self._get_file(key)
|
|
271
|
+
if file_value is not None:
|
|
272
|
+
return file_value
|
|
273
|
+
if self._use_file_backend:
|
|
274
|
+
if self._platform is Platform.MACOS:
|
|
275
|
+
raise SecretStoreError("cannot confirm credential absence while OS credential store is unavailable")
|
|
276
|
+
return None
|
|
277
|
+
try:
|
|
278
|
+
return self._get_os(key)
|
|
279
|
+
except self._backend_errors as exc:
|
|
280
|
+
self._pin_file_backend()
|
|
281
|
+
raise SecretStoreError("could not confirm credential absence from OS credential store") from exc
|
|
282
|
+
|
|
283
|
+
def put(self, key: str, value: str) -> None:
|
|
284
|
+
# _use_file_backend starts False only on macOS (see __init__), so
|
|
285
|
+
# this branch is always the sealed envelope path -- there is no
|
|
286
|
+
# other OS-backend implementation to route to.
|
|
287
|
+
if not self._use_file_backend:
|
|
288
|
+
try:
|
|
289
|
+
self._put_sealed(key, value)
|
|
290
|
+
return
|
|
291
|
+
except self._backend_errors:
|
|
292
|
+
self._pin_file_backend()
|
|
293
|
+
self._put_file(key, value)
|
|
294
|
+
|
|
295
|
+
def delete(self, key: str) -> None:
|
|
296
|
+
if not self._use_file_backend:
|
|
297
|
+
try:
|
|
298
|
+
self._delete_os(key)
|
|
299
|
+
except self._backend_errors as exc:
|
|
300
|
+
self._pin_file_backend()
|
|
301
|
+
raise SecretStoreError("could not confirm deletion from the OS credential store") from exc
|
|
302
|
+
self._delete_file_checked(key)
|
|
303
|
+
return
|
|
304
|
+
if self._platform is Platform.MACOS:
|
|
305
|
+
raise SecretStoreError("cannot confirm deletion from the OS credential store while it is unavailable")
|
|
306
|
+
self._delete_file_checked(key)
|
|
307
|
+
|
|
308
|
+
def _delete_file_checked(self, key: str) -> None:
|
|
309
|
+
try:
|
|
310
|
+
self._delete_file(key)
|
|
311
|
+
except OSError as exc:
|
|
312
|
+
raise SecretStoreError("could not delete stored file credential") from exc
|
|
313
|
+
|
|
314
|
+
def upgrade(self, key: str) -> None:
|
|
315
|
+
"""Best-effort: reseal `key` into the v3 envelope format if it is
|
|
316
|
+
still stored as an older-format (v2/legacy) file or a bare pre-v3
|
|
317
|
+
Keychain value. No-op if already v3, absent, or the OS backend is
|
|
318
|
+
pinned to the file fallback. Callers must hold whatever lock
|
|
319
|
+
serializes concurrent access to `key` (Switcher.lock) -- running
|
|
320
|
+
this for the same key from two processes at once could interleave
|
|
321
|
+
two different data keys with the wrong ciphertext file."""
|
|
322
|
+
if self._platform is not Platform.MACOS or self._use_file_backend or self._sealed_file_path(key).exists():
|
|
323
|
+
return
|
|
324
|
+
value = self.get(key)
|
|
325
|
+
if value is not None:
|
|
326
|
+
self.put(key, value)
|
|
327
|
+
|
|
328
|
+
def record_location(self, key: str) -> RecordLocation:
|
|
329
|
+
"""Read-only classification of how `key` is currently stored, for
|
|
330
|
+
`doctor`. Never raises or mutates state; a Keychain outage just
|
|
331
|
+
falls back to what's visible on disk alone."""
|
|
332
|
+
if self._platform is Platform.MACOS:
|
|
333
|
+
if self._sealed_file_path(key).exists():
|
|
334
|
+
return RecordLocation.SEALED
|
|
335
|
+
if not self._use_file_backend:
|
|
336
|
+
try:
|
|
337
|
+
if self._get_os(key) is not None:
|
|
338
|
+
return RecordLocation.SEALED
|
|
339
|
+
except self._backend_errors:
|
|
340
|
+
pass
|
|
341
|
+
if self._file_path(key).exists():
|
|
342
|
+
return RecordLocation.FILE_FALLBACK
|
|
343
|
+
if self._has_legacy_fallback(key) and self._legacy_file_path(key).exists():
|
|
344
|
+
return RecordLocation.FILE_FALLBACK
|
|
345
|
+
return RecordLocation.MISSING
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class Registry:
|
|
349
|
+
"""registry.json: non-secret account metadata."""
|
|
350
|
+
|
|
351
|
+
def __init__(self, path: Path):
|
|
352
|
+
self._path = path
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _verify_existing_v1_backup(path: Path, data: JsonObject) -> None:
|
|
356
|
+
try:
|
|
357
|
+
backup_stat = path.lstat()
|
|
358
|
+
if not stat.S_ISREG(backup_stat.st_mode):
|
|
359
|
+
raise RegistryError(f"existing registry migration backup at {path} is not a regular file")
|
|
360
|
+
previous = json.loads(path.read_text(encoding="utf-8"))
|
|
361
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
362
|
+
raise RegistryError(f"could not verify existing registry migration backup at {path}") from exc
|
|
363
|
+
if previous != data:
|
|
364
|
+
raise RegistryError(f"refusing registry migration because {path} contains a different backup")
|
|
365
|
+
path.chmod(0o600)
|
|
366
|
+
|
|
367
|
+
def migrate(self) -> bool:
|
|
368
|
+
"""Atomically migrate registry v1; secret-store keys already use provider scope."""
|
|
369
|
+
if not self._path.exists():
|
|
370
|
+
return False
|
|
371
|
+
data = self._read_json()
|
|
372
|
+
if data.get("version") == REGISTRY_VERSION:
|
|
373
|
+
return False
|
|
374
|
+
if data.get("version") != 1 or set(data) != {"version", "active", "accounts"}:
|
|
375
|
+
raise RegistryError(f"{self._path} has an unsupported registry version")
|
|
376
|
+
old_accounts = data.get("accounts")
|
|
377
|
+
old_active = data.get("active")
|
|
378
|
+
if not isinstance(old_accounts, dict) or (old_active is not None and not isinstance(old_active, str)):
|
|
379
|
+
raise RegistryError(f"{self._path} does not look like a valid opencode-swap registry")
|
|
380
|
+
|
|
381
|
+
nested: JsonObject = {}
|
|
382
|
+
active: JsonObject = {}
|
|
383
|
+
for name, raw_meta in old_accounts.items():
|
|
384
|
+
if not isinstance(name, str):
|
|
385
|
+
raise RegistryError(f"{self._path} has an invalid account name")
|
|
386
|
+
meta = AccountMeta.from_dict(name, raw_meta)
|
|
387
|
+
try:
|
|
388
|
+
normalize_provider_id(meta.provider)
|
|
389
|
+
except ValueError as exc:
|
|
390
|
+
raise RegistryError(f"{self._path} has an invalid provider id") from exc
|
|
391
|
+
provider_accounts = nested.setdefault(meta.provider, {})
|
|
392
|
+
assert isinstance(provider_accounts, dict)
|
|
393
|
+
provider_accounts[name] = meta.to_dict()
|
|
394
|
+
if old_active == name:
|
|
395
|
+
active[meta.provider] = name
|
|
396
|
+
if old_active is not None and not active:
|
|
397
|
+
raise RegistryError(f"{self._path} has an invalid active account")
|
|
398
|
+
|
|
399
|
+
backup_path = self._path.with_name(REGISTRY_V1_BACKUP)
|
|
400
|
+
try:
|
|
401
|
+
atomic_write_json_exclusive(backup_path, data)
|
|
402
|
+
except FileExistsError:
|
|
403
|
+
self._verify_existing_v1_backup(backup_path, data)
|
|
404
|
+
self._save({"version": REGISTRY_VERSION, "active": active, "accounts": nested})
|
|
405
|
+
return True
|
|
406
|
+
|
|
407
|
+
def _read_json(self) -> JsonObject:
|
|
408
|
+
try:
|
|
409
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
410
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
411
|
+
raise RegistryError(f"could not read registry at {self._path}: {exc}") from exc
|
|
412
|
+
if not isinstance(data, dict):
|
|
413
|
+
raise RegistryError(f"{self._path} does not look like a valid opencode-swap registry")
|
|
414
|
+
return data
|
|
415
|
+
|
|
416
|
+
def _load(self) -> JsonObject:
|
|
417
|
+
if not self._path.exists():
|
|
418
|
+
return {"version": REGISTRY_VERSION, "active": {}, "accounts": {}}
|
|
419
|
+
data = self._read_json()
|
|
420
|
+
if not isinstance(data, dict) or set(data) != {"version", "active", "accounts"}:
|
|
421
|
+
raise RegistryError(f"{self._path} does not look like a valid opencode-swap registry")
|
|
422
|
+
if type(data["version"]) is not int or data["version"] != REGISTRY_VERSION:
|
|
423
|
+
raise RegistryError(f"{self._path} has an unsupported registry version")
|
|
424
|
+
if not isinstance(data["accounts"], dict) or not isinstance(data["active"], dict):
|
|
425
|
+
raise RegistryError(f"{self._path} does not look like a valid opencode-swap registry")
|
|
426
|
+
accounts = data["accounts"]
|
|
427
|
+
active = data["active"]
|
|
428
|
+
assert isinstance(accounts, dict) and isinstance(active, dict)
|
|
429
|
+
for provider_id, provider_accounts in accounts.items():
|
|
430
|
+
try:
|
|
431
|
+
normalize_provider_id(provider_id)
|
|
432
|
+
except (TypeError, ValueError) as exc:
|
|
433
|
+
raise RegistryError(f"{self._path} has an invalid provider id") from exc
|
|
434
|
+
if not isinstance(provider_accounts, dict):
|
|
435
|
+
raise RegistryError(f"{self._path} has invalid provider accounts")
|
|
436
|
+
for name, meta in provider_accounts.items():
|
|
437
|
+
parsed = AccountMeta.from_dict(name, meta)
|
|
438
|
+
if parsed.provider != provider_id:
|
|
439
|
+
raise RegistryError(f"registry account {name!r} has mismatched provider")
|
|
440
|
+
for provider_id, name in active.items():
|
|
441
|
+
provider_accounts = accounts.get(provider_id)
|
|
442
|
+
if not isinstance(name, str) or not isinstance(provider_accounts, dict) or name not in provider_accounts:
|
|
443
|
+
raise RegistryError(f"{self._path} has an invalid active account")
|
|
444
|
+
return data
|
|
445
|
+
|
|
446
|
+
def _save(self, data: JsonObject) -> None:
|
|
447
|
+
atomic_write_json(self._path, data)
|
|
448
|
+
|
|
449
|
+
def scoped_accounts(self, provider_id: str | None = None) -> dict[AccountKey, AccountMeta]:
|
|
450
|
+
data = self._load()
|
|
451
|
+
accounts = data["accounts"]
|
|
452
|
+
assert isinstance(accounts, dict)
|
|
453
|
+
result: dict[AccountKey, AccountMeta] = {}
|
|
454
|
+
for stored_provider, provider_accounts in accounts.items():
|
|
455
|
+
if provider_id is not None and stored_provider != provider_id:
|
|
456
|
+
continue
|
|
457
|
+
assert isinstance(stored_provider, str) and isinstance(provider_accounts, dict)
|
|
458
|
+
for name, meta in provider_accounts.items():
|
|
459
|
+
assert isinstance(name, str)
|
|
460
|
+
result[(stored_provider, name)] = AccountMeta.from_dict(name, meta)
|
|
461
|
+
return result
|
|
462
|
+
|
|
463
|
+
def accounts(self, provider_id: str | None = None) -> dict[str, AccountMeta]:
|
|
464
|
+
"""Return name-keyed accounts for one provider; defaults to OpenAI."""
|
|
465
|
+
selected_provider = provider_id or "openai"
|
|
466
|
+
return {name: meta for (_provider, name), meta in self.scoped_accounts(selected_provider).items()}
|
|
467
|
+
|
|
468
|
+
def get_active(self, provider_id: str = "openai") -> str | None:
|
|
469
|
+
active = self._load().get("active")
|
|
470
|
+
value = active.get(provider_id) if isinstance(active, dict) else None
|
|
471
|
+
return value if isinstance(value, str) else None
|
|
472
|
+
|
|
473
|
+
def upsert_account(self, meta: AccountMeta) -> None:
|
|
474
|
+
data = self._load()
|
|
475
|
+
accounts = data["accounts"]
|
|
476
|
+
assert isinstance(accounts, dict)
|
|
477
|
+
provider_accounts = accounts.setdefault(meta.provider, {})
|
|
478
|
+
assert isinstance(provider_accounts, dict)
|
|
479
|
+
provider_accounts[meta.name] = meta.to_dict()
|
|
480
|
+
self._save(data)
|
|
481
|
+
|
|
482
|
+
def add_accounts(self, metas: list[AccountMeta]) -> None:
|
|
483
|
+
"""Add several new accounts in one atomic registry publication."""
|
|
484
|
+
data = self._load()
|
|
485
|
+
accounts = data["accounts"]
|
|
486
|
+
assert isinstance(accounts, dict)
|
|
487
|
+
collisions = [meta.name for meta in metas if isinstance(accounts.get(meta.provider), dict) and meta.name in accounts[meta.provider]]
|
|
488
|
+
if collisions:
|
|
489
|
+
raise RegistryError(f"account already exists: {collisions[0]}")
|
|
490
|
+
for meta in metas:
|
|
491
|
+
provider_accounts = accounts.setdefault(meta.provider, {})
|
|
492
|
+
assert isinstance(provider_accounts, dict)
|
|
493
|
+
provider_accounts[meta.name] = meta.to_dict()
|
|
494
|
+
self._save(data)
|
|
495
|
+
|
|
496
|
+
def upsert_accounts(self, metas: list[AccountMeta]) -> None:
|
|
497
|
+
"""Add or replace several accounts in one atomic registry publication."""
|
|
498
|
+
data = self._load()
|
|
499
|
+
accounts = data["accounts"]
|
|
500
|
+
assert isinstance(accounts, dict)
|
|
501
|
+
for meta in metas:
|
|
502
|
+
provider_accounts = accounts.setdefault(meta.provider, {})
|
|
503
|
+
assert isinstance(provider_accounts, dict)
|
|
504
|
+
provider_accounts[meta.name] = meta.to_dict()
|
|
505
|
+
self._save(data)
|
|
506
|
+
|
|
507
|
+
def remove_account(self, name: str, provider_id: str = "openai") -> None:
|
|
508
|
+
data = self._load()
|
|
509
|
+
accounts = data["accounts"]
|
|
510
|
+
assert isinstance(accounts, dict)
|
|
511
|
+
provider_accounts = accounts.get(provider_id)
|
|
512
|
+
if isinstance(provider_accounts, dict):
|
|
513
|
+
provider_accounts.pop(name, None)
|
|
514
|
+
if not provider_accounts:
|
|
515
|
+
accounts.pop(provider_id, None)
|
|
516
|
+
active = data["active"]
|
|
517
|
+
assert isinstance(active, dict)
|
|
518
|
+
if active.get(provider_id) == name:
|
|
519
|
+
active.pop(provider_id, None)
|
|
520
|
+
self._save(data)
|
|
521
|
+
|
|
522
|
+
def rename_account(self, old: str, new: str, provider_id: str = "openai") -> None:
|
|
523
|
+
data = self._load()
|
|
524
|
+
accounts = data["accounts"]
|
|
525
|
+
assert isinstance(accounts, dict)
|
|
526
|
+
provider_accounts = accounts.get(provider_id)
|
|
527
|
+
if not isinstance(provider_accounts, dict) or old not in provider_accounts:
|
|
528
|
+
raise RegistryError(f"no such account: {old}")
|
|
529
|
+
if new in provider_accounts:
|
|
530
|
+
raise RegistryError(f"account already exists: {new}")
|
|
531
|
+
provider_accounts[new] = provider_accounts.pop(old)
|
|
532
|
+
active = data["active"]
|
|
533
|
+
assert isinstance(active, dict)
|
|
534
|
+
if active.get(provider_id) == old:
|
|
535
|
+
active[provider_id] = new
|
|
536
|
+
self._save(data)
|
|
537
|
+
|
|
538
|
+
def set_active(self, name: str | None, provider_id: str = "openai") -> None:
|
|
539
|
+
data = self._load()
|
|
540
|
+
accounts = data["accounts"]
|
|
541
|
+
assert isinstance(accounts, dict)
|
|
542
|
+
provider_accounts = accounts.get(provider_id)
|
|
543
|
+
if name is not None and (not isinstance(provider_accounts, dict) or name not in provider_accounts):
|
|
544
|
+
raise RegistryError(f"no such account: {name}")
|
|
545
|
+
active = data["active"]
|
|
546
|
+
assert isinstance(active, dict)
|
|
547
|
+
if name is None:
|
|
548
|
+
active.pop(provider_id, None)
|
|
549
|
+
else:
|
|
550
|
+
active[provider_id] = name
|
|
551
|
+
self._save(data)
|