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,210 @@
1
+ """OpenAI's Decisions API: ``POST /v1/decisions`` with ``{model, input, questions}``.
2
+
3
+ https://developers.openai.com/api/docs/guides/decisions
4
+
5
+ The state is sent as JSON text. With images, the input becomes one user message
6
+ with the state as ``input_text`` followed by ``input_image`` data URLs. Questions
7
+ map to Decisions types: ``choice`` to ``choice``, ``score`` to ``score``, and
8
+ ``bool`` to ``predicate``. Predicates have no criteria field, so the meanings of
9
+ true and false are appended to the instructions.
10
+
11
+ Only OpenAI API keys work: Sign in with ChatGPT tokens are rejected on this route.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import Mapping
18
+ from typing import Any
19
+ from urllib.parse import urljoin
20
+
21
+ from ..types import (
22
+ ClassifierAnswer,
23
+ ClassifierBoolAnswer,
24
+ ClassifierBoolQuestion,
25
+ ClassifierChoiceAnswer,
26
+ ClassifierContext,
27
+ ClassifierModel,
28
+ ClassifierQuestion,
29
+ ClassifierResult,
30
+ ClassifierScoreAnswer,
31
+ now_ms,
32
+ )
33
+ from ..utils.error_body import format_provider_error, normalize_provider_error
34
+ from ..utils.model_operations import classifier_error_result
35
+ from .classifier_shared import (
36
+ classifier_questions,
37
+ is_record,
38
+ parse_classifier_usage,
39
+ post_classifier_request,
40
+ required_number,
41
+ )
42
+
43
+ __all__ = ["classify"]
44
+
45
+ LABEL = "OpenAI Decisions"
46
+
47
+ #: The endpoint accepts at most this many image parts per request.
48
+ MAX_IMAGES = 128
49
+
50
+ #: Cloudflare in front of api.openai.com answers 504 with an HTML page when a request runs
51
+ #: longer than about five seconds. Large inputs, currently above roughly 600K tokens, hit
52
+ #: this limit, and retrying the same input runs into it again, so 504 is not retried.
53
+ NO_RETRY_STATUSES = (504,)
54
+
55
+
56
+ def _predicate_instructions(question: ClassifierBoolQuestion) -> str:
57
+ meanings = [
58
+ f"True means: {question.criteria['true']}" if question.criteria.get("true") else "",
59
+ f"False means: {question.criteria['false']}" if question.criteria.get("false") else "",
60
+ ]
61
+ meanings = [meaning for meaning in meanings if meaning]
62
+ return question.instructions + "\n\n" + "\n".join(meanings) if meanings else question.instructions
63
+
64
+
65
+ def _wire_question(name: str, question: ClassifierQuestion) -> dict[str, Any]:
66
+ if question.type == "choice":
67
+ return {
68
+ "type": "choice",
69
+ "name": name,
70
+ "instructions": question.instructions,
71
+ "choices": [
72
+ {"value": value, "description": description} if description else {"value": value}
73
+ for value, description in question.criteria.items()
74
+ ],
75
+ }
76
+ if question.type == "score":
77
+ return {
78
+ "type": "score",
79
+ "name": name,
80
+ "instructions": question.instructions,
81
+ "levels": [{"label": label} for label in question.criteria],
82
+ }
83
+ return {"type": "predicate", "name": name, "instructions": _predicate_instructions(question)}
84
+
85
+
86
+ def _wire_input(context: ClassifierContext) -> Any:
87
+ state = json.dumps(context.state, ensure_ascii=False, separators=(",", ":"))
88
+ images = context.images or []
89
+ if not images:
90
+ return state
91
+ if len(images) > MAX_IMAGES:
92
+ raise ValueError(f"{LABEL} accepts at most {MAX_IMAGES} images, got {len(images)}")
93
+ return [
94
+ {
95
+ "role": "user",
96
+ "content": [
97
+ {"type": "input_text", "text": state},
98
+ *(
99
+ {"type": "input_image", "image_url": f"data:{image.mime_type};base64,{image.data}"}
100
+ for image in images
101
+ ),
102
+ ],
103
+ }
104
+ ]
105
+
106
+
107
+ def _choice_probabilities(value: Any, question_id: str) -> dict[str, float]:
108
+ if not isinstance(value, list):
109
+ raise ValueError(f"{LABEL} returned invalid probabilities for {question_id}")
110
+ probabilities: dict[str, float] = {}
111
+ for entry in value:
112
+ if not is_record(entry) or not isinstance(entry.get("value"), str):
113
+ raise ValueError(f"{LABEL} returned invalid probabilities for {question_id}")
114
+ choice = entry["value"]
115
+ probabilities[choice] = required_number(
116
+ LABEL, entry.get("probability"), f"probability for {question_id}.{choice}"
117
+ )
118
+ return probabilities
119
+
120
+
121
+ def _parse_answer(question_id: str, question: ClassifierQuestion, answer: Mapping[str, Any]) -> ClassifierAnswer:
122
+ kind = answer.get("type")
123
+ if kind == "refusal":
124
+ raise ValueError(f"{LABEL} refused to answer {question_id}")
125
+ if question.type == "choice":
126
+ choice = answer.get("choice")
127
+ if kind != "choice" or not isinstance(choice, str):
128
+ raise ValueError(f"{LABEL} did not return a choice answer for {question_id}")
129
+ return ClassifierChoiceAnswer(
130
+ choice=choice,
131
+ probabilities=_choice_probabilities(answer.get("probabilities"), question_id),
132
+ confidence=required_number(LABEL, answer.get("confidence"), f"confidence for {question_id}"),
133
+ )
134
+ if question.type == "score":
135
+ if kind != "score":
136
+ raise ValueError(f"{LABEL} did not return a score answer for {question_id}")
137
+ return ClassifierScoreAnswer(
138
+ score=required_number(LABEL, answer.get("score"), f"score for {question_id}"),
139
+ confidence=required_number(LABEL, answer.get("confidence"), f"confidence for {question_id}"),
140
+ )
141
+ if kind != "predicate":
142
+ raise ValueError(f"{LABEL} did not return a predicate answer for {question_id}")
143
+ return ClassifierBoolAnswer(
144
+ probability=required_number(LABEL, answer.get("probability"), f"probability for {question_id}")
145
+ )
146
+
147
+
148
+ def _parse_answers(value: Any, questions: Mapping[str, ClassifierQuestion]) -> dict[str, ClassifierAnswer]:
149
+ if not isinstance(value, list):
150
+ raise ValueError(f"{LABEL} returned an unexpected response")
151
+ by_name: dict[str, Mapping[str, Any]] = {}
152
+ for answer in value:
153
+ if is_record(answer) and isinstance(answer.get("name"), str):
154
+ by_name[answer["name"]] = answer
155
+ answers: dict[str, ClassifierAnswer] = {}
156
+ for question_id, question in questions.items():
157
+ answer = by_name.get(question_id)
158
+ if answer is None:
159
+ raise ValueError(f"{LABEL} did not return an answer for {question_id}")
160
+ answers[question_id] = _parse_answer(question_id, question, answer)
161
+ return answers
162
+
163
+
164
+ def _error_message(error: Exception) -> str:
165
+ if getattr(error, "status", None) == 504:
166
+ return (
167
+ f"{LABEL} error (504): the request timed out at the gateway. Very large inputs "
168
+ "(above roughly 600K tokens) currently exceed its time limit."
169
+ )
170
+ return format_provider_error(normalize_provider_error(error), f"{LABEL} error")
171
+
172
+
173
+ async def classify(
174
+ model: ClassifierModel, context: ClassifierContext, options: Mapping[str, Any] | None = None
175
+ ) -> ClassifierResult:
176
+ """Classification through OpenAI's Decisions API. Never raises."""
177
+ options = options or {}
178
+ output = ClassifierResult(
179
+ api=model.api, provider=model.provider, model=model.id, answers={}, stop_reason="stop", timestamp=now_ms()
180
+ )
181
+ try:
182
+ if model.api != "openai-decisions":
183
+ raise ValueError(f"Unsupported classifier API: {model.api}")
184
+ questions = classifier_questions(context)
185
+ body = await post_classifier_request(
186
+ LABEL,
187
+ urljoin(f"{model.base_url.rstrip('/')}/", "decisions"),
188
+ model,
189
+ {
190
+ "model": model.id,
191
+ "input": _wire_input(context),
192
+ "questions": [_wire_question(name, question) for name, question in questions.items()],
193
+ },
194
+ options,
195
+ NO_RETRY_STATUSES,
196
+ )
197
+ if not is_record(body):
198
+ raise ValueError(f"{LABEL} returned an unexpected response")
199
+ # Set before parsing answers: a request with malformed or refused answers was still billed.
200
+ usage = parse_classifier_usage(body.get("usage"), model)
201
+ if usage is not None:
202
+ output.usage = usage
203
+ output.answers = _parse_answers(body.get("answers"), questions)
204
+ return output
205
+ except Exception as error:
206
+ signal = options.get("signal")
207
+ failed = classifier_error_result(model, _error_message(error), aborted=signal is not None and signal.aborted)
208
+ failed.usage = output.usage
209
+ failed.timestamp = output.timestamp
210
+ return failed
@@ -0,0 +1,19 @@
1
+ """OpenAI prompt-cache key helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH", "clamp_openai_prompt_cache_key"]
6
+
7
+ OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
8
+
9
+
10
+ def clamp_openai_prompt_cache_key(key: str | None) -> str | None:
11
+ """Truncate a prompt cache key to OpenAI's limit, counting code points.
12
+
13
+ JavaScript's ``Array.from`` counts code points too, so keys match the TypeScript port.
14
+ """
15
+ if key is None:
16
+ return None
17
+ if len(key) <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH:
18
+ return key
19
+ return key[:OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH]
@@ -0,0 +1,418 @@
1
+ # pyright: reportAttributeAccessIssue=false, reportArgumentType=false
2
+ # Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
3
+ """OpenAI Responses API (``openai-responses``).
4
+
5
+ Streams ``responses.create`` through the official ``openai`` SDK (``AsyncOpenAI``).
6
+ Also used by OpenAI-compatible Responses endpoints (xAI, GitHub Copilot, OpenRouter,
7
+ OpenCode, Cloudflare AI Gateway, ...).
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
+ StreamOptions,
26
+ TranscriptContext,
27
+ Usage,
28
+ )
29
+ from ..utils.callbacks import maybe_await
30
+ from ..utils.error_body import format_provider_error, normalize_provider_error
31
+ from ..utils.misc import get_pi_user_agent, get_provider_env_value, headers_to_record
32
+ from ..utils.transcript import get_declared_tools, resolve_transcript, resolve_transcript_tools
33
+ from .constrained_sampling import create_grammar_tool_input_properties
34
+ from .github_copilot_headers import build_copilot_dynamic_headers, has_copilot_vision_input
35
+ from .openai_prompt_cache import clamp_openai_prompt_cache_key
36
+ from .openai_responses_shared import convert_responses_messages, convert_responses_tools, process_responses_stream
37
+ from .openai_sdk import (
38
+ create_openai_client,
39
+ iter_sdk_stream,
40
+ merge_sdk_headers,
41
+ open_sdk_stream,
42
+ openai_request,
43
+ sdk_body_arguments,
44
+ )
45
+ from .simple_options import build_base_options, resolve_sampling_params
46
+
47
+ __all__ = [
48
+ "OpenAIResponsesOptions",
49
+ "OPENAI_TOOL_CALL_PROVIDERS",
50
+ "stream",
51
+ "stream_simple",
52
+ ]
53
+
54
+ OPENAI_TOOL_CALL_PROVIDERS = frozenset({"openai", "openai-codex", "opencode"})
55
+ # OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
56
+ OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16
57
+ CHATGPT_USAGE_URL = "https://chatgpt.com/settings/usage"
58
+ # Fixed reasoning.effort while configuration_update items carry the thinking level.
59
+ CONFIGURATION_UPDATE_REQUEST_EFFORT = "medium"
60
+
61
+
62
+ class OpenAIResponsesOptions(StreamOptions, total=False):
63
+ reasoning_effort: Literal["minimal", "low", "medium", "high", "xhigh", "max"]
64
+ reasoning_summary: Literal["auto", "detailed", "concise"] | None
65
+ service_tier: str
66
+ tool_choice: Any
67
+
68
+
69
+ def _is_chatgpt_sign_in(model: Model, api_key: str | None) -> bool:
70
+ """OpenAI API keys start with ``sk-``; another credential sent directly to OpenAI is a
71
+ Sign in with ChatGPT access token."""
72
+ return (
73
+ model.provider == "openai"
74
+ and model.base_url == "https://api.openai.com/v1"
75
+ and api_key is not None
76
+ and not api_key.startswith("sk-")
77
+ )
78
+
79
+
80
+ def _has_header(headers: Mapping[str, str | None] | None, name: str) -> bool:
81
+ if not headers:
82
+ return False
83
+ expected = name.lower()
84
+ return any(key.lower() == expected and value is not None and value.strip() != "" for key, value in headers.items())
85
+
86
+
87
+ def _get_client_api_key(provider: str, api_key: str | None, headers: Mapping[str, str | None] | None) -> str:
88
+ if api_key:
89
+ return api_key
90
+ if _has_header(headers, "authorization") or _has_header(headers, "cf-aig-authorization"):
91
+ return "unused"
92
+ raise RuntimeError(f"No API key for provider: {provider}")
93
+
94
+
95
+ def _detect_session_affinity_format(model: Model) -> str:
96
+ return "openrouter" if model.provider == "openrouter" or "openrouter.ai" in model.base_url else "openai"
97
+
98
+
99
+ def _resolve_cache_retention(cache_retention: str | None, env: Mapping[str, str] | None) -> str:
100
+ """Default ``"short"``; ``PI_CACHE_RETENTION=long`` is honored for backward compatibility."""
101
+ if cache_retention:
102
+ return cache_retention
103
+ if get_provider_env_value("PI_CACHE_RETENTION", env) == "long":
104
+ return "long"
105
+ return "short"
106
+
107
+
108
+ def _compat_value(compat: Mapping[str, Any], key: str, default: Any) -> Any:
109
+ value = compat.get(key)
110
+ return default if value is None else value
111
+
112
+
113
+ def _get_compat(model: Model) -> dict[str, Any]:
114
+ compat = model.compat or {}
115
+ return {
116
+ "supportsDeveloperRole": _compat_value(compat, "supportsDeveloperRole", True),
117
+ "supportsMidConvoSystemMessages": _compat_value(compat, "supportsMidConvoSystemMessages", False),
118
+ "sessionAffinityFormat": _compat_value(compat, "sessionAffinityFormat", None)
119
+ or _detect_session_affinity_format(model),
120
+ "supportsLongCacheRetention": _compat_value(compat, "supportsLongCacheRetention", True),
121
+ "supportsStrictMode": _compat_value(compat, "supportsStrictMode", False),
122
+ "supportsOpenAIGrammarTools": _compat_value(compat, "supportsOpenAIGrammarTools", False),
123
+ "supportsAdditionalTools": _compat_value(compat, "supportsAdditionalTools", False),
124
+ "supportsToolSearch": _compat_value(compat, "supportsToolSearch", False),
125
+ "supportsExplicitPromptCacheMode": _compat_value(compat, "supportsExplicitPromptCacheMode", False),
126
+ "supportsMaxOutputTokens": _compat_value(compat, "supportsMaxOutputTokens", True),
127
+ "supportsMidConvoEffort": _compat_value(compat, "supportsMidConvoEffort", False),
128
+ }
129
+
130
+
131
+ def _map_thinking_level(model: Model, level: str) -> str:
132
+ level_map = model.thinking_level_map or {}
133
+ mapped = level_map.get(level)
134
+ return mapped if mapped is not None else level
135
+
136
+
137
+ def _get_configuration_update_effort(model: Model, options: Mapping[str, Any], compat: Mapping[str, Any]) -> str | None:
138
+ """Effort to send in ``configuration_update`` items, or ``None`` to send it as ``reasoning.effort``."""
139
+ reasoning_effort = options.get("reasoning_effort")
140
+ if (
141
+ not compat["supportsMidConvoEffort"]
142
+ or not reasoning_effort
143
+ # Sign in with ChatGPT rejects configuration_update items.
144
+ or _is_chatgpt_sign_in(model, options.get("api_key"))
145
+ ):
146
+ return None
147
+ return _map_thinking_level(model, reasoning_effort)
148
+
149
+
150
+ def _get_prompt_cache_retention(compat: Mapping[str, Any], cache_retention: str) -> str | None:
151
+ if (
152
+ cache_retention == "long"
153
+ and compat["supportsLongCacheRetention"]
154
+ and not compat["supportsExplicitPromptCacheMode"]
155
+ ):
156
+ return "24h"
157
+ return None
158
+
159
+
160
+ def _get_prompt_cache_options(compat: Mapping[str, Any], cache_retention: str) -> dict[str, Any] | None:
161
+ if not compat["supportsExplicitPromptCacheMode"]:
162
+ return None
163
+ if cache_retention == "none":
164
+ return {"mode": "explicit"}
165
+ if cache_retention == "long" and compat["supportsLongCacheRetention"]:
166
+ return {"ttl": "30m"}
167
+ return None
168
+
169
+
170
+ def stream(
171
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
172
+ ) -> AssistantMessageEventStream:
173
+ """Stream a response from the OpenAI Responses API."""
174
+ opts: dict[str, Any] = dict(options or {})
175
+ out = AssistantMessageEventStream()
176
+ normalized_context = resolve_transcript(context, _get_compat(model)["supportsMidConvoSystemMessages"])
177
+ out.start(_run(model, normalized_context, opts, out))
178
+ return out
179
+
180
+
181
+ async def _run(
182
+ model: Model, context: TranscriptContext, options: dict[str, Any], out: AssistantMessageEventStream
183
+ ) -> None:
184
+ compat = _get_compat(model)
185
+ configuration_update_effort = _get_configuration_update_effort(model, options, compat)
186
+ output = AssistantMessage(
187
+ [],
188
+ api=model.api,
189
+ provider=model.provider,
190
+ model=model.id,
191
+ usage=Usage(),
192
+ stop_reason="pending",
193
+ provider_thinking_level=configuration_update_effort,
194
+ )
195
+ signal = options.get("signal")
196
+ try:
197
+ async with abort_scope(signal):
198
+ api_key = _get_client_api_key(model.provider, options.get("api_key"), options.get("headers"))
199
+ cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
200
+ cache_session_id = None if cache_retention == "none" else options.get("session_id")
201
+ grammar_tool_input_properties = create_grammar_tool_input_properties(
202
+ get_declared_tools(context.messages), compat["supportsOpenAIGrammarTools"]
203
+ )
204
+ headers = _build_request_headers(model, context, options, compat, cache_session_id)
205
+ client = create_openai_client(api_key=api_key, base_url=model.base_url, headers=headers, options=options)
206
+ params: Any = _build_params(
207
+ model, context, options, compat, grammar_tool_input_properties, configuration_update_effort
208
+ )
209
+ on_payload = options.get("on_payload")
210
+ if on_payload is not None:
211
+ next_params = await maybe_await(on_payload(params, model))
212
+ if next_params is not None:
213
+ params = next_params
214
+ create = client.responses.with_raw_response.create
215
+ body_arguments = sdk_body_arguments(params, headers=headers)
216
+ raw = await openai_request(lambda: create(stream=True, **body_arguments), options)
217
+ sdk_stream = open_sdk_stream(raw)
218
+ try:
219
+ on_response = options.get("on_response")
220
+ if on_response is not None:
221
+ await maybe_await(
222
+ on_response(ProviderResponse(raw.status_code, headers_to_record(raw.headers)), model)
223
+ )
224
+ out.push(StartEvent(output))
225
+ await process_responses_stream(
226
+ iter_sdk_stream(sdk_stream),
227
+ output,
228
+ out,
229
+ model,
230
+ {
231
+ "on_provider_stream_event": options.get("on_provider_stream_event"),
232
+ "service_tier": options.get("service_tier"),
233
+ "grammar_tool_input_properties": grammar_tool_input_properties,
234
+ "apply_service_tier_pricing": lambda usage, tier: _apply_service_tier_pricing(
235
+ usage, tier, model
236
+ ),
237
+ },
238
+ )
239
+ finally:
240
+ await sdk_stream.close()
241
+
242
+ if signal is not None and signal.aborted:
243
+ raise RuntimeError("Request was aborted")
244
+ if output.stop_reason == "pending":
245
+ raise RuntimeError("OpenAI Responses stream ended without a stop reason")
246
+ if output.stop_reason in ("aborted", "error"):
247
+ raise RuntimeError(output.error_message or "An unknown error occurred")
248
+
249
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
250
+ out.end()
251
+ except Exception as error:
252
+ aborted = signal is not None and signal.aborted
253
+ output.stop_reason = "aborted" if aborted else "error"
254
+ if aborted:
255
+ error_message = "Request was aborted"
256
+ else:
257
+ prefix = "OpenAI" if model.provider == "openai" else model.provider
258
+ error_message = format_provider_error(normalize_provider_error(error), f"{prefix} API error")
259
+ # Sign in with ChatGPT shares the subscription's usage limit with other apps.
260
+ output.error_message = (
261
+ f"{error_message}\nCheck your ChatGPT usage: {CHATGPT_USAGE_URL}"
262
+ if "subscription_sharing_usage_limit_exceeded" in error_message
263
+ else error_message
264
+ )
265
+ out.push(ErrorEvent(output.stop_reason, output)) # type: ignore[arg-type]
266
+ out.end()
267
+
268
+
269
+ def stream_simple(
270
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
271
+ ) -> AssistantMessageEventStream:
272
+ """Stream with provider-neutral options (``reasoning``, ``tool_choice``, ...)."""
273
+ options = options or {}
274
+ _get_client_api_key(model.provider, options.get("api_key"), options.get("headers"))
275
+
276
+ base = build_base_options(model, context, options, options.get("api_key"))
277
+ if options.get("tool_choice") is not None:
278
+ base["tool_choice"] = options["tool_choice"]
279
+ reasoning = options.get("reasoning")
280
+ clamped = clamp_thinking_level(model, reasoning) if reasoning else None
281
+ if clamped is not None and clamped != "off":
282
+ base["reasoning_effort"] = clamped
283
+ return stream(model, context, base)
284
+
285
+
286
+ def _build_request_headers(
287
+ model: Model,
288
+ context: TranscriptContext,
289
+ options: Mapping[str, Any],
290
+ compat: Mapping[str, Any],
291
+ session_id: str | None,
292
+ ) -> dict[str, str | None]:
293
+ """The client's ``default_headers``: ``None`` removes a header, SDK defaults included."""
294
+ copilot_headers: dict[str, str] | None = None
295
+ if model.provider == "github-copilot":
296
+ copilot_headers = build_copilot_dynamic_headers(
297
+ messages=context.messages, has_images=has_copilot_vision_input(context.messages)
298
+ )
299
+
300
+ session_headers: dict[str, str] = {}
301
+ if session_id:
302
+ affinity_format = compat["sessionAffinityFormat"]
303
+ if affinity_format == "openrouter":
304
+ session_headers["x-session-id"] = session_id
305
+ else:
306
+ if affinity_format == "openai":
307
+ session_headers["session_id"] = session_id
308
+ session_headers["x-client-request-id"] = session_id
309
+
310
+ return merge_sdk_headers(
311
+ {"User-Agent": get_pi_user_agent()},
312
+ model.headers,
313
+ copilot_headers,
314
+ session_headers,
315
+ # Options headers last so they can override defaults.
316
+ options.get("headers"),
317
+ )
318
+
319
+
320
+ def _build_params(
321
+ model: Model,
322
+ context: TranscriptContext,
323
+ options: Mapping[str, Any],
324
+ compat: Mapping[str, Any],
325
+ grammar_tool_input_properties: Mapping[str, str],
326
+ configuration_update_effort: str | None,
327
+ ) -> dict[str, Any]:
328
+ transcript_tools = resolve_transcript_tools(
329
+ context.messages, compat["supportsAdditionalTools"] or compat["supportsToolSearch"]
330
+ )
331
+ tool_options = {
332
+ "supports_strict_mode": compat["supportsStrictMode"],
333
+ "supports_openai_grammar_tools": compat["supportsOpenAIGrammarTools"],
334
+ }
335
+ messages_options: dict[str, Any] = {
336
+ "grammar_tool_input_properties": grammar_tool_input_properties,
337
+ "supports_mid_convo_system_messages": compat["supportsMidConvoSystemMessages"],
338
+ "supports_additional_tools": compat["supportsAdditionalTools"],
339
+ "supports_tool_search": compat["supportsToolSearch"],
340
+ "tool_options": tool_options,
341
+ }
342
+ if configuration_update_effort is not None:
343
+ messages_options["configuration_update_effort"] = configuration_update_effort
344
+ messages = convert_responses_messages(model, context, OPENAI_TOOL_CALL_PROVIDERS, messages_options) # type: ignore[arg-type]
345
+
346
+ cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
347
+ # Sign in with ChatGPT rejects these request fields.
348
+ omit_unsupported_fields = _is_chatgpt_sign_in(model, options.get("api_key"))
349
+ params: dict[str, Any] = {"model": model.id, "input": messages, "stream": True}
350
+ prompt_cache_key = None if cache_retention == "none" else clamp_openai_prompt_cache_key(options.get("session_id"))
351
+ if prompt_cache_key is not None:
352
+ params["prompt_cache_key"] = prompt_cache_key
353
+ if not omit_unsupported_fields:
354
+ retention = _get_prompt_cache_retention(compat, cache_retention)
355
+ if retention is not None:
356
+ params["prompt_cache_retention"] = retention
357
+ cache_options = _get_prompt_cache_options(compat, cache_retention)
358
+ if cache_options is not None:
359
+ params["prompt_cache_options"] = cache_options
360
+ params["store"] = False
361
+
362
+ max_tokens = options.get("max_tokens")
363
+ if max_tokens and compat["supportsMaxOutputTokens"] and not omit_unsupported_fields:
364
+ params["max_output_tokens"] = max(max_tokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS)
365
+
366
+ if options.get("temperature") is not None and not omit_unsupported_fields:
367
+ params["temperature"] = options["temperature"]
368
+
369
+ if options.get("service_tier") is not None:
370
+ params["service_tier"] = options["service_tier"]
371
+
372
+ if transcript_tools.request_tools:
373
+ params["tools"] = convert_responses_tools(transcript_tools.request_tools, tool_options) # type: ignore[arg-type]
374
+
375
+ if options.get("tool_choice") is not None:
376
+ params["tool_choice"] = options["tool_choice"]
377
+
378
+ requested = options.get("reasoning_effort")
379
+ reasoning_effort = requested or ("medium" if options.get("reasoning_summary") else None)
380
+ if model.reasoning:
381
+ level_map = model.thinking_level_map or {}
382
+ if reasoning_effort:
383
+ requested_effort = _map_thinking_level(model, requested) if requested else reasoning_effort
384
+ effort = requested_effort if configuration_update_effort is None else CONFIGURATION_UPDATE_REQUEST_EFFORT
385
+ params["reasoning"] = {"effort": effort, "summary": options.get("reasoning_summary") or "auto"}
386
+ params["include"] = ["reasoning.encrypted_content"]
387
+ elif model.provider != "github-copilot" and not ("off" in level_map and level_map["off"] is None):
388
+ off = level_map.get("off")
389
+ params["reasoning"] = {"effort": off if off is not None else "none"}
390
+ if model.provider == "xai":
391
+ params["include"] = ["reasoning.encrypted_content"]
392
+
393
+ # Last so model and request sampling parameters override named request fields.
394
+ sampling_params = resolve_sampling_params(model, reasoning_effort or "off", options.get("sampling_params"))
395
+ if sampling_params:
396
+ params.update(sampling_params)
397
+
398
+ return params
399
+
400
+
401
+ def _get_service_tier_cost_multiplier(model: Model, service_tier: Any) -> float:
402
+ if service_tier == "flex":
403
+ return 0.5
404
+ if service_tier in ("priority", "fast"):
405
+ return 2.5 if model.id == "gpt-5.5" else 2
406
+ return 1
407
+
408
+
409
+ def _apply_service_tier_pricing(usage: Usage, service_tier: Any, model: Model) -> None:
410
+ multiplier = _get_service_tier_cost_multiplier(model, service_tier)
411
+ if multiplier == 1:
412
+ return
413
+ cost = usage.cost
414
+ cost.input *= multiplier
415
+ cost.output *= multiplier
416
+ cost.cache_read *= multiplier
417
+ cost.cache_write *= multiplier
418
+ cost.total = cost.input + cost.output + cost.cache_read + cost.cache_write