toolproxy 0.2.0__tar.gz → 0.3.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.
- {toolproxy-0.2.0 → toolproxy-0.3.0}/PKG-INFO +13 -1
- {toolproxy-0.2.0 → toolproxy-0.3.0}/pyproject.toml +22 -1
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/__init__.py +52 -2
- toolproxy-0.3.0/src/toolproxy/adapters/__init__.py +27 -0
- toolproxy-0.3.0/src/toolproxy/adapters/anthropic.py +242 -0
- toolproxy-0.3.0/src/toolproxy/adapters/azure.py +213 -0
- toolproxy-0.3.0/src/toolproxy/adapters/cohere.py +83 -0
- toolproxy-0.3.0/src/toolproxy/adapters/gemini.py +89 -0
- toolproxy-0.3.0/src/toolproxy/adapters/groq.py +83 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/agent.py +127 -47
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/llm_client.py +42 -5
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/loop.py +113 -42
- toolproxy-0.3.0/src/toolproxy/mcp.py +344 -0
- toolproxy-0.3.0/src/toolproxy/memory.py +222 -0
- toolproxy-0.3.0/src/toolproxy/observability/__init__.py +24 -0
- toolproxy-0.3.0/src/toolproxy/observability/base.py +53 -0
- toolproxy-0.3.0/src/toolproxy/observability/file.py +146 -0
- toolproxy-0.3.0/src/toolproxy/observability/otel.py +116 -0
- toolproxy-0.3.0/src/toolproxy/observability/print_tracer.py +121 -0
- toolproxy-0.3.0/tests/test_adapters.py +335 -0
- toolproxy-0.3.0/tests/test_mcp.py +388 -0
- toolproxy-0.3.0/tests/test_memory.py +251 -0
- toolproxy-0.3.0/tests/test_observability.py +284 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/.gitignore +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/LICENSE +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/README.md +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/examples/basic_chat.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/examples/local_ollama.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/examples/openrouter_tools.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/config.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/exceptions.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/executor.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/planner.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/py.typed +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/schemas.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/tools.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/conftest.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_agent_basic.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_async_agent.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_emulated_mode.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_error_handling.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_native_mode.py +0 -0
- {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_tool_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: toolproxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Universal tool-calling wrapper for non-tool-native LLMs — emulates function calling via structured JSON planning
|
|
5
5
|
Project-URL: Homepage, https://github.com/yourusername/toolproxy
|
|
6
6
|
Project-URL: Repository, https://github.com/yourusername/toolproxy
|
|
@@ -24,12 +24,24 @@ Requires-Python: >=3.10
|
|
|
24
24
|
Requires-Dist: httpx>=0.25
|
|
25
25
|
Requires-Dist: openai>=1.0
|
|
26
26
|
Requires-Dist: pydantic>=2.0
|
|
27
|
+
Provides-Extra: anthropic
|
|
28
|
+
Requires-Dist: anthropic>=0.25; extra == 'anthropic'
|
|
29
|
+
Provides-Extra: cohere
|
|
27
30
|
Provides-Extra: dev
|
|
28
31
|
Requires-Dist: build>=1.0; extra == 'dev'
|
|
29
32
|
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
30
33
|
Requires-Dist: pytest-mock>=3.0; extra == 'dev'
|
|
31
34
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
32
35
|
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
36
|
+
Provides-Extra: full
|
|
37
|
+
Requires-Dist: anthropic>=0.25; extra == 'full'
|
|
38
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'full'
|
|
39
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'full'
|
|
40
|
+
Provides-Extra: gemini
|
|
41
|
+
Provides-Extra: groq
|
|
42
|
+
Provides-Extra: otel
|
|
43
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
|
|
44
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'otel'
|
|
33
45
|
Description-Content-Type: text/markdown
|
|
34
46
|
|
|
35
47
|
# toolproxy
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "toolproxy"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Universal tool-calling wrapper for non-tool-native LLMs — emulates function calling via structured JSON planning"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -46,6 +46,27 @@ dev = [
|
|
|
46
46
|
"build>=1.0",
|
|
47
47
|
"twine>=5.0",
|
|
48
48
|
]
|
|
49
|
+
anthropic = [
|
|
50
|
+
"anthropic>=0.25",
|
|
51
|
+
]
|
|
52
|
+
gemini = [
|
|
53
|
+
# No extra package needed — uses Google's OpenAI-compat endpoint
|
|
54
|
+
]
|
|
55
|
+
groq = [
|
|
56
|
+
# No extra package needed — uses Groq's OpenAI-compat endpoint
|
|
57
|
+
]
|
|
58
|
+
cohere = [
|
|
59
|
+
# No extra package needed — uses Cohere's OpenAI-compat endpoint
|
|
60
|
+
]
|
|
61
|
+
otel = [
|
|
62
|
+
"opentelemetry-sdk>=1.20",
|
|
63
|
+
"opentelemetry-api>=1.20",
|
|
64
|
+
]
|
|
65
|
+
full = [
|
|
66
|
+
"anthropic>=0.25",
|
|
67
|
+
"opentelemetry-sdk>=1.20",
|
|
68
|
+
"opentelemetry-api>=1.20",
|
|
69
|
+
]
|
|
49
70
|
|
|
50
71
|
[project.urls]
|
|
51
72
|
Homepage = "https://github.com/yourusername/toolproxy"
|
|
@@ -13,6 +13,18 @@ Public API:
|
|
|
13
13
|
Action — emulated-mode structured output
|
|
14
14
|
StreamChunk — (v0.2) streaming event from agent.stream()
|
|
15
15
|
|
|
16
|
+
Memory (v0.3):
|
|
17
|
+
BaseMemory, ConversationBuffer, SlidingWindowMemory, TokenWindowMemory
|
|
18
|
+
|
|
19
|
+
Observability (v0.3):
|
|
20
|
+
BaseTracer, FileTracer, PrintTracer, OpenTelemetryTracer
|
|
21
|
+
|
|
22
|
+
Adapters (v0.3):
|
|
23
|
+
AnthropicClient, GeminiClient, AzureOpenAIClient, GroqClient, CohereClient
|
|
24
|
+
|
|
25
|
+
MCP (v0.3):
|
|
26
|
+
MCPToolset, MCPClient, MCPToolInfo
|
|
27
|
+
|
|
16
28
|
Utilities:
|
|
17
29
|
probe_tool_support — (v0.2) async helper to detect native tool support
|
|
18
30
|
|
|
@@ -57,6 +69,24 @@ from .schemas import (
|
|
|
57
69
|
)
|
|
58
70
|
from .tools import ToolDefinition, ToolRegistry, tool
|
|
59
71
|
|
|
72
|
+
# v0.3 — Memory
|
|
73
|
+
from .memory import BaseMemory, ConversationBuffer, SlidingWindowMemory, TokenWindowMemory
|
|
74
|
+
|
|
75
|
+
# v0.3 — Observability
|
|
76
|
+
from .observability import BaseTracer, FileTracer, PrintTracer, OpenTelemetryTracer
|
|
77
|
+
|
|
78
|
+
# v0.3 — Adapters (lazy imports — no hard deps until used)
|
|
79
|
+
from .adapters import (
|
|
80
|
+
AnthropicClient,
|
|
81
|
+
GeminiClient,
|
|
82
|
+
AzureOpenAIClient,
|
|
83
|
+
GroqClient,
|
|
84
|
+
CohereClient,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# v0.3 — MCP
|
|
88
|
+
from .mcp import MCPClient, MCPToolInfo, MCPToolset
|
|
89
|
+
|
|
60
90
|
__all__ = [
|
|
61
91
|
# Main API
|
|
62
92
|
"UniversalAgent",
|
|
@@ -76,7 +106,7 @@ __all__ = [
|
|
|
76
106
|
"ToolCall",
|
|
77
107
|
"ToolResult",
|
|
78
108
|
"TraceEntry",
|
|
79
|
-
#
|
|
109
|
+
# Core clients
|
|
80
110
|
"LLMClient",
|
|
81
111
|
"ModelResponse",
|
|
82
112
|
"OpenRouterClient",
|
|
@@ -84,6 +114,26 @@ __all__ = [
|
|
|
84
114
|
"OllamaClient",
|
|
85
115
|
"MockClient",
|
|
86
116
|
"get_client",
|
|
117
|
+
# v0.3 Adapters
|
|
118
|
+
"AnthropicClient",
|
|
119
|
+
"GeminiClient",
|
|
120
|
+
"AzureOpenAIClient",
|
|
121
|
+
"GroqClient",
|
|
122
|
+
"CohereClient",
|
|
123
|
+
# v0.3 Memory
|
|
124
|
+
"BaseMemory",
|
|
125
|
+
"ConversationBuffer",
|
|
126
|
+
"SlidingWindowMemory",
|
|
127
|
+
"TokenWindowMemory",
|
|
128
|
+
# v0.3 Observability
|
|
129
|
+
"BaseTracer",
|
|
130
|
+
"FileTracer",
|
|
131
|
+
"PrintTracer",
|
|
132
|
+
"OpenTelemetryTracer",
|
|
133
|
+
# v0.3 MCP
|
|
134
|
+
"MCPClient",
|
|
135
|
+
"MCPToolInfo",
|
|
136
|
+
"MCPToolset",
|
|
87
137
|
# Exceptions
|
|
88
138
|
"UniversalAgentError",
|
|
89
139
|
"ToolNotFoundError",
|
|
@@ -94,4 +144,4 @@ __all__ = [
|
|
|
94
144
|
"ExecutionPolicyError",
|
|
95
145
|
]
|
|
96
146
|
|
|
97
|
-
__version__ = "0.
|
|
147
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
toolproxy.adapters — provider-specific LLM client implementations.
|
|
3
|
+
|
|
4
|
+
New providers added in v0.3:
|
|
5
|
+
AnthropicClient — Claude models via the Anthropic API.
|
|
6
|
+
GeminiClient — Gemini models via Google's OpenAI-compat endpoint.
|
|
7
|
+
AzureOpenAIClient — GPT models via Azure OpenAI Service.
|
|
8
|
+
GroqClient — Fast inference via Groq's OpenAI-compat API.
|
|
9
|
+
CohereClient — Command R+ via Cohere's OpenAI-compat v2 API.
|
|
10
|
+
|
|
11
|
+
Each adapter uses optional imports: the underlying SDK or httpx is imported
|
|
12
|
+
only when the client is instantiated, giving a clear error message if the
|
|
13
|
+
required package is not installed.
|
|
14
|
+
"""
|
|
15
|
+
from .anthropic import AnthropicClient
|
|
16
|
+
from .gemini import GeminiClient
|
|
17
|
+
from .azure import AzureOpenAIClient
|
|
18
|
+
from .groq import GroqClient
|
|
19
|
+
from .cohere import CohereClient
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AnthropicClient",
|
|
23
|
+
"GeminiClient",
|
|
24
|
+
"AzureOpenAIClient",
|
|
25
|
+
"GroqClient",
|
|
26
|
+
"CohereClient",
|
|
27
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AnthropicClient — Claude models via the Anthropic Python SDK.
|
|
3
|
+
|
|
4
|
+
Requires the ``anthropic`` package::
|
|
5
|
+
|
|
6
|
+
pip install "toolproxy[anthropic]"
|
|
7
|
+
# or
|
|
8
|
+
pip install anthropic
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from toolproxy import UniversalAgent
|
|
13
|
+
|
|
14
|
+
agent = UniversalAgent(
|
|
15
|
+
model="anthropic/claude-3-5-sonnet-20241022",
|
|
16
|
+
api_key="sk-ant-...",
|
|
17
|
+
tools=[...],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
Supported model prefixes:
|
|
21
|
+
``anthropic/claude-3-5-sonnet-20241022``
|
|
22
|
+
``anthropic/claude-3-5-haiku-20241022``
|
|
23
|
+
``anthropic/claude-3-opus-20240229``
|
|
24
|
+
``anthropic/claude-3-haiku-20240307``
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from typing import Any, AsyncIterator, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
from ..llm_client import LLMClient, ModelResponse
|
|
33
|
+
from ..schemas import Message, StreamChunk, ToolCall
|
|
34
|
+
from ..exceptions import ModelError
|
|
35
|
+
from ..config import model_supports_native_tools
|
|
36
|
+
|
|
37
|
+
# Anthropic models all support native tool calling
|
|
38
|
+
_ANTHROPIC_NATIVE_TOOLS = True
|
|
39
|
+
_DEFAULT_MAX_TOKENS = 4096
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _convert_messages_to_anthropic(
|
|
43
|
+
messages: List[Message],
|
|
44
|
+
) -> tuple[Optional[str], List[Dict[str, Any]]]:
|
|
45
|
+
"""
|
|
46
|
+
Convert toolproxy Messages to Anthropic API format.
|
|
47
|
+
|
|
48
|
+
Returns (system_prompt, messages_list).
|
|
49
|
+
Anthropic separates system prompts from the messages list.
|
|
50
|
+
"""
|
|
51
|
+
system_prompt: Optional[str] = None
|
|
52
|
+
anthropic_messages: List[Dict[str, Any]] = []
|
|
53
|
+
|
|
54
|
+
for msg in messages:
|
|
55
|
+
if msg.role == "system":
|
|
56
|
+
# Anthropic takes system as a top-level param, not in messages
|
|
57
|
+
system_prompt = msg.content
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
if msg.role == "tool":
|
|
61
|
+
# Tool results go as user messages with tool_result content blocks
|
|
62
|
+
anthropic_messages.append(
|
|
63
|
+
{
|
|
64
|
+
"role": "user",
|
|
65
|
+
"content": [
|
|
66
|
+
{
|
|
67
|
+
"type": "tool_result",
|
|
68
|
+
"tool_use_id": msg.tool_call_id or "unknown",
|
|
69
|
+
"content": msg.content,
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
elif msg.role == "assistant":
|
|
75
|
+
anthropic_messages.append({"role": "assistant", "content": msg.content})
|
|
76
|
+
else:
|
|
77
|
+
anthropic_messages.append({"role": "user", "content": msg.content})
|
|
78
|
+
|
|
79
|
+
return system_prompt, anthropic_messages
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _convert_tools_to_anthropic(
|
|
83
|
+
tools: List[Dict[str, Any]],
|
|
84
|
+
) -> List[Dict[str, Any]]:
|
|
85
|
+
"""Convert OpenAI-style tool schemas to Anthropic format."""
|
|
86
|
+
anthropic_tools = []
|
|
87
|
+
for t in tools:
|
|
88
|
+
fn = t.get("function", t)
|
|
89
|
+
anthropic_tools.append(
|
|
90
|
+
{
|
|
91
|
+
"name": fn["name"],
|
|
92
|
+
"description": fn.get("description", ""),
|
|
93
|
+
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
return anthropic_tools
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_anthropic_response(response: Any) -> ModelResponse:
|
|
100
|
+
"""Parse an Anthropic Message object into a provider-agnostic ModelResponse."""
|
|
101
|
+
raw_text = ""
|
|
102
|
+
tool_calls: List[ToolCall] = []
|
|
103
|
+
|
|
104
|
+
for block in response.content:
|
|
105
|
+
if block.type == "text":
|
|
106
|
+
raw_text += block.text
|
|
107
|
+
elif block.type == "tool_use":
|
|
108
|
+
tool_calls.append(
|
|
109
|
+
ToolCall(
|
|
110
|
+
tool_name=block.name,
|
|
111
|
+
arguments=block.input if isinstance(block.input, dict) else {},
|
|
112
|
+
call_id=block.id,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return ModelResponse(raw_text=raw_text, tool_calls=tool_calls)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class AnthropicClient(LLMClient):
|
|
120
|
+
"""
|
|
121
|
+
LLM client for Anthropic's Claude models.
|
|
122
|
+
|
|
123
|
+
Wraps the official ``anthropic`` Python SDK. All Claude models support
|
|
124
|
+
native tool calling, so this client always returns ``supports_native_tools()
|
|
125
|
+
== True``.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
model:
|
|
130
|
+
Model name (e.g. ``"claude-3-5-sonnet-20241022"``).
|
|
131
|
+
api_key:
|
|
132
|
+
Anthropic API key. Falls back to ``ANTHROPIC_API_KEY`` env var.
|
|
133
|
+
max_tokens:
|
|
134
|
+
Maximum tokens in the response (default 4096).
|
|
135
|
+
timeout:
|
|
136
|
+
Request timeout in seconds (default 60).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
model: str,
|
|
142
|
+
api_key: Optional[str] = None,
|
|
143
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
144
|
+
timeout: float = 60.0,
|
|
145
|
+
) -> None:
|
|
146
|
+
try:
|
|
147
|
+
import anthropic as _anthropic # noqa: F401
|
|
148
|
+
except ImportError:
|
|
149
|
+
raise ImportError(
|
|
150
|
+
"The 'anthropic' package is required for AnthropicClient.\n"
|
|
151
|
+
"Install it with: pip install 'toolproxy[anthropic]'\n"
|
|
152
|
+
"or: pip install anthropic"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
import anthropic
|
|
156
|
+
|
|
157
|
+
self.model = model
|
|
158
|
+
self._max_tokens = max_tokens
|
|
159
|
+
self._timeout = timeout
|
|
160
|
+
|
|
161
|
+
resolved_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
162
|
+
self._client = anthropic.Anthropic(api_key=resolved_key)
|
|
163
|
+
self._async_client = anthropic.AsyncAnthropic(api_key=resolved_key)
|
|
164
|
+
|
|
165
|
+
def supports_native_tools(self) -> bool:
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
def generate(
|
|
169
|
+
self,
|
|
170
|
+
messages: List[Message],
|
|
171
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
172
|
+
) -> ModelResponse:
|
|
173
|
+
system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
|
|
174
|
+
|
|
175
|
+
kwargs: Dict[str, Any] = {
|
|
176
|
+
"model": self.model,
|
|
177
|
+
"max_tokens": self._max_tokens,
|
|
178
|
+
"messages": anthropic_messages,
|
|
179
|
+
}
|
|
180
|
+
if system_prompt:
|
|
181
|
+
kwargs["system"] = system_prompt
|
|
182
|
+
if tools:
|
|
183
|
+
kwargs["tools"] = _convert_tools_to_anthropic(tools)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
response = self._client.messages.create(**kwargs)
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
raise ModelError(f"Anthropic API error: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
return _parse_anthropic_response(response)
|
|
191
|
+
|
|
192
|
+
async def agenerate(
|
|
193
|
+
self,
|
|
194
|
+
messages: List[Message],
|
|
195
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
196
|
+
) -> ModelResponse:
|
|
197
|
+
system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
|
|
198
|
+
|
|
199
|
+
kwargs: Dict[str, Any] = {
|
|
200
|
+
"model": self.model,
|
|
201
|
+
"max_tokens": self._max_tokens,
|
|
202
|
+
"messages": anthropic_messages,
|
|
203
|
+
}
|
|
204
|
+
if system_prompt:
|
|
205
|
+
kwargs["system"] = system_prompt
|
|
206
|
+
if tools:
|
|
207
|
+
kwargs["tools"] = _convert_tools_to_anthropic(tools)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
response = await self._async_client.messages.create(**kwargs)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
raise ModelError(f"Anthropic API error: {exc}") from exc
|
|
213
|
+
|
|
214
|
+
return _parse_anthropic_response(response)
|
|
215
|
+
|
|
216
|
+
async def astream(
|
|
217
|
+
self,
|
|
218
|
+
messages: List[Message],
|
|
219
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
220
|
+
) -> AsyncIterator[StreamChunk]:
|
|
221
|
+
system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
|
|
222
|
+
|
|
223
|
+
kwargs: Dict[str, Any] = {
|
|
224
|
+
"model": self.model,
|
|
225
|
+
"max_tokens": self._max_tokens,
|
|
226
|
+
"messages": anthropic_messages,
|
|
227
|
+
}
|
|
228
|
+
if system_prompt:
|
|
229
|
+
kwargs["system"] = system_prompt
|
|
230
|
+
if tools:
|
|
231
|
+
kwargs["tools"] = _convert_tools_to_anthropic(tools)
|
|
232
|
+
|
|
233
|
+
accumulated = ""
|
|
234
|
+
try:
|
|
235
|
+
async with self._async_client.messages.stream(**kwargs) as stream:
|
|
236
|
+
async for text in stream.text_stream:
|
|
237
|
+
accumulated += text
|
|
238
|
+
yield StreamChunk(delta=text)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
raise ModelError(f"Anthropic streaming error: {exc}") from exc
|
|
241
|
+
|
|
242
|
+
yield StreamChunk(done=True, content=accumulated)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AzureOpenAIClient — GPT models via Azure OpenAI Service.
|
|
3
|
+
|
|
4
|
+
Wraps the Azure variant of the OpenAI-compatible API. The key differences
|
|
5
|
+
from the standard OpenAI client are:
|
|
6
|
+
|
|
7
|
+
- Endpoint is per-deployment (not per-model).
|
|
8
|
+
- Authentication uses an ``api-key`` header (or AAD Bearer token).
|
|
9
|
+
- Requires an ``api-version`` query parameter.
|
|
10
|
+
|
|
11
|
+
No extra packages required beyond ``httpx``.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from toolproxy import UniversalAgent
|
|
16
|
+
|
|
17
|
+
agent = UniversalAgent(
|
|
18
|
+
model="azure/gpt-4o",
|
|
19
|
+
base_url="https://<your-resource>.openai.azure.com/openai/deployments/<deployment>",
|
|
20
|
+
api_key="<azure-api-key>",
|
|
21
|
+
tools=[...],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Or via environment variables:
|
|
25
|
+
# AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT
|
|
26
|
+
|
|
27
|
+
Environment variables:
|
|
28
|
+
AZURE_OPENAI_API_KEY — API key
|
|
29
|
+
AZURE_OPENAI_ENDPOINT — e.g. https://<resource>.openai.azure.com
|
|
30
|
+
AZURE_OPENAI_DEPLOYMENT — deployment name (model alias in Azure)
|
|
31
|
+
AZURE_OPENAI_API_VERSION — API version (default: 2024-10-21)
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
from typing import Any, AsyncIterator, Dict, List, Optional
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
from ..llm_client import (
|
|
42
|
+
LLMClient,
|
|
43
|
+
ModelResponse,
|
|
44
|
+
_messages_to_openai,
|
|
45
|
+
_parse_native_tool_calls,
|
|
46
|
+
_parse_openai_response,
|
|
47
|
+
)
|
|
48
|
+
from ..schemas import Message, StreamChunk, ToolCall
|
|
49
|
+
from ..exceptions import ModelError
|
|
50
|
+
from ..config import model_supports_native_tools
|
|
51
|
+
|
|
52
|
+
_DEFAULT_API_VERSION = "2024-10-21"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AzureOpenAIClient(LLMClient):
|
|
56
|
+
"""
|
|
57
|
+
LLM client for Azure OpenAI Service.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
model:
|
|
62
|
+
Model / deployment name (e.g. ``"gpt-4o"``).
|
|
63
|
+
api_key:
|
|
64
|
+
Azure OpenAI API key. Falls back to ``AZURE_OPENAI_API_KEY``.
|
|
65
|
+
endpoint:
|
|
66
|
+
Azure resource endpoint (e.g. ``https://<resource>.openai.azure.com``).
|
|
67
|
+
Falls back to ``AZURE_OPENAI_ENDPOINT``.
|
|
68
|
+
deployment:
|
|
69
|
+
Deployment name. Defaults to *model* if not specified.
|
|
70
|
+
Falls back to ``AZURE_OPENAI_DEPLOYMENT``.
|
|
71
|
+
api_version:
|
|
72
|
+
Azure API version string. Defaults to ``"2024-10-21"``.
|
|
73
|
+
native_tools:
|
|
74
|
+
Override native tool-calling detection.
|
|
75
|
+
timeout:
|
|
76
|
+
Request timeout in seconds.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
model: str,
|
|
82
|
+
api_key: Optional[str] = None,
|
|
83
|
+
endpoint: Optional[str] = None,
|
|
84
|
+
deployment: Optional[str] = None,
|
|
85
|
+
api_version: str = _DEFAULT_API_VERSION,
|
|
86
|
+
native_tools: Optional[bool] = None,
|
|
87
|
+
timeout: float = 60.0,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.model = model
|
|
90
|
+
self._api_key = api_key or os.environ.get("AZURE_OPENAI_API_KEY", "")
|
|
91
|
+
self._endpoint = (
|
|
92
|
+
endpoint
|
|
93
|
+
or os.environ.get("AZURE_OPENAI_ENDPOINT", "")
|
|
94
|
+
).rstrip("/")
|
|
95
|
+
self._deployment = (
|
|
96
|
+
deployment
|
|
97
|
+
or os.environ.get("AZURE_OPENAI_DEPLOYMENT")
|
|
98
|
+
or model
|
|
99
|
+
)
|
|
100
|
+
self._api_version = api_version
|
|
101
|
+
self._native_tools = (
|
|
102
|
+
native_tools if native_tools is not None else model_supports_native_tools(model)
|
|
103
|
+
)
|
|
104
|
+
self._timeout = timeout
|
|
105
|
+
|
|
106
|
+
def _chat_url(self) -> str:
|
|
107
|
+
return (
|
|
108
|
+
f"{self._endpoint}/openai/deployments/{self._deployment}"
|
|
109
|
+
f"/chat/completions?api-version={self._api_version}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _headers(self) -> Dict[str, str]:
|
|
113
|
+
return {
|
|
114
|
+
"api-key": self._api_key,
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def _body(
|
|
119
|
+
self,
|
|
120
|
+
messages: List[Message],
|
|
121
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
122
|
+
stream: bool = False,
|
|
123
|
+
) -> Dict[str, Any]:
|
|
124
|
+
body: Dict[str, Any] = {"messages": _messages_to_openai(messages)}
|
|
125
|
+
if tools and self._native_tools:
|
|
126
|
+
body["tools"] = tools
|
|
127
|
+
body["tool_choice"] = "auto"
|
|
128
|
+
if stream:
|
|
129
|
+
body["stream"] = True
|
|
130
|
+
return body
|
|
131
|
+
|
|
132
|
+
def supports_native_tools(self) -> bool:
|
|
133
|
+
return self._native_tools
|
|
134
|
+
|
|
135
|
+
def generate(
|
|
136
|
+
self,
|
|
137
|
+
messages: List[Message],
|
|
138
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
139
|
+
) -> ModelResponse:
|
|
140
|
+
try:
|
|
141
|
+
resp = httpx.post(
|
|
142
|
+
self._chat_url(),
|
|
143
|
+
headers=self._headers(),
|
|
144
|
+
json=self._body(messages, tools),
|
|
145
|
+
timeout=self._timeout,
|
|
146
|
+
)
|
|
147
|
+
resp.raise_for_status()
|
|
148
|
+
except httpx.HTTPStatusError as exc:
|
|
149
|
+
raise ModelError(f"Azure HTTP {exc.response.status_code}: {exc.response.text}") from exc
|
|
150
|
+
except httpx.RequestError as exc:
|
|
151
|
+
raise ModelError(f"Azure request failed: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
return _parse_openai_response(resp.json())
|
|
154
|
+
|
|
155
|
+
async def agenerate(
|
|
156
|
+
self,
|
|
157
|
+
messages: List[Message],
|
|
158
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
159
|
+
) -> ModelResponse:
|
|
160
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
161
|
+
try:
|
|
162
|
+
resp = await client.post(
|
|
163
|
+
self._chat_url(),
|
|
164
|
+
headers=self._headers(),
|
|
165
|
+
json=self._body(messages, tools),
|
|
166
|
+
)
|
|
167
|
+
resp.raise_for_status()
|
|
168
|
+
except httpx.HTTPStatusError as exc:
|
|
169
|
+
raise ModelError(f"Azure HTTP {exc.response.status_code}: {exc.response.text}") from exc
|
|
170
|
+
except httpx.RequestError as exc:
|
|
171
|
+
raise ModelError(f"Azure request failed: {exc}") from exc
|
|
172
|
+
|
|
173
|
+
return _parse_openai_response(resp.json())
|
|
174
|
+
|
|
175
|
+
async def astream(
|
|
176
|
+
self,
|
|
177
|
+
messages: List[Message],
|
|
178
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
179
|
+
) -> AsyncIterator[StreamChunk]:
|
|
180
|
+
accumulated = ""
|
|
181
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
182
|
+
async with client.stream(
|
|
183
|
+
"POST",
|
|
184
|
+
self._chat_url(),
|
|
185
|
+
headers=self._headers(),
|
|
186
|
+
json=self._body(messages, tools, stream=True),
|
|
187
|
+
) as resp:
|
|
188
|
+
try:
|
|
189
|
+
resp.raise_for_status()
|
|
190
|
+
except httpx.HTTPStatusError as exc:
|
|
191
|
+
raise ModelError(
|
|
192
|
+
f"Azure HTTP {exc.response.status_code}: {exc.response.text}"
|
|
193
|
+
) from exc
|
|
194
|
+
|
|
195
|
+
async for line in resp.aiter_lines():
|
|
196
|
+
if not line.startswith("data:"):
|
|
197
|
+
continue
|
|
198
|
+
data_str = line[5:].strip()
|
|
199
|
+
if data_str == "[DONE]":
|
|
200
|
+
break
|
|
201
|
+
try:
|
|
202
|
+
chunk_data = json.loads(data_str)
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
continue
|
|
205
|
+
choices = chunk_data.get("choices", [])
|
|
206
|
+
if not choices:
|
|
207
|
+
continue
|
|
208
|
+
token = choices[0].get("delta", {}).get("content") or ""
|
|
209
|
+
if token:
|
|
210
|
+
accumulated += token
|
|
211
|
+
yield StreamChunk(delta=token)
|
|
212
|
+
|
|
213
|
+
yield StreamChunk(done=True, content=accumulated)
|