optio-opencode 0.4.0__tar.gz → 0.5.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.
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/PKG-INFO +7 -4
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/pyproject.toml +10 -4
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/__init__.py +3 -0
- optio_opencode-0.5.0/src/optio_opencode/account.py +93 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/cred_watcher.py +63 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/__init__.py +13 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/anthropic.py +33 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/deepseek.py +91 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/openai.py +33 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/xai.py +100 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/session.py +72 -33
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/types.py +23 -23
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/verify.py +46 -10
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/PKG-INFO +7 -4
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/SOURCES.txt +9 -0
- optio_opencode-0.5.0/src/optio_opencode.egg-info/requires.txt +13 -0
- optio_opencode-0.5.0/tests/test_account.py +160 -0
- optio_opencode-0.5.0/tests/test_account_xai_apikey.py +115 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_cred_watcher.py +62 -0
- optio_opencode-0.5.0/tests/test_provider_deepseek.py +93 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_session_resume.py +37 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_session_seed.py +229 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_session_seed_saveback.py +80 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_verify_seed.py +61 -2
- optio_opencode-0.4.0/src/optio_opencode.egg-info/requires.txt +0 -10
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/README.md +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/setup.cfg +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/conversation.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/fs_allowlist.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/host_actions.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/info.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/model_probe.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/prompt.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/seed_manifest.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_agent_info.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_agent_sender_opencode.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_claustrum.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_config_harmonization.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_conversation_config.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_conversation_driver.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_conversation_session.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_conversation_ui_model.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_conversation_ui_session.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_conversation_upload.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_file_download.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_file_upload.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_host_actions.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_host_local.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_host_primitives_remote.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_host_remote_resume.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_model_probe.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_model_probe_wiring.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_prompt.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_purge_seed.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_resume_sentence_opencode.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_sanity.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_seed_config.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_session_blob_hooks.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_session_hooks.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_session_local.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_session_remote.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_smart_install.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.0}/tests/test_snapshots.py +0 -0
- {optio_opencode-0.4.0 → optio_opencode-0.5.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.
|
|
3
|
+
Version: 0.5.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.
|
|
24
|
-
Requires-Dist: optio-host<0.
|
|
25
|
-
Requires-Dist: optio-agents<0.
|
|
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.6,>=0.5
|
|
27
|
+
Requires-Dist: optio-codex<0.4,>=0.3
|
|
28
|
+
Requires-Dist: optio-grok<0.4,>=0.3
|
|
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.
|
|
7
|
+
version = "0.5.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.
|
|
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.
|
|
33
|
-
"optio-agents>=0.
|
|
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.5,<0.6",
|
|
38
|
+
"optio-codex>=0.3,<0.4",
|
|
39
|
+
"optio-grok>=0.3,<0.4",
|
|
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
|