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,54 @@
1
+ """Whole-file read/validate/atomic-write of OpenCode's auth.json.
2
+
3
+ Generic across providers — a Provider only interprets the one key it owns
4
+ (see providers/base.py). This module never inspects provider-specific
5
+ fields; it just guarantees a safe, atomic round-trip of the file OpenCode
6
+ itself writes non-atomically and without a lock (verified:
7
+ opencode/packages/core/src/fs-util.ts:110-114 truncate+write then a separate
8
+ chmod, no temp file, no rename; opencode/packages/opencode/src/auth/index.ts
9
+ :75-79 does an unlocked read-merge-write). opencode-swap holds itself to a
10
+ higher bar: 0600 temp file in the same directory, then os.replace — the only
11
+ publish point, so a crash mid-write never leaves auth.json truncated.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ from opencode_swap.atomic import atomic_write_json
20
+ from opencode_swap.exceptions import AuthFileError
21
+ from opencode_swap.models import JsonObject
22
+
23
+
24
+ def read_auth(path: Path) -> JsonObject:
25
+ """Read and parse auth.json. Raises AuthFileError if missing/malformed.
26
+
27
+ An absent file is a distinct, expected state (OpenCode with no accounts
28
+ configured yet) — callers that want to treat "no file" as "empty auth"
29
+ should catch AuthFileError and check path.exists() themselves; this
30
+ function never silently invents an empty dict, since a genuinely
31
+ unreadable file (permissions, corruption) must not look identical to
32
+ "nothing here yet."
33
+ """
34
+ try:
35
+ text = path.read_text(encoding="utf-8")
36
+ except FileNotFoundError as exc:
37
+ raise AuthFileError(f"auth.json not found at {path}") from exc
38
+ except OSError as exc:
39
+ raise AuthFileError(f"could not read {path}: {exc}") from exc
40
+
41
+ try:
42
+ data = json.loads(text)
43
+ except json.JSONDecodeError as exc:
44
+ raise AuthFileError(f"{path} is not valid JSON: {exc}") from exc
45
+
46
+ if not isinstance(data, dict):
47
+ raise AuthFileError(f"{path} does not contain a JSON object at the top level")
48
+
49
+ return data
50
+
51
+
52
+ def atomic_write_auth(path: Path, data: JsonObject) -> None:
53
+ """Write auth.json atomically: 0600 temp file in the same dir, then rename."""
54
+ atomic_write_json(path, data)
opencode_swap/paths.py ADDED
@@ -0,0 +1,83 @@
1
+ """Path resolution for OpenCode's auth state and opencode-swap's own data.
2
+
3
+ Mirrors OpenCode's own path resolution so opencode-swap reads and writes
4
+ exactly the file OpenCode does. Verified from OpenCode source
5
+ (``packages/core/src/global.ts``, via xdg-basedir) and cross-checked against
6
+ opencode-balancer's independent reimplementation (``src/core/path.ts``):
7
+
8
+ - Data dir: ``$XDG_DATA_HOME/opencode`` if ``XDG_DATA_HOME`` is set (and
9
+ absolute), else ``~/.local/share/opencode``. Identical on Linux and macOS —
10
+ OpenCode uses xdg-basedir defaults everywhere it runs on POSIX; there is no
11
+ macOS-specific data location.
12
+ - Auth file: ``<data dir>/auth.json``.
13
+ - ``OPENCODE_AUTH_CONTENT``, if set in the *OpenCode process's* environment,
14
+ makes ``Auth.all()`` return that JSON in-memory and ignore the file
15
+ entirely (``auth/index.ts:59-63``). We can only detect whether it is set in
16
+ *our own* process env — useful for ``doctor`` diagnostics, not for writing.
17
+ - ``OPENCODE_CONFIG_DIR`` overrides OpenCode's *config* dir only, not the
18
+ data dir — it does not move auth.json (``global.ts:64``).
19
+ - ``OPENCODE_TEST_HOME`` overrides OpenCode's notion of home (``global.ts:19``)
20
+ and is honored here too, so integration tests can point both OpenCode and
21
+ opencode-swap at the same fake home without touching the real one.
22
+
23
+ opencode-swap's own data (registry, backups, fallback secrets) lives under
24
+ ``$XDG_DATA_HOME/opencode-swap`` using the same resolution — uniform across
25
+ Linux and macOS since this is a new tool with no legacy layout to preserve.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from pathlib import Path
32
+
33
+ OPENCODE_DIRNAME = "opencode"
34
+ OPENCODE_SWAP_DIRNAME = "opencode-swap"
35
+ AUTH_FILENAME = "auth.json"
36
+
37
+
38
+ def effective_home() -> Path:
39
+ """Return the home directory, honoring OPENCODE_TEST_HOME for test parity."""
40
+ test_home = os.environ.get("OPENCODE_TEST_HOME")
41
+ if test_home:
42
+ return Path(test_home)
43
+ return Path.home()
44
+
45
+
46
+ def xdg_data_home() -> Path:
47
+ """Return XDG_DATA_HOME, falling back to ``<home>/.local/share``.
48
+
49
+ Per the XDG spec (and OpenCode's own xdg-basedir usage), an unset, empty,
50
+ or non-absolute XDG_DATA_HOME is ignored in favor of the default.
51
+ """
52
+ env = os.environ.get("XDG_DATA_HOME", "")
53
+ if env:
54
+ candidate = Path(os.path.expanduser(env))
55
+ if candidate.is_absolute():
56
+ return candidate
57
+ return effective_home() / ".local" / "share"
58
+
59
+
60
+ def get_opencode_data_dir() -> Path:
61
+ """Return OpenCode's data directory (``$XDG_DATA_HOME/opencode``)."""
62
+ return xdg_data_home() / OPENCODE_DIRNAME
63
+
64
+
65
+ def get_opencode_auth_path() -> Path:
66
+ """Return the path to OpenCode's auth.json."""
67
+ return get_opencode_data_dir() / AUTH_FILENAME
68
+
69
+
70
+ def opencode_auth_content_override_active() -> bool:
71
+ """Return True if OPENCODE_AUTH_CONTENT is set in our own process env.
72
+
73
+ When set in the *OpenCode* process's environment, OpenCode ignores
74
+ auth.json entirely and reads this instead — a swap would have no effect.
75
+ We can only observe our own env, so this is a best-effort diagnostic
76
+ signal for ``doctor``, not a guarantee about OpenCode's runtime.
77
+ """
78
+ return bool(os.environ.get("OPENCODE_AUTH_CONTENT"))
79
+
80
+
81
+ def get_data_root() -> Path:
82
+ """Return opencode-swap's own data root (``$XDG_DATA_HOME/opencode-swap``)."""
83
+ return xdg_data_home() / OPENCODE_SWAP_DIRNAME
@@ -0,0 +1,29 @@
1
+ """Best-effort detection of a running OpenCode process.
2
+
3
+ OpenCode has no cooperative lock protocol opencode-swap can join (verified:
4
+ Auth.set does an unlocked read-merge-write, auth/index.ts:75-79). The
5
+ closest thing to a safety net is warning the caller that a swap is racing a
6
+ live OpenCode process, which might be mid-refresh and write auth.json again
7
+ right after opencode-swap does. Detection failure (no `pgrep`, permission
8
+ denied, etc.) degrades to "not detected" rather than raising — this is
9
+ advisory, not a security boundary.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+
16
+ _TIMEOUT = 2.0
17
+
18
+
19
+ def is_opencode_running() -> bool:
20
+ try:
21
+ result = subprocess.run(
22
+ ["pgrep", "-x", "opencode"],
23
+ capture_output=True,
24
+ timeout=_TIMEOUT,
25
+ check=False,
26
+ )
27
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
28
+ return False
29
+ return result.returncode == 0
@@ -0,0 +1,26 @@
1
+ """Provider registry: maps an OpenCode provider id to its Provider implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from opencode_swap.models import normalize_provider_id
6
+ from opencode_swap.providers.api import ApiProvider
7
+ from opencode_swap.providers.base import Provider
8
+ from opencode_swap.providers.github_copilot import GitHubCopilotProvider
9
+ from opencode_swap.providers.openai import OpenAiProvider
10
+ from opencode_swap.providers.poe import PoeProvider
11
+ from opencode_swap.providers.xai import XaiProvider
12
+ from opencode_swap.providers.zai import ZaiProvider
13
+
14
+ PROVIDERS: dict[str, Provider] = {
15
+ "openai": OpenAiProvider(),
16
+ "github-copilot": GitHubCopilotProvider(),
17
+ "poe": PoeProvider(),
18
+ "xai": XaiProvider(),
19
+ "zai-coding-plan": ZaiProvider(),
20
+ }
21
+
22
+
23
+ def get_provider(provider_id: str) -> Provider:
24
+ """Return specialized handling or canonical API-only fallback."""
25
+ provider_id = normalize_provider_id(provider_id)
26
+ return PROVIDERS.get(provider_id) or ApiProvider(provider_id)
@@ -0,0 +1,50 @@
1
+ """Generic static API-key provider supported by OpenCode's common path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from opencode_swap import usage
6
+ from opencode_swap.exceptions import SchemaError
7
+ from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
8
+ from opencode_swap.providers.common import credential_values, extract_raw, key_account_hint, published_raw, validate_api
9
+
10
+
11
+ class ApiProvider:
12
+ usage_record_types: frozenset[str] = frozenset() # no known usage endpoint for an arbitrary API provider
13
+
14
+ def __init__(self, provider_id: str):
15
+ self.id = provider_id
16
+
17
+ def extract(self, auth: JsonObject) -> AuthRecord | None:
18
+ raw = extract_raw(auth, self.id)
19
+ if raw is None:
20
+ return None
21
+ if raw.get("type") != "api":
22
+ raise SchemaError(f"{self.id} auth type is not supported by opencode-swap")
23
+ return validate_api(raw, self.id)
24
+
25
+ def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
26
+ if record.type != "api":
27
+ raise SchemaError(f"{self.id} auth type is not supported by opencode-swap")
28
+ return {**auth, self.id: published_raw(record.raw)}
29
+
30
+ def identity(self, record: AuthRecord) -> str:
31
+ key = record.raw.get("key")
32
+ return f"api-key\0{key if isinstance(key, str) else ''}"
33
+
34
+ def identity_is_stable(self, record: AuthRecord) -> bool:
35
+ return True
36
+
37
+ def credential_values(self, record: AuthRecord) -> set[str]:
38
+ return credential_values(record)
39
+
40
+ def describe(self, record: AuthRecord) -> AccountDesc:
41
+ return AccountDesc(type="api", email=None, account_id=key_account_hint(record), expires=None)
42
+
43
+ def validate(self, record: AuthRecord) -> Validity:
44
+ return Validity.OK if record.type == "api" else Validity.INVALID
45
+
46
+ def refresh(self, record: AuthRecord) -> AuthRecord | None:
47
+ return None # API keys don't expire/rotate; nothing to refresh
48
+
49
+ def fetch_usage(self, record: AuthRecord) -> usage.UsageSnapshot | None:
50
+ return None # no usage endpoint for an arbitrary API provider (usage_record_types is empty)
@@ -0,0 +1,91 @@
1
+ """The Provider seam: the only part of opencode-swap that varies per OpenCode
2
+ provider/account type.
3
+
4
+ auth.json's whole-file handling (read/validate/atomic-write) is generic and
5
+ lives in opencode_auth.py. A Provider owns only what's provider-specific:
6
+ which key(s) it occupies in auth.json, and how to read identity/validity out
7
+ of its record. Adding a second provider means adding a second Provider
8
+ implementation — no changes to the switch/store/lock machinery.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Protocol
14
+
15
+ from opencode_swap import usage
16
+ from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
17
+
18
+
19
+ class Provider(Protocol):
20
+ id: str
21
+
22
+ usage_record_types: frozenset[str]
23
+ """Record types (``"api"``, ``"oauth"``, ...) this provider can look up
24
+ live usage for via ``fetch_usage``. Empty (the default for every provider
25
+ without a known usage endpoint) tells ``Switcher.fetch_usage`` to skip all
26
+ lock/refresh work and report "not applicable" rather than "unavailable"."""
27
+
28
+ def extract(self, auth: JsonObject) -> AuthRecord | None:
29
+ """Pull this provider's entry out of a parsed auth.json.
30
+
31
+ Returns None if the provider has no entry. Raises SchemaError if an
32
+ entry exists but doesn't match a known shape (fail-safe: never guess).
33
+ """
34
+ ...
35
+
36
+ def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
37
+ """Return a new auth.json dict with this provider's entry replaced.
38
+
39
+ Does not mutate ``auth``; all other providers' keys are preserved.
40
+
41
+ Implementations must route the record through
42
+ ``providers.common.published_raw`` -- this is the only path by which
43
+ opencode-swap content reaches OpenCode, so it is where read-side
44
+ tolerance has to stop. ``test_every_provider_splice_publishes_an
45
+ _integer_expiry`` enforces this for every registered provider.
46
+ """
47
+ ...
48
+
49
+ def identity(self, record: AuthRecord) -> str:
50
+ """A stable string identifying which real-world account this record
51
+ belongs to, independent of token rotation (e.g. account id, or the
52
+ refresh token itself as a fallback)."""
53
+ ...
54
+
55
+ def identity_is_stable(self, record: AuthRecord) -> bool:
56
+ """Whether identity remains unchanged when OpenCode refreshes it."""
57
+ ...
58
+
59
+ def credential_values(self, record: AuthRecord) -> set[str]:
60
+ """Secret strings which must never enter metadata or output."""
61
+ ...
62
+
63
+ def describe(self, record: AuthRecord) -> AccountDesc:
64
+ """Human-facing, secret-free description of the account."""
65
+ ...
66
+
67
+ def validate(self, record: AuthRecord) -> Validity:
68
+ """Whether the record looks usable, expired, or malformed."""
69
+ ...
70
+
71
+ def refresh(self, record: AuthRecord) -> AuthRecord | None:
72
+ """Rotated-token copy of `record` from a standalone OAuth refresh.
73
+
74
+ Returns None if this provider/record type has no standalone refresh
75
+ (the default for every provider but OpenAI oauth records). Raises
76
+ RefreshError if a refresh was attempted and the grant was rejected
77
+ or the request otherwise failed -- callers must not treat that the
78
+ same as "no refresh available".
79
+ """
80
+ ...
81
+
82
+ def fetch_usage(self, record: AuthRecord) -> usage.UsageSnapshot | None:
83
+ """Live usage/quota for `record`, fetched over the network.
84
+
85
+ Only called for record types in ``usage_record_types``. Returns None
86
+ when this particular record can't be looked up (missing field, secret
87
+ store out of sync) -- distinct from ``UsageSnapshot(available=False)``
88
+ ("looked it up, the request failed"). Never raises: network and shape
89
+ failures come back as ``available=False``.
90
+ """
91
+ ...
@@ -0,0 +1,144 @@
1
+ """Shared validation for OpenCode's canonical auth record shapes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TypeGuard
7
+
8
+ from opencode_swap.exceptions import SchemaError
9
+ from opencode_swap.models import AuthRecord, JsonObject
10
+
11
+
12
+ def require_str(raw: JsonObject, field: str, provider_id: str, entry_type: str) -> str:
13
+ value = raw.get(field)
14
+ if not isinstance(value, str) or not value:
15
+ raise SchemaError(f"{provider_id} {entry_type} entry missing/invalid field {field!r}")
16
+ return value
17
+
18
+
19
+ def optional_str(raw: JsonObject, field: str, provider_id: str, entry_type: str) -> str | None:
20
+ if field not in raw:
21
+ return None
22
+ value = raw[field]
23
+ if not isinstance(value, str):
24
+ raise SchemaError(f"{provider_id} {entry_type} entry has invalid field {field!r}")
25
+ return value
26
+
27
+
28
+ def is_json_number(value: object) -> TypeGuard[int | float]:
29
+ """Whether value survives OpenCode's JavaScript JSON-number boundary."""
30
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
31
+ return False
32
+ try:
33
+ return math.isfinite(float(value))
34
+ except OverflowError:
35
+ return False
36
+
37
+
38
+ def require_expiry(raw: JsonObject, provider_id: str) -> int | float:
39
+ expires = raw.get("expires")
40
+ if not is_json_number(expires) or expires < 0:
41
+ raise SchemaError(f"{provider_id} oauth entry has invalid 'expires'")
42
+ return expires
43
+
44
+
45
+ def published_raw(raw: JsonObject) -> JsonObject:
46
+ """Copy of `raw` safe to publish into OpenCode's auth.json.
47
+
48
+ Every `Provider.splice` routes its record through this -- splice is the
49
+ only path by which opencode-swap content reaches auth.json, and the one
50
+ place where our tolerance for what we *read* must stop.
51
+
52
+ OpenCode types `expires` as `NonNegativeInt` (auth/index.ts's Oauth
53
+ class, via packages/schema/src/schema.ts's `Schema.Int`), and `Auth.all()`
54
+ decodes with `Record.filterMap`, which drops entries that fail to decode
55
+ instead of reporting them. Publishing a fractional `expires` therefore
56
+ makes the provider vanish from OpenCode entirely, with the first symptom
57
+ appearing far away as an `auth.type` access on an undefined record
58
+ (codex.ts:331).
59
+
60
+ Deliberately not done in `extract`: that is a validator on records we
61
+ did not write, and normalizing there would also rewrite the live record
62
+ captured by `backup.write_unclaimed`, whose entire purpose is to
63
+ preserve a foreign login exactly as found. Reads stay verbatim; only
64
+ what we hand back to OpenCode is constrained. Records written by older
65
+ versions (see oauth_refresh.py) are healed on the next switch as a
66
+ consequence.
67
+
68
+ Scoped to `type == "oauth"`: that is the only OpenCode auth variant
69
+ whose schema types `expires` as `NonNegativeInt` at all (Api and
70
+ WellKnown carry no `expires` field in OpenCode's schema). Truncating an
71
+ `expires`-named key on any other type -- or any other unknown field --
72
+ would silently mutate data that isn't ours to touch and OpenCode was
73
+ never going to reject in the first place.
74
+ """
75
+ if raw.get("type") != "oauth":
76
+ return dict(raw)
77
+ expires = raw.get("expires")
78
+ if not is_json_number(expires) or isinstance(expires, int):
79
+ return dict(raw) # absent, non-numeric (rejected elsewhere), or already integral
80
+ return {**raw, "expires": int(expires)}
81
+
82
+
83
+ def extract_raw(auth: JsonObject, provider_id: str) -> JsonObject | None:
84
+ raw = auth.get(provider_id)
85
+ if raw is None:
86
+ return None
87
+ if not isinstance(raw, dict) or "type" not in raw:
88
+ raise SchemaError(f"{provider_id} entry is not a recognizable auth record")
89
+ return raw
90
+
91
+
92
+ def validate_api(raw: JsonObject, provider_id: str) -> AuthRecord:
93
+ require_str(raw, "key", provider_id, "api")
94
+ metadata = raw.get("metadata")
95
+ if "metadata" in raw and (
96
+ not isinstance(metadata, dict) or any(not isinstance(key, str) or not isinstance(value, str) for key, value in metadata.items())
97
+ ):
98
+ raise SchemaError(f"{provider_id} api entry has invalid field 'metadata'")
99
+ return AuthRecord(type="api", raw=dict(raw))
100
+
101
+
102
+ def validate_oauth(raw: JsonObject, provider_id: str) -> AuthRecord:
103
+ require_str(raw, "refresh", provider_id, "oauth")
104
+ require_str(raw, "access", provider_id, "oauth")
105
+ require_expiry(raw, provider_id)
106
+ optional_str(raw, "accountId", provider_id, "oauth")
107
+ optional_str(raw, "enterpriseUrl", provider_id, "oauth")
108
+ return AuthRecord(type="oauth", raw=dict(raw))
109
+
110
+
111
+ _MIN_KEY_LENGTH_FOR_HINT = 20 # leaves >=16 characters hidden; see key_account_hint
112
+
113
+
114
+ def credential_values(record: AuthRecord) -> set[str]:
115
+ return {value for field in ("refresh", "access", "key", "token") if isinstance((value := record.raw.get(field)), str) and value}
116
+
117
+
118
+ def key_account_hint(record: AuthRecord) -> str | None:
119
+ """`"...{last 4 chars}"` of a static API key -- a stable identifier for
120
+ *which* key a saved account holds, shown in the account-id column in
121
+ place of an account id for static-key providers (which have none). This
122
+ is the same head/tail form providers show on their own API-key
123
+ dashboards, so a row can be matched against one; the literal `...`
124
+ carries no information about the key itself, it only marks the value as
125
+ a deliberate partial (matching how `cli._redact_account_id` marks a
126
+ truncated account id the same way).
127
+
128
+ Only ``record.type == "api"`` is considered: an oauth record's raw dict
129
+ can carry an unrelated/unverified `key` field (schema validation doesn't
130
+ forbid extra fields), and that is not the credential this hint is about.
131
+
132
+ Returns None below `_MIN_KEY_LENGTH_FOR_HINT`: revealing 4 characters of
133
+ a short, possibly low-entropy value (a hand-picked token, not a real API
134
+ key) can materially narrow it -- e.g. an 8-character key exposes half of
135
+ it. The threshold instead guarantees at least 16 characters stay hidden,
136
+ which no realistic API key format (all comfortably 20+ chars) is close
137
+ to tripping.
138
+ """
139
+ if record.type != "api":
140
+ return None
141
+ key = record.raw.get("key")
142
+ if not isinstance(key, str) or len(key) < _MIN_KEY_LENGTH_FOR_HINT:
143
+ return None
144
+ return f"...{key[-4:]}"
@@ -0,0 +1,46 @@
1
+ """GitHub Copilot auth as implemented by OpenCode's built-in plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from opencode_swap.exceptions import SchemaError
6
+ from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
7
+ from opencode_swap.providers.common import credential_values, extract_raw, published_raw, validate_oauth
8
+
9
+
10
+ class GitHubCopilotProvider:
11
+ id = "github-copilot"
12
+ usage_record_types: frozenset[str] = frozenset() # no known usage endpoint
13
+
14
+ def extract(self, auth: JsonObject) -> AuthRecord | None:
15
+ raw = extract_raw(auth, self.id)
16
+ if raw is None:
17
+ return None
18
+ if raw.get("type") != "oauth":
19
+ raise SchemaError("github-copilot auth type is not supported by opencode-swap")
20
+ return validate_oauth(raw, self.id)
21
+
22
+ def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
23
+ return {**auth, self.id: published_raw(record.raw)}
24
+
25
+ def identity(self, record: AuthRecord) -> str:
26
+ refresh = record.raw.get("refresh")
27
+ enterprise = record.raw.get("enterpriseUrl")
28
+ return f"copilot\0{enterprise if isinstance(enterprise, str) else ''}\0{refresh if isinstance(refresh, str) else ''}"
29
+
30
+ def identity_is_stable(self, record: AuthRecord) -> bool:
31
+ return True
32
+
33
+ def credential_values(self, record: AuthRecord) -> set[str]:
34
+ return credential_values(record)
35
+
36
+ def describe(self, record: AuthRecord) -> AccountDesc:
37
+ return AccountDesc(type="oauth", email=None, account_id=None, expires=None)
38
+
39
+ def validate(self, record: AuthRecord) -> Validity:
40
+ return Validity.OK if record.type == "oauth" else Validity.INVALID
41
+
42
+ def refresh(self, record: AuthRecord) -> AuthRecord | None:
43
+ return None # no verified standalone refresh flow for this provider yet
44
+
45
+ def fetch_usage(self, record: AuthRecord) -> None:
46
+ return None # unreachable: usage_record_types is empty