返回 JoyAI-Echo
azure_openai_provider.py
根目录 / echo_longvideo / Director_Agent / nanobot / providers / azure_openai_provider.py
1 """Azure OpenAI provider using the OpenAI SDK Responses API.
2
3 Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which
4 routes to the Responses API (``/responses``). Reuses shared conversion
5 helpers from :mod:`nanobot.providers.openai_responses`.
6 """
7
8 from __future__ import annotations
9
10 import uuid
11 from collections.abc import Awaitable, Callable
12 from typing import Any
13
14 from openai import AsyncOpenAI
15
16 from nanobot.providers.base import LLMProvider, LLMResponse
17 from nanobot.providers.openai_responses import (
18 consume_sdk_stream,
19 convert_messages,
20 convert_tools,
21 parse_response_output,
22 )
23
24
25 class AzureOpenAIProvider(LLMProvider):
26 """Azure OpenAI provider backed by the Responses API.
27
28 Features:
29 - Uses the OpenAI Python SDK (``AsyncOpenAI``) with
30 ``base_url = {endpoint}/openai/v1/``
31 - Calls ``client.responses.create()`` (Responses API)
32 - Reuses shared message/tool/SSE conversion from
33 ``openai_responses``
34 """
35
36 def __init__(
37 self,
38 api_key: str = "",
39 api_base: str = "",
40 default_model: str = "gpt-5.2-chat",
41 ):
42 super().__init__(api_key, api_base)
43 self.default_model = default_model
44
45 if not api_key:
46 raise ValueError("Azure OpenAI api_key is required")
47 if not api_base:
48 raise ValueError("Azure OpenAI api_base is required")
49
50 # Normalise: ensure trailing slash
51 if not api_base.endswith("/"):
52 api_base += "/"
53 self.api_base = api_base
54
55 # SDK client targeting the Azure Responses API endpoint
56 base_url = f"{api_base.rstrip('/')}/openai/v1/"
57 self._client = AsyncOpenAI(
58 api_key=api_key,
59 base_url=base_url,
60 default_headers={"x-session-affinity": uuid.uuid4().hex},
61 max_retries=0,
62 )
63
64 # ------------------------------------------------------------------
65 # Helpers
66 # ------------------------------------------------------------------
67
68 @staticmethod
69 def _supports_temperature(
70 deployment_name: str,
71 reasoning_effort: str | None = None,
72 ) -> bool:
73 """Return True when temperature is likely supported for this deployment."""
74 if reasoning_effort:
75 return False
76 name = deployment_name.lower()
77 return not any(token in name for token in ("gpt-5", "o1", "o3", "o4"))
78
79 def _build_body(
80 self,
81 messages: list[dict[str, Any]],
82 tools: list[dict[str, Any]] | None,
83 model: str | None,
84 max_tokens: int,
85 temperature: float,
86 reasoning_effort: str | None,
87 tool_choice: str | dict[str, Any] | None,
88 ) -> dict[str, Any]:
89 """Build the Responses API request body from Chat-Completions-style args."""
90 deployment = model or self.default_model
91 instructions, input_items = convert_messages(self._sanitize_empty_content(messages))
92
93 body: dict[str, Any] = {
94 "model": deployment,
95 "instructions": instructions or None,
96 "input": input_items,
97 "max_output_tokens": max(1, max_tokens),
98 "store": False,
99 "stream": False,
100 }
101
102 if self._supports_temperature(deployment, reasoning_effort):
103 body["temperature"] = temperature
104
105 if reasoning_effort:
106 body["reasoning"] = {"effort": reasoning_effort}
107 body["include"] = ["reasoning.encrypted_content"]
108
109 if tools:
110 body["tools"] = convert_tools(tools)
111 body["tool_choice"] = tool_choice or "auto"
112
113 return body
114
115 @staticmethod
116 def _handle_error(e: Exception) -> LLMResponse:
117 response = getattr(e, "response", None)
118 body = getattr(e, "body", None) or getattr(response, "text", None)
119 body_text = str(body).strip() if body is not None else ""
120 msg = f"Error: {body_text[:500]}" if body_text else f"Error calling Azure OpenAI: {e}"
121 retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None))
122 if retry_after is None:
123 retry_after = LLMProvider._extract_retry_after(msg)
124 return LLMResponse(content=msg, finish_reason="error", retry_after=retry_after)
125
126 # ------------------------------------------------------------------
127 # Public API
128 # ------------------------------------------------------------------
129
130 async def chat(
131 self,
132 messages: list[dict[str, Any]],
133 tools: list[dict[str, Any]] | None = None,
134 model: str | None = None,
135 max_tokens: int = 4096,
136 temperature: float = 0.7,
137 reasoning_effort: str | None = None,
138 tool_choice: str | dict[str, Any] | None = None,
139 ) -> LLMResponse:
140 body = self._build_body(
141 messages, tools, model, max_tokens, temperature,
142 reasoning_effort, tool_choice,
143 )
144 try:
145 response = await self._client.responses.create(**body)
146 return parse_response_output(response)
147 except Exception as e:
148 return self._handle_error(e)
149
150 async def chat_stream(
151 self,
152 messages: list[dict[str, Any]],
153 tools: list[dict[str, Any]] | None = None,
154 model: str | None = None,
155 max_tokens: int = 4096,
156 temperature: float = 0.7,
157 reasoning_effort: str | None = None,
158 tool_choice: str | dict[str, Any] | None = None,
159 on_content_delta: Callable[[str], Awaitable[None]] | None = None,
160 ) -> LLMResponse:
161 body = self._build_body(
162 messages, tools, model, max_tokens, temperature,
163 reasoning_effort, tool_choice,
164 )
165 body["stream"] = True
166
167 try:
168 stream = await self._client.responses.create(**body)
169 content, tool_calls, finish_reason, usage, reasoning_content = (
170 await consume_sdk_stream(stream, on_content_delta)
171 )
172 return LLMResponse(
173 content=content or None,
174 tool_calls=tool_calls,
175 finish_reason=finish_reason,
176 usage=usage,
177 reasoning_content=reasoning_content,
178 )
179 except Exception as e:
180 return self._handle_error(e)
181
182 def get_default_model(self) -> str:
183 return self.default_model
184
184 lines PYTHON