optio-opencode 0.2.4__tar.gz → 0.3.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.2.4 → optio_opencode-0.3.0}/PKG-INFO +3 -2
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/pyproject.toml +6 -2
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/conversation.py +39 -1
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/host_actions.py +9 -2
- optio_opencode-0.3.0/src/optio_opencode/model_probe.py +141 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/session.py +348 -6
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/types.py +103 -31
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode.egg-info/PKG-INFO +3 -2
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode.egg-info/SOURCES.txt +5 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode.egg-info/requires.txt +2 -1
- optio_opencode-0.3.0/tests/test_config_harmonization.py +79 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_conversation_driver.py +118 -1
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_conversation_session.py +4 -0
- optio_opencode-0.3.0/tests/test_conversation_ui_model.py +124 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_conversation_ui_session.py +90 -2
- optio_opencode-0.3.0/tests/test_conversation_upload.py +164 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_host_actions.py +13 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_host_primitives_remote.py +4 -54
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_host_remote_resume.py +3 -55
- optio_opencode-0.3.0/tests/test_model_probe.py +275 -0
- optio_opencode-0.3.0/tests/test_model_probe_wiring.py +140 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_session_local.py +6 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_session_remote.py +6 -54
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_session_resume.py +6 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/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.0}/README.md +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/setup.cfg +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/__init__.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/cred_watcher.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/prompt.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/seed_manifest.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode/verify.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_agent_sender_opencode.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_conversation_config.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_cred_watcher.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_file_download.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_file_upload.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_host_local.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_prompt.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_purge_seed.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_resume_sentence_opencode.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_sanity.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_seed_config.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_session_blob_hooks.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_session_hooks.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_session_seed.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_session_seed_saveback.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_smart_install.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/tests/test_snapshots.py +0 -0
- {optio_opencode-0.2.4 → optio_opencode-0.3.0}/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.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
|
|
@@ -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.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"
|
|
@@ -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
|
+
]
|
|
@@ -57,6 +57,12 @@ class OpencodeConversation:
|
|
|
57
57
|
# The server resolves instance directories (symlinks etc.) before
|
|
58
58
|
# stamping them onto /global/event frames; compare against realpath too.
|
|
59
59
|
self._directory_real = os.path.realpath(directory)
|
|
60
|
+
# Active model as "providerID/modelID" (or None → let opencode pick its
|
|
61
|
+
# own default). The operator UI attaches the model itself, client-side,
|
|
62
|
+
# per prompt_async; this field is used by the server-side model probe,
|
|
63
|
+
# which drives a throwaway conversation and must run each turn under a
|
|
64
|
+
# specific model. Split back into {providerID, modelID} in send().
|
|
65
|
+
self.current_model_id: str | None = None
|
|
60
66
|
self._pending = False
|
|
61
67
|
self._closed = asyncio.Event()
|
|
62
68
|
self._close_reason: str | None = None
|
|
@@ -73,6 +79,12 @@ class OpencodeConversation:
|
|
|
73
79
|
self._part_texts: dict[str, dict[str, str]] = {}
|
|
74
80
|
self._dispatcher_task: asyncio.Task | None = None
|
|
75
81
|
self._http: aiohttp.ClientSession | None = None
|
|
82
|
+
# Set once the reader owns an aiohttp session AND the /global/event
|
|
83
|
+
# stream is connected — i.e. it is safe to send() and expect the answer/
|
|
84
|
+
# error events back. The model probe waits on this before its first turn
|
|
85
|
+
# (otherwise it would send() while _http is still None and mark every
|
|
86
|
+
# model unusable — a startup race).
|
|
87
|
+
self._ready = asyncio.Event()
|
|
76
88
|
|
|
77
89
|
# -- wiring ------------------------------------------------------------
|
|
78
90
|
|
|
@@ -111,6 +123,10 @@ class OpencodeConversation:
|
|
|
111
123
|
self._url("/global/event"), timeout=timeout,
|
|
112
124
|
) as resp:
|
|
113
125
|
resp.raise_for_status()
|
|
126
|
+
# The stream is live and _http exists: sends will now reach the
|
|
127
|
+
# server and their answer/error events will arrive. On a reconnect
|
|
128
|
+
# this is already set (harmless).
|
|
129
|
+
self._ready.set()
|
|
114
130
|
# A (re)connect can postdate permission.asked events we never saw.
|
|
115
131
|
await self._sweep_permissions()
|
|
116
132
|
data_lines: list[str] = []
|
|
@@ -280,12 +296,21 @@ class OpencodeConversation:
|
|
|
280
296
|
async def send(self, text: str) -> None:
|
|
281
297
|
if self._closed.is_set():
|
|
282
298
|
raise ConversationClosed(self._close_reason or "conversation closed")
|
|
299
|
+
if self._http is None:
|
|
300
|
+
# The reader hasn't created the aiohttp session yet (or was never
|
|
301
|
+
# started). Fail loudly instead of an opaque AttributeError.
|
|
302
|
+
raise ConversationClosed("conversation not started")
|
|
283
303
|
self._pending = True
|
|
304
|
+
body: dict = {"parts": [{"type": "text", "text": text}]}
|
|
305
|
+
if self.current_model_id:
|
|
306
|
+
prov, _, mod = self.current_model_id.partition("/")
|
|
307
|
+
if prov and mod:
|
|
308
|
+
body["model"] = {"providerID": prov, "modelID": mod}
|
|
284
309
|
try:
|
|
285
310
|
async with self._http.post(
|
|
286
311
|
self._url(f"/session/{self._session_id}/prompt_async"),
|
|
287
312
|
params=self._params(),
|
|
288
|
-
json=
|
|
313
|
+
json=body,
|
|
289
314
|
) as resp:
|
|
290
315
|
resp.raise_for_status()
|
|
291
316
|
except (aiohttp.ClientError, ConnectionError) as exc:
|
|
@@ -327,6 +352,19 @@ class OpencodeConversation:
|
|
|
327
352
|
) as resp:
|
|
328
353
|
resp.raise_for_status()
|
|
329
354
|
|
|
355
|
+
async def set_active_model(self, model: str) -> None:
|
|
356
|
+
"""Record the model (``"providerID/modelID"``) attached to subsequent
|
|
357
|
+
sends. Used by the server-side model probe; the operator UI drives its
|
|
358
|
+
own selection client-side (see set_control)."""
|
|
359
|
+
self.current_model_id = model
|
|
360
|
+
|
|
361
|
+
async def set_control(self, control_id: str, value: "str | bool") -> None:
|
|
362
|
+
# opencode's session controls (model + reasoning_effort) are both
|
|
363
|
+
# resolved and applied entirely UI-local — attached per-prompt to
|
|
364
|
+
# prompt_async (``model`` / ``variant``); there is no server round-trip,
|
|
365
|
+
# so this satisfies the Conversation protocol as a no-op for either id.
|
|
366
|
+
return None
|
|
367
|
+
|
|
330
368
|
async def close(self) -> None:
|
|
331
369
|
self.close_requested.set()
|
|
332
370
|
|
|
@@ -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
|
|
|
@@ -52,7 +52,7 @@ _OPENCODE_CACHE_SHELL_DEFAULT = (
|
|
|
52
52
|
async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
|
|
53
53
|
"""Resolve the opencode binary-cache dir as an absolute path on the worker.
|
|
54
54
|
|
|
55
|
-
``install_dir`` (config.
|
|
55
|
+
``install_dir`` (config.install_dir) overrides. Else the worker's
|
|
56
56
|
OPENCODE_CACHE_DIR / XDG_CACHE_HOME / $HOME decide it — resolved via a shell
|
|
57
57
|
echo so RemoteHost gets the remote cache. Resolved from the worker's REAL env
|
|
58
58
|
(this runs before per-task XDG isolation), so the cache stays shared and
|
|
@@ -488,9 +488,16 @@ async def launch_opencode(
|
|
|
488
488
|
# The browser-suppression env (PATH prepend + BROWSER) comes from extra_env.
|
|
489
489
|
# The HOME/XDG isolation env (from _isolation_env) points opencode's
|
|
490
490
|
# auth/config/data at <workdir>/home so per-task seeding works.
|
|
491
|
+
# OPENCODE_DISABLE_AUTOUPDATE=1 disables opencode's in-agent auto-updater
|
|
492
|
+
# (the binary's update fn early-returns on this var). A managed wrapper pins
|
|
493
|
+
# the binary via optio's own smart-install.sh --check, so a self-download
|
|
494
|
+
# would fight our version control and can stall/bloat a session mid-run;
|
|
495
|
+
# disabling it here is safe because the cache stays fresh independently.
|
|
496
|
+
# Set as a base default; a caller extra_env may still override.
|
|
491
497
|
env = {
|
|
492
498
|
**_isolation_env(host),
|
|
493
499
|
"OPENCODE_DB": f"{host.taskdir}/opencode.db",
|
|
500
|
+
"OPENCODE_DISABLE_AUTOUPDATE": "1",
|
|
494
501
|
**(extra_env or {}),
|
|
495
502
|
}
|
|
496
503
|
|
|
@@ -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
|
+
)
|