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.
Files changed (58) hide show
  1. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/PKG-INFO +3 -2
  2. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/pyproject.toml +6 -2
  3. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/__init__.py +2 -0
  4. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/conversation.py +43 -1
  5. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/host_actions.py +13 -4
  6. optio_opencode-0.3.1/src/optio_opencode/info.py +7 -0
  7. optio_opencode-0.3.1/src/optio_opencode/model_probe.py +141 -0
  8. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/session.py +352 -9
  9. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/types.py +103 -31
  10. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/PKG-INFO +3 -2
  11. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/SOURCES.txt +7 -0
  12. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/requires.txt +2 -1
  13. optio_opencode-0.3.1/tests/test_agent_info.py +14 -0
  14. optio_opencode-0.3.1/tests/test_config_harmonization.py +79 -0
  15. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_conversation_driver.py +118 -1
  16. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_conversation_session.py +4 -0
  17. optio_opencode-0.3.1/tests/test_conversation_ui_model.py +124 -0
  18. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_conversation_ui_session.py +90 -2
  19. optio_opencode-0.3.1/tests/test_conversation_upload.py +164 -0
  20. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_actions.py +13 -0
  21. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_primitives_remote.py +4 -54
  22. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_remote_resume.py +3 -55
  23. optio_opencode-0.3.1/tests/test_model_probe.py +275 -0
  24. optio_opencode-0.3.1/tests/test_model_probe_wiring.py +140 -0
  25. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_local.py +6 -0
  26. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_remote.py +6 -54
  27. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_resume.py +6 -0
  28. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_types.py +60 -0
  29. optio_opencode-0.2.4/tests/test_conversation_ui_model.py +0 -78
  30. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/README.md +0 -0
  31. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/setup.cfg +0 -0
  32. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/cred_watcher.py +0 -0
  33. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/prompt.py +0 -0
  34. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/seed_manifest.py +0 -0
  35. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/snapshots.py +0 -0
  36. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode/verify.py +0 -0
  37. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  38. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/src/optio_opencode.egg-info/top_level.txt +0 -0
  39. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_agent_sender_opencode.py +0 -0
  40. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_conversation_config.py +0 -0
  41. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_cred_watcher.py +0 -0
  42. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_file_download.py +0 -0
  43. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_file_upload.py +0 -0
  44. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_local.py +0 -0
  45. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_primitives_local.py +0 -0
  46. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_host_resume.py +0 -0
  47. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_prompt.py +0 -0
  48. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_purge_seed.py +0 -0
  49. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_resume_sentence_opencode.py +0 -0
  50. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_sanity.py +0 -0
  51. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_seed_config.py +0 -0
  52. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_blob_hooks.py +0 -0
  53. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_hooks.py +0 -0
  54. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_seed.py +0 -0
  55. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_session_seed_saveback.py +0 -0
  56. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_smart_install.py +0 -0
  57. {optio_opencode-0.2.4 → optio_opencode-0.3.1}/tests/test_snapshots.py +0 -0
  58. {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.2.4
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.4,>=0.3
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.2.4"
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.3,<0.4",
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={"parts": [{"type": "text", "text": text}]},
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.opencode_install_dir``.
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.opencode_install_dir) overrides. Else the worker's
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 opencode installation…")
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 opencode…")
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,7 @@
1
+ from optio_agents import AgentInfo
2
+
3
+ AGENT_INFO = AgentInfo(
4
+ slug="opencode",
5
+ name="OpenCode",
6
+ url="https://opencode.ai",
7
+ )
@@ -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
+ )