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,680 @@
1
+ """In-memory provider with scripted responses for tests and demos.
2
+
3
+ ::
4
+
5
+ faux = faux_provider()
6
+ models = create_models()
7
+ models.set_provider(faux.provider)
8
+ faux.set_responses([faux_assistant_message("Hello!")])
9
+ message = await models.complete(faux.get_model(), Context(messages=[UserMessage("Hi")]))
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import copy
16
+ import json
17
+ import math
18
+ import random
19
+ import string
20
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, TypeAlias, overload
23
+
24
+ from ..auth.types import ApiKeyAuth, AuthResult, ModelAuth, ProviderAuth
25
+ from ..event_stream import AssistantMessageEventStream
26
+ from ..models import Provider, create_provider
27
+ from ..types import (
28
+ AssistantMessage,
29
+ DeferredHandle,
30
+ DoneEvent,
31
+ ErrorEvent,
32
+ Model,
33
+ ModelCost,
34
+ ModelInputLimits,
35
+ ProviderResponse,
36
+ StartEvent,
37
+ StopReason,
38
+ TextContent,
39
+ TextDeltaEvent,
40
+ TextEndEvent,
41
+ TextStartEvent,
42
+ ThinkingContent,
43
+ ThinkingDeltaEvent,
44
+ ThinkingEndEvent,
45
+ ThinkingStartEvent,
46
+ ToolCall,
47
+ ToolCallDeltaEvent,
48
+ ToolCallEndEvent,
49
+ ToolCallStartEvent,
50
+ TranscriptContext,
51
+ Usage,
52
+ UsageCost,
53
+ now_ms,
54
+ )
55
+ from ..utils.callbacks import maybe_await
56
+ from ..utils.text import append_text, append_thinking, get_system_message_text
57
+
58
+ __all__ = [
59
+ "FauxModelDefinition",
60
+ "FauxProviderState",
61
+ "FauxResponseFactory",
62
+ "FauxResponseStep",
63
+ "FauxProviderHandle",
64
+ "faux_text",
65
+ "faux_thinking",
66
+ "faux_tool_call",
67
+ "faux_assistant_message",
68
+ "faux_provider",
69
+ "create_faux_core",
70
+ ]
71
+
72
+ _DEFAULT_API = "faux"
73
+ _DEFAULT_PROVIDER = "faux"
74
+ _DEFAULT_MODEL_ID = "faux-1"
75
+ _DEFAULT_MODEL_NAME = "Faux Model"
76
+ _DEFAULT_BASE_URL = "http://localhost:0"
77
+ _DEFAULT_MIN_TOKEN_SIZE = 3
78
+ _DEFAULT_MAX_TOKEN_SIZE = 5
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class FauxModelDefinition:
83
+ id: str
84
+ name: str | None = None
85
+ reasoning: bool = False
86
+ input: list[str] | None = None
87
+ input_limits: ModelInputLimits | None = None
88
+ cost: ModelCost | None = None
89
+ context_window: int = 128_000
90
+ max_tokens: int = 16_384
91
+
92
+
93
+ FauxContentBlock: TypeAlias = TextContent | ThinkingContent | ToolCall
94
+
95
+
96
+ def faux_text(text: str) -> TextContent:
97
+ return TextContent(text)
98
+
99
+
100
+ def faux_thinking(thinking: str) -> ThinkingContent:
101
+ return ThinkingContent(thinking)
102
+
103
+
104
+ def _random_id(prefix: str) -> str:
105
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=11))
106
+ return f"{prefix}:{now_ms()}:{suffix}"
107
+
108
+
109
+ def faux_tool_call(name: str, arguments: dict[str, Any], *, id: str | None = None) -> ToolCall:
110
+ return ToolCall(id or _random_id("tool"), name, arguments)
111
+
112
+
113
+ def faux_assistant_message(
114
+ content: str | FauxContentBlock | Sequence[FauxContentBlock],
115
+ *,
116
+ stop_reason: StopReason = "stop",
117
+ deferred: DeferredHandle | None = None,
118
+ error_message: str | None = None,
119
+ response_id: str | None = None,
120
+ timestamp: int | None = None,
121
+ ) -> AssistantMessage:
122
+ """Build a scripted assistant reply. A string becomes one text block."""
123
+ if isinstance(content, str):
124
+ blocks: list[Any] = [faux_text(content)]
125
+ elif isinstance(content, (TextContent, ThinkingContent, ToolCall)):
126
+ blocks = [content]
127
+ else:
128
+ blocks = list(content)
129
+ return AssistantMessage(
130
+ blocks,
131
+ api=_DEFAULT_API,
132
+ provider=_DEFAULT_PROVIDER,
133
+ model=_DEFAULT_MODEL_ID,
134
+ usage=Usage(),
135
+ stop_reason=stop_reason,
136
+ deferred=deferred,
137
+ error_message=error_message,
138
+ response_id=response_id,
139
+ timestamp=timestamp if timestamp is not None else now_ms(),
140
+ )
141
+
142
+
143
+ @dataclass(slots=True)
144
+ class FauxProviderState:
145
+ call_count: int = 0
146
+ deferred_fetch_count: int = 0
147
+ cancelled_deferred: list[DeferredHandle] = field(default_factory=list)
148
+
149
+
150
+ FauxResponseFactory = Callable[
151
+ [TranscriptContext, Mapping[str, Any] | None, FauxProviderState, Model],
152
+ "AssistantMessage | Awaitable[AssistantMessage]",
153
+ ]
154
+ FauxResponseStep: TypeAlias = "AssistantMessage | FauxResponseFactory"
155
+
156
+
157
+ def _estimate_tokens(text: str) -> int:
158
+ return math.ceil(len(text) / 4)
159
+
160
+
161
+ def _content_to_text(content: Any) -> str:
162
+ if isinstance(content, str):
163
+ return content
164
+ parts = []
165
+ for block in content:
166
+ if block.type == "text":
167
+ parts.append(block.text)
168
+ else:
169
+ parts.append(f"[image:{block.mime_type}:{len(block.data)}]")
170
+ return "\n".join(parts)
171
+
172
+
173
+ def _assistant_content_to_text(content: Sequence[Any]) -> str:
174
+ parts = []
175
+ for block in content:
176
+ if block.type == "text":
177
+ parts.append(block.text)
178
+ elif block.type == "thinking":
179
+ parts.append(block.thinking)
180
+ else:
181
+ parts.append(f"{block.name}:{json.dumps(block.arguments, separators=(',', ':'))}")
182
+ return "\n".join(parts)
183
+
184
+
185
+ def _tool_json(tool: Any) -> str:
186
+ from ..serialization import to_json_value
187
+
188
+ return json.dumps(to_json_value(tool), separators=(",", ":"))
189
+
190
+
191
+ def _message_to_text(message: Any) -> str:
192
+ role = message.role
193
+ if role == "system":
194
+ parts = [get_system_message_text(message)]
195
+ parts.extend(f"tool-:{_tool_json(tool)}" for tool in message.tools_removed or ())
196
+ parts.extend(f"tool+:{_tool_json(tool)}" for tool in message.tools_added or ())
197
+ return "\n".join(part for part in parts if part)
198
+ if role == "user":
199
+ return _content_to_text(message.content)
200
+ if role == "assistant":
201
+ return _assistant_content_to_text(message.content)
202
+ if role == "toolResult":
203
+ return "\n".join([message.tool_name, *(_content_to_text([block]) for block in message.content)])
204
+ return ""
205
+
206
+
207
+ def _joined_length(messages: Sequence[str], count: int | None = None) -> int:
208
+ count = len(messages) if count is None else count
209
+ length = (count - 1) * 2 if count > 0 else 0
210
+ for index in range(count):
211
+ length += len(messages[index])
212
+ return length
213
+
214
+
215
+ def _common_prefix_length(a: str, b: str) -> int:
216
+ length = min(len(a), len(b))
217
+ index = 0
218
+ while index < length and a[index] == b[index]:
219
+ index += 1
220
+ return index
221
+
222
+
223
+ def _common_prompt_prefix_length(previous: Sequence[str], current: Sequence[str]) -> int:
224
+ index = 0
225
+ while index < len(previous) and index < len(current) and previous[index] == current[index]:
226
+ index += 1
227
+
228
+ def rest(messages: Sequence[str]) -> str:
229
+ if index == len(messages):
230
+ return ""
231
+ return ("\n\n" if index > 0 else "") + "\n\n".join(messages[index:])
232
+
233
+ return _joined_length(previous, index) + _common_prefix_length(rest(previous), rest(current))
234
+
235
+
236
+ def _with_usage_estimate(
237
+ message: AssistantMessage,
238
+ context: TranscriptContext,
239
+ options: Mapping[str, Any] | None,
240
+ prompt_cache: dict[str, list[str]],
241
+ ) -> AssistantMessage:
242
+ prompt = [f"{m.role}:{_message_to_text(m)}" for m in context.messages]
243
+ prompt_length = _joined_length(prompt)
244
+ prompt_tokens = math.ceil(prompt_length / 4)
245
+ output_tokens = _estimate_tokens(_assistant_content_to_text(message.content))
246
+ input_tokens = prompt_tokens
247
+ cache_read = 0
248
+ cache_write = 0
249
+ session_id = options.get("session_id") if options else None
250
+ if session_id and (options or {}).get("cache_retention") != "none":
251
+ previous = prompt_cache.get(session_id)
252
+ if previous is not None:
253
+ cached_chars = _common_prompt_prefix_length(previous, prompt)
254
+ cache_read = math.ceil(cached_chars / 4)
255
+ cache_write = math.ceil((prompt_length - cached_chars) / 4)
256
+ input_tokens = max(0, prompt_tokens - cache_read)
257
+ else:
258
+ cache_write = prompt_tokens
259
+ prompt_cache[session_id] = prompt
260
+ message.usage = Usage(
261
+ input_tokens,
262
+ output_tokens,
263
+ cache_read,
264
+ cache_write,
265
+ input_tokens + output_tokens + cache_read + cache_write,
266
+ UsageCost(),
267
+ )
268
+ return message
269
+
270
+
271
+ def _split_by_token_size(text: str, min_size: int, max_size: int) -> list[str]:
272
+ chunks: list[str] = []
273
+ index = 0
274
+ while index < len(text):
275
+ token_size = min_size + int(random.random() * (max_size - min_size + 1))
276
+ char_size = max(1, token_size * 4)
277
+ chunks.append(text[index : index + char_size])
278
+ index += char_size
279
+ return chunks or [""]
280
+
281
+
282
+ def _clone_message(message: AssistantMessage, api: str, provider: str, model_id: str) -> AssistantMessage:
283
+ cloned = copy.deepcopy(message)
284
+ cloned.api = api
285
+ cloned.provider = provider
286
+ cloned.model = model_id
287
+ if cloned.usage is None:
288
+ cloned.usage = Usage()
289
+ return cloned
290
+
291
+
292
+ def _deferred_message(model: Model, handle: DeferredHandle) -> AssistantMessage:
293
+ return AssistantMessage(
294
+ [],
295
+ api=model.api,
296
+ provider=model.provider,
297
+ model=model.id,
298
+ usage=Usage(),
299
+ stop_reason="deferred",
300
+ deferred=handle,
301
+ )
302
+
303
+
304
+ def _error_message(error: Any, api: str, provider: str, model_id: str) -> AssistantMessage:
305
+ return AssistantMessage(
306
+ [],
307
+ api=api,
308
+ provider=provider,
309
+ model=model_id,
310
+ usage=Usage(),
311
+ stop_reason="error",
312
+ error_message=str(error),
313
+ )
314
+
315
+
316
+ def _aborted(partial: AssistantMessage) -> AssistantMessage:
317
+ aborted = partial.shallow_copy()
318
+ aborted.stop_reason = "aborted"
319
+ aborted.error_message = "Request was aborted"
320
+ aborted.timestamp = now_ms()
321
+ return aborted
322
+
323
+
324
+ async def _schedule_chunk(chunk: str, tokens_per_second: float | None) -> None:
325
+ if not tokens_per_second or tokens_per_second <= 0:
326
+ await asyncio.sleep(0)
327
+ return
328
+ await asyncio.sleep(_estimate_tokens(chunk) / tokens_per_second)
329
+
330
+
331
+ async def _stream_with_deltas(
332
+ stream: AssistantMessageEventStream,
333
+ message: AssistantMessage,
334
+ min_size: int,
335
+ max_size: int,
336
+ tokens_per_second: float | None,
337
+ signal: Any,
338
+ ) -> None:
339
+ partial = message.shallow_copy()
340
+ partial.content = []
341
+ partial.stop_reason = "pending"
342
+
343
+ def abort_now() -> bool:
344
+ if signal is not None and signal.aborted:
345
+ aborted = _aborted(partial)
346
+ stream.push(ErrorEvent("aborted", aborted))
347
+ stream.end(aborted)
348
+ return True
349
+ return False
350
+
351
+ if abort_now():
352
+ return
353
+ stream.push(StartEvent(partial.shallow_copy()))
354
+
355
+ for index, block in enumerate(message.content):
356
+ if abort_now():
357
+ return
358
+ if block.type == "thinking":
359
+ current = ThinkingContent("")
360
+ partial.content.append(current)
361
+ stream.push(ThinkingStartEvent(index, partial.shallow_copy()))
362
+ for chunk in _split_by_token_size(block.thinking, min_size, max_size):
363
+ await _schedule_chunk(chunk, tokens_per_second)
364
+ if abort_now():
365
+ return
366
+ append_thinking(current, chunk)
367
+ stream.push(ThinkingDeltaEvent(index, chunk, partial.shallow_copy()))
368
+ stream.push(ThinkingEndEvent(index, block.thinking, partial.shallow_copy()))
369
+ continue
370
+ if block.type == "text":
371
+ current_text = TextContent("")
372
+ partial.content.append(current_text)
373
+ stream.push(TextStartEvent(index, partial.shallow_copy()))
374
+ for chunk in _split_by_token_size(block.text, min_size, max_size):
375
+ await _schedule_chunk(chunk, tokens_per_second)
376
+ if abort_now():
377
+ return
378
+ append_text(current_text, chunk)
379
+ stream.push(TextDeltaEvent(index, chunk, partial.shallow_copy()))
380
+ stream.push(TextEndEvent(index, block.text, partial.shallow_copy()))
381
+ continue
382
+ current_call = ToolCall(block.id, block.name, {})
383
+ partial.content.append(current_call)
384
+ stream.push(ToolCallStartEvent(index, partial.shallow_copy()))
385
+ for chunk in _split_by_token_size(json.dumps(block.arguments, separators=(",", ":")), min_size, max_size):
386
+ await _schedule_chunk(chunk, tokens_per_second)
387
+ if abort_now():
388
+ return
389
+ stream.push(ToolCallDeltaEvent(index, chunk, partial.shallow_copy()))
390
+ current_call.arguments = block.arguments
391
+ stream.push(ToolCallEndEvent(index, block, partial.shallow_copy()))
392
+
393
+ if message.stop_reason == "pending":
394
+ raise RuntimeError("Faux response ended without a stop reason")
395
+ if message.stop_reason in ("error", "aborted"):
396
+ stream.push(ErrorEvent(message.stop_reason, message)) # type: ignore[arg-type]
397
+ stream.end(message)
398
+ return
399
+ stream.push(DoneEvent(message.stop_reason, message)) # type: ignore[arg-type]
400
+ stream.end(message)
401
+
402
+
403
+ @dataclass(slots=True)
404
+ class _DeferredEntry:
405
+ handle: DeferredHandle
406
+ step: Any
407
+ context: TranscriptContext
408
+ options: dict[str, Any] | None
409
+ model: Model
410
+ pending_fetches: int
411
+ cancelled: bool = False
412
+ final: AssistantMessage | None = None
413
+
414
+
415
+ class _FauxCore:
416
+ def __init__(
417
+ self,
418
+ *,
419
+ api: str | None = None,
420
+ provider: str | None = None,
421
+ models: Sequence[FauxModelDefinition | Mapping[str, Any]] | None = None,
422
+ deferred: Mapping[str, Any] | None = None,
423
+ tokens_per_second: float | None = None,
424
+ token_size: Mapping[str, int] | None = None,
425
+ ) -> None:
426
+ self.api = api or _random_id(_DEFAULT_API)
427
+ self.provider = provider or _DEFAULT_PROVIDER
428
+ sizes = token_size or {}
429
+ max_size_option = sizes.get("max", _DEFAULT_MAX_TOKEN_SIZE)
430
+ self.min_size = max(1, min(sizes.get("min", _DEFAULT_MIN_TOKEN_SIZE), max_size_option))
431
+ self.max_size = max(self.min_size, max_size_option)
432
+ self.tokens_per_second = tokens_per_second
433
+ self.deferred_options = dict(deferred or {})
434
+ self.state = FauxProviderState()
435
+ self._pending: list[Any] = []
436
+ self._prompt_cache: dict[str, list[str]] = {}
437
+ self._deferred: dict[str, _DeferredEntry] = {}
438
+ definitions = [
439
+ definition if isinstance(definition, FauxModelDefinition) else FauxModelDefinition(**dict(definition))
440
+ for definition in (models or [])
441
+ ] or [
442
+ FauxModelDefinition(
443
+ id=_DEFAULT_MODEL_ID,
444
+ name=_DEFAULT_MODEL_NAME,
445
+ input=["text", "image"],
446
+ context_window=128_000,
447
+ max_tokens=16_384,
448
+ )
449
+ ]
450
+ self.models: list[Model] = [
451
+ Model(
452
+ id=definition.id,
453
+ name=definition.name or definition.id,
454
+ api=self.api,
455
+ provider=self.provider,
456
+ base_url=_DEFAULT_BASE_URL,
457
+ reasoning=definition.reasoning,
458
+ input=list(definition.input or ["text", "image"]),
459
+ input_limits=definition.input_limits,
460
+ cost=definition.cost or ModelCost(),
461
+ context_window=definition.context_window,
462
+ max_tokens=definition.max_tokens,
463
+ )
464
+ for definition in definitions
465
+ ]
466
+
467
+ def get_model(self, model_id: str | None = None) -> Model | None:
468
+ if not model_id:
469
+ return self.models[0]
470
+ return next((model for model in self.models if model.id == model_id), None)
471
+
472
+ async def _resolve(
473
+ self, step: Any, context: TranscriptContext, options: Mapping[str, Any] | None, model: Model
474
+ ) -> AssistantMessage:
475
+ resolved = await maybe_await(step(context, options, self.state, model)) if callable(step) else step
476
+ return _with_usage_estimate(
477
+ _clone_message(resolved, self.api, self.provider, model.id), context, options, self._prompt_cache
478
+ )
479
+
480
+ def stream(
481
+ self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
482
+ ) -> AssistantMessageEventStream:
483
+ outer = AssistantMessageEventStream()
484
+ step = self._pending.pop(0) if self._pending else None
485
+ self.state.call_count += 1
486
+ options = dict(options) if options else None
487
+
488
+ async def run() -> None:
489
+ try:
490
+ on_response = options.get("on_response") if options else None
491
+ if on_response is not None:
492
+ await maybe_await(on_response(ProviderResponse(200, {}), model))
493
+ if step is None:
494
+ message = _error_message("No more faux responses queued", self.api, self.provider, model.id)
495
+ message = _with_usage_estimate(message, context, options, self._prompt_cache)
496
+ outer.push(ErrorEvent("error", message))
497
+ outer.end(message)
498
+ return
499
+ signal = options.get("signal") if options else None
500
+ if options and options.get("deferred"):
501
+ handle = DeferredHandle(model.provider, model.id, model.api, _random_id("deferred"))
502
+ if self.deferred_options.get("poll_after_ms") is not None:
503
+ handle.poll_after_ms = self.deferred_options["poll_after_ms"]
504
+ self._deferred[handle.id] = _DeferredEntry(
505
+ handle=handle,
506
+ step=step,
507
+ context=context,
508
+ options=options,
509
+ model=model,
510
+ pending_fetches=max(0, int(self.deferred_options.get("pending_fetches", 0))),
511
+ )
512
+ await _stream_with_deltas(
513
+ outer,
514
+ _deferred_message(model, handle),
515
+ self.min_size,
516
+ self.max_size,
517
+ self.tokens_per_second,
518
+ signal,
519
+ )
520
+ return
521
+ message = await self._resolve(step, context, options, model)
522
+ await _stream_with_deltas(outer, message, self.min_size, self.max_size, self.tokens_per_second, signal)
523
+ except Exception as error:
524
+ message = _error_message(error, self.api, self.provider, model.id)
525
+ outer.push(ErrorEvent("error", message))
526
+ outer.end(message)
527
+
528
+ outer.start(run())
529
+ return outer
530
+
531
+ def stream_simple(
532
+ self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
533
+ ) -> AssistantMessageEventStream:
534
+ return self.stream(model, context, options)
535
+
536
+ def fetch_deferred(
537
+ self, model: Model, handle: DeferredHandle, options: Mapping[str, Any] | None = None
538
+ ) -> AssistantMessageEventStream:
539
+ outer = AssistantMessageEventStream()
540
+ self.state.deferred_fetch_count += 1
541
+
542
+ async def run() -> None:
543
+ try:
544
+ on_response = options.get("on_response") if options else None
545
+ if on_response is not None:
546
+ await maybe_await(on_response(ProviderResponse(200, {}), model))
547
+ entry = self._deferred.get(handle.id)
548
+ if (
549
+ entry is None
550
+ or entry.handle.provider != handle.provider
551
+ or entry.handle.model_id != handle.model_id
552
+ or entry.handle.api != handle.api
553
+ ):
554
+ raise RuntimeError(f"Unknown faux deferred response: {handle.id}")
555
+ if entry.cancelled:
556
+ raise RuntimeError(f"Faux deferred response was cancelled: {handle.id}")
557
+ signal = options.get("signal") if options else None
558
+ if entry.pending_fetches > 0:
559
+ entry.pending_fetches -= 1
560
+ await _stream_with_deltas(
561
+ outer,
562
+ _deferred_message(model, entry.handle),
563
+ self.min_size,
564
+ self.max_size,
565
+ self.tokens_per_second,
566
+ signal,
567
+ )
568
+ return
569
+ if entry.final is None:
570
+ submission = {
571
+ key: value
572
+ for key, value in (entry.options or {}).items()
573
+ if key not in ("deferred", "signal", "on_response")
574
+ }
575
+ try:
576
+ entry.final = await self._resolve(entry.step, entry.context, submission, entry.model)
577
+ except Exception as error:
578
+ entry.final = _error_message(error, self.api, self.provider, entry.model.id)
579
+ await _stream_with_deltas(
580
+ outer, entry.final, self.min_size, self.max_size, self.tokens_per_second, signal
581
+ )
582
+ except Exception as error:
583
+ message = _error_message(error, self.api, self.provider, model.id)
584
+ outer.push(ErrorEvent("error", message))
585
+ outer.end(message)
586
+
587
+ outer.start(run())
588
+ return outer
589
+
590
+ async def cancel_deferred(
591
+ self, model: Model, handle: DeferredHandle, options: Mapping[str, Any] | None = None
592
+ ) -> None:
593
+ self.state.cancelled_deferred.append(copy.deepcopy(handle))
594
+ entry = self._deferred.get(handle.id)
595
+ if entry is not None:
596
+ entry.cancelled = True
597
+ on_response = options.get("on_response") if options else None
598
+ if on_response is not None:
599
+ await maybe_await(on_response(ProviderResponse(200, {}), model))
600
+
601
+ def set_responses(self, responses: Sequence[FauxResponseStep]) -> None:
602
+ self._pending = list(responses)
603
+
604
+ def append_responses(self, responses: Sequence[FauxResponseStep]) -> None:
605
+ self._pending.extend(responses)
606
+
607
+ def get_pending_response_count(self) -> int:
608
+ return len(self._pending)
609
+
610
+
611
+ def create_faux_core(**options: Any) -> _FauxCore:
612
+ return _FauxCore(**options)
613
+
614
+
615
+ class FauxProviderHandle:
616
+ """Handle returned by :func:`faux_provider` for scripting responses."""
617
+
618
+ def __init__(self, core: _FauxCore, provider: Provider) -> None:
619
+ self._core = core
620
+ self.provider = provider
621
+ self.api = core.api
622
+ self.models = core.models
623
+ self.state = core.state
624
+
625
+ @overload
626
+ def get_model(self) -> Model: ...
627
+
628
+ @overload
629
+ def get_model(self, model_id: str) -> Model | None: ...
630
+
631
+ def get_model(self, model_id: str | None = None) -> Model | None:
632
+ """The first faux model, or the one with ``model_id`` (``None`` if unknown)."""
633
+ return self._core.get_model(model_id)
634
+
635
+ def set_responses(self, responses: Sequence[FauxResponseStep]) -> None:
636
+ """Replace the remaining response queue."""
637
+ self._core.set_responses(responses)
638
+
639
+ def append_responses(self, responses: Sequence[FauxResponseStep]) -> None:
640
+ self._core.append_responses(responses)
641
+
642
+ def get_pending_response_count(self) -> int:
643
+ return self._core.get_pending_response_count()
644
+
645
+
646
+ def faux_provider(
647
+ *,
648
+ api: str | None = None,
649
+ provider: str | None = None,
650
+ models: Sequence[FauxModelDefinition | Mapping[str, Any]] | None = None,
651
+ deferred: Mapping[str, Any] | None = None,
652
+ tokens_per_second: float | None = None,
653
+ token_size: Mapping[str, int] | None = None,
654
+ ) -> FauxProviderHandle:
655
+ """Create an in-memory provider with scripted responses.
656
+
657
+ Responses are consumed in request start order. When the queue is empty the
658
+ request fails with ``"No more faux responses queued"``. Usage is estimated at
659
+ about 4 characters per token; prompt caching is simulated when ``session_id``
660
+ is set. ``tokens_per_second`` paces chunk delivery in real time.
661
+ """
662
+ core = _FauxCore(
663
+ api=api,
664
+ provider=provider,
665
+ models=models,
666
+ deferred=deferred,
667
+ tokens_per_second=tokens_per_second,
668
+ token_size=token_size,
669
+ )
670
+
671
+ async def resolve(**_: Any) -> AuthResult:
672
+ return AuthResult(ModelAuth())
673
+
674
+ created = create_provider(
675
+ id=core.provider,
676
+ auth=ProviderAuth(api_key=ApiKeyAuth("Faux", resolve)),
677
+ models=core.models,
678
+ api=core,
679
+ )
680
+ return FauxProviderHandle(core, created)
@@ -0,0 +1,26 @@
1
+ """Fireworks provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import anthropic_messages_api, openai_completions_api
6
+ from ..auth.helpers import env_api_key_auth
7
+ from ..auth.types import ProviderAuth
8
+ from ..models import Provider, create_provider
9
+ from .catalog import catalog_models
10
+
11
+ __all__ = ["fireworks_provider"]
12
+
13
+
14
+ def fireworks_provider() -> Provider:
15
+ """Fireworks (Anthropic Messages and Chat Completions), authenticated with ``FIREWORKS_API_KEY``."""
16
+ return create_provider(
17
+ id="fireworks",
18
+ name="Fireworks",
19
+ base_url="https://api.fireworks.ai/inference",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Fireworks API key", ["FIREWORKS_API_KEY"])),
21
+ models=lambda: catalog_models("fireworks"),
22
+ api={
23
+ "anthropic-messages": anthropic_messages_api(),
24
+ "openai-completions": openai_completions_api(),
25
+ },
26
+ )