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/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)