runspec-console 0.1.0__py3-none-any.whl

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 @@
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")
@@ -0,0 +1,145 @@
1
+ """
2
+ pip install runspec-console[bedrock]
3
+
4
+ Uses anthropic[bedrock] — Claude models via AWS Bedrock.
5
+
6
+ Standard auth: AWS credential chain (env vars, ~/.aws/credentials, IAM role).
7
+ Custom proxy auth: set base_url + api_key (HTTP Basic token) for a corporate
8
+ Bedrock proxy — the anthropic SDK passes api_key as the Authorization header.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ import anthropic
16
+
17
+ from .base import ChatResponse, ModelAdapter, ToolCall
18
+
19
+ DEFAULT_MODEL = "anthropic.claude-sonnet-4-6"
20
+ DEFAULT_SYSTEM = (
21
+ "You are a helpful assistant with access to runspec tools running on local and remote hosts. "
22
+ "Use tools when they help answer the user's request. "
23
+ "When you call a tool, briefly explain what you're doing before the result."
24
+ )
25
+
26
+
27
+ class BedrockAdapter(ModelAdapter):
28
+ def __init__(
29
+ self,
30
+ model: str = DEFAULT_MODEL,
31
+ system: str = DEFAULT_SYSTEM,
32
+ aws_region: str | None = None,
33
+ aws_access_key: str | None = None,
34
+ aws_secret_key: str | None = None,
35
+ aws_session_token: str | None = None,
36
+ # Corporate proxy path: supply base_url + token instead of AWS creds
37
+ base_url: str | None = None,
38
+ api_key: str | None = None,
39
+ ) -> None:
40
+ self.model = model
41
+ self.system = system
42
+ if base_url:
43
+ # Corporate proxy — treat as a regular Anthropic-compatible endpoint
44
+ self.client: anthropic.AsyncAnthropic | anthropic.AsyncAnthropicBedrock = (
45
+ anthropic.AsyncAnthropic(base_url=base_url, api_key=api_key or "unused")
46
+ )
47
+ else:
48
+ kwargs: dict[str, Any] = {}
49
+ if aws_region:
50
+ kwargs["aws_region"] = aws_region
51
+ if aws_access_key:
52
+ kwargs["aws_access_key"] = aws_access_key
53
+ if aws_secret_key:
54
+ kwargs["aws_secret_key"] = aws_secret_key
55
+ if aws_session_token:
56
+ kwargs["aws_session_token"] = aws_session_token
57
+ self.client = anthropic.AsyncAnthropicBedrock(**kwargs)
58
+
59
+ async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> ChatResponse:
60
+ kwargs: dict[str, Any] = dict(
61
+ model=self.model,
62
+ max_tokens=4096,
63
+ messages=messages,
64
+ system=self.system,
65
+ )
66
+ if tools:
67
+ kwargs["tools"] = tools
68
+ response = await self.client.messages.create(**kwargs)
69
+ text = next(
70
+ (block.text for block in response.content if hasattr(block, "text")), None
71
+ )
72
+ tool_calls = [
73
+ ToolCall(id=block.id, name=block.name, input=block.input)
74
+ for block in response.content
75
+ if block.type == "tool_use"
76
+ ]
77
+ return ChatResponse(text=text, tool_calls=tool_calls, stop_reason=response.stop_reason, _raw=response)
78
+
79
+ async def stream_chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]): # type: ignore[override]
80
+ kwargs: dict[str, Any] = dict(
81
+ model=self.model,
82
+ max_tokens=4096,
83
+ messages=messages,
84
+ system=self.system,
85
+ )
86
+ if tools:
87
+ kwargs["tools"] = tools
88
+ async with self.client.messages.stream(**kwargs) as stream:
89
+ async for token in stream.text_stream:
90
+ yield token
91
+
92
+ async def stream_with_tools(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]): # type: ignore[override]
93
+ import json
94
+ kwargs: dict[str, Any] = dict(
95
+ model=self.model,
96
+ max_tokens=4096,
97
+ messages=messages,
98
+ system=self.system,
99
+ )
100
+ if tools:
101
+ kwargs["tools"] = tools
102
+ tool_map: dict[int, dict[str, Any]] = {}
103
+ stop_reason = "end_turn"
104
+ async with self.client.messages.stream(**kwargs) as stream:
105
+ async for event in stream:
106
+ ev_type = getattr(event, "type", None)
107
+ if ev_type == "content_block_start":
108
+ cb = getattr(event, "content_block", None)
109
+ if cb and getattr(cb, "type", None) == "tool_use":
110
+ tool_map[event.index] = {"id": cb.id, "name": cb.name, "json": ""}
111
+ elif ev_type == "content_block_delta":
112
+ d = getattr(event, "delta", None)
113
+ if d:
114
+ if getattr(d, "type", None) == "text_delta":
115
+ yield ("text", d.text)
116
+ elif getattr(d, "type", None) == "input_json_delta":
117
+ if event.index in tool_map:
118
+ tool_map[event.index]["json"] += d.partial_json
119
+ elif ev_type == "message_delta":
120
+ d = getattr(event, "delta", None)
121
+ if d:
122
+ stop_reason = getattr(d, "stop_reason", stop_reason) or stop_reason
123
+ final = await stream.get_final_message()
124
+ tool_calls = []
125
+ for tc in tool_map.values():
126
+ try:
127
+ inp = json.loads(tc["json"]) if tc["json"] else {}
128
+ except Exception:
129
+ inp = {}
130
+ tool_calls.append(ToolCall(id=tc["id"], name=tc["name"], input=inp))
131
+ yield ("done", ChatResponse(text=None, tool_calls=tool_calls, stop_reason=stop_reason, _raw=final))
132
+
133
+ def make_tool_turn(
134
+ self, response: ChatResponse, results: list[tuple[ToolCall, str]]
135
+ ) -> list[dict[str, Any]]:
136
+ return [
137
+ {"role": "assistant", "content": response._raw.content},
138
+ {
139
+ "role": "user",
140
+ "content": [
141
+ {"type": "tool_result", "tool_use_id": tc.id, "content": result}
142
+ for tc, result in results
143
+ ],
144
+ },
145
+ ]
@@ -0,0 +1,97 @@
1
+ """pip install runspec-console[openai]"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import openai as _openai
8
+
9
+ from .base import ChatResponse, ModelAdapter, ToolCall
10
+
11
+ DEFAULT_MODEL = "gpt-4o"
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
+ def _to_openai_function(t: dict[str, Any]) -> dict[str, Any]:
20
+ """Convert Anthropic-format tool (input_schema) to OpenAI function format (parameters)."""
21
+ func: dict[str, Any] = {"name": t["name"]}
22
+ if t.get("description"):
23
+ func["description"] = t["description"]
24
+ func["parameters"] = t.get("input_schema") or t.get("parameters") or {
25
+ "type": "object", "properties": {}
26
+ }
27
+ return func
28
+
29
+
30
+ class OpenAIAdapter(ModelAdapter):
31
+ def __init__(
32
+ self,
33
+ model: str = DEFAULT_MODEL,
34
+ system: str = DEFAULT_SYSTEM,
35
+ api_key: str | None = None,
36
+ base_url: str | None = None,
37
+ ) -> None:
38
+ self.client = _openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
39
+ self.model = model
40
+ self.system = system
41
+
42
+ async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> ChatResponse:
43
+ system_msg = {"role": "system", "content": self.system}
44
+ kwargs: dict[str, Any] = dict(
45
+ model=self.model,
46
+ messages=[system_msg, *messages],
47
+ )
48
+ if tools:
49
+ kwargs["tools"] = [{"type": "function", "function": _to_openai_function(t)} for t in tools]
50
+ kwargs["tool_choice"] = "auto"
51
+ response = await self.client.chat.completions.create(**kwargs)
52
+ msg = response.choices[0].message
53
+ text = msg.content
54
+ tool_calls = []
55
+ if msg.tool_calls:
56
+ import json
57
+ tool_calls = [
58
+ ToolCall(id=tc.id, name=tc.function.name, input=json.loads(tc.function.arguments))
59
+ for tc in msg.tool_calls
60
+ ]
61
+ stop = response.choices[0].finish_reason or "stop"
62
+ return ChatResponse(text=text, tool_calls=tool_calls, stop_reason=stop, _raw=response)
63
+
64
+ async def stream_chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]): # type: ignore[override]
65
+ system_msg = {"role": "system", "content": self.system}
66
+ kwargs: dict[str, Any] = dict(
67
+ model=self.model,
68
+ messages=[system_msg, *messages],
69
+ stream=True,
70
+ )
71
+ if tools:
72
+ kwargs["tools"] = [{"type": "function", "function": _to_openai_function(t)} for t in tools]
73
+ kwargs["tool_choice"] = "auto"
74
+ async for chunk in await self.client.chat.completions.create(**kwargs):
75
+ delta = chunk.choices[0].delta.content
76
+ if delta:
77
+ yield delta
78
+
79
+ async def stream_with_tools(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]): # type: ignore[override]
80
+ # Fall back to non-streaming chat() — accumulating streaming deltas for tool calls
81
+ # requires complex state management; non-streaming is simpler and correct for tool turns.
82
+ response = await self.chat(messages, tools)
83
+ if response.text:
84
+ yield ("text", response.text)
85
+ yield ("done", response)
86
+
87
+ def make_tool_turn(
88
+ self, response: ChatResponse, results: list[tuple[ToolCall, str]]
89
+ ) -> list[dict[str, Any]]:
90
+ raw_msg = response._raw.choices[0].message
91
+ turns: list[dict[str, Any]] = [{"role": "assistant", "content": raw_msg.content, "tool_calls": [
92
+ {"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": str(tc.input)}}
93
+ for tc, _ in results
94
+ ]}]
95
+ for tc, result in results:
96
+ turns.append({"role": "tool", "tool_call_id": tc.id, "content": result})
97
+ return turns
runspec_console/app.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ app.py — pywebview entry point for runspec-console.
3
+
4
+ Dev mode (--dev): points the window at the Vite dev server (http://localhost:<port>)
5
+ Prod mode (default): serves the built Vite dist folder via a local HTTP server.
6
+
7
+ Usage:
8
+ runspec-console # production — requires packages/console-ui/dist/
9
+ runspec-console --dev # development — requires `npm run dev` running
10
+ runspec-console --dev --port 5174
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ import threading
18
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
19
+ from pathlib import Path
20
+
21
+
22
+ def _find_dist() -> Path:
23
+ """Locate the built console-ui dist folder relative to this package."""
24
+ candidates = [
25
+ # Installed package data (future: bundle dist into the wheel)
26
+ Path(__file__).parent / "dist",
27
+ # Development: sibling packages directory
28
+ Path(__file__).parents[3] / "console-ui" / "dist",
29
+ ]
30
+ for c in candidates:
31
+ if (c / "index.html").exists():
32
+ return c
33
+ raise FileNotFoundError(
34
+ "console-ui dist not found. Run `npm run build` in packages/console-ui, "
35
+ "or use --dev to point at the Vite dev server instead."
36
+ )
37
+
38
+
39
+ def _start_static_server(dist: Path) -> int:
40
+ """Serve dist/ on a random free port. Returns the port number."""
41
+ import socket
42
+
43
+ class _Handler(SimpleHTTPRequestHandler):
44
+ def __init__(self, *a: object, **kw: object) -> None:
45
+ super().__init__(*a, directory=str(dist), **kw)
46
+
47
+ def log_message(self, *_: object) -> None:
48
+ pass # silence request logs
49
+
50
+ with socket.socket() as s:
51
+ s.bind(("127.0.0.1", 0))
52
+ port = s.getsockname()[1]
53
+
54
+ server = HTTPServer(("127.0.0.1", port), _Handler)
55
+ t = threading.Thread(target=server.serve_forever, daemon=True)
56
+ t.start()
57
+ return port
58
+
59
+
60
+ def main() -> None:
61
+ import runspec
62
+
63
+ args = runspec.parse("runspec-console")
64
+ dev: bool = bool(args.dev.value)
65
+ port: int = int(args.port.value) if args.port.value is not None else 5173
66
+
67
+ import webview
68
+
69
+ from .bridge import Bridge
70
+
71
+ bridge = Bridge()
72
+
73
+ if dev:
74
+ url = f"http://localhost:{port}"
75
+ else:
76
+ try:
77
+ dist = _find_dist()
78
+ static_port = _start_static_server(dist)
79
+ url = f"http://127.0.0.1:{static_port}"
80
+ except FileNotFoundError as exc:
81
+ print(f"✗ {exc}", file=sys.stderr)
82
+ sys.exit(1)
83
+
84
+ window = webview.create_window(
85
+ "runspec console",
86
+ url,
87
+ js_api=bridge,
88
+ width=1440,
89
+ height=900,
90
+ min_size=(1024, 600),
91
+ frameless=True,
92
+ )
93
+ bridge.set_window(window)
94
+
95
+ webview.start(debug=dev)