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.
Files changed (43) hide show
  1. {toolproxy-0.2.0 → toolproxy-0.3.0}/PKG-INFO +13 -1
  2. {toolproxy-0.2.0 → toolproxy-0.3.0}/pyproject.toml +22 -1
  3. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/__init__.py +52 -2
  4. toolproxy-0.3.0/src/toolproxy/adapters/__init__.py +27 -0
  5. toolproxy-0.3.0/src/toolproxy/adapters/anthropic.py +242 -0
  6. toolproxy-0.3.0/src/toolproxy/adapters/azure.py +213 -0
  7. toolproxy-0.3.0/src/toolproxy/adapters/cohere.py +83 -0
  8. toolproxy-0.3.0/src/toolproxy/adapters/gemini.py +89 -0
  9. toolproxy-0.3.0/src/toolproxy/adapters/groq.py +83 -0
  10. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/agent.py +127 -47
  11. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/llm_client.py +42 -5
  12. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/loop.py +113 -42
  13. toolproxy-0.3.0/src/toolproxy/mcp.py +344 -0
  14. toolproxy-0.3.0/src/toolproxy/memory.py +222 -0
  15. toolproxy-0.3.0/src/toolproxy/observability/__init__.py +24 -0
  16. toolproxy-0.3.0/src/toolproxy/observability/base.py +53 -0
  17. toolproxy-0.3.0/src/toolproxy/observability/file.py +146 -0
  18. toolproxy-0.3.0/src/toolproxy/observability/otel.py +116 -0
  19. toolproxy-0.3.0/src/toolproxy/observability/print_tracer.py +121 -0
  20. toolproxy-0.3.0/tests/test_adapters.py +335 -0
  21. toolproxy-0.3.0/tests/test_mcp.py +388 -0
  22. toolproxy-0.3.0/tests/test_memory.py +251 -0
  23. toolproxy-0.3.0/tests/test_observability.py +284 -0
  24. {toolproxy-0.2.0 → toolproxy-0.3.0}/.gitignore +0 -0
  25. {toolproxy-0.2.0 → toolproxy-0.3.0}/LICENSE +0 -0
  26. {toolproxy-0.2.0 → toolproxy-0.3.0}/README.md +0 -0
  27. {toolproxy-0.2.0 → toolproxy-0.3.0}/examples/basic_chat.py +0 -0
  28. {toolproxy-0.2.0 → toolproxy-0.3.0}/examples/local_ollama.py +0 -0
  29. {toolproxy-0.2.0 → toolproxy-0.3.0}/examples/openrouter_tools.py +0 -0
  30. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/config.py +0 -0
  31. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/exceptions.py +0 -0
  32. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/executor.py +0 -0
  33. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/planner.py +0 -0
  34. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/py.typed +0 -0
  35. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/schemas.py +0 -0
  36. {toolproxy-0.2.0 → toolproxy-0.3.0}/src/toolproxy/tools.py +0 -0
  37. {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/conftest.py +0 -0
  38. {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_agent_basic.py +0 -0
  39. {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_async_agent.py +0 -0
  40. {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_emulated_mode.py +0 -0
  41. {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_error_handling.py +0 -0
  42. {toolproxy-0.2.0 → toolproxy-0.3.0}/tests/test_native_mode.py +0 -0
  43. {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.2.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.2.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
- # Clients
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.2.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)