optio-opencode 0.4.0__tar.gz → 0.6.0__tar.gz

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.
Files changed (69) hide show
  1. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/PKG-INFO +7 -4
  2. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/pyproject.toml +10 -4
  3. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/__init__.py +3 -0
  4. optio_opencode-0.6.0/src/optio_opencode/account.py +93 -0
  5. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/cred_watcher.py +63 -0
  6. optio_opencode-0.6.0/src/optio_opencode/providers/__init__.py +13 -0
  7. optio_opencode-0.6.0/src/optio_opencode/providers/anthropic.py +33 -0
  8. optio_opencode-0.6.0/src/optio_opencode/providers/deepseek.py +91 -0
  9. optio_opencode-0.6.0/src/optio_opencode/providers/openai.py +33 -0
  10. optio_opencode-0.6.0/src/optio_opencode/providers/xai.py +100 -0
  11. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/session.py +72 -33
  12. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/types.py +23 -23
  13. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/verify.py +46 -10
  14. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode.egg-info/PKG-INFO +7 -4
  15. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode.egg-info/SOURCES.txt +9 -0
  16. optio_opencode-0.6.0/src/optio_opencode.egg-info/requires.txt +13 -0
  17. optio_opencode-0.6.0/tests/test_account.py +160 -0
  18. optio_opencode-0.6.0/tests/test_account_xai_apikey.py +115 -0
  19. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_cred_watcher.py +62 -0
  20. optio_opencode-0.6.0/tests/test_provider_deepseek.py +93 -0
  21. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_session_resume.py +37 -0
  22. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_session_seed.py +229 -0
  23. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_session_seed_saveback.py +80 -0
  24. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_verify_seed.py +61 -2
  25. optio_opencode-0.4.0/src/optio_opencode.egg-info/requires.txt +0 -10
  26. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/README.md +0 -0
  27. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/setup.cfg +0 -0
  28. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/conversation.py +0 -0
  29. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/fs_allowlist.py +0 -0
  30. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/host_actions.py +0 -0
  31. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/info.py +0 -0
  32. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/model_probe.py +0 -0
  33. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/prompt.py +0 -0
  34. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/seed_manifest.py +0 -0
  35. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode/snapshots.py +0 -0
  36. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  37. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
  38. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_agent_info.py +0 -0
  39. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_agent_sender_opencode.py +0 -0
  40. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_claustrum.py +0 -0
  41. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_config_harmonization.py +0 -0
  42. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_conversation_config.py +0 -0
  43. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_conversation_driver.py +0 -0
  44. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_conversation_session.py +0 -0
  45. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_conversation_ui_model.py +0 -0
  46. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_conversation_ui_session.py +0 -0
  47. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_conversation_upload.py +0 -0
  48. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_file_download.py +0 -0
  49. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_file_upload.py +0 -0
  50. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_host_actions.py +0 -0
  51. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_host_local.py +0 -0
  52. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_host_primitives_local.py +0 -0
  53. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_host_primitives_remote.py +0 -0
  54. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_host_remote_resume.py +0 -0
  55. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_host_resume.py +0 -0
  56. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_model_probe.py +0 -0
  57. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_model_probe_wiring.py +0 -0
  58. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_prompt.py +0 -0
  59. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_purge_seed.py +0 -0
  60. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_resume_sentence_opencode.py +0 -0
  61. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_sanity.py +0 -0
  62. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_seed_config.py +0 -0
  63. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_session_blob_hooks.py +0 -0
  64. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_session_hooks.py +0 -0
  65. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_session_local.py +0 -0
  66. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_session_remote.py +0 -0
  67. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_smart_install.py +0 -0
  68. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_snapshots.py +0 -0
  69. {optio_opencode-0.4.0 → optio_opencode-0.6.0}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -20,9 +20,12 @@ Classifier: Topic :: Software Development :: Code Generators
20
20
  Classifier: Framework :: AsyncIO
21
21
  Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
- Requires-Dist: optio-core<0.4,>=0.3
24
- Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.6,>=0.5
23
+ Requires-Dist: optio-core<0.5,>=0.4
24
+ Requires-Dist: optio-host<0.4,>=0.3
25
+ Requires-Dist: optio-agents<0.7,>=0.6
26
+ Requires-Dist: optio-claudecode<0.7,>=0.6
27
+ Requires-Dist: optio-codex<0.5,>=0.4
28
+ Requires-Dist: optio-grok<0.5,>=0.4
26
29
  Requires-Dist: asyncssh>=2.14
27
30
  Requires-Dist: aiohttp>=3.9
28
31
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-opencode"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  description = "Run opencode web as an optio task; local subprocess or remote via SSH."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -26,11 +26,17 @@ classifiers = [
26
26
  "Framework :: AsyncIO",
27
27
  ]
28
28
  dependencies = [
29
- "optio-core>=0.3,<0.4",
29
+ "optio-core>=0.4,<0.5",
30
30
  # 0.1.1 introduces the bind_addr kwarg on Host.establish_tunnel —
31
31
  # required for OPTIO_WIDGET_TUNNEL_BIND to work.
32
- "optio-host>=0.2,<0.3",
33
- "optio-agents>=0.5,<0.6",
32
+ "optio-host>=0.3,<0.4",
33
+ "optio-agents>=0.6,<0.7",
34
+ # The account meta-analyzer's per-provider handlers reuse the extracted
35
+ # vendor map helpers (account_from_oauth_token / account_from_openai /
36
+ # account_from_xai) rather than reimplement each vendor's fetch+map.
37
+ "optio-claudecode>=0.6,<0.7",
38
+ "optio-codex>=0.4,<0.5",
39
+ "optio-grok>=0.4,<0.5",
34
40
  "asyncssh>=2.14",
35
41
  "aiohttp>=3.9",
36
42
  ]
@@ -8,6 +8,7 @@ from optio_host import (
8
8
  RunResult,
9
9
  SSHConfig,
10
10
  )
11
+ from optio_opencode.account import analyze_accounts, resolve_capture_accounts
11
12
  from optio_opencode.info import AGENT_INFO
12
13
  from optio_opencode.session import create_opencode_task, run_opencode_session
13
14
  from optio_opencode.types import (
@@ -58,4 +59,6 @@ __all__ = [
58
59
  "ConversationMode",
59
60
  "ToolVerbosity",
60
61
  "verify_and_refresh_seed",
62
+ "analyze_accounts",
63
+ "resolve_capture_accounts",
61
64
  ]
@@ -0,0 +1,93 @@
1
+ """opencode account meta-analyzer.
2
+
3
+ opencode is a model-selection TUI over third-party providers: a seed's
4
+ ``auth.json`` holds credentials for whichever provider(s) the operator logged
5
+ into. So an opencode seed's "account" is really **one account per configured
6
+ provider**. Unlike the single-vendor wrappers, opencode needs a meta-analyzer:
7
+ dispatch each provider entry to its handler (which reuses the vendor map
8
+ helpers), aggregate into a ``list[AccountInfo]``.
9
+
10
+ Every configured provider yields exactly one ``AccountInfo`` — an analyzed one
11
+ when a handler resolves it, else a placeholder (``"Unknown account · <provider>"``)
12
+ so nothing silently disappears from the pool popover. Fail-soft throughout: a
13
+ provider handler never breaks the others; empty/unreadable auth → ``[]``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ from dataclasses import replace
22
+
23
+ from optio_agents.account import EMPTY, AccountInfo
24
+ from optio_opencode.providers import anthropic, deepseek, openai, xai
25
+
26
+ _LOG = logging.getLogger(__name__)
27
+
28
+ # models.dev provider id → per-provider handler. Providers absent here (the
29
+ # ~146 remaining) yield a placeholder AccountInfo.
30
+ _REGISTRY = {
31
+ "anthropic": anthropic.handle,
32
+ "openai": openai.handle,
33
+ "xai": xai.handle, # oauth (grok reuse) + api-key (/v1/api-key) branches
34
+ "deepseek": deepseek.handle, # api-key: /user/balance
35
+ }
36
+
37
+ _AUTH_RELPATH = "home/.local/share/opencode/auth.json"
38
+
39
+
40
+ def _placeholder(provider_id: str, entry: dict) -> AccountInfo:
41
+ """A stand-in for a provider we do not (yet) analyze. Carries the ``provider``
42
+ so the frame's summary renders ``"<provider> · unknown account"`` — attributed
43
+ to its provider even though nothing was fetched."""
44
+ return AccountInfo(
45
+ provider=provider_id,
46
+ account_id=entry.get("accountId"),
47
+ raw={"provider": provider_id, "unanalyzed": True},
48
+ )
49
+
50
+
51
+ async def analyze_accounts(auth: dict) -> "list[AccountInfo]":
52
+ """Map an opencode ``auth.json`` dict → one ``AccountInfo`` per provider.
53
+
54
+ For each ``provider_id -> entry``: skip non-dict entries; dispatch to the
55
+ registered handler (fail-soft); append the analyzed account, or a
56
+ placeholder when there is no handler / it declines / it fails / it returns
57
+ the ``EMPTY`` sentinel. Empty or non-dict auth → ``[]``."""
58
+ if not isinstance(auth, dict):
59
+ return []
60
+ items = [(pid, e) for pid, e in auth.items() if isinstance(e, dict)]
61
+
62
+ async def _one(provider_id: str, entry: dict) -> AccountInfo:
63
+ handler = _REGISTRY.get(provider_id)
64
+ info: AccountInfo | None = None
65
+ if handler is not None:
66
+ try:
67
+ info = await handler(entry)
68
+ except Exception: # noqa: BLE001 — one bad provider never drops the rest
69
+ _LOG.exception("opencode provider handler failed: %s", provider_id)
70
+ info = None
71
+ if info is not None and info != EMPTY:
72
+ # Attribute every analyzed account to its provider so the summary is
73
+ # unambiguous across a multi-provider seed ("openai · Plan: …").
74
+ return replace(info, provider=provider_id)
75
+ return _placeholder(provider_id, entry)
76
+
77
+ # Providers analyzed CONCURRENTLY — each hits a different vendor with its own
78
+ # (up to 15s) HTTP timeout, so a multi-provider seed must not pay the SUM of
79
+ # those; the verify/verify-free RPC deadline can't absorb it (see the
80
+ # asyncio.wait_for budget in verify.py).
81
+ return list(await asyncio.gather(*(_one(pid, e) for pid, e in items)))
82
+
83
+
84
+ async def resolve_capture_accounts(host) -> "list[AccountInfo]":
85
+ """Live-host capture variant: read the isolated HOME's ``auth.json`` and
86
+ analyze it. Fail-soft → ``[]`` on any read/parse failure."""
87
+ path = f"{host.workdir.rstrip('/')}/{_AUTH_RELPATH}"
88
+ try:
89
+ raw = await host.fetch_bytes_from_host(path)
90
+ auth = json.loads(raw.decode("utf-8"))
91
+ except Exception: # noqa: BLE001 — missing/unreadable/malformed auth → []
92
+ return []
93
+ return await analyze_accounts(auth)
@@ -69,6 +69,64 @@ async def capture_gate_ok(host: Host) -> bool:
69
69
  return isinstance(cfg, dict) and bool(cfg.get("model"))
70
70
 
71
71
 
72
+ class UnsliceableSeed(Exception):
73
+ """The seed's auth.json holds several providers but cannot be safely
74
+ reduced to the one backing the configured default model."""
75
+
76
+
77
+ def _provider_of(model: str | None) -> str | None:
78
+ """The provider id of a `provider/model` string, or None if it carries no
79
+ `provider/` prefix."""
80
+ if not model or "/" not in model:
81
+ return None
82
+ return model.split("/", 1)[0]
83
+
84
+
85
+ async def _read_json(host: Host, relpath: str) -> dict | None:
86
+ path = f"{host.workdir.rstrip('/')}/{relpath}"
87
+ try:
88
+ raw = await host.fetch_bytes_from_host(path)
89
+ data = json.loads(raw.decode("utf-8"))
90
+ except (FileNotFoundError, ValueError, UnicodeDecodeError):
91
+ return None
92
+ return data if isinstance(data, dict) else None
93
+
94
+
95
+ async def slim_auth_to_selected_provider(host: Host) -> bool:
96
+ """Enforce one-provider-per-seed: prune the live auth.json to the single
97
+ provider backing the configured default model (`small_model || model`),
98
+ dropping the rest. Returns True if it rewrote auth.json, False on no-op
99
+ (auth absent/invalid, or already one provider).
100
+
101
+ Raises UnsliceableSeed when the seed cannot be reduced to one provider:
102
+ the selected model has no `provider/` prefix, `model` and `small_model`
103
+ resolve to different providers, or the selected provider is absent from
104
+ auth.json. The caller decides what an un-sliceable seed means (capture
105
+ refuses it; save-back leaves the seed untouched)."""
106
+ auth = await _read_json(host, _CRED_RELPATH)
107
+ if not auth: # missing/invalid/empty -> nothing to slim
108
+ return False
109
+ if len(auth) <= 1: # already single-provider
110
+ return False
111
+
112
+ cfg = await _read_json(host, _MODEL_RELPATH) or {}
113
+ selected = _provider_of(cfg.get("model"))
114
+ if selected is None:
115
+ raise UnsliceableSeed("no provider-qualified model in opencode.json")
116
+ small = _provider_of(cfg.get("small_model"))
117
+ if small is not None and small != selected:
118
+ raise UnsliceableSeed(
119
+ f"model provider {selected!r} != small_model provider {small!r}")
120
+ if selected not in auth:
121
+ raise UnsliceableSeed(
122
+ f"selected provider {selected!r} not in auth.json {sorted(auth)}")
123
+
124
+ dropped = sorted(k for k in auth if k != selected)
125
+ await host.write_text(_CRED_RELPATH, json.dumps({selected: auth[selected]}))
126
+ _LOG.info("slimmed seed auth to provider %r; dropped %s", selected, dropped)
127
+ return True
128
+
129
+
72
130
  async def save_back_if_changed(
73
131
  ctx,
74
132
  host: Host,
@@ -81,6 +139,11 @@ async def save_back_if_changed(
81
139
  """If the live auth.json differs from `baseline` and is valid, save it
82
140
  back into the seed and return the new fingerprint. Otherwise return
83
141
  `baseline` unchanged. Never raises — save-back is best-effort."""
142
+ try:
143
+ await slim_auth_to_selected_provider(host)
144
+ except UnsliceableSeed as e:
145
+ _LOG.warning("seed %s: save-back skipped, un-sliceable auth (%s)", seed_id, e)
146
+ return baseline
84
147
  fp = await cred_fingerprint(host)
85
148
  if fp is None or fp == baseline:
86
149
  return baseline
@@ -0,0 +1,13 @@
1
+ """Per-provider account handlers for the opencode meta-analyzer.
2
+
3
+ opencode has no native account — a seed's ``auth.json`` holds credentials for
4
+ whichever third-party provider(s) the operator logged into. Each handler
5
+ ``async def handle(entry: dict) -> AccountInfo | None`` maps one provider's
6
+ auth entry into the shared ``AccountInfo`` (or ``None`` → the meta-analyzer
7
+ emits a placeholder). This phase ships the three OAuth reuse handlers
8
+ (anthropic, openai, xai), each delegating to the extracted vendor map helper.
9
+ """
10
+
11
+ from optio_opencode.providers import anthropic, openai, xai
12
+
13
+ __all__ = ["anthropic", "openai", "xai"]
@@ -0,0 +1,33 @@
1
+ """anthropic provider handler — reuses claudecode's OAuth map helper.
2
+
3
+ opencode's anthropic-oauth entry stores the same shape of OAuth access token
4
+ claudecode drives, so we delegate to ``account_from_oauth_token`` rather than
5
+ reimplement the profile+usage fetch. Imported at module scope so tests can
6
+ monkeypatch it here (patch-where-used)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from optio_agents.account import EMPTY, AccountInfo
13
+ from optio_claudecode.account import account_from_oauth_token
14
+
15
+ _LOG = logging.getLogger(__name__)
16
+
17
+
18
+ async def handle(entry: dict) -> "AccountInfo | None":
19
+ """Map an anthropic auth entry → AccountInfo, or None (→ placeholder).
20
+
21
+ This phase handles ``oauth`` only; ``api`` (raw keys expose ~no account)
22
+ and any other type decline. Fail-soft: never raises."""
23
+ if entry.get("type") != "oauth":
24
+ return None
25
+ access = entry.get("access")
26
+ if not access:
27
+ return None
28
+ try:
29
+ info = await account_from_oauth_token(access)
30
+ except Exception: # noqa: BLE001 — analysis must never break the meta-analyzer
31
+ _LOG.exception("anthropic account analysis failed")
32
+ return None
33
+ return info if info != EMPTY else None
@@ -0,0 +1,91 @@
1
+ """deepseek provider handler — api-key balance → AccountInfo.
2
+
3
+ DeepSeek is an api-key-only provider with no identity endpoint: the account's
4
+ only distinguishing surface is its prepaid balance. So this handler fetches
5
+ ``GET https://api.deepseek.com/user/balance`` (Bearer <key>) and maps the first
6
+ ``balance_infos`` row into ``plan`` — ``"DeepSeek · <total> <currency>"`` — so
7
+ the pool popover shows the balance as the account label. Identity-less
8
+ (name/email/account_id all None), no usage windows.
9
+
10
+ The urllib GET runs in an executor via the ``_fetch`` seam (mirroring the reuse
11
+ handlers' vendor helpers); tests monkeypatch ``_fetch`` — never the network.
12
+ Fail-soft throughout: any decline/error → None so the meta-analyzer emits a
13
+ placeholder instead of dropping the provider."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ import urllib.request
21
+ from urllib.error import HTTPError, URLError
22
+
23
+ from optio_agents.account import AccountInfo
24
+
25
+ _LOG = logging.getLogger(__name__)
26
+
27
+ _BALANCE_URL = "https://api.deepseek.com/user/balance"
28
+ _USER_AGENT = "optio-opencode/account (external)"
29
+ _HTTP_TIMEOUT_S = 15
30
+
31
+
32
+ # --- synchronous HTTP (run in an executor; no host) --------------------------
33
+
34
+
35
+ def _get_sync(url: str, key: str) -> "dict | None":
36
+ """Bearer-authed read-only GET → parsed JSON dict, or None on any HTTP/parse
37
+ error (fail-soft)."""
38
+ req = urllib.request.Request(
39
+ url,
40
+ headers={
41
+ "Authorization": f"Bearer {key}",
42
+ "Accept": "application/json",
43
+ "User-Agent": _USER_AGENT,
44
+ },
45
+ method="GET",
46
+ )
47
+ try:
48
+ with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_S) as resp:
49
+ data = json.loads(resp.read().decode("utf-8"))
50
+ return data if isinstance(data, dict) else None
51
+ except (HTTPError, URLError, OSError, ValueError):
52
+ return None
53
+
54
+
55
+ async def _fetch(key: str) -> "dict | None":
56
+ """Async wrapper around the sync Bearer GET (urllib in an executor). This is
57
+ the monkeypatch seam the tests replace to avoid network."""
58
+ return await asyncio.get_event_loop().run_in_executor(
59
+ None, _get_sync, _BALANCE_URL, key
60
+ )
61
+
62
+
63
+ async def handle(entry: dict) -> "AccountInfo | None":
64
+ """Map a deepseek auth entry → AccountInfo, or None (→ placeholder).
65
+
66
+ Handles ``api`` only (deepseek has no oauth). Fail-soft: never raises. None
67
+ when the type is not ``api``, the key is missing, the fetch fails, or the
68
+ balance list is empty."""
69
+ if entry.get("type") != "api":
70
+ return None
71
+ key = entry.get("key")
72
+ if not key:
73
+ return None
74
+ try:
75
+ body = await _fetch(key)
76
+ except Exception: # noqa: BLE001 — analysis must never break the meta-analyzer
77
+ _LOG.exception("deepseek account analysis failed")
78
+ return None
79
+ if not isinstance(body, dict):
80
+ return None
81
+ infos = body.get("balance_infos")
82
+ if not isinstance(infos, list) or not infos:
83
+ return None
84
+ first = infos[0]
85
+ total_balance = first.get("total_balance")
86
+ currency = first.get("currency")
87
+ return AccountInfo(
88
+ plan=f"Balance {total_balance} {currency}",
89
+ windows=(),
90
+ raw={"balance": body},
91
+ )
@@ -0,0 +1,33 @@
1
+ """openai provider handler — reuses codex's OAuth map helper.
2
+
3
+ opencode's openai-oauth entry stores ``{access, accountId}`` with **no**
4
+ ``id_token``, so identity comes from codex's ``account_from_openai`` (which
5
+ fetches ``GET /backend-api/me`` rather than decoding an id_token). Imported at
6
+ module scope so tests can monkeypatch it here (patch-where-used)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from optio_agents.account import EMPTY, AccountInfo
13
+ from optio_codex.account import account_from_openai
14
+
15
+ _LOG = logging.getLogger(__name__)
16
+
17
+
18
+ async def handle(entry: dict) -> "AccountInfo | None":
19
+ """Map an openai auth entry → AccountInfo, or None (→ placeholder).
20
+
21
+ This phase handles ``oauth`` only (raw ``api`` keys are admin-gated for
22
+ account/usage). Fail-soft: never raises."""
23
+ if entry.get("type") != "oauth":
24
+ return None
25
+ access = entry.get("access")
26
+ if not access:
27
+ return None
28
+ try:
29
+ info = await account_from_openai(access, entry.get("accountId"))
30
+ except Exception: # noqa: BLE001 — analysis must never break the meta-analyzer
31
+ _LOG.exception("openai account analysis failed")
32
+ return None
33
+ return info if info != EMPTY else None
@@ -0,0 +1,100 @@
1
+ """xai provider handler — oauth reuses grok's map helper; api reads /v1/api-key.
2
+
3
+ opencode's xai-``oauth`` entry stores the same OAuth access token grok drives,
4
+ so we delegate to ``account_from_xai`` (userinfo + subscriptions). Imported at
5
+ module scope so tests can monkeypatch it here (patch-where-used).
6
+
7
+ An xai-``api`` entry is a raw api-key (``{"type": "api", "key": "xai-..."}``).
8
+ The only identity surface it can reach is ``GET https://api.x.ai/v1/api-key``
9
+ (Bearer <key>), returning the key's metadata — owning ``user_id``/``team_id``,
10
+ the key's label ``name``, ``acls``, and block flags — but no person name, no
11
+ email, no plan, no usage. We map ``user_id`` (fallback ``team_id``) into
12
+ ``account_id`` and stash the whole body under ``raw``. The key's ``name`` field
13
+ is a key *label*, not a person, so it is deliberately NOT used as identity."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ import urllib.request
21
+ from urllib.error import HTTPError, URLError
22
+
23
+ from optio_agents.account import EMPTY, AccountInfo
24
+ from optio_grok.account import account_from_xai
25
+
26
+ _LOG = logging.getLogger(__name__)
27
+
28
+ # Raw api-key identity surface (Bearer <key>, GET). Returns the key metadata;
29
+ # no name/email/plan/usage is reachable with an api-key credential.
30
+ _API_KEY_URL = "https://api.x.ai/v1/api-key"
31
+ _USER_AGENT = "optio-opencode/account (external)"
32
+ _HTTP_TIMEOUT_S = 15
33
+
34
+
35
+ def _get_api_key_sync(key: str) -> "dict | None":
36
+ """Bearer-authed read-only GET of ``/v1/api-key`` → parsed JSON dict, or None
37
+ on any HTTP/parse error (fail-soft)."""
38
+ req = urllib.request.Request(
39
+ _API_KEY_URL,
40
+ headers={
41
+ "Authorization": f"Bearer {key}",
42
+ "Accept": "application/json",
43
+ "User-Agent": _USER_AGENT,
44
+ },
45
+ method="GET",
46
+ )
47
+ try:
48
+ with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_S) as resp:
49
+ data = json.loads(resp.read().decode("utf-8"))
50
+ return data if isinstance(data, dict) else None
51
+ except (HTTPError, URLError, OSError, ValueError):
52
+ return None
53
+
54
+
55
+ async def _fetch_api_key(key: str) -> "dict | None":
56
+ """Async wrapper around the sync ``/v1/api-key`` GET (urllib in an executor).
57
+ This is the monkeypatch seam the tests replace to avoid network."""
58
+ return await asyncio.get_event_loop().run_in_executor(None, _get_api_key_sync, key)
59
+
60
+
61
+ async def _handle_api(entry: dict) -> "AccountInfo | None":
62
+ """Map an xai ``api`` (raw api-key) entry → AccountInfo, or None. Fail-soft."""
63
+ key = entry.get("key")
64
+ if not key:
65
+ return None
66
+ try:
67
+ body = await _fetch_api_key(key)
68
+ except Exception: # noqa: BLE001 — analysis must never break the meta-analyzer
69
+ _LOG.exception("xai api-key account analysis failed")
70
+ return None
71
+ if not body:
72
+ return None
73
+ account_id = body.get("user_id") or body.get("team_id")
74
+ if not account_id:
75
+ return None
76
+ # name/email/plan intentionally None (the api-key exposes no such identity);
77
+ # windows empty (no usage surface). raw carries the full key metadata.
78
+ return AccountInfo(account_id=account_id, raw={"api_key": body})
79
+
80
+
81
+ async def handle(entry: dict) -> "AccountInfo | None":
82
+ """Map an xai auth entry → AccountInfo, or None (→ placeholder).
83
+
84
+ Dispatches on ``type``: ``oauth`` delegates to grok's map helper, ``api``
85
+ reads the raw key's metadata. Any other type declines. Fail-soft: never
86
+ raises."""
87
+ etype = entry.get("type")
88
+ if etype == "api":
89
+ return await _handle_api(entry)
90
+ if etype != "oauth":
91
+ return None
92
+ access = entry.get("access")
93
+ if not access:
94
+ return None
95
+ try:
96
+ info = await account_from_xai(access)
97
+ except Exception: # noqa: BLE001 — analysis must never break the meta-analyzer
98
+ _LOG.exception("xai account analysis failed")
99
+ return None
100
+ return info if info != EMPTY else None