pi-ai-python 1.3.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.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,138 @@
1
+ """Azure OpenAI endpoint resolution (base URL, API version, deployment names)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import Any, NamedTuple
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+ from ..types import StreamOptions
11
+ from ..utils.misc import get_provider_env_value
12
+
13
+ __all__ = [
14
+ "DEFAULT_AZURE_API_VERSION",
15
+ "AzureEndpointOptions",
16
+ "AzureConfig",
17
+ "resolve_deployment_name",
18
+ "resolve_azure_base_url",
19
+ "resolve_azure_config",
20
+ ]
21
+
22
+ DEFAULT_AZURE_API_VERSION = "v1"
23
+
24
+ _TRAILING_SLASHES = re.compile(r"/+$")
25
+ _URL_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*$")
26
+ _SPECIAL_SCHEMES = frozenset({"http", "https", "ws", "wss", "ftp", "file"})
27
+
28
+
29
+ class AzureEndpointOptions(StreamOptions, total=False):
30
+ """Azure models ship without a base URL: one resource per user, resolved per request."""
31
+
32
+ azure_api_version: str
33
+ azure_resource_name: str
34
+ azure_base_url: str
35
+ azure_deployment_name: str
36
+
37
+
38
+ class AzureConfig(NamedTuple):
39
+ base_url: str
40
+ api_version: str
41
+
42
+
43
+ def _parse_deployment_name_map(value: str | None) -> dict[str, str]:
44
+ mapping: dict[str, str] = {}
45
+ if not value:
46
+ return mapping
47
+ for entry in value.split(","):
48
+ trimmed = entry.strip()
49
+ if not trimmed:
50
+ continue
51
+ model_id, _, deployment_name = trimmed.partition("=")
52
+ # JavaScript's split("=", 2) keeps only the text up to a second "=".
53
+ deployment_name = deployment_name.split("=", 1)[0]
54
+ if not model_id or not deployment_name:
55
+ continue
56
+ mapping[model_id.strip()] = deployment_name.strip()
57
+ return mapping
58
+
59
+
60
+ def resolve_deployment_name(model: Any, options: Mapping[str, Any] | None = None) -> str:
61
+ """Deployment for ``model.id``: ``azure_deployment_name``, ``AZURE_OPENAI_DEPLOYMENT_NAME_MAP``, or the id."""
62
+ options = options or {}
63
+ explicit = options.get("azure_deployment_name")
64
+ if explicit:
65
+ return explicit
66
+ mapped = _parse_deployment_name_map(
67
+ get_provider_env_value("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options.get("env"))
68
+ ).get(model.id)
69
+ return mapped or model.id
70
+
71
+
72
+ def _normalize_azure_base_url(base_url: str) -> str:
73
+ trimmed = _TRAILING_SLASHES.sub("", base_url.strip())
74
+ try:
75
+ parts = urlsplit(trimmed)
76
+ except ValueError as error:
77
+ raise ValueError(f"Invalid Azure OpenAI base URL: {base_url}") from error
78
+ scheme = parts.scheme.lower()
79
+ if not scheme or not _URL_SCHEME.match(scheme) or (scheme in _SPECIAL_SCHEMES and not parts.netloc):
80
+ raise ValueError(f"Invalid Azure OpenAI base URL: {base_url}")
81
+
82
+ hostname = parts.hostname or ""
83
+ is_azure_host = (
84
+ hostname.endswith(".openai.azure.com")
85
+ or hostname.endswith(".cognitiveservices.azure.com")
86
+ or hostname.endswith(".ai.azure.com")
87
+ )
88
+ normalized_path = _TRAILING_SLASHES.sub("", parts.path)
89
+ path = parts.path
90
+ query = parts.query
91
+
92
+ # Ensure Azure hosts have /openai/v1 as base path so /responses and ?api-version=v1
93
+ # are appended correctly.
94
+ if is_azure_host and normalized_path in ("", "/", "/openai", "/openai/v1/responses"):
95
+ path = "/openai/v1"
96
+ query = ""
97
+
98
+ netloc = parts.netloc if "@" in parts.netloc else parts.netloc.lower()
99
+ if not path and scheme in _SPECIAL_SCHEMES:
100
+ path = "/"
101
+ url = urlunsplit((scheme, netloc, path, query, parts.fragment))
102
+ return _TRAILING_SLASHES.sub("", url)
103
+
104
+
105
+ def _build_default_base_url(resource_name: str) -> str:
106
+ return f"https://{resource_name}.openai.azure.com/openai/v1"
107
+
108
+
109
+ def resolve_azure_base_url(model: Any, options: Mapping[str, Any] | None = None) -> str:
110
+ """Base URL from ``azure_base_url``, ``AZURE_OPENAI_BASE_URL``, the resource name, or ``model.base_url``."""
111
+ options = options or {}
112
+ env = options.get("env")
113
+ explicit = (options.get("azure_base_url") or "").strip()
114
+ base_url = explicit or (get_provider_env_value("AZURE_OPENAI_BASE_URL", env) or "").strip() or None
115
+ resource_name = options.get("azure_resource_name") or get_provider_env_value("AZURE_OPENAI_RESOURCE_NAME", env)
116
+
117
+ resolved_base_url = base_url
118
+ if not resolved_base_url and resource_name:
119
+ resolved_base_url = _build_default_base_url(resource_name)
120
+ if not resolved_base_url and getattr(model, "base_url", None):
121
+ resolved_base_url = model.base_url
122
+ if not resolved_base_url:
123
+ raise ValueError(
124
+ "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, "
125
+ "or pass azure_base_url, azure_resource_name, or model.base_url."
126
+ )
127
+ return _normalize_azure_base_url(resolved_base_url)
128
+
129
+
130
+ def resolve_azure_config(model: Any, options: Mapping[str, Any] | None = None) -> AzureConfig:
131
+ """Base URL and API version (``azure_api_version``, ``AZURE_OPENAI_API_VERSION``, or ``"v1"``)."""
132
+ options = options or {}
133
+ return AzureConfig(
134
+ base_url=resolve_azure_base_url(model, options),
135
+ api_version=options.get("azure_api_version")
136
+ or get_provider_env_value("AZURE_OPENAI_API_VERSION", options.get("env"))
137
+ or DEFAULT_AZURE_API_VERSION,
138
+ )
@@ -0,0 +1,245 @@
1
+ # pyright: reportAttributeAccessIssue=false, reportArgumentType=false
2
+ # Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
3
+ """Azure OpenAI Responses API (``azure-openai-responses``).
4
+
5
+ Streams ``responses.create`` through the official ``openai`` SDK's ``AsyncAzureOpenAI``
6
+ client (``POST {base_url}/responses?api-version=<version>`` with an ``api-key`` header).
7
+ The deployment name goes in the body's ``model`` field.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from typing import Any, Literal
14
+
15
+ from ..abort import abort_scope
16
+ from ..event_stream import AssistantMessageEventStream
17
+ from ..models import clamp_thinking_level
18
+ from ..types import (
19
+ AssistantMessage,
20
+ DoneEvent,
21
+ ErrorEvent,
22
+ Model,
23
+ ProviderResponse,
24
+ StartEvent,
25
+ TranscriptContext,
26
+ Usage,
27
+ )
28
+ from ..utils.callbacks import maybe_await
29
+ from ..utils.error_body import format_provider_error, normalize_provider_error
30
+ from ..utils.misc import get_pi_user_agent, headers_to_record
31
+ from ..utils.transcript import get_declared_tools, resolve_transcript, resolve_transcript_tools
32
+ from .azure_openai_config import AzureEndpointOptions, resolve_azure_config, resolve_deployment_name
33
+ from .constrained_sampling import create_grammar_tool_input_properties
34
+ from .openai_prompt_cache import clamp_openai_prompt_cache_key
35
+ from .openai_responses_shared import convert_responses_messages, convert_responses_tools, process_responses_stream
36
+ from .openai_sdk import (
37
+ create_openai_client,
38
+ iter_sdk_stream,
39
+ merge_sdk_headers,
40
+ open_sdk_stream,
41
+ openai_request,
42
+ sdk_body_arguments,
43
+ )
44
+ from .simple_options import build_base_options, resolve_sampling_params
45
+
46
+ __all__ = ["AzureOpenAIResponsesOptions", "AZURE_TOOL_CALL_PROVIDERS", "stream", "stream_simple"]
47
+
48
+ AZURE_TOOL_CALL_PROVIDERS = frozenset({"openai", "openai-codex", "opencode", "azure"})
49
+ # OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
50
+ OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16
51
+
52
+
53
+ class AzureOpenAIResponsesOptions(AzureEndpointOptions, total=False):
54
+ reasoning_effort: Literal["minimal", "low", "medium", "high", "xhigh", "max"]
55
+ tool_choice: Any
56
+ reasoning_summary: Literal["auto", "detailed", "concise"] | None
57
+
58
+
59
+ def _format_azure_openai_error(error: Any) -> str:
60
+ return format_provider_error(normalize_provider_error(error), "Azure OpenAI API error")
61
+
62
+
63
+ def stream(
64
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
65
+ ) -> AssistantMessageEventStream:
66
+ """Stream a response from an Azure OpenAI Responses deployment."""
67
+ opts: dict[str, Any] = dict(options or {})
68
+ out = AssistantMessageEventStream()
69
+ normalized_context = resolve_transcript(context, (model.compat or {}).get("supportsMidConvoSystemMessages"))
70
+ out.start(_run(model, normalized_context, opts, out))
71
+ return out
72
+
73
+
74
+ async def _run(
75
+ model: Model, context: TranscriptContext, options: dict[str, Any], out: AssistantMessageEventStream
76
+ ) -> None:
77
+ deployment_name = resolve_deployment_name(model, options)
78
+ output = AssistantMessage(
79
+ [],
80
+ api="azure-openai-responses",
81
+ provider=model.provider,
82
+ model=model.id,
83
+ usage=Usage(),
84
+ stop_reason="pending",
85
+ )
86
+ signal = options.get("signal")
87
+ try:
88
+ async with abort_scope(signal):
89
+ api_key = options.get("api_key")
90
+ if not api_key:
91
+ raise RuntimeError(f"No API key for provider: {model.provider}")
92
+ headers = merge_sdk_headers({"User-Agent": get_pi_user_agent()}, model.headers, options.get("headers"))
93
+ base_url, api_version = resolve_azure_config(model, options)
94
+ client = create_openai_client(
95
+ api_key=api_key,
96
+ # The SDK appends the path to the base URL and sends ``api-version`` as the query string.
97
+ base_url=base_url.partition("?")[0],
98
+ headers=headers,
99
+ options=options,
100
+ azure_api_version=api_version,
101
+ )
102
+ grammar_tool_input_properties = create_grammar_tool_input_properties(
103
+ get_declared_tools(context.messages), bool((model.compat or {}).get("supportsOpenAIGrammarTools"))
104
+ )
105
+ params: Any = _build_params(model, context, options, deployment_name, grammar_tool_input_properties)
106
+ on_payload = options.get("on_payload")
107
+ if on_payload is not None:
108
+ next_params = await maybe_await(on_payload(params, model))
109
+ if next_params is not None:
110
+ params = next_params
111
+ create = client.responses.with_raw_response.create
112
+ body_arguments = sdk_body_arguments(params, headers=headers)
113
+ raw = await openai_request(lambda: create(stream=True, **body_arguments), options)
114
+ sdk_stream = open_sdk_stream(raw)
115
+ try:
116
+ on_response = options.get("on_response")
117
+ if on_response is not None:
118
+ await maybe_await(
119
+ on_response(ProviderResponse(raw.status_code, headers_to_record(raw.headers)), model)
120
+ )
121
+ out.push(StartEvent(output))
122
+ await process_responses_stream(
123
+ iter_sdk_stream(sdk_stream),
124
+ output,
125
+ out,
126
+ model,
127
+ {
128
+ "on_provider_stream_event": options.get("on_provider_stream_event"),
129
+ "grammar_tool_input_properties": grammar_tool_input_properties,
130
+ },
131
+ )
132
+ finally:
133
+ await sdk_stream.close()
134
+
135
+ if signal is not None and signal.aborted:
136
+ raise RuntimeError("Request was aborted")
137
+ if output.stop_reason == "pending":
138
+ raise RuntimeError("Azure OpenAI Responses stream ended without a stop reason")
139
+ if output.stop_reason in ("aborted", "error"):
140
+ raise RuntimeError(output.error_message or "An unknown error occurred")
141
+
142
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
143
+ out.end()
144
+ except Exception as error:
145
+ aborted = signal is not None and signal.aborted
146
+ output.stop_reason = "aborted" if aborted else "error"
147
+ output.error_message = "Request was aborted" if aborted else _format_azure_openai_error(error)
148
+ out.push(ErrorEvent(output.stop_reason, output)) # type: ignore[arg-type]
149
+ out.end()
150
+
151
+
152
+ def stream_simple(
153
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
154
+ ) -> AssistantMessageEventStream:
155
+ """Stream with provider-neutral options (``reasoning``, ``tool_choice``, ...)."""
156
+ options = options or {}
157
+ api_key = options.get("api_key")
158
+ if not api_key:
159
+ raise RuntimeError(f"No API key for provider: {model.provider}")
160
+
161
+ base = build_base_options(model, context, options, api_key)
162
+ if options.get("tool_choice") is not None:
163
+ base["tool_choice"] = options["tool_choice"]
164
+ reasoning = options.get("reasoning")
165
+ clamped = clamp_thinking_level(model, reasoning) if reasoning else None
166
+ if clamped is not None and clamped != "off":
167
+ base["reasoning_effort"] = clamped
168
+ # Azure endpoint options are not part of the simple options; keep them when callers pass them.
169
+ for key in ("azure_api_version", "azure_resource_name", "azure_base_url", "azure_deployment_name"):
170
+ if options.get(key) is not None:
171
+ base[key] = options[key]
172
+ return stream(model, context, base)
173
+
174
+
175
+ def _build_params(
176
+ model: Model,
177
+ context: TranscriptContext,
178
+ options: Mapping[str, Any],
179
+ deployment_name: str,
180
+ grammar_tool_input_properties: Mapping[str, str],
181
+ ) -> dict[str, Any]:
182
+ compat = model.compat or {}
183
+ supports_additional_tools = bool(compat.get("supportsAdditionalTools"))
184
+ supports_tool_search = bool(compat.get("supportsToolSearch"))
185
+ supports_strict_mode = compat.get("supportsStrictMode")
186
+ if supports_strict_mode is None:
187
+ supports_strict_mode = True
188
+ supports_openai_grammar_tools = bool(compat.get("supportsOpenAIGrammarTools"))
189
+ tool_options = {
190
+ "supports_strict_mode": supports_strict_mode,
191
+ "supports_openai_grammar_tools": supports_openai_grammar_tools,
192
+ }
193
+ transcript_tools = resolve_transcript_tools(context.messages, supports_additional_tools or supports_tool_search)
194
+ messages = convert_responses_messages(
195
+ model,
196
+ context,
197
+ AZURE_TOOL_CALL_PROVIDERS,
198
+ {
199
+ "grammar_tool_input_properties": grammar_tool_input_properties,
200
+ "supports_mid_convo_system_messages": bool(compat.get("supportsMidConvoSystemMessages")),
201
+ "supports_additional_tools": supports_additional_tools,
202
+ "supports_tool_search": supports_tool_search,
203
+ "tool_options": tool_options, # type: ignore[typeddict-item]
204
+ },
205
+ )
206
+
207
+ params: dict[str, Any] = {"model": deployment_name, "input": messages, "stream": True}
208
+ prompt_cache_key = clamp_openai_prompt_cache_key(options.get("session_id"))
209
+ if prompt_cache_key is not None:
210
+ params["prompt_cache_key"] = prompt_cache_key
211
+ params["store"] = False
212
+
213
+ max_tokens = options.get("max_tokens")
214
+ if max_tokens:
215
+ params["max_output_tokens"] = max(max_tokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS)
216
+
217
+ if options.get("temperature") is not None:
218
+ params["temperature"] = options["temperature"]
219
+
220
+ if transcript_tools.request_tools:
221
+ params["tools"] = convert_responses_tools(transcript_tools.request_tools, tool_options) # type: ignore[arg-type]
222
+ if options.get("tool_choice") is not None:
223
+ params["tool_choice"] = options["tool_choice"]
224
+
225
+ requested = options.get("reasoning_effort")
226
+ reasoning_effort = requested or ("medium" if options.get("reasoning_summary") else None)
227
+ if model.reasoning:
228
+ level_map = model.thinking_level_map or {}
229
+ if reasoning_effort:
230
+ effort = reasoning_effort
231
+ if requested:
232
+ mapped = level_map.get(requested)
233
+ effort = mapped if mapped is not None else requested
234
+ params["reasoning"] = {"effort": effort, "summary": options.get("reasoning_summary") or "auto"}
235
+ params["include"] = ["reasoning.encrypted_content"]
236
+ elif not ("off" in level_map and level_map["off"] is None):
237
+ off = level_map.get("off")
238
+ params["reasoning"] = {"effort": off if off is not None else "none"}
239
+
240
+ # Last so model and request sampling parameters override named request fields.
241
+ sampling_params = resolve_sampling_params(model, reasoning_effort or "off", options.get("sampling_params"))
242
+ if sampling_params:
243
+ params.update(sampling_params)
244
+
245
+ return params