toolproxy 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,409 @@
1
+ """
2
+ LLM client abstraction layer for toolproxy.
3
+
4
+ Provides a common interface (LLMClient) over different backends:
5
+ - OpenRouterClient (OpenRouter via OpenAI-compatible API)
6
+ - OpenAIClient (Direct OpenAI or OpenAI-compatible endpoint)
7
+ - OllamaClient (Local Ollama server)
8
+ - MockClient (For testing without real API keys)
9
+
10
+ Factory function:
11
+ get_client(model, **kwargs) -> LLMClient
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from abc import ABC, abstractmethod
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ import httpx
21
+
22
+ from .config import model_supports_native_tools
23
+ from .exceptions import ModelError
24
+ from .schemas import Message, ToolCall
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Provider-agnostic response
29
+ # ---------------------------------------------------------------------------
30
+
31
+ class ModelResponse:
32
+ """
33
+ Provider-agnostic response from an LLM.
34
+
35
+ raw_text — the plain-text content of the response (may be empty if
36
+ the model produced only tool calls).
37
+ tool_calls — list of native ToolCall objects (empty if none).
38
+ raw — the full provider response dict, for debugging.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ raw_text: str = "",
44
+ tool_calls: Optional[List[ToolCall]] = None,
45
+ raw: Optional[Dict[str, Any]] = None,
46
+ ) -> None:
47
+ self.raw_text = raw_text
48
+ self.tool_calls: List[ToolCall] = tool_calls or []
49
+ self.raw = raw or {}
50
+
51
+ @property
52
+ def has_tool_calls(self) -> bool:
53
+ return bool(self.tool_calls)
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Abstract base
58
+ # ---------------------------------------------------------------------------
59
+
60
+ class LLMClient(ABC):
61
+ """Abstract interface all LLM clients must implement."""
62
+
63
+ @abstractmethod
64
+ def generate(
65
+ self,
66
+ messages: List[Message],
67
+ tools: Optional[List[Dict[str, Any]]] = None,
68
+ ) -> ModelResponse:
69
+ """
70
+ Send *messages* to the model and return a ModelResponse.
71
+
72
+ If *tools* is provided and the backend supports native tools, pass
73
+ them to the provider; otherwise ignore.
74
+ """
75
+
76
+ @abstractmethod
77
+ def supports_native_tools(self) -> bool:
78
+ """Return True if this client/model supports native tool calling."""
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _messages_to_openai(messages: List[Message]) -> List[Dict[str, Any]]:
86
+ """Convert internal Message objects to OpenAI chat format."""
87
+ result = []
88
+ for msg in messages:
89
+ d: Dict[str, Any] = {"role": msg.role, "content": msg.content}
90
+ if msg.role == "tool":
91
+ d["tool_call_id"] = msg.tool_call_id or "unknown"
92
+ d["name"] = msg.tool_name or "unknown"
93
+ result.append(d)
94
+ return result
95
+
96
+
97
+ def _parse_native_tool_calls(raw_calls: List[Dict[str, Any]]) -> List[ToolCall]:
98
+ """Parse OpenAI-format tool_calls into internal ToolCall objects."""
99
+ parsed = []
100
+ for c in raw_calls:
101
+ fn = c.get("function", {})
102
+ try:
103
+ args = json.loads(fn.get("arguments", "{}"))
104
+ except json.JSONDecodeError:
105
+ args = {}
106
+ parsed.append(
107
+ ToolCall(
108
+ tool_name=fn.get("name", ""),
109
+ arguments=args,
110
+ call_id=c.get("id"),
111
+ )
112
+ )
113
+ return parsed
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # OpenRouter client (uses OpenAI-compatible REST API)
118
+ # ---------------------------------------------------------------------------
119
+
120
+ class OpenRouterClient(LLMClient):
121
+ """
122
+ Client for the OpenRouter API.
123
+
124
+ Reads OPENROUTER_API_KEY from the environment unless *api_key* is given.
125
+ Native tool support is determined by the MODEL_TOOL_SUPPORT map (see config).
126
+ """
127
+
128
+ BASE_URL = "https://openrouter.ai/api/v1"
129
+
130
+ def __init__(
131
+ self,
132
+ model: str,
133
+ api_key: Optional[str] = None,
134
+ native_tools: Optional[bool] = None,
135
+ timeout: float = 60.0,
136
+ ) -> None:
137
+ self.model = model
138
+ self._api_key = api_key or os.environ.get("OPENROUTER_API_KEY", "")
139
+ self._native_tools = (
140
+ native_tools
141
+ if native_tools is not None
142
+ else model_supports_native_tools(model)
143
+ )
144
+ self._timeout = timeout
145
+
146
+ def supports_native_tools(self) -> bool:
147
+ return self._native_tools
148
+
149
+ def generate(
150
+ self,
151
+ messages: List[Message],
152
+ tools: Optional[List[Dict[str, Any]]] = None,
153
+ ) -> ModelResponse:
154
+ headers = {
155
+ "Authorization": f"Bearer {self._api_key}",
156
+ "Content-Type": "application/json",
157
+ "HTTP-Referer": "https://github.com/universal-agent",
158
+ "X-Title": "universal-agent",
159
+ }
160
+ body: Dict[str, Any] = {
161
+ "model": self.model,
162
+ "messages": _messages_to_openai(messages),
163
+ }
164
+ if tools and self._native_tools:
165
+ body["tools"] = tools
166
+ body["tool_choice"] = "auto"
167
+
168
+ try:
169
+ resp = httpx.post(
170
+ f"{self.BASE_URL}/chat/completions",
171
+ headers=headers,
172
+ json=body,
173
+ timeout=self._timeout,
174
+ )
175
+ resp.raise_for_status()
176
+ except httpx.HTTPStatusError as exc:
177
+ raise ModelError(f"HTTP {exc.response.status_code}: {exc.response.text}") from exc
178
+ except httpx.RequestError as exc:
179
+ raise ModelError(f"Request failed: {exc}") from exc
180
+
181
+ data = resp.json()
182
+ return self._parse_response(data)
183
+
184
+ def _parse_response(self, data: Dict[str, Any]) -> ModelResponse:
185
+ choices = data.get("choices", [])
186
+ if not choices:
187
+ raise ModelError("No choices in model response.")
188
+ msg = choices[0].get("message", {})
189
+ raw_text = msg.get("content") or ""
190
+ native_calls = msg.get("tool_calls") or []
191
+ tool_calls = _parse_native_tool_calls(native_calls) if native_calls else []
192
+ return ModelResponse(raw_text=raw_text, tool_calls=tool_calls, raw=data)
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # OpenAI client (direct OpenAI or any OpenAI-compatible endpoint)
197
+ # ---------------------------------------------------------------------------
198
+
199
+ class OpenAIClient(LLMClient):
200
+ """
201
+ Client for OpenAI or any OpenAI-compatible endpoint (e.g., Together, Anyscale).
202
+ """
203
+
204
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
205
+
206
+ def __init__(
207
+ self,
208
+ model: str,
209
+ api_key: Optional[str] = None,
210
+ base_url: Optional[str] = None,
211
+ native_tools: Optional[bool] = None,
212
+ timeout: float = 60.0,
213
+ ) -> None:
214
+ self.model = model
215
+ self._api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
216
+ self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
217
+ self._native_tools = (
218
+ native_tools
219
+ if native_tools is not None
220
+ else model_supports_native_tools(model)
221
+ )
222
+ self._timeout = timeout
223
+
224
+ def supports_native_tools(self) -> bool:
225
+ return self._native_tools
226
+
227
+ def generate(
228
+ self,
229
+ messages: List[Message],
230
+ tools: Optional[List[Dict[str, Any]]] = None,
231
+ ) -> ModelResponse:
232
+ headers = {
233
+ "Authorization": f"Bearer {self._api_key}",
234
+ "Content-Type": "application/json",
235
+ }
236
+ body: Dict[str, Any] = {
237
+ "model": self.model,
238
+ "messages": _messages_to_openai(messages),
239
+ }
240
+ if tools and self._native_tools:
241
+ body["tools"] = tools
242
+ body["tool_choice"] = "auto"
243
+
244
+ try:
245
+ resp = httpx.post(
246
+ f"{self._base_url}/chat/completions",
247
+ headers=headers,
248
+ json=body,
249
+ timeout=self._timeout,
250
+ )
251
+ resp.raise_for_status()
252
+ except httpx.HTTPStatusError as exc:
253
+ raise ModelError(f"HTTP {exc.response.status_code}: {exc.response.text}") from exc
254
+ except httpx.RequestError as exc:
255
+ raise ModelError(f"Request failed: {exc}") from exc
256
+
257
+ data = resp.json()
258
+ choices = data.get("choices", [])
259
+ if not choices:
260
+ raise ModelError("No choices in model response.")
261
+ msg = choices[0].get("message", {})
262
+ raw_text = msg.get("content") or ""
263
+ native_calls = msg.get("tool_calls") or []
264
+ tool_calls = _parse_native_tool_calls(native_calls) if native_calls else []
265
+ return ModelResponse(raw_text=raw_text, tool_calls=tool_calls, raw=data)
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Ollama client (local HTTP server, tool support = False by default)
270
+ # ---------------------------------------------------------------------------
271
+
272
+ class OllamaClient(LLMClient):
273
+ """
274
+ Client for a local Ollama server.
275
+
276
+ Default base URL: http://localhost:11434
277
+ Tool calling: disabled by default (emulated mode).
278
+ """
279
+
280
+ DEFAULT_BASE_URL = "http://localhost:11434"
281
+
282
+ def __init__(
283
+ self,
284
+ model: str,
285
+ base_url: Optional[str] = None,
286
+ native_tools: bool = False,
287
+ timeout: float = 120.0,
288
+ ) -> None:
289
+ self.model = model
290
+ self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
291
+ self._native_tools = native_tools
292
+ self._timeout = timeout
293
+
294
+ def supports_native_tools(self) -> bool:
295
+ return self._native_tools
296
+
297
+ def generate(
298
+ self,
299
+ messages: List[Message],
300
+ tools: Optional[List[Dict[str, Any]]] = None,
301
+ ) -> ModelResponse:
302
+ body: Dict[str, Any] = {
303
+ "model": self.model,
304
+ "messages": _messages_to_openai(messages),
305
+ "stream": False,
306
+ }
307
+ if tools and self._native_tools:
308
+ body["tools"] = tools
309
+
310
+ try:
311
+ resp = httpx.post(
312
+ f"{self._base_url}/api/chat",
313
+ json=body,
314
+ timeout=self._timeout,
315
+ )
316
+ resp.raise_for_status()
317
+ except httpx.HTTPStatusError as exc:
318
+ raise ModelError(f"HTTP {exc.response.status_code}: {exc.response.text}") from exc
319
+ except httpx.RequestError as exc:
320
+ raise ModelError(f"Request failed: {exc}") from exc
321
+
322
+ data = resp.json()
323
+ msg = data.get("message", {})
324
+ raw_text = msg.get("content") or ""
325
+ return ModelResponse(raw_text=raw_text, raw=data)
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Mock client (for testing / demos without real API)
330
+ # ---------------------------------------------------------------------------
331
+
332
+ class MockClient(LLMClient):
333
+ """
334
+ A mock LLM client for unit tests.
335
+
336
+ Provide a list of pre-programmed responses; each call pops the next one.
337
+ Responses can be raw text strings or ModelResponse objects.
338
+ """
339
+
340
+ def __init__(
341
+ self,
342
+ responses: Optional[List[Any]] = None,
343
+ native_tools: bool = False,
344
+ ) -> None:
345
+ self._responses: List[Any] = list(responses or [])
346
+ self._native_tools = native_tools
347
+ self.call_count = 0
348
+ self.call_history: List[List[Message]] = []
349
+
350
+ def supports_native_tools(self) -> bool:
351
+ return self._native_tools
352
+
353
+ def generate(
354
+ self,
355
+ messages: List[Message],
356
+ tools: Optional[List[Dict[str, Any]]] = None,
357
+ ) -> ModelResponse:
358
+ self.call_history.append(messages)
359
+ self.call_count += 1
360
+ if not self._responses:
361
+ return ModelResponse(raw_text='{"type": "final", "content": "No more responses."}')
362
+ resp = self._responses.pop(0)
363
+ if isinstance(resp, ModelResponse):
364
+ return resp
365
+ return ModelResponse(raw_text=str(resp))
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Client factory
370
+ # ---------------------------------------------------------------------------
371
+
372
+ def get_client(
373
+ model: str,
374
+ api_key: Optional[str] = None,
375
+ base_url: Optional[str] = None,
376
+ native_tools: Optional[bool] = None,
377
+ ) -> LLMClient:
378
+ """
379
+ Create and return the appropriate LLMClient for *model*.
380
+
381
+ Model routing:
382
+ - Starts with "openrouter/" → OpenRouterClient (strips prefix)
383
+ - Starts with "ollama/" → OllamaClient (strips prefix)
384
+ - Starts with "mock/" → MockClient (for testing)
385
+ - Everything else → OpenAIClient
386
+ """
387
+ if model.startswith("openrouter/"):
388
+ actual_model = model[len("openrouter/"):]
389
+ return OpenRouterClient(
390
+ model=actual_model,
391
+ api_key=api_key,
392
+ native_tools=native_tools,
393
+ )
394
+ if model.startswith("ollama/"):
395
+ actual_model = model[len("ollama/"):]
396
+ return OllamaClient(
397
+ model=actual_model,
398
+ base_url=base_url,
399
+ native_tools=native_tools or False,
400
+ )
401
+ if model.startswith("mock/"):
402
+ return MockClient(native_tools=native_tools or False)
403
+ # Default: OpenAI-compatible
404
+ return OpenAIClient(
405
+ model=model,
406
+ api_key=api_key,
407
+ base_url=base_url,
408
+ native_tools=native_tools,
409
+ )
toolproxy/loop.py ADDED
@@ -0,0 +1,180 @@
1
+ """
2
+ Loop controller for toolproxy.
3
+
4
+ Orchestrates the full agent execution cycle:
5
+ 1. Inject system + user messages.
6
+ 2. Ask the Planner what to do (tool call or final answer).
7
+ 3. Execute tools via the Executor.
8
+ 4. Append results to the conversation and loop.
9
+ 5. Return AgentResponse (with optional trace).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Callable, List, Optional, Union
14
+
15
+ from .exceptions import MaxStepsExceededError
16
+ from .executor import Executor
17
+ from .planner import Planner
18
+ from .schemas import (
19
+ Action,
20
+ AgentResponse,
21
+ AgentTrace,
22
+ Message,
23
+ ToolCall,
24
+ ToolResult,
25
+ TraceEntry,
26
+ )
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Callback type aliases
31
+ # ---------------------------------------------------------------------------
32
+ OnToolCallCb = Callable[[int, ToolCall], None]
33
+ OnToolResultCb = Callable[[int, ToolResult], None]
34
+ OnModelOutputCb = Callable[[int, str], None]
35
+
36
+
37
+ class LoopController:
38
+ """
39
+ Runs the main agent loop until a final answer or max_steps is reached.
40
+
41
+ Callbacks (all optional):
42
+ - on_tool_call(step, tool_call)
43
+ - on_tool_result(step, tool_result)
44
+ - on_model_output(step, raw_text)
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ planner: Planner,
50
+ executor: Executor,
51
+ max_steps: int = 10,
52
+ return_trace: bool = False,
53
+ ) -> None:
54
+ self._planner = planner
55
+ self._executor = executor
56
+ self._max_steps = max_steps
57
+ self._return_trace = return_trace
58
+
59
+ # ------------------------------------------------------------------
60
+ # Main run method
61
+ # ------------------------------------------------------------------
62
+
63
+ def run(
64
+ self,
65
+ messages: List[Message],
66
+ on_tool_call: Optional[OnToolCallCb] = None,
67
+ on_tool_result: Optional[OnToolResultCb] = None,
68
+ on_model_output: Optional[OnModelOutputCb] = None,
69
+ ) -> AgentResponse:
70
+ """
71
+ Execute the agent loop and return the final AgentResponse.
72
+
73
+ Parameters
74
+ ----------
75
+ messages:
76
+ Initial conversation (system + user messages).
77
+ on_tool_call / on_tool_result / on_model_output:
78
+ Optional streaming callbacks fired at each step.
79
+ """
80
+ conversation: List[Message] = list(messages)
81
+ trace = AgentTrace() if self._return_trace else None
82
+ steps_taken = 0
83
+
84
+ for step in range(1, self._max_steps + 1):
85
+ steps_taken = step
86
+
87
+ # Ask the planner
88
+ plan_result: Union[List[ToolCall], Action] = self._planner.plan(conversation)
89
+
90
+ # ----------------------------------------------------------
91
+ # Handle result
92
+ # ----------------------------------------------------------
93
+ if isinstance(plan_result, list):
94
+ # Native mode: list of tool calls
95
+ tool_calls = plan_result
96
+ if not tool_calls:
97
+ # Unexpected: no calls and no final answer — treat as done
98
+ return AgentResponse(
99
+ content="(No response from model)",
100
+ trace=trace,
101
+ steps_taken=steps_taken,
102
+ )
103
+
104
+ for tc in tool_calls:
105
+ if on_tool_call:
106
+ on_tool_call(step, tc)
107
+
108
+ result = self._executor.execute(tc)
109
+
110
+ if on_tool_result:
111
+ on_tool_result(step, result)
112
+
113
+ if trace is not None:
114
+ trace.steps.append(
115
+ TraceEntry(step=step, tool_call=tc, tool_result=result)
116
+ )
117
+
118
+ # Append tool result to conversation
119
+ conversation.append(
120
+ Message(
121
+ role="tool",
122
+ content=result.as_message_content(),
123
+ tool_name=result.tool_name,
124
+ tool_call_id=result.call_id,
125
+ )
126
+ )
127
+
128
+ elif isinstance(plan_result, Action):
129
+ action = plan_result
130
+
131
+ if on_model_output:
132
+ on_model_output(step, action.content or "")
133
+
134
+ if action.type == "final":
135
+ if trace is not None:
136
+ trace.steps.append(
137
+ TraceEntry(step=step, model_output=action.content)
138
+ )
139
+ return AgentResponse(
140
+ content=action.content or "",
141
+ trace=trace,
142
+ steps_taken=steps_taken,
143
+ )
144
+
145
+ # Emulated tool call
146
+ if action.tool is None:
147
+ # Shouldn't happen (planner validates), but guard anyway
148
+ raise ValueError("Action type='tool_call' has no tool field.")
149
+
150
+ tc = action.tool
151
+ if on_tool_call:
152
+ on_tool_call(step, tc)
153
+
154
+ result = self._executor.execute(tc)
155
+
156
+ if on_tool_result:
157
+ on_tool_result(step, result)
158
+
159
+ if trace is not None:
160
+ trace.steps.append(
161
+ TraceEntry(step=step, tool_call=tc, tool_result=result)
162
+ )
163
+
164
+ # Append tool result to conversation as an assistant + user pair
165
+ # so emulated mode can follow the result
166
+ conversation.append(
167
+ Message(
168
+ role="assistant",
169
+ content=f"[Calling tool: {tc.tool_name}]",
170
+ )
171
+ )
172
+ conversation.append(
173
+ Message(
174
+ role="user",
175
+ content=result.as_message_content(),
176
+ )
177
+ )
178
+
179
+ # Max steps exceeded
180
+ raise MaxStepsExceededError(self._max_steps)