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