runspec-console 0.1.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.
@@ -0,0 +1,58 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
@@ -0,0 +1,38 @@
1
+ # Changelog — runspec-console
2
+
3
+ All notable changes to this package are documented here.
4
+
5
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ Version numbers follow [Semantic Versioning](https://semver.org/).
7
+
8
+ ---
9
+
10
+ ## [0.1.0] — 2026-05-27
11
+
12
+ ### Added
13
+
14
+ - **runspec-console** — desktop GUI for runspec, packaged as a pip-installable wheel.
15
+ Ships a pywebview window hosting a Vite/React UI (the `console-ui` package built
16
+ and bundled at release time).
17
+ - **Frameless window** with custom title bar: drag regions on sidebar and tab bar,
18
+ minimize / maximize-toggle / close controls (— □ ×).
19
+ - **Hosts view** — displays connected and disconnected jump hosts; one-click SSH
20
+ connection test per host.
21
+ - **Runnables view** — lists all runnables discovered on each host; shows group,
22
+ host, and autonomy level.
23
+ - **Forms view** — per-runnable argument form with type-aware controls (text, number,
24
+ boolean toggle, choice dropdown), range validation, and positional-arg support.
25
+ - **Console view** — live streaming output for in-flight invocations; collapsible
26
+ log blocks; truncation guard for large outputs.
27
+ - **History view** — full invocation audit trail per host with arg provenance.
28
+ - **Schedules view** — create, list, and delete cron-style scheduled invocations.
29
+ - **Settings drawer** — jump-host config, SSH key generation and 90-day rotation
30
+ reminder, general preferences.
31
+ - **Today summary** — at-a-glance counts of today's runs, failures, and upcoming
32
+ scheduled tasks.
33
+ - **Built-in runnables** — `generate-ssh-key`, `disk-usage`, `ping-host`,
34
+ `flush-dns`, `check-port` shipped as console entry points.
35
+ - **Chat integration** (optional extras `anthropic`, `openai`, `bedrock`) — natural-
36
+ language invocation via the slash menu.
37
+
38
+ ---
@@ -0,0 +1,143 @@
1
+ # runspec-console — Implementation Notes
2
+
3
+ Details worth capturing for documentation. Covers behaviour that isn't obvious
4
+ from reading the code and that users / operators will ask about.
5
+
6
+ ---
7
+
8
+ ## Background refresh cycle
9
+
10
+ All discovery and connectivity work runs in a daemon thread — the UI never blocks
11
+ waiting for SSH. On app start a single background cycle begins immediately, then
12
+ repeats every **30 seconds**.
13
+
14
+ **Phase 1 — connectivity probes** (fast: `ssh -o ConnectTimeout=3 <host> true`)
15
+ - One thread per remote host, run concurrently
16
+ - Results written to `_connected_cache`; local is always True
17
+ - Fires `runspec:hosts_updated` → App re-fetches host list (dot colours update)
18
+
19
+ **Phase 2 — runnables discovery** (heavier: `runspec local --format json`)
20
+ - One thread per host, run concurrently; unreachable hosts are skipped
21
+ - Results written to `_runnables_cache`
22
+ - Fires `runspec:runnables_updated` → App re-fetches runnable list
23
+
24
+ `get_hosts()` and `get_runnables()` both read from cache and return instantly.
25
+ On first render the runnable list is empty and dots are grey; both populate within
26
+ a few seconds as the first cycle completes.
27
+
28
+ A fresh cycle is also triggered immediately after `save_jump_hosts()` or
29
+ `import_jump_hosts()` so newly added hosts appear without waiting 30 s.
30
+
31
+ ---
32
+
33
+ ## Host connectivity dots
34
+
35
+ The coloured dot next to each host in the sidebar reflects a cached SSH probe result.
36
+
37
+ - **Green** — last probe succeeded (`ssh -o ConnectTimeout=3 <host> true` exited 0)
38
+ - **Grey** — not yet probed, or last probe failed / timed out
39
+ - **Local host** — always green (no probe needed)
40
+
41
+ **Refresh cadence:** probes run concurrently (one thread per remote host) immediately
42
+ on app start, then every **30 seconds** in a background daemon thread. The UI dot
43
+ updates automatically when each round completes via a `runspec:hosts_updated` event.
44
+
45
+ **First load behaviour:** hosts appear grey on the initial render (cache is empty).
46
+ Dots flip to green a few seconds later once the first probe round finishes. This is
47
+ intentional — `get_hosts()` returns instantly rather than blocking for SSH round-trips.
48
+
49
+ ---
50
+
51
+ ## Streaming (invoke_runnable)
52
+
53
+ Output from a runnable is streamed line-by-line from a background thread back to
54
+ the frontend via `window.evaluate_js(CustomEvent)`. Two events:
55
+
56
+ - `runspec:output { id, line, stream }` — one stdout/stderr line
57
+ - `runspec:run_end { id, exit_code, duration_ms }` — invocation complete
58
+
59
+ Both local and SSH-remote runnables use the same streaming path (`executor.py`).
60
+ For SSH, the subprocess is `ssh -o BatchMode=yes <host> <remote_runspec_path> [args]`.
61
+
62
+ **No stdin support** — `BatchMode=yes` is set and the subprocess has no stdin pipe.
63
+ Scripts that prompt for confirmation will stall and time out.
64
+
65
+ ---
66
+
67
+ ## Chat / LLM (agentic loop)
68
+
69
+ `send_chat` always runs `_agentic_chat_turn`, which loops up to **10 iterations**:
70
+
71
+ 1. Call `adapter.stream_with_tools(history, tools)` — yields `('text', token)` then
72
+ `('done', ChatResponse)`.
73
+ 2. Dispatch each `token` as `runspec:token`. On `('done', ...)`, inspect `stop_reason`.
74
+ 3. If `stop_reason == 'tool_use'`, run each `ToolCall` via `asyncio.to_thread(_run_tool_sync)`,
75
+ dispatch `runspec:tool_start` / `runspec:tool_end`, build the tool-result turns,
76
+ extend `history`, and loop.
77
+ 4. Otherwise break — model is done.
78
+
79
+ **Streaming implementation by adapter:**
80
+ - **Anthropic / Bedrock** — `stream_with_tools()` iterates raw SSE events from the SDK
81
+ stream (`async for event in stream`). Text deltas yield tokens; `input_json_delta`
82
+ events accumulate JSON strings per block index. `get_final_message()` is called after
83
+ the loop to get the `Message` object needed by `make_tool_turn`.
84
+ - **OpenAI** — falls back to `chat()` (non-streaming). Streaming + tool call delta
85
+ accumulation is complex; non-streaming is correct for tool turns.
86
+
87
+ **Tool schemas** — `_runnables_to_tools()` converts the `_runnables_cache` to
88
+ Anthropic-format `input_schema` tool definitions. Tool name: `{host}__{runnable}`,
89
+ sanitised to `[a-zA-Z0-9_-]` and truncated to 64 chars. OpenAI adapter converts
90
+ `input_schema` → `parameters` via `_to_openai_function()`.
91
+
92
+ **`_run_tool_sync`** is blocking — safe because it's called via `asyncio.to_thread()`.
93
+ Calls `run_local` / `run_remote` directly (they block). Output capped at **16 KB**;
94
+ non-zero exit codes prepend `[exit N]` to the output string.
95
+
96
+ **Tool output cap** — tool results sent to the LLM are truncated at 2 000 chars in the
97
+ `runspec:tool_end` event (for the UI); the full ≤16 KB goes into `make_tool_turn`.
98
+
99
+ **Frontend rendering** — `InvocationBlock` for chat blocks uses `segments: BlockSegment[]`
100
+ (interleaved `{ kind: 'text', text }` and `{ kind: 'tool', entry: ToolCallEntry }`) plus
101
+ `currentText: string` for the currently-streaming text. When `runspec:tool_start` fires,
102
+ `currentText` is flushed to a text segment and a running tool entry is appended.
103
+ `runspec:tool_end` marks the entry complete (output stored, `running: false`). `runspec:run_end`
104
+ flushes any remaining `currentText` and sets `done: true`. Tool call blocks in the UI show
105
+ the runnable name (host prefix stripped), args inline, and a collapsible output section.
106
+
107
+ **Provider config** lives in `%APPDATA%\runspec-console\runspec_config.toml` under `[llm]`:
108
+ - `provider` — `"anthropic"` | `"openai"` | `"bedrock"`
109
+ - `api_key` — for Anthropic/OpenAI; or Bedrock proxy token
110
+ - `model` — defaults: `claude-sonnet-4-6`, `gpt-4o`, `anthropic.claude-sonnet-4-6`
111
+ - `base_url` — optional; for OpenAI-compatible endpoints or Bedrock corporate proxy
112
+ - `aws_region` — Bedrock only
113
+ - Configurable in-app via Settings → General. Provider dropdown shows relevant fields
114
+ only (e.g. AWS Region only appears for Bedrock).
115
+
116
+ ---
117
+
118
+ ## Production build
119
+
120
+ Only `--dev` mode is wired up (pywebview connects to Vite dev server on port 5173).
121
+ `npm run build` → embedded `dist/` path is not yet configured in `app.py`. Running
122
+ without `--dev` will show a blank window.
123
+
124
+ ---
125
+
126
+ ## Schedules
127
+
128
+ Stored at `%APPDATA%\runspec-console\runspec_schedules.toml` as a TOML array of
129
+ `[[schedule]]` entries. No scheduler process exists yet — schedules are persisted
130
+ but nothing executes them. `nextRun` is always blank (`—`) until a scheduler is
131
+ implemented. Marked as a **server-side / central git repo** feature for a later pass.
132
+
133
+ ---
134
+
135
+ ## File locations (Windows)
136
+
137
+ | File | Path |
138
+ |------|------|
139
+ | Hosts config | `%APPDATA%\runspec-console\runspec_hosts.toml` |
140
+ | App config (LLM / SSH defaults) | `%APPDATA%\runspec-console\runspec_config.toml` |
141
+ | Schedules | `%APPDATA%\runspec-console\runspec_schedules.toml` |
142
+ | Local venv logs | `{venv_root}\logs\{runnable}.log` |
143
+ | Remote logs | fetched via one-shot SSH cat on demand |
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-console
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: pywebview>=5.0
6
+ Requires-Dist: pywin32>=306
7
+ Requires-Dist: runspec>=0.18.0
8
+ Provides-Extra: anthropic
9
+ Requires-Dist: anthropic>=0.40.0; extra == 'anthropic'
10
+ Provides-Extra: bedrock
11
+ Requires-Dist: anthropic[bedrock]>=0.40.0; extra == 'bedrock'
12
+ Provides-Extra: dev
13
+ Requires-Dist: mypy; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Requires-Dist: ruff; extra == 'dev'
16
+ Provides-Extra: openai
17
+ Requires-Dist: openai>=1.0.0; extra == 'openai'
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python3
2
+ """Build runspec-console wheel.
3
+
4
+ Steps:
5
+ 1. npm run build — Vite bundles the UI into runspec_console/dist/
6
+ 2. python -m build — hatchling packages the wheel (force-includes dist/)
7
+
8
+ Run from any directory:
9
+ python packages/python/runspec-console/build.py
10
+ """
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).parent.parent.parent.parent # repo root
16
+ CONSOLE_UI = ROOT / "packages" / "console-ui"
17
+ PYTHON_PKG = ROOT / "packages" / "python" / "runspec-console"
18
+
19
+
20
+ def run(cmd: list[str], cwd: Path, *, shell: bool = False) -> None:
21
+ display = " ".join(cmd)
22
+ print(f"\n>>> {display} (in {cwd.relative_to(ROOT)})")
23
+ result = subprocess.run(cmd, cwd=cwd, shell=shell)
24
+ if result.returncode != 0:
25
+ sys.exit(result.returncode)
26
+
27
+
28
+ def main() -> None:
29
+ # On Windows, npm is npm.cmd — use shell=True so PATH resolution works
30
+ is_windows = sys.platform == "win32"
31
+ npm = ["npm.cmd", "run", "build"] if is_windows else ["npm", "run", "build"]
32
+
33
+ run(npm, CONSOLE_UI, shell=is_windows)
34
+
35
+ dist_dir = PYTHON_PKG / "runspec_console" / "dist"
36
+ if not dist_dir.exists():
37
+ print(f"ERROR: Vite output not found at {dist_dir}", file=sys.stderr)
38
+ sys.exit(1)
39
+
40
+ run([sys.executable, "-m", "build"], PYTHON_PKG)
41
+
42
+ wheels = list((PYTHON_PKG / "dist").glob("*.whl"))
43
+ if wheels:
44
+ print(f"\nWheel ready: {wheels[-1].name}")
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-console"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.11"
9
+ dependencies = [
10
+ "pywebview>=5.0",
11
+ "runspec>=0.18.0",
12
+ "pywin32>=306",
13
+ ]
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["runspec_console"]
17
+
18
+ [tool.hatch.build.targets.wheel.force-include]
19
+ "runspec_console/runspec.toml" = "runspec_console/runspec.toml"
20
+ # Vite build output — generated by `npm run build` in packages/console-ui
21
+ # gitignored but force-included so `pip install` gets the full UI
22
+ "runspec_console/dist" = "runspec_console/dist"
23
+
24
+ [project.scripts]
25
+ runspec-console = "runspec_console.app:main"
26
+ disk-usage = "runspec_console.tools.disk_usage:main"
27
+ ping-host = "runspec_console.tools.ping_host:main"
28
+ flush-dns = "runspec_console.tools.flush_dns:main"
29
+ check-port = "runspec_console.tools.check_port:main"
30
+ generate-ssh-key = "runspec_console.tools.generate_ssh_key:main"
31
+
32
+ [project.optional-dependencies]
33
+ anthropic = ["anthropic>=0.40.0"]
34
+ openai = ["openai>=1.0.0"]
35
+ bedrock = ["anthropic[bedrock]>=0.40.0"]
36
+ dev = [
37
+ "ruff",
38
+ "mypy",
39
+ "pytest>=8.0",
40
+ ]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+
45
+ [tool.mypy]
46
+ python_version = "3.11"
47
+ mypy_path = "../runspec"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,117 @@
1
+ """pip install runspec-console[anthropic]"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import anthropic
8
+
9
+ from .base import ChatResponse, ModelAdapter, ToolCall
10
+
11
+ DEFAULT_MODEL = "claude-sonnet-4-6"
12
+ DEFAULT_SYSTEM = (
13
+ "You are a helpful assistant with access to runspec tools running on local and remote hosts. "
14
+ "Use tools when they help answer the user's request. "
15
+ "When you call a tool, briefly explain what you're doing before the result."
16
+ )
17
+
18
+
19
+ class AnthropicAdapter(ModelAdapter):
20
+ def __init__(
21
+ self,
22
+ model: str = DEFAULT_MODEL,
23
+ system: str = DEFAULT_SYSTEM,
24
+ api_key: str | None = None,
25
+ ) -> None:
26
+ self.client = anthropic.AsyncAnthropic(api_key=api_key)
27
+ self.model = model
28
+ self.system = system
29
+
30
+ async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> ChatResponse:
31
+ kwargs: dict[str, Any] = dict(
32
+ model=self.model,
33
+ max_tokens=4096,
34
+ messages=messages,
35
+ system=self.system,
36
+ )
37
+ if tools:
38
+ kwargs["tools"] = tools
39
+ response = await self.client.messages.create(**kwargs)
40
+ text = next(
41
+ (block.text for block in response.content if hasattr(block, "text")), None
42
+ )
43
+ tool_calls = [
44
+ ToolCall(id=block.id, name=block.name, input=block.input)
45
+ for block in response.content
46
+ if block.type == "tool_use"
47
+ ]
48
+ return ChatResponse(text=text, tool_calls=tool_calls, stop_reason=response.stop_reason, _raw=response)
49
+
50
+ async def stream_chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]): # type: ignore[override]
51
+ kwargs: dict[str, Any] = dict(
52
+ model=self.model,
53
+ max_tokens=4096,
54
+ messages=messages,
55
+ system=self.system,
56
+ )
57
+ if tools:
58
+ kwargs["tools"] = tools
59
+ async with self.client.messages.stream(**kwargs) as stream:
60
+ async for token in stream.text_stream:
61
+ yield token
62
+
63
+ async def stream_with_tools(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]): # type: ignore[override]
64
+ import json
65
+ kwargs: dict[str, Any] = dict(
66
+ model=self.model,
67
+ max_tokens=4096,
68
+ messages=messages,
69
+ system=self.system,
70
+ )
71
+ if tools:
72
+ kwargs["tools"] = tools
73
+ # Collect tool input JSON chunks indexed by content-block position
74
+ tool_map: dict[int, dict[str, Any]] = {}
75
+ stop_reason = "end_turn"
76
+ async with self.client.messages.stream(**kwargs) as stream:
77
+ async for event in stream:
78
+ ev_type = getattr(event, "type", None)
79
+ if ev_type == "content_block_start":
80
+ cb = getattr(event, "content_block", None)
81
+ if cb and getattr(cb, "type", None) == "tool_use":
82
+ tool_map[event.index] = {"id": cb.id, "name": cb.name, "json": ""}
83
+ elif ev_type == "content_block_delta":
84
+ d = getattr(event, "delta", None)
85
+ if d:
86
+ if getattr(d, "type", None) == "text_delta":
87
+ yield ("text", d.text)
88
+ elif getattr(d, "type", None) == "input_json_delta":
89
+ if event.index in tool_map:
90
+ tool_map[event.index]["json"] += d.partial_json
91
+ elif ev_type == "message_delta":
92
+ d = getattr(event, "delta", None)
93
+ if d:
94
+ stop_reason = getattr(d, "stop_reason", stop_reason) or stop_reason
95
+ final = await stream.get_final_message()
96
+ tool_calls = []
97
+ for tc in tool_map.values():
98
+ try:
99
+ inp = json.loads(tc["json"]) if tc["json"] else {}
100
+ except Exception:
101
+ inp = {}
102
+ tool_calls.append(ToolCall(id=tc["id"], name=tc["name"], input=inp))
103
+ yield ("done", ChatResponse(text=None, tool_calls=tool_calls, stop_reason=stop_reason, _raw=final))
104
+
105
+ def make_tool_turn(
106
+ self, response: ChatResponse, results: list[tuple[ToolCall, str]]
107
+ ) -> list[dict[str, Any]]:
108
+ return [
109
+ {"role": "assistant", "content": response._raw.content},
110
+ {
111
+ "role": "user",
112
+ "content": [
113
+ {"type": "tool_result", "tool_use_id": tc.id, "content": result}
114
+ for tc, result in results
115
+ ],
116
+ },
117
+ ]
@@ -0,0 +1,92 @@
1
+ """
2
+ base.py — ModelAdapter ABC shared across all LLM providers.
3
+
4
+ Identical contract to runspec-chat's adapter.py so implementations
5
+ can be ported between the two packages without changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, AsyncIterator
13
+
14
+
15
+ @dataclass
16
+ class ToolCall:
17
+ id: str
18
+ name: str
19
+ input: dict[str, Any]
20
+
21
+
22
+ @dataclass
23
+ class ChatResponse:
24
+ text: str | None
25
+ tool_calls: list[ToolCall]
26
+ stop_reason: str # "tool_use" | "end_turn" | "stop"
27
+ _raw: Any = field(repr=False, default=None)
28
+
29
+
30
+ class ModelAdapter(ABC):
31
+ @abstractmethod
32
+ async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> ChatResponse: ...
33
+
34
+ @abstractmethod
35
+ def stream_chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AsyncIterator[str]:
36
+ """Yield text tokens as they arrive from the model."""
37
+ ...
38
+
39
+ async def stream_with_tools(
40
+ self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]
41
+ ):
42
+ """
43
+ Yield ('text', str) for each text token, then ('done', ChatResponse) at the end.
44
+
45
+ Default falls back to non-streaming chat(). Override for true streaming with tools.
46
+ """
47
+ response = await self.chat(messages, tools)
48
+ if response.text:
49
+ yield ("text", response.text)
50
+ yield ("done", response)
51
+
52
+ @abstractmethod
53
+ def make_tool_turn(
54
+ self, response: ChatResponse, results: list[tuple[ToolCall, str]]
55
+ ) -> list[dict[str, Any]]:
56
+ """Return [assistant_turn, tool_result_turn] to append to the conversation."""
57
+ ...
58
+
59
+
60
+ def load_adapter(provider: str, **kwargs: Any) -> ModelAdapter:
61
+ """
62
+ Instantiate the named adapter. Raises ImportError with install instructions
63
+ if the required extra is not installed.
64
+
65
+ provider: "anthropic" | "openai" | "bedrock"
66
+ kwargs: passed straight through to the adapter __init__
67
+ """
68
+ if provider == "anthropic":
69
+ try:
70
+ from .anthropic import AnthropicAdapter
71
+ return AnthropicAdapter(**kwargs)
72
+ except ImportError:
73
+ raise ImportError(
74
+ "Install the Anthropic extra: pip install runspec-console[anthropic]"
75
+ )
76
+ if provider == "openai":
77
+ try:
78
+ from .openai import OpenAIAdapter
79
+ return OpenAIAdapter(**kwargs)
80
+ except ImportError:
81
+ raise ImportError(
82
+ "Install the OpenAI extra: pip install runspec-console[openai]"
83
+ )
84
+ if provider == "bedrock":
85
+ try:
86
+ from .bedrock import BedrockAdapter
87
+ return BedrockAdapter(**kwargs)
88
+ except ImportError:
89
+ raise ImportError(
90
+ "Install the Bedrock extra: pip install runspec-console[bedrock]"
91
+ )
92
+ raise ValueError(f"Unknown LLM provider: {provider!r}. Choose from: anthropic, openai, bedrock")