optio-opencode 0.2.4__tar.gz → 0.3.1__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.2.4 → optio_opencode-0.3.1}/PKG-INFO +3 -2
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/pyproject.toml +6 -2
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/__init__.py +2 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/conversation.py +43 -1
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/host_actions.py +13 -4
- optio_opencode-0.3.1/src/optio_opencode/info.py +7 -0
- optio_opencode-0.3.1/src/optio_opencode/model_probe.py +141 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/session.py +352 -9
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/types.py +103 -31
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/PKG-INFO +3 -2
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/SOURCES.txt +7 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/requires.txt +2 -1
- optio_opencode-0.3.1/tests/test_agent_info.py +14 -0
- optio_opencode-0.3.1/tests/test_config_harmonization.py +79 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_conversation_driver.py +118 -1
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_conversation_session.py +4 -0
- optio_opencode-0.3.1/tests/test_conversation_ui_model.py +124 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_conversation_ui_session.py +90 -2
- optio_opencode-0.3.1/tests/test_conversation_upload.py +164 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_actions.py +13 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_primitives_remote.py +4 -54
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_remote_resume.py +3 -55
- optio_opencode-0.3.1/tests/test_model_probe.py +275 -0
- optio_opencode-0.3.1/tests/test_model_probe_wiring.py +140 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_local.py +6 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_remote.py +6 -54
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_resume.py +6 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_types.py +60 -0
- optio_opencode-0.2.4/tests/test_conversation_ui_model.py +0 -78
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/README.md +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/setup.cfg +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/cred_watcher.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/prompt.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/seed_manifest.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/verify.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_agent_sender_opencode.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_conversation_config.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_cred_watcher.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_file_download.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_file_upload.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_local.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_prompt.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_purge_seed.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_resume_sentence_opencode.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_sanity.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_seed_config.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_blob_hooks.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_hooks.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_seed.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_seed_saveback.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_smart_install.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_snapshots.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_verify_seed.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: optio-opencode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -22,12 +22,13 @@ Requires-Python: >=3.11
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
Requires-Dist: optio-core<0.4,>=0.3
|
|
24
24
|
Requires-Dist: optio-host<0.3,>=0.2
|
|
25
|
-
Requires-Dist: optio-agents<0.
|
|
25
|
+
Requires-Dist: optio-agents<0.5,>=0.4
|
|
26
26
|
Requires-Dist: asyncssh>=2.14
|
|
27
27
|
Requires-Dist: aiohttp>=3.9
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
30
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-xdist>=3.6; extra == "dev"
|
|
31
32
|
|
|
32
33
|
# optio-opencode
|
|
33
34
|
|
|
@@ -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.3.1"
|
|
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"
|
|
@@ -30,7 +30,7 @@ dependencies = [
|
|
|
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
32
|
"optio-host>=0.2,<0.3",
|
|
33
|
-
"optio-agents>=0.
|
|
33
|
+
"optio-agents>=0.4,<0.5",
|
|
34
34
|
"asyncssh>=2.14",
|
|
35
35
|
"aiohttp>=3.9",
|
|
36
36
|
]
|
|
@@ -39,6 +39,7 @@ dependencies = [
|
|
|
39
39
|
dev = [
|
|
40
40
|
"pytest>=8.0",
|
|
41
41
|
"pytest-asyncio>=0.23",
|
|
42
|
+
"pytest-xdist>=3.6",
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
[project.urls]
|
|
@@ -52,3 +53,6 @@ where = ["src"]
|
|
|
52
53
|
[tool.pytest.ini_options]
|
|
53
54
|
asyncio_mode = "auto"
|
|
54
55
|
testpaths = ["tests"]
|
|
56
|
+
markers = [
|
|
57
|
+
"serial: spawn-heavy, timing-fragile test that must run in a final non-parallel phase",
|
|
58
|
+
]
|
|
@@ -8,6 +8,7 @@ from optio_host import (
|
|
|
8
8
|
RunResult,
|
|
9
9
|
SSHConfig,
|
|
10
10
|
)
|
|
11
|
+
from optio_opencode.info import AGENT_INFO
|
|
11
12
|
from optio_opencode.session import create_opencode_task, run_opencode_session
|
|
12
13
|
from optio_opencode.types import (
|
|
13
14
|
ConversationMode,
|
|
@@ -36,6 +37,7 @@ from optio_opencode.verify import verify_and_refresh_seed
|
|
|
36
37
|
_logging.getLogger("asyncssh").setLevel(_logging.WARNING)
|
|
37
38
|
|
|
38
39
|
__all__ = [
|
|
40
|
+
"AGENT_INFO",
|
|
39
41
|
"create_opencode_task",
|
|
40
42
|
"run_opencode_session",
|
|
41
43
|
"DeliverableCallback",
|
|
@@ -37,6 +37,8 @@ from optio_agents.conversation import (
|
|
|
37
37
|
PermissionRequest,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
+
from .info import AGENT_INFO
|
|
41
|
+
|
|
40
42
|
_LOG = logging.getLogger(__name__)
|
|
41
43
|
|
|
42
44
|
# Reconnect backoff for the SSE reader (capped; the session body cancels the
|
|
@@ -49,7 +51,9 @@ class OpencodeConversation:
|
|
|
49
51
|
|
|
50
52
|
def __init__(
|
|
51
53
|
self, *, port: int, password: str, session_id: str, directory: str,
|
|
54
|
+
agent_label: str = AGENT_INFO.slug,
|
|
52
55
|
) -> None:
|
|
56
|
+
self._agent_label = agent_label
|
|
53
57
|
self._base = f"http://127.0.0.1:{port}"
|
|
54
58
|
self._auth = aiohttp.BasicAuth("opencode", password)
|
|
55
59
|
self._session_id = session_id
|
|
@@ -57,6 +61,12 @@ class OpencodeConversation:
|
|
|
57
61
|
# The server resolves instance directories (symlinks etc.) before
|
|
58
62
|
# stamping them onto /global/event frames; compare against realpath too.
|
|
59
63
|
self._directory_real = os.path.realpath(directory)
|
|
64
|
+
# Active model as "providerID/modelID" (or None → let opencode pick its
|
|
65
|
+
# own default). The operator UI attaches the model itself, client-side,
|
|
66
|
+
# per prompt_async; this field is used by the server-side model probe,
|
|
67
|
+
# which drives a throwaway conversation and must run each turn under a
|
|
68
|
+
# specific model. Split back into {providerID, modelID} in send().
|
|
69
|
+
self.current_model_id: str | None = None
|
|
60
70
|
self._pending = False
|
|
61
71
|
self._closed = asyncio.Event()
|
|
62
72
|
self._close_reason: str | None = None
|
|
@@ -73,6 +83,12 @@ class OpencodeConversation:
|
|
|
73
83
|
self._part_texts: dict[str, dict[str, str]] = {}
|
|
74
84
|
self._dispatcher_task: asyncio.Task | None = None
|
|
75
85
|
self._http: aiohttp.ClientSession | None = None
|
|
86
|
+
# Set once the reader owns an aiohttp session AND the /global/event
|
|
87
|
+
# stream is connected — i.e. it is safe to send() and expect the answer/
|
|
88
|
+
# error events back. The model probe waits on this before its first turn
|
|
89
|
+
# (otherwise it would send() while _http is still None and mark every
|
|
90
|
+
# model unusable — a startup race).
|
|
91
|
+
self._ready = asyncio.Event()
|
|
76
92
|
|
|
77
93
|
# -- wiring ------------------------------------------------------------
|
|
78
94
|
|
|
@@ -111,6 +127,10 @@ class OpencodeConversation:
|
|
|
111
127
|
self._url("/global/event"), timeout=timeout,
|
|
112
128
|
) as resp:
|
|
113
129
|
resp.raise_for_status()
|
|
130
|
+
# The stream is live and _http exists: sends will now reach the
|
|
131
|
+
# server and their answer/error events will arrive. On a reconnect
|
|
132
|
+
# this is already set (harmless).
|
|
133
|
+
self._ready.set()
|
|
114
134
|
# A (re)connect can postdate permission.asked events we never saw.
|
|
115
135
|
await self._sweep_permissions()
|
|
116
136
|
data_lines: list[str] = []
|
|
@@ -280,12 +300,21 @@ class OpencodeConversation:
|
|
|
280
300
|
async def send(self, text: str) -> None:
|
|
281
301
|
if self._closed.is_set():
|
|
282
302
|
raise ConversationClosed(self._close_reason or "conversation closed")
|
|
303
|
+
if self._http is None:
|
|
304
|
+
# The reader hasn't created the aiohttp session yet (or was never
|
|
305
|
+
# started). Fail loudly instead of an opaque AttributeError.
|
|
306
|
+
raise ConversationClosed("conversation not started")
|
|
283
307
|
self._pending = True
|
|
308
|
+
body: dict = {"parts": [{"type": "text", "text": text}]}
|
|
309
|
+
if self.current_model_id:
|
|
310
|
+
prov, _, mod = self.current_model_id.partition("/")
|
|
311
|
+
if prov and mod:
|
|
312
|
+
body["model"] = {"providerID": prov, "modelID": mod}
|
|
284
313
|
try:
|
|
285
314
|
async with self._http.post(
|
|
286
315
|
self._url(f"/session/{self._session_id}/prompt_async"),
|
|
287
316
|
params=self._params(),
|
|
288
|
-
json=
|
|
317
|
+
json=body,
|
|
289
318
|
) as resp:
|
|
290
319
|
resp.raise_for_status()
|
|
291
320
|
except (aiohttp.ClientError, ConnectionError) as exc:
|
|
@@ -327,6 +356,19 @@ class OpencodeConversation:
|
|
|
327
356
|
) as resp:
|
|
328
357
|
resp.raise_for_status()
|
|
329
358
|
|
|
359
|
+
async def set_active_model(self, model: str) -> None:
|
|
360
|
+
"""Record the model (``"providerID/modelID"``) attached to subsequent
|
|
361
|
+
sends. Used by the server-side model probe; the operator UI drives its
|
|
362
|
+
own selection client-side (see set_control)."""
|
|
363
|
+
self.current_model_id = model
|
|
364
|
+
|
|
365
|
+
async def set_control(self, control_id: str, value: "str | bool") -> None:
|
|
366
|
+
# opencode's session controls (model + reasoning_effort) are both
|
|
367
|
+
# resolved and applied entirely UI-local — attached per-prompt to
|
|
368
|
+
# prompt_async (``model`` / ``variant``); there is no server round-trip,
|
|
369
|
+
# so this satisfies the Conversation protocol as a no-op for either id.
|
|
370
|
+
return None
|
|
371
|
+
|
|
330
372
|
async def close(self) -> None:
|
|
331
373
|
self.close_requested.set()
|
|
332
374
|
|
|
@@ -14,7 +14,7 @@ binary cache on the worker
|
|
|
14
14
|
(``OPENCODE_CACHE_DIR`` / ``${XDG_CACHE_HOME:-$HOME/.cache}/optio-opencode/bin``,
|
|
15
15
|
resolved per host — never the host home bin dir) and is overridable via the
|
|
16
16
|
``install_dir`` keyword argument on the public entry points; consumers
|
|
17
|
-
expose this as ``OpencodeTaskConfig.
|
|
17
|
+
expose this as ``OpencodeTaskConfig.install_dir``.
|
|
18
18
|
No isinstance branches.
|
|
19
19
|
"""
|
|
20
20
|
|
|
@@ -28,6 +28,8 @@ from typing import TYPE_CHECKING, Callable
|
|
|
28
28
|
|
|
29
29
|
from optio_host.host import ProcessHandle
|
|
30
30
|
|
|
31
|
+
from optio_opencode.info import AGENT_INFO
|
|
32
|
+
|
|
31
33
|
if TYPE_CHECKING:
|
|
32
34
|
from optio_host.host import Host
|
|
33
35
|
|
|
@@ -52,7 +54,7 @@ _OPENCODE_CACHE_SHELL_DEFAULT = (
|
|
|
52
54
|
async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
|
|
53
55
|
"""Resolve the opencode binary-cache dir as an absolute path on the worker.
|
|
54
56
|
|
|
55
|
-
``install_dir`` (config.
|
|
57
|
+
``install_dir`` (config.install_dir) overrides. Else the worker's
|
|
56
58
|
OPENCODE_CACHE_DIR / XDG_CACHE_HOME / $HOME decide it — resolved via a shell
|
|
57
59
|
echo so RemoteHost gets the remote cache. Resolved from the worker's REAL env
|
|
58
60
|
(this runs before per-task XDG isolation), so the cache stays shared and
|
|
@@ -259,7 +261,7 @@ async def ensure_opencode_installed(
|
|
|
259
261
|
# dashboard shows it working rather than stuck at 0% while the install
|
|
260
262
|
# check (and any subsequent download child task) runs.
|
|
261
263
|
if report_progress is not None:
|
|
262
|
-
report_progress(None, "Checking
|
|
264
|
+
report_progress(None, f"Checking {AGENT_INFO.name} installation…")
|
|
263
265
|
kind, url = await _smart_install_check(host, install_dir=resolved_install_dir)
|
|
264
266
|
if kind == "ok":
|
|
265
267
|
# Resolve the on-PATH path. Login shell so ``$HOME``-relative
|
|
@@ -286,7 +288,7 @@ async def ensure_opencode_installed(
|
|
|
286
288
|
)
|
|
287
289
|
assert url is not None # _smart_install_check guarantees
|
|
288
290
|
if report_progress is not None:
|
|
289
|
-
report_progress(None, "Installing
|
|
291
|
+
report_progress(None, f"Installing {AGENT_INFO.name}…")
|
|
290
292
|
return await _install_opencode_from_zip(
|
|
291
293
|
host, download, url, install_dir=resolved_install_dir,
|
|
292
294
|
)
|
|
@@ -488,9 +490,16 @@ async def launch_opencode(
|
|
|
488
490
|
# The browser-suppression env (PATH prepend + BROWSER) comes from extra_env.
|
|
489
491
|
# The HOME/XDG isolation env (from _isolation_env) points opencode's
|
|
490
492
|
# auth/config/data at <workdir>/home so per-task seeding works.
|
|
493
|
+
# OPENCODE_DISABLE_AUTOUPDATE=1 disables opencode's in-agent auto-updater
|
|
494
|
+
# (the binary's update fn early-returns on this var). A managed wrapper pins
|
|
495
|
+
# the binary via optio's own smart-install.sh --check, so a self-download
|
|
496
|
+
# would fight our version control and can stall/bloat a session mid-run;
|
|
497
|
+
# disabling it here is safe because the cache stays fresh independently.
|
|
498
|
+
# Set as a base default; a caller extra_env may still override.
|
|
491
499
|
env = {
|
|
492
500
|
**_isolation_env(host),
|
|
493
501
|
"OPENCODE_DB": f"{host.taskdir}/opencode.db",
|
|
502
|
+
"OPENCODE_DISABLE_AUTOUPDATE": "1",
|
|
494
503
|
**(extra_env or {}),
|
|
495
504
|
}
|
|
496
505
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""opencode's conversation-mode model-availability probe (+ per-seed cache).
|
|
2
|
+
|
|
3
|
+
opencode's ``GET /config/providers`` lists every configured provider/model with
|
|
4
|
+
no per-account entitlement flag. Selecting a model the account cannot use is not
|
|
5
|
+
rejected up front — the turn simply ERRORS (e.g. a ChatGPT-account Codex auth
|
|
6
|
+
rejects ``gpt-5.5-pro`` with ``Bad Request: … not supported when using Codex with
|
|
7
|
+
a ChatGPT account``). So we probe: send each model a trivial question and keep the
|
|
8
|
+
ones that answer without erroring. The result is cached per seed.
|
|
9
|
+
|
|
10
|
+
The mechanics (cache, disabled-map surfacing, the generic probe loop) live in the
|
|
11
|
+
engine-agnostic ``optio_agents.model_probe`` — shared with optio-cursor. This
|
|
12
|
+
module pins opencode's usable signal (an answer arrived and the turn did NOT
|
|
13
|
+
error), its ``/config/providers`` id enumeration, its cache-collection suffix, and
|
|
14
|
+
its disabled-reason string.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from optio_agents import model_probe as _shared
|
|
19
|
+
|
|
20
|
+
PROBE_QUESTION = _shared.PROBE_QUESTION
|
|
21
|
+
PROBE_CACHE_SUFFIX = "_opencode_model_probe"
|
|
22
|
+
PROBE_CACHE_TTL = _shared.PROBE_CACHE_TTL
|
|
23
|
+
# Surfaced in the picker (as a tooltip) for a model the probe found unusable —
|
|
24
|
+
# the account/plan can't run it (the provider rejected the probe turn).
|
|
25
|
+
DISABLED_REASON = "Not usable with this account (the provider rejected it)"
|
|
26
|
+
|
|
27
|
+
probe_cache_key = _shared.probe_cache_key
|
|
28
|
+
run_probe_child = _shared.run_probe_child
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _error_from_event(ev: dict):
|
|
32
|
+
"""Map one raw conversation event to an error object, or None. opencode
|
|
33
|
+
surfaces a failed turn either as a ``session.error`` event or as a completed
|
|
34
|
+
assistant ``message.updated`` whose ``info`` carries an ``error`` field."""
|
|
35
|
+
t = ev.get("type")
|
|
36
|
+
props = ev.get("properties") or {}
|
|
37
|
+
if t == "session.error":
|
|
38
|
+
return props.get("error") or {"message": "session error"}
|
|
39
|
+
if t == "message.updated":
|
|
40
|
+
info = props.get("info") or {}
|
|
41
|
+
if info.get("role") == "assistant" and info.get("error"):
|
|
42
|
+
return info.get("error")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _usable(answer, error) -> bool:
|
|
47
|
+
"""A model is usable iff the probe turn produced an answer AND did not end in
|
|
48
|
+
an error (a timeout leaves answer="" and error=None → unusable)."""
|
|
49
|
+
return error is None and bool((answer or "").strip())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def probe_models(conversation, model_ids, *, per_model_timeout: float = 30.0, report=None):
|
|
53
|
+
"""Return ``{model_id: usable}`` for opencode's error-signal usable-check."""
|
|
54
|
+
return await _shared.probe_models(
|
|
55
|
+
conversation, model_ids,
|
|
56
|
+
usable_check=_usable,
|
|
57
|
+
set_model=lambda conv, mid: conv.set_active_model(mid),
|
|
58
|
+
error_from_event=_error_from_event,
|
|
59
|
+
per_model_timeout=per_model_timeout,
|
|
60
|
+
report=report,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_model_ids(providers_json) -> list[str]:
|
|
65
|
+
"""Enumerate ``"providerID/modelID"`` ids from a ``GET /config/providers``
|
|
66
|
+
response (Python peer of the UI's ``parseProviders``). Shape:
|
|
67
|
+
``{providers: [{id, name, models: {<modelId>: {id, providerID, name}}}], …}``."""
|
|
68
|
+
if not isinstance(providers_json, dict):
|
|
69
|
+
return []
|
|
70
|
+
providers = providers_json.get("providers")
|
|
71
|
+
if not isinstance(providers, list):
|
|
72
|
+
return []
|
|
73
|
+
ids: list[str] = []
|
|
74
|
+
for p in providers:
|
|
75
|
+
if not isinstance(p, dict):
|
|
76
|
+
continue
|
|
77
|
+
pid = p.get("id")
|
|
78
|
+
models = p.get("models")
|
|
79
|
+
if not isinstance(models, dict):
|
|
80
|
+
continue
|
|
81
|
+
for m in models.values():
|
|
82
|
+
m = m if isinstance(m, dict) else {}
|
|
83
|
+
prov = m.get("providerID") or pid
|
|
84
|
+
mod = m.get("id")
|
|
85
|
+
if prov and mod:
|
|
86
|
+
ids.append(f"{prov}/{mod}")
|
|
87
|
+
return ids
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_model_variants(providers_json) -> dict[str, list[str]]:
|
|
91
|
+
"""Map ``"providerID/modelID" -> [ordered variant keys]`` for every model
|
|
92
|
+
that declares a non-empty ``variants`` map in a ``GET /config/providers``
|
|
93
|
+
response. opencode's per-model ``variants`` are named reasoning-effort
|
|
94
|
+
presets (e.g. ``{"low": …, "medium": …, "high": …}``); their KEYS are the
|
|
95
|
+
graded effort levels the widget builds its effort slider from.
|
|
96
|
+
|
|
97
|
+
``parse_model_ids`` (the probe's id enumeration) discards ``variants``; this
|
|
98
|
+
sibling reads the keys the probe throws away. Models with no ``variants``
|
|
99
|
+
map are omitted entirely — an unsupported model ⇒ no effort control."""
|
|
100
|
+
if not isinstance(providers_json, dict):
|
|
101
|
+
return {}
|
|
102
|
+
providers = providers_json.get("providers")
|
|
103
|
+
if not isinstance(providers, list):
|
|
104
|
+
return {}
|
|
105
|
+
out: dict[str, list[str]] = {}
|
|
106
|
+
for p in providers:
|
|
107
|
+
if not isinstance(p, dict):
|
|
108
|
+
continue
|
|
109
|
+
pid = p.get("id")
|
|
110
|
+
models = p.get("models")
|
|
111
|
+
if not isinstance(models, dict):
|
|
112
|
+
continue
|
|
113
|
+
for m in models.values():
|
|
114
|
+
m = m if isinstance(m, dict) else {}
|
|
115
|
+
prov = m.get("providerID") or pid
|
|
116
|
+
mod = m.get("id")
|
|
117
|
+
variants = m.get("variants")
|
|
118
|
+
if not (prov and mod and isinstance(variants, dict) and variants):
|
|
119
|
+
continue
|
|
120
|
+
keys = [str(k) for k in variants.keys()]
|
|
121
|
+
if keys:
|
|
122
|
+
out[f"{prov}/{mod}"] = keys
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def disabled_map(usable: dict[str, bool]) -> dict[str, str]:
|
|
127
|
+
"""``{model_id: DISABLED_REASON}`` for every unusable id — published in
|
|
128
|
+
widgetData so OpencodeView greys those models in its client-fetched picker."""
|
|
129
|
+
return _shared.disabled_map(usable, DISABLED_REASON)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def load_probe_cache(db, prefix: str, seed_id: str, *, now=None):
|
|
133
|
+
return await _shared.load_probe_cache(
|
|
134
|
+
db, prefix, seed_id, suffix=PROBE_CACHE_SUFFIX, now=now,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def save_probe_cache(db, prefix: str, seed_id: str, usable: dict[str, bool], *, now=None):
|
|
139
|
+
return await _shared.save_probe_cache(
|
|
140
|
+
db, prefix, seed_id, usable, suffix=PROBE_CACHE_SUFFIX, now=now,
|
|
141
|
+
)
|