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.
- runspec_console-0.1.0/.gitignore +58 -0
- runspec_console-0.1.0/CHANGELOG.md +38 -0
- runspec_console-0.1.0/IMPL_NOTES.md +143 -0
- runspec_console-0.1.0/PKG-INFO +17 -0
- runspec_console-0.1.0/dev_build.py +48 -0
- runspec_console-0.1.0/pyproject.toml +47 -0
- runspec_console-0.1.0/runspec_console/__init__.py +1 -0
- runspec_console-0.1.0/runspec_console/adapters/__init__.py +0 -0
- runspec_console-0.1.0/runspec_console/adapters/anthropic.py +117 -0
- runspec_console-0.1.0/runspec_console/adapters/base.py +92 -0
- runspec_console-0.1.0/runspec_console/adapters/bedrock.py +145 -0
- runspec_console-0.1.0/runspec_console/adapters/openai.py +97 -0
- runspec_console-0.1.0/runspec_console/app.py +95 -0
- runspec_console-0.1.0/runspec_console/bridge.py +851 -0
- runspec_console-0.1.0/runspec_console/config.py +76 -0
- runspec_console-0.1.0/runspec_console/discovery.py +174 -0
- runspec_console-0.1.0/runspec_console/executor.py +169 -0
- runspec_console-0.1.0/runspec_console/hosts.py +81 -0
- runspec_console-0.1.0/runspec_console/runspec.toml +67 -0
- runspec_console-0.1.0/runspec_console/tools/__init__.py +0 -0
- runspec_console-0.1.0/runspec_console/tools/check_port.py +24 -0
- runspec_console-0.1.0/runspec_console/tools/disk_usage.py +45 -0
- runspec_console-0.1.0/runspec_console/tools/flush_dns.py +29 -0
- runspec_console-0.1.0/runspec_console/tools/generate_ssh_key.py +69 -0
- runspec_console-0.1.0/runspec_console/tools/ping_host.py +26 -0
- runspec_console-0.1.0/tests/__init__.py +0 -0
|
@@ -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"
|
|
File without changes
|
|
@@ -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")
|