nrgrd 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.
- nrgrd/__init__.py +7 -0
- nrgrd/__main__.py +5 -0
- nrgrd/agent/__init__.py +29 -0
- nrgrd/agent/events.py +86 -0
- nrgrd/agent/loop.py +182 -0
- nrgrd/agent/permissions.py +43 -0
- nrgrd/api/__init__.py +15 -0
- nrgrd/api/openai_compatible.py +186 -0
- nrgrd/api/provider.py +78 -0
- nrgrd/app.py +1603 -0
- nrgrd/config/__init__.py +15 -0
- nrgrd/config/config.py +141 -0
- nrgrd/config/credentials.py +135 -0
- nrgrd/context/__init__.py +6 -0
- nrgrd/context/models.py +17 -0
- nrgrd/context/tokens.py +55 -0
- nrgrd/mcp.py +316 -0
- nrgrd/screens/__init__.py +3 -0
- nrgrd/screens/connect.py +232 -0
- nrgrd/sessions/__init__.py +13 -0
- nrgrd/sessions/manager.py +146 -0
- nrgrd/sessions/store.py +363 -0
- nrgrd/system/__init__.py +13 -0
- nrgrd/system/prompt.py +126 -0
- nrgrd/theme/__init__.py +0 -0
- nrgrd/theme/colors.py +23 -0
- nrgrd/tools/__init__.py +56 -0
- nrgrd/tools/filesystem.py +35 -0
- nrgrd/tools/git.py +45 -0
- nrgrd/tools/registry.py +107 -0
- nrgrd/tools/search.py +98 -0
- nrgrd/tools/shell.py +64 -0
- nrgrd/widgets/__init__.py +0 -0
- nrgrd/widgets/logo.py +178 -0
- nrgrd/workspace/__init__.py +12 -0
- nrgrd/workspace/discovery.py +131 -0
- nrgrd/workspace/identity.py +58 -0
- nrgrd/workspace/mcp_server.py +74 -0
- nrgrd/workspace/tools.py +304 -0
- nrgrd-0.1.0.dist-info/METADATA +188 -0
- nrgrd-0.1.0.dist-info/RECORD +44 -0
- nrgrd-0.1.0.dist-info/WHEEL +4 -0
- nrgrd-0.1.0.dist-info/entry_points.txt +3 -0
- nrgrd-0.1.0.dist-info/licenses/LICENSE +21 -0
nrgrd/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# Set at build time from the git tag, so there is one source of truth.
|
|
5
|
+
__version__ = version("nrgrd")
|
|
6
|
+
except PackageNotFoundError: # Running from a checkout that was never installed.
|
|
7
|
+
__version__ = "0.0.0"
|
nrgrd/__main__.py
ADDED
nrgrd/agent/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from nrgrd.agent.events import (
|
|
2
|
+
AgentCancelled,
|
|
3
|
+
AgentError,
|
|
4
|
+
AgentEvent,
|
|
5
|
+
AgentFinished,
|
|
6
|
+
AgentStarted,
|
|
7
|
+
AssistantChunk,
|
|
8
|
+
PermissionRequested,
|
|
9
|
+
ToolCallDenied,
|
|
10
|
+
ToolCallOutput,
|
|
11
|
+
ToolCallStarted,
|
|
12
|
+
)
|
|
13
|
+
from nrgrd.agent.loop import Agent
|
|
14
|
+
from nrgrd.agent.permissions import PermissionManager
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Agent",
|
|
18
|
+
"AgentCancelled",
|
|
19
|
+
"AgentError",
|
|
20
|
+
"AgentEvent",
|
|
21
|
+
"AgentFinished",
|
|
22
|
+
"AgentStarted",
|
|
23
|
+
"AssistantChunk",
|
|
24
|
+
"PermissionManager",
|
|
25
|
+
"PermissionRequested",
|
|
26
|
+
"ToolCallDenied",
|
|
27
|
+
"ToolCallOutput",
|
|
28
|
+
"ToolCallStarted",
|
|
29
|
+
]
|
nrgrd/agent/events.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Events emitted by the agent loop.
|
|
2
|
+
|
|
3
|
+
The TUI (or any other interface) renders these; the agent never renders
|
|
4
|
+
anything itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AgentStarted:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AssistantChunk:
|
|
17
|
+
"""A streamed slice of the assistant's reply."""
|
|
18
|
+
|
|
19
|
+
delta: str
|
|
20
|
+
text: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ToolCallStarted:
|
|
25
|
+
name: str
|
|
26
|
+
arguments: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ToolCallOutput:
|
|
31
|
+
name: str
|
|
32
|
+
result: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PermissionRequested:
|
|
37
|
+
"""The agent is about to ask whether a gated tool may run."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
arguments: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ToolCallDenied:
|
|
45
|
+
"""A tool call the permission system rejected."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
arguments: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class AgentError:
|
|
53
|
+
"""A failure, split so the interface can render it usefully.
|
|
54
|
+
|
|
55
|
+
``message`` says what happened, ``detail`` shows the configuration
|
|
56
|
+
involved, and ``hint`` says what to do next.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
message: str
|
|
60
|
+
detail: str = ""
|
|
61
|
+
hint: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class AgentCancelled:
|
|
66
|
+
"""The user interrupted. ``text`` is whatever had been generated."""
|
|
67
|
+
|
|
68
|
+
text: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class AgentFinished:
|
|
73
|
+
text: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
AgentEvent = (
|
|
77
|
+
AgentStarted
|
|
78
|
+
| AgentCancelled
|
|
79
|
+
| AssistantChunk
|
|
80
|
+
| PermissionRequested
|
|
81
|
+
| ToolCallStarted
|
|
82
|
+
| ToolCallOutput
|
|
83
|
+
| ToolCallDenied
|
|
84
|
+
| AgentError
|
|
85
|
+
| AgentFinished
|
|
86
|
+
)
|
nrgrd/agent/loop.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The nrgrd agent runtime: an iterative tool-use loop over an
|
|
2
|
+
OpenAI-compatible model.
|
|
3
|
+
|
|
4
|
+
This module has no dependency on Rich or Textual. It mutates the message
|
|
5
|
+
list it is given and yields events as they happen; it never prints or
|
|
6
|
+
renders. That keeps it usable headlessly (CLI, tests, future interfaces)
|
|
7
|
+
and testable with a mocked model provider.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from nrgrd.agent.events import (
|
|
15
|
+
AgentCancelled,
|
|
16
|
+
AgentError,
|
|
17
|
+
AgentEvent,
|
|
18
|
+
AgentFinished,
|
|
19
|
+
AgentStarted,
|
|
20
|
+
AssistantChunk,
|
|
21
|
+
PermissionRequested,
|
|
22
|
+
ToolCallDenied,
|
|
23
|
+
ToolCallOutput,
|
|
24
|
+
ToolCallStarted,
|
|
25
|
+
)
|
|
26
|
+
from nrgrd.agent.permissions import PermissionManager
|
|
27
|
+
from nrgrd.api.provider import ModelProvider, ProviderError
|
|
28
|
+
from nrgrd.tools.registry import PermissionLevel, ToolRegistry
|
|
29
|
+
|
|
30
|
+
MAX_ITERATIONS = 12
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Agent:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
provider: ModelProvider,
|
|
37
|
+
model: str,
|
|
38
|
+
tool_registry: ToolRegistry,
|
|
39
|
+
permissions: PermissionManager | None = None,
|
|
40
|
+
max_iterations: int = MAX_ITERATIONS,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.provider = provider
|
|
43
|
+
self.model = model
|
|
44
|
+
self.tool_registry = tool_registry
|
|
45
|
+
self.permissions = permissions or PermissionManager()
|
|
46
|
+
self.max_iterations = max_iterations
|
|
47
|
+
self._call_ids = itertools.count(1)
|
|
48
|
+
|
|
49
|
+
def run(self, messages: list[dict[str, Any]]) -> Iterator[AgentEvent]:
|
|
50
|
+
"""Run the tool-use loop, mutating ``messages`` in place."""
|
|
51
|
+
yield AgentStarted()
|
|
52
|
+
|
|
53
|
+
for _ in range(self.max_iterations):
|
|
54
|
+
response_text = ""
|
|
55
|
+
tool_calls: dict[int, dict[str, str]] = {}
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
for delta in self.provider.stream_chat(
|
|
59
|
+
model=self.model,
|
|
60
|
+
messages=messages,
|
|
61
|
+
tools=self.tool_registry.schemas(),
|
|
62
|
+
):
|
|
63
|
+
if delta.content:
|
|
64
|
+
response_text += delta.content
|
|
65
|
+
yield AssistantChunk(delta.content, response_text)
|
|
66
|
+
for fragment in delta.tool_calls:
|
|
67
|
+
# Some compatible servers omit ``index`` and send
|
|
68
|
+
# each call whole in one chunk. Keying those on
|
|
69
|
+
# None would glue separate calls into one garbled
|
|
70
|
+
# call, so give each its own slot instead.
|
|
71
|
+
slot = (
|
|
72
|
+
fragment.index
|
|
73
|
+
if fragment.index is not None
|
|
74
|
+
else len(tool_calls)
|
|
75
|
+
)
|
|
76
|
+
item = tool_calls.setdefault(
|
|
77
|
+
slot, {"id": "", "name": "", "arguments": ""}
|
|
78
|
+
)
|
|
79
|
+
if fragment.id:
|
|
80
|
+
item["id"] = fragment.id
|
|
81
|
+
if fragment.name:
|
|
82
|
+
item["name"] += fragment.name
|
|
83
|
+
if fragment.arguments:
|
|
84
|
+
item["arguments"] += fragment.arguments
|
|
85
|
+
except KeyboardInterrupt:
|
|
86
|
+
# Keep the partial reply: it is context the user may still
|
|
87
|
+
# want, and dropping it mid-turn loses their work.
|
|
88
|
+
yield AgentCancelled(response_text)
|
|
89
|
+
return
|
|
90
|
+
except ProviderError as error:
|
|
91
|
+
yield AgentError(error.summary, error.detail, error.hint)
|
|
92
|
+
return
|
|
93
|
+
except Exception as error:
|
|
94
|
+
yield AgentError(str(error))
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if not tool_calls:
|
|
98
|
+
yield AgentFinished(response_text)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
calls = list(tool_calls.values())
|
|
102
|
+
for call in calls:
|
|
103
|
+
# Tool results are matched to calls by id, and a blank id
|
|
104
|
+
# is rejected. Servers that do not send ids get stable,
|
|
105
|
+
# unique ones.
|
|
106
|
+
if not call["id"]:
|
|
107
|
+
call["id"] = f"call_{next(self._call_ids)}"
|
|
108
|
+
|
|
109
|
+
messages.append(
|
|
110
|
+
{
|
|
111
|
+
"role": "assistant",
|
|
112
|
+
"content": response_text or None,
|
|
113
|
+
"tool_calls": [
|
|
114
|
+
{
|
|
115
|
+
"id": call["id"],
|
|
116
|
+
"type": "function",
|
|
117
|
+
"function": {
|
|
118
|
+
"name": call["name"],
|
|
119
|
+
"arguments": call["arguments"],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
for call in calls
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
cancelled = False
|
|
128
|
+
for call in calls:
|
|
129
|
+
name, arguments = call["name"], call["arguments"]
|
|
130
|
+
tool = self.tool_registry.get(name)
|
|
131
|
+
|
|
132
|
+
if cancelled:
|
|
133
|
+
# Interrupted earlier in this batch: run nothing further,
|
|
134
|
+
# but still answer the call. An assistant message whose
|
|
135
|
+
# tool_calls are not all answered is rejected by the
|
|
136
|
+
# endpoint, so a half-filled batch would poison the
|
|
137
|
+
# conversation rather than simply end it.
|
|
138
|
+
result = "Cancelled by the user."
|
|
139
|
+
elif tool is None:
|
|
140
|
+
# Nothing would run, so there is nothing to approve:
|
|
141
|
+
# asking "allow delete_file?" for a tool that does not
|
|
142
|
+
# exist only confuses the user. Tell the model instead.
|
|
143
|
+
yield ToolCallStarted(name, arguments)
|
|
144
|
+
result = self.tool_registry.execute(name, arguments)
|
|
145
|
+
yield ToolCallOutput(name, result)
|
|
146
|
+
else:
|
|
147
|
+
level = tool.permission
|
|
148
|
+
|
|
149
|
+
if level is PermissionLevel.ASK:
|
|
150
|
+
# Yielded before asking, so an interface can stop
|
|
151
|
+
# any live rendering first; otherwise a refreshing
|
|
152
|
+
# stream panel draws over the prompt.
|
|
153
|
+
yield PermissionRequested(name, arguments)
|
|
154
|
+
|
|
155
|
+
if not self.permissions.check(name, arguments, level):
|
|
156
|
+
result = "Permission denied by the user."
|
|
157
|
+
yield ToolCallDenied(name, arguments)
|
|
158
|
+
else:
|
|
159
|
+
yield ToolCallStarted(name, arguments)
|
|
160
|
+
try:
|
|
161
|
+
result = self.tool_registry.execute(name, arguments)
|
|
162
|
+
except KeyboardInterrupt:
|
|
163
|
+
cancelled = True
|
|
164
|
+
result = "Cancelled by the user."
|
|
165
|
+
else:
|
|
166
|
+
yield ToolCallOutput(name, result)
|
|
167
|
+
|
|
168
|
+
messages.append(
|
|
169
|
+
{
|
|
170
|
+
"role": "tool",
|
|
171
|
+
"tool_call_id": call["id"],
|
|
172
|
+
"content": result,
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if cancelled:
|
|
177
|
+
yield AgentCancelled(response_text)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
yield AgentError(
|
|
181
|
+
"Tool call limit reached; please continue with a narrower request."
|
|
182
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Permission gating for tool calls.
|
|
2
|
+
|
|
3
|
+
``ALLOW`` and ``DENY`` are decided without user interaction. ``ASK`` defers
|
|
4
|
+
to a callback supplied by the interface (TUI or CLI); the manager never
|
|
5
|
+
silently downgrades ``ASK`` to allowed just because no callback was given.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from nrgrd.tools.registry import PermissionLevel
|
|
11
|
+
|
|
12
|
+
PermissionCallback = Callable[[str, str], str]
|
|
13
|
+
"""Given a tool name and its raw JSON arguments, return "yes", "no", or "always"."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PermissionManager:
|
|
17
|
+
def __init__(self, callback: PermissionCallback | None = None) -> None:
|
|
18
|
+
self.callback = callback
|
|
19
|
+
self._always_allowed: set[str] = set()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def session_grants(self) -> list[str]:
|
|
23
|
+
"""Tools the user chose to always allow for this session."""
|
|
24
|
+
return sorted(self._always_allowed)
|
|
25
|
+
|
|
26
|
+
def revoke_session_grants(self) -> None:
|
|
27
|
+
"""Forget every "always allow", so those tools ask again."""
|
|
28
|
+
self._always_allowed.clear()
|
|
29
|
+
|
|
30
|
+
def check(self, name: str, arguments: str, level: PermissionLevel) -> bool:
|
|
31
|
+
if level == PermissionLevel.ALLOW:
|
|
32
|
+
return True
|
|
33
|
+
if level == PermissionLevel.DENY:
|
|
34
|
+
return False
|
|
35
|
+
if name in self._always_allowed:
|
|
36
|
+
return True
|
|
37
|
+
if self.callback is None:
|
|
38
|
+
return False
|
|
39
|
+
decision = self.callback(name, arguments)
|
|
40
|
+
if decision == "always":
|
|
41
|
+
self._always_allowed.add(name)
|
|
42
|
+
return True
|
|
43
|
+
return decision == "yes"
|
nrgrd/api/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from nrgrd.api.openai_compatible import OpenAICompatibleProvider
|
|
2
|
+
from nrgrd.api.provider import (
|
|
3
|
+
ChatDelta,
|
|
4
|
+
ModelProvider,
|
|
5
|
+
ProviderError,
|
|
6
|
+
ToolCallDelta,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ChatDelta",
|
|
11
|
+
"ModelProvider",
|
|
12
|
+
"OpenAICompatibleProvider",
|
|
13
|
+
"ProviderError",
|
|
14
|
+
"ToolCallDelta",
|
|
15
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""A provider for endpoints that speak the OpenAI HTTP API.
|
|
2
|
+
|
|
3
|
+
This is not OpenAI. It is the *wire format*: a NeuroGrid deployment, vLLM,
|
|
4
|
+
Ollama, LM Studio, or anything else that serves ``/v1/chat/completions``
|
|
5
|
+
works here, because all nrgrd is given is a URL, a key, and a model name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from openai import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APIError,
|
|
14
|
+
APIStatusError,
|
|
15
|
+
APITimeoutError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
OpenAI,
|
|
19
|
+
PermissionDeniedError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from nrgrd.api.provider import ChatDelta, ModelProvider, ProviderError, ToolCallDelta
|
|
24
|
+
|
|
25
|
+
DEFAULT_TIMEOUT_SECONDS = 120.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenAICompatibleProvider(ModelProvider):
|
|
29
|
+
name = "OpenAI-compatible"
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
endpoint: str,
|
|
34
|
+
api_key: str,
|
|
35
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
36
|
+
client: Any | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.endpoint = endpoint.strip()
|
|
39
|
+
self._api_key = api_key.strip()
|
|
40
|
+
self._client = client or OpenAI(
|
|
41
|
+
base_url=self.endpoint,
|
|
42
|
+
api_key=self._api_key,
|
|
43
|
+
timeout=timeout,
|
|
44
|
+
max_retries=1,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def list_models(self) -> list[str]:
|
|
48
|
+
try:
|
|
49
|
+
response = self._client.models.list()
|
|
50
|
+
except Exception as error:
|
|
51
|
+
raise self._describe(error, model=None) from error
|
|
52
|
+
return sorted(item.id for item in response.data)
|
|
53
|
+
|
|
54
|
+
def stream_chat(
|
|
55
|
+
self,
|
|
56
|
+
model: str,
|
|
57
|
+
messages: Sequence[dict[str, Any]],
|
|
58
|
+
tools: Sequence[dict[str, Any]] | None = None,
|
|
59
|
+
) -> Iterator[ChatDelta]:
|
|
60
|
+
request: dict[str, Any] = {
|
|
61
|
+
"model": model,
|
|
62
|
+
"messages": list(messages),
|
|
63
|
+
"stream": True,
|
|
64
|
+
}
|
|
65
|
+
# Some compatible servers reject an empty tools array, so only send
|
|
66
|
+
# the key when there is something to send.
|
|
67
|
+
if tools:
|
|
68
|
+
request["tools"] = list(tools)
|
|
69
|
+
request["tool_choice"] = "auto"
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
stream = self._client.chat.completions.create(**request)
|
|
73
|
+
for chunk in stream:
|
|
74
|
+
if not chunk.choices:
|
|
75
|
+
continue
|
|
76
|
+
delta = chunk.choices[0].delta
|
|
77
|
+
calls = tuple(
|
|
78
|
+
ToolCallDelta(
|
|
79
|
+
index=call.index,
|
|
80
|
+
id=call.id,
|
|
81
|
+
name=call.function.name if call.function else None,
|
|
82
|
+
arguments=call.function.arguments if call.function else None,
|
|
83
|
+
)
|
|
84
|
+
for call in (delta.tool_calls or [])
|
|
85
|
+
)
|
|
86
|
+
if delta.content or calls:
|
|
87
|
+
yield ChatDelta(content=delta.content, tool_calls=calls)
|
|
88
|
+
except ProviderError:
|
|
89
|
+
raise
|
|
90
|
+
except Exception as error:
|
|
91
|
+
raise self._describe(error, model=model) from error
|
|
92
|
+
|
|
93
|
+
def complete_chat(
|
|
94
|
+
self,
|
|
95
|
+
model: str,
|
|
96
|
+
messages: Sequence[dict[str, Any]],
|
|
97
|
+
) -> str:
|
|
98
|
+
try:
|
|
99
|
+
response = self._client.chat.completions.create(
|
|
100
|
+
model=model,
|
|
101
|
+
messages=list(messages),
|
|
102
|
+
stream=False,
|
|
103
|
+
)
|
|
104
|
+
except Exception as error:
|
|
105
|
+
raise self._describe(error, model=model) from error
|
|
106
|
+
return response.choices[0].message.content or ""
|
|
107
|
+
|
|
108
|
+
def _redact(self, text: str) -> str:
|
|
109
|
+
"""Strip the API key out of anything on its way to the user."""
|
|
110
|
+
if self._api_key and self._api_key in text:
|
|
111
|
+
return text.replace(self._api_key, "***")
|
|
112
|
+
return text
|
|
113
|
+
|
|
114
|
+
def _configuration(self, model: str | None) -> str:
|
|
115
|
+
lines = [f"Endpoint: {self.endpoint or '(not set)'}"]
|
|
116
|
+
if model:
|
|
117
|
+
lines.append(f"Model: {model}")
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
def _describe(self, error: Exception, model: str | None) -> ProviderError:
|
|
121
|
+
"""Turn a transport-level failure into something actionable."""
|
|
122
|
+
detail = self._configuration(model)
|
|
123
|
+
change = "Run /config edit to change the configuration."
|
|
124
|
+
|
|
125
|
+
if isinstance(error, AuthenticationError):
|
|
126
|
+
return ProviderError(
|
|
127
|
+
"The endpoint rejected the API key.",
|
|
128
|
+
detail,
|
|
129
|
+
f"Check the key issued with this deployment. {change}",
|
|
130
|
+
)
|
|
131
|
+
if isinstance(error, PermissionDeniedError):
|
|
132
|
+
return ProviderError(
|
|
133
|
+
"The endpoint refused this request (HTTP 403).",
|
|
134
|
+
detail,
|
|
135
|
+
f"The key may not cover this model or route. {change}",
|
|
136
|
+
)
|
|
137
|
+
if isinstance(error, NotFoundError):
|
|
138
|
+
return ProviderError(
|
|
139
|
+
"The endpoint returned HTTP 404 (not found).",
|
|
140
|
+
detail,
|
|
141
|
+
"Check the endpoint path (it usually ends in /v1) and that "
|
|
142
|
+
f"the model name exists. {change}",
|
|
143
|
+
)
|
|
144
|
+
if isinstance(error, RateLimitError):
|
|
145
|
+
return ProviderError(
|
|
146
|
+
"The endpoint is rate limiting requests (HTTP 429).",
|
|
147
|
+
detail,
|
|
148
|
+
"Wait a moment and try again.",
|
|
149
|
+
)
|
|
150
|
+
if isinstance(error, APITimeoutError):
|
|
151
|
+
return ProviderError(
|
|
152
|
+
"The endpoint timed out.",
|
|
153
|
+
detail,
|
|
154
|
+
"The deployment may be loading the model or overloaded. "
|
|
155
|
+
"Try again shortly.",
|
|
156
|
+
)
|
|
157
|
+
if isinstance(error, APIConnectionError):
|
|
158
|
+
return ProviderError(
|
|
159
|
+
"Could not reach the endpoint.",
|
|
160
|
+
detail,
|
|
161
|
+
"Check the URL is right, the deployment is still running, "
|
|
162
|
+
f"and that you are online. {change}",
|
|
163
|
+
)
|
|
164
|
+
if isinstance(error, APIStatusError):
|
|
165
|
+
return ProviderError(
|
|
166
|
+
f"The endpoint returned HTTP {error.status_code}.",
|
|
167
|
+
detail,
|
|
168
|
+
self._redact(str(getattr(error, "message", "")).strip()) or change,
|
|
169
|
+
)
|
|
170
|
+
if not isinstance(error, APIError):
|
|
171
|
+
# The request itself went through, but the reply could not be
|
|
172
|
+
# read as an API response. Almost always the URL points at
|
|
173
|
+
# something that is not an OpenAI-compatible API — a web UI, a
|
|
174
|
+
# proxy error page, or simply the wrong port.
|
|
175
|
+
return ProviderError(
|
|
176
|
+
"The endpoint replied with something that is not an "
|
|
177
|
+
"OpenAI-compatible API response.",
|
|
178
|
+
detail,
|
|
179
|
+
"Check the URL points at the API itself (it usually ends "
|
|
180
|
+
f"in /v1) rather than a web page. {change}",
|
|
181
|
+
)
|
|
182
|
+
return ProviderError(
|
|
183
|
+
"The model request failed.",
|
|
184
|
+
detail,
|
|
185
|
+
self._redact(str(error)),
|
|
186
|
+
)
|
nrgrd/api/provider.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The model provider interface the agent runtime talks to.
|
|
2
|
+
|
|
3
|
+
The agent never learns which service is behind the endpoint. A provider owns
|
|
4
|
+
HTTP communication, authentication, streaming, and turning wire-level
|
|
5
|
+
failures into :class:`ProviderError`s a user can act on; the agent owns
|
|
6
|
+
context, tools, permissions, and iteration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Iterator, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ToolCallDelta:
|
|
17
|
+
"""A slice of one streamed tool call.
|
|
18
|
+
|
|
19
|
+
Providers stream tool calls in fragments: the name arrives in one
|
|
20
|
+
chunk, the arguments across several more. ``index`` identifies which
|
|
21
|
+
call a fragment belongs to.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
index: int
|
|
25
|
+
id: str | None = None
|
|
26
|
+
name: str | None = None
|
|
27
|
+
arguments: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ChatDelta:
|
|
32
|
+
"""One streamed step of a reply: text, tool-call fragments, or both."""
|
|
33
|
+
|
|
34
|
+
content: str | None = None
|
|
35
|
+
tool_calls: tuple[ToolCallDelta, ...] = ()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProviderError(Exception):
|
|
39
|
+
"""A model request failed, described in terms the user can act on.
|
|
40
|
+
|
|
41
|
+
``summary`` says what happened, ``detail`` shows the configuration the
|
|
42
|
+
request used, and ``hint`` says what to do about it. None of them may
|
|
43
|
+
ever contain the API key.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, summary: str, detail: str = "", hint: str = "") -> None:
|
|
47
|
+
super().__init__(summary)
|
|
48
|
+
self.summary = summary
|
|
49
|
+
self.detail = detail
|
|
50
|
+
self.hint = hint
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ModelProvider(ABC):
|
|
54
|
+
"""Any endpoint the agent can talk to."""
|
|
55
|
+
|
|
56
|
+
name: str = "model provider"
|
|
57
|
+
endpoint: str = ""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def list_models(self) -> list[str]:
|
|
61
|
+
"""Return the model ids the endpoint exposes."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def stream_chat(
|
|
65
|
+
self,
|
|
66
|
+
model: str,
|
|
67
|
+
messages: Sequence[dict[str, Any]],
|
|
68
|
+
tools: Sequence[dict[str, Any]] | None = None,
|
|
69
|
+
) -> Iterator[ChatDelta]:
|
|
70
|
+
"""Stream a reply, yielding normalized deltas as they arrive."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def complete_chat(
|
|
74
|
+
self,
|
|
75
|
+
model: str,
|
|
76
|
+
messages: Sequence[dict[str, Any]],
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Return a complete reply in one shot (used for compaction)."""
|