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.
- runspec_console/__init__.py +1 -0
- runspec_console/adapters/__init__.py +0 -0
- runspec_console/adapters/anthropic.py +117 -0
- runspec_console/adapters/base.py +92 -0
- runspec_console/adapters/bedrock.py +145 -0
- runspec_console/adapters/openai.py +97 -0
- runspec_console/app.py +95 -0
- runspec_console/bridge.py +851 -0
- runspec_console/config.py +76 -0
- runspec_console/discovery.py +174 -0
- runspec_console/dist/assets/index-D3-_7YCn.js +435 -0
- runspec_console/dist/assets/mock-D6lTNzDW.js +7 -0
- runspec_console/dist/index.html +12 -0
- runspec_console/executor.py +169 -0
- runspec_console/hosts.py +81 -0
- runspec_console/runspec.toml +67 -0
- runspec_console/tools/__init__.py +0 -0
- runspec_console/tools/check_port.py +24 -0
- runspec_console/tools/disk_usage.py +45 -0
- runspec_console/tools/flush_dns.py +29 -0
- runspec_console/tools/generate_ssh_key.py +69 -0
- runspec_console/tools/ping_host.py +26 -0
- runspec_console-0.1.0.dist-info/METADATA +17 -0
- runspec_console-0.1.0.dist-info/RECORD +26 -0
- runspec_console-0.1.0.dist-info/WHEEL +4 -0
- runspec_console-0.1.0.dist-info/entry_points.txt +7 -0
|
@@ -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)
|