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
pi_ai/env_api_keys.py ADDED
@@ -0,0 +1,137 @@
1
+ """Known API-key environment variables per built-in provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+
8
+ from .utils.misc import get_provider_env_value
9
+
10
+ __all__ = [
11
+ "ANTHROPIC_AUTH_TOKEN_ENV",
12
+ "ANTHROPIC_OAUTH_TOKEN_ENV",
13
+ "ANTHROPIC_API_KEY_ENV",
14
+ "ANTHROPIC_FEDERATION_RULE_ID_ENV",
15
+ "ANTHROPIC_ORGANIZATION_ID_ENV",
16
+ "ANTHROPIC_SERVICE_ACCOUNT_ID_ENV",
17
+ "ANTHROPIC_IDENTITY_TOKEN_FILE_ENV",
18
+ "ANTHROPIC_WORKSPACE_ID_ENV",
19
+ "PROVIDER_API_KEY_ENV_VARS",
20
+ "find_env_keys",
21
+ "get_env_api_key",
22
+ ]
23
+
24
+ ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN"
25
+ ANTHROPIC_OAUTH_TOKEN_ENV = "ANTHROPIC_OAUTH_TOKEN"
26
+ ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"
27
+ ANTHROPIC_FEDERATION_RULE_ID_ENV = "ANTHROPIC_FEDERATION_RULE_ID"
28
+ ANTHROPIC_ORGANIZATION_ID_ENV = "ANTHROPIC_ORGANIZATION_ID"
29
+ ANTHROPIC_SERVICE_ACCOUNT_ID_ENV = "ANTHROPIC_SERVICE_ACCOUNT_ID"
30
+ ANTHROPIC_IDENTITY_TOKEN_FILE_ENV = "ANTHROPIC_IDENTITY_TOKEN_FILE"
31
+ ANTHROPIC_WORKSPACE_ID_ENV = "ANTHROPIC_WORKSPACE_ID"
32
+
33
+ PROVIDER_API_KEY_ENV_VARS: dict[str, tuple[str, ...]] = {
34
+ "github-copilot": ("COPILOT_GITHUB_TOKEN",),
35
+ "anthropic": (ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV),
36
+ "ant-ling": ("ANT_LING_API_KEY",),
37
+ "qwen-token-plan": ("QWEN_TOKEN_PLAN_API_KEY",),
38
+ "qwen-token-plan-cn": ("QWEN_TOKEN_PLAN_CN_API_KEY",),
39
+ "qwen-token-plan-individual": ("QWEN_TOKEN_PLAN_API_KEY",),
40
+ "openai": ("OPENAI_API_KEY",),
41
+ "azure": ("AZURE_OPENAI_API_KEY",),
42
+ "nvidia": ("NVIDIA_API_KEY",),
43
+ "deepseek": ("DEEPSEEK_API_KEY",),
44
+ "google": ("GEMINI_API_KEY",),
45
+ "google-vertex": ("GOOGLE_CLOUD_API_KEY",),
46
+ "groq": ("GROQ_API_KEY",),
47
+ "cerebras": ("CEREBRAS_API_KEY",),
48
+ "xai": ("XAI_API_KEY",),
49
+ "typesafe": ("TYPESAFE_API_KEY",),
50
+ "radius": ("RADIUS_API_KEY",),
51
+ "openrouter": ("OPENROUTER_API_KEY",),
52
+ "vercel-ai-gateway": ("AI_GATEWAY_API_KEY",),
53
+ "zai": ("ZAI_API_KEY",),
54
+ "zai-coding-cn": ("ZAI_CODING_CN_API_KEY",),
55
+ "mistral": ("MISTRAL_API_KEY",),
56
+ "minimax": ("MINIMAX_API_KEY",),
57
+ "minimax-cn": ("MINIMAX_CN_API_KEY",),
58
+ "moonshotai": ("MOONSHOT_API_KEY",),
59
+ "moonshotai-cn": ("MOONSHOT_API_KEY",),
60
+ "huggingface": ("HF_TOKEN",),
61
+ "fireworks": ("FIREWORKS_API_KEY",),
62
+ "together": ("TOGETHER_API_KEY",),
63
+ "baseten": ("BASETEN_API_KEY",),
64
+ "opencode": ("OPENCODE_API_KEY",),
65
+ "opencode-go": ("OPENCODE_API_KEY",),
66
+ "kimi-coding": ("KIMI_API_KEY",),
67
+ "meta": ("META_API_KEY",),
68
+ "cloudflare-workers-ai": ("CLOUDFLARE_API_KEY",),
69
+ "cloudflare-ai-gateway": ("CLOUDFLARE_API_KEY",),
70
+ "xiaomi": ("XIAOMI_API_KEY",),
71
+ "xiaomi-token-plan-cn": ("XIAOMI_TOKEN_PLAN_CN_API_KEY",),
72
+ "xiaomi-token-plan-ams": ("XIAOMI_TOKEN_PLAN_AMS_API_KEY",),
73
+ "xiaomi-token-plan-sgp": ("XIAOMI_TOKEN_PLAN_SGP_API_KEY",),
74
+ }
75
+
76
+ _vertex_adc_exists: bool | None = None
77
+
78
+
79
+ def _has_vertex_adc_credentials(env: Mapping[str, str] | None) -> bool:
80
+ global _vertex_adc_exists
81
+ explicit = env.get("GOOGLE_APPLICATION_CREDENTIALS") if env else None
82
+ if explicit:
83
+ return os.path.exists(os.path.expanduser(explicit))
84
+ if _vertex_adc_exists is None:
85
+ path = get_provider_env_value("GOOGLE_APPLICATION_CREDENTIALS", env)
86
+ if path:
87
+ _vertex_adc_exists = os.path.exists(os.path.expanduser(path))
88
+ else:
89
+ _vertex_adc_exists = os.path.exists(
90
+ os.path.join(os.path.expanduser("~"), ".config", "gcloud", "application_default_credentials.json")
91
+ )
92
+ return _vertex_adc_exists
93
+
94
+
95
+ def find_env_keys(provider: str, env: Mapping[str, str] | None = None) -> list[str] | None:
96
+ """Configured env vars that provide an API key for ``provider`` (ambient sources excluded)."""
97
+ names = PROVIDER_API_KEY_ENV_VARS.get(provider)
98
+ if not names:
99
+ return None
100
+ found = [name for name in names if get_provider_env_value(name, env)]
101
+ return found or None
102
+
103
+
104
+ def get_env_api_key(provider: str, env: Mapping[str, str] | None = None) -> str | None:
105
+ """API key for ``provider`` from known env vars (``"<authenticated>"`` for ambient Vertex/Bedrock auth)."""
106
+ found = find_env_keys(provider, env)
107
+ if found:
108
+ name = (
109
+ next((key for key in found if key != ANTHROPIC_AUTH_TOKEN_ENV), None)
110
+ if provider == "anthropic"
111
+ else found[0]
112
+ )
113
+ if name:
114
+ return get_provider_env_value(name, env)
115
+
116
+ if provider == "google-vertex":
117
+ has_project = bool(
118
+ get_provider_env_value("GOOGLE_CLOUD_PROJECT", env) or get_provider_env_value("GCLOUD_PROJECT", env)
119
+ )
120
+ has_location = bool(get_provider_env_value("GOOGLE_CLOUD_LOCATION", env))
121
+ if _has_vertex_adc_credentials(env) and has_project and has_location:
122
+ return "<authenticated>"
123
+
124
+ if provider == "amazon-bedrock":
125
+ if (
126
+ get_provider_env_value("AWS_PROFILE", env)
127
+ or (
128
+ get_provider_env_value("AWS_ACCESS_KEY_ID", env)
129
+ and get_provider_env_value("AWS_SECRET_ACCESS_KEY", env)
130
+ )
131
+ or get_provider_env_value("AWS_BEARER_TOKEN_BEDROCK", env)
132
+ or get_provider_env_value("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", env)
133
+ or get_provider_env_value("AWS_CONTAINER_CREDENTIALS_FULL_URI", env)
134
+ or get_provider_env_value("AWS_WEB_IDENTITY_TOKEN_FILE", env)
135
+ ):
136
+ return "<authenticated>"
137
+ return None
pi_ai/event_stream.py ADDED
@@ -0,0 +1,329 @@
1
+ """Async event streams with a final result.
2
+
3
+ :class:`EventStream` is a single-producer queue that consumers read with
4
+ ``async for`` while ``await stream.result()`` yields the final value. It mirrors
5
+ pi's ``EventStream`` with a few Python-specific improvements:
6
+
7
+ * Streams can be created outside a running event loop; the producer starts on the
8
+ first ``async for``/``result()`` call.
9
+ * The producer task is owned by the stream, so it cannot be garbage collected
10
+ mid-flight, and ``await stream.aclose()`` (or ``async with stream:``) cancels it.
11
+ * An unexpected producer crash ends the stream and surfaces from ``result()``
12
+ instead of leaving consumers waiting forever.
13
+ * Forwarding one stream into another (:meth:`EventStream.forward_to`) wires the
14
+ producer directly into the target, so wrapped streams cost no extra queue hop
15
+ per event.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import time
22
+ from collections import deque
23
+ from collections.abc import AsyncIterator, Callable, Coroutine
24
+ from typing import Any, Generic, TypeVar
25
+
26
+ from .types import AssistantMessage, AssistantMessageEvent, DoneEvent, ErrorEvent, now_ms
27
+
28
+ __all__ = [
29
+ "EventStream",
30
+ "AssistantMessageEventStream",
31
+ "create_assistant_message_event_stream",
32
+ ]
33
+
34
+ T = TypeVar("T")
35
+ R = TypeVar("R")
36
+
37
+ _UNSET: Any = object()
38
+
39
+
40
+ class EventStream(Generic[T, R]):
41
+ """Generic event stream for async iteration with a final result."""
42
+
43
+ __slots__ = (
44
+ "__weakref__",
45
+ "_closing",
46
+ "_done",
47
+ "_exception",
48
+ "_extract_result",
49
+ "_is_complete",
50
+ "_pending",
51
+ "_queue",
52
+ "_result",
53
+ "_result_waiters",
54
+ "_sink",
55
+ "_task",
56
+ "_upstream",
57
+ "_waiters",
58
+ )
59
+
60
+ def __init__(self, is_complete: Callable[[T], bool], extract_result: Callable[[T], R]) -> None:
61
+ self._queue: deque[T] = deque()
62
+ self._waiters: deque[asyncio.Future[Any]] = deque()
63
+ self._done = False
64
+ self._result: Any = _UNSET
65
+ self._result_waiters: list[asyncio.Future[Any]] | None = None
66
+ self._exception: BaseException | None = None
67
+ self._is_complete = is_complete
68
+ self._extract_result = extract_result
69
+ self._task: asyncio.Task[Any] | None = None
70
+ self._pending: Coroutine[Any, Any, Any] | None = None
71
+ self._sink: EventStream[T, R] | None = None
72
+ self._upstream: EventStream[Any, Any] | None = None
73
+ self._closing = False
74
+
75
+ # -- producer side -----------------------------------------------------
76
+
77
+ @property
78
+ def done(self) -> bool:
79
+ """True once a terminal event was pushed or :meth:`end` was called."""
80
+ return self._done
81
+
82
+ def push(self, event: T) -> None:
83
+ if self._done:
84
+ return
85
+ if self._is_complete(event):
86
+ self._done = True
87
+ self._set_result(self._extract_result(event))
88
+ sink = self._sink
89
+ if sink is not None:
90
+ sink.push(event)
91
+ return
92
+ waiters = self._waiters
93
+ while waiters:
94
+ waiter = waiters.popleft()
95
+ if not waiter.done():
96
+ waiter.set_result(event)
97
+ return
98
+ self._queue.append(event)
99
+
100
+ def end(self, result: R | None = None) -> None:
101
+ self._done = True
102
+ if result is not None:
103
+ self._set_result(result)
104
+ sink = self._sink
105
+ if sink is not None:
106
+ sink.end(result if result is not None else (None if self._result is _UNSET else self._result))
107
+ return
108
+ self._wake_all()
109
+
110
+ def start(self, producer: Coroutine[Any, Any, Any]) -> None:
111
+ """Run ``producer`` as this stream's background task.
112
+
113
+ The task starts immediately when an event loop is running; otherwise it
114
+ starts on first consumption.
115
+ """
116
+ try:
117
+ loop = asyncio.get_running_loop()
118
+ except RuntimeError:
119
+ self._pending = producer
120
+ return
121
+ self._launch(loop, producer)
122
+
123
+ def forward_to(self, target: EventStream[T, R]) -> None:
124
+ """Deliver this stream's events and result into ``target``.
125
+
126
+ Already queued events are moved immediately; later events go straight to
127
+ ``target.push`` without an intermediate task.
128
+ """
129
+ target._upstream = self
130
+ self._ensure_started()
131
+ queue = self._queue
132
+ while queue:
133
+ target.push(queue.popleft())
134
+ if self._exception is not None:
135
+ target._fail(self._exception)
136
+ return
137
+ if self._done:
138
+ target.end(None if self._result is _UNSET else self._result)
139
+ return
140
+ self._sink = target
141
+
142
+ async def aclose(self) -> None:
143
+ """Cancel the producer (and any forwarded upstream producer) and end the stream."""
144
+ self._closing = True
145
+ upstream = self._upstream
146
+ if upstream is not None:
147
+ await upstream.aclose()
148
+ pending = self._pending
149
+ if pending is not None:
150
+ self._pending = None
151
+ pending.close()
152
+ task = self._task
153
+ if task is not None and not task.done():
154
+ task.cancel()
155
+ try:
156
+ await task
157
+ except BaseException:
158
+ pass
159
+ if not self._done:
160
+ self._done = True
161
+ self._wake_all()
162
+
163
+ async def __aenter__(self) -> EventStream[T, R]:
164
+ return self
165
+
166
+ async def __aexit__(self, *exc: object) -> None:
167
+ await self.aclose()
168
+
169
+ # -- consumer side -----------------------------------------------------
170
+
171
+ def __aiter__(self) -> AsyncIterator[T]:
172
+ self._ensure_started()
173
+ return self
174
+
175
+ async def __anext__(self) -> T:
176
+ queue = self._queue
177
+ if queue:
178
+ return queue.popleft()
179
+ if self._done:
180
+ if self._exception is not None:
181
+ raise self._exception
182
+ raise StopAsyncIteration
183
+ self._ensure_started()
184
+ waiter = asyncio.get_running_loop().create_future()
185
+ self._waiters.append(waiter)
186
+ event = await waiter
187
+ if event is _UNSET:
188
+ if self._exception is not None:
189
+ raise self._exception
190
+ raise StopAsyncIteration
191
+ return event
192
+
193
+ async def result(self) -> R:
194
+ """Wait for and return the stream's final result."""
195
+ self._ensure_started()
196
+ if self._result is not _UNSET:
197
+ return self._result
198
+ if self._exception is not None:
199
+ raise self._exception
200
+ if self._done:
201
+ raise RuntimeError("Stream ended without a result")
202
+ waiter = asyncio.get_running_loop().create_future()
203
+ if self._result_waiters is None:
204
+ self._result_waiters = []
205
+ self._result_waiters.append(waiter)
206
+ return await waiter
207
+
208
+ # -- internals ---------------------------------------------------------
209
+
210
+ def _set_result(self, result: R) -> None:
211
+ if self._result is not _UNSET:
212
+ return
213
+ self._result = result
214
+ waiters, self._result_waiters = self._result_waiters, None
215
+ for waiter in waiters or ():
216
+ if not waiter.done():
217
+ waiter.set_result(result)
218
+
219
+ def _wake_all(self) -> None:
220
+ waiters = self._waiters
221
+ while waiters:
222
+ waiter = waiters.popleft()
223
+ if not waiter.done():
224
+ waiter.set_result(_UNSET)
225
+ if self._result is _UNSET and self._result_waiters:
226
+ waiters_r, self._result_waiters = self._result_waiters, None
227
+ error = self._exception or RuntimeError("Stream ended without a result")
228
+ for waiter in waiters_r:
229
+ if not waiter.done():
230
+ waiter.set_exception(error)
231
+
232
+ def _fail(self, error: BaseException) -> None:
233
+ if self._done and self._result is not _UNSET:
234
+ return
235
+ self._exception = error
236
+ self._done = True
237
+ sink = self._sink
238
+ if sink is not None:
239
+ sink._fail(error)
240
+ return
241
+ self._wake_all()
242
+
243
+ def __del__(self) -> None:
244
+ # A stream created outside an event loop and never consumed: close its producer
245
+ # instead of leaving a "coroutine was never awaited" warning behind.
246
+ pending = getattr(self, "_pending", None)
247
+ if pending is not None:
248
+ self._pending = None
249
+ pending.close()
250
+
251
+ def _ensure_started(self) -> None:
252
+ pending = self._pending
253
+ if pending is not None:
254
+ self._pending = None
255
+ self._launch(asyncio.get_running_loop(), pending)
256
+
257
+ def _launch(self, loop: asyncio.AbstractEventLoop, producer: Coroutine[Any, Any, Any]) -> None:
258
+ task = loop.create_task(producer)
259
+ self._task = task
260
+ task.add_done_callback(self._on_task_done)
261
+
262
+ def _on_task_done(self, task: asyncio.Task[Any]) -> None:
263
+ if task.cancelled():
264
+ if self._closing:
265
+ if not self._done:
266
+ self.end()
267
+ elif not self._done:
268
+ # Never hand a CancelledError to consumers running in other tasks.
269
+ self._fail(RuntimeError("Stream producer was cancelled"))
270
+ return
271
+ error = task.exception()
272
+ if error is not None and not (self._done and self._result is not _UNSET):
273
+ self._fail(error)
274
+ elif not self._done and self._upstream is None:
275
+ # The producer returned without a terminal event: never leave consumers waiting.
276
+ self.end()
277
+
278
+
279
+ class AssistantMessageEventStream(EventStream[AssistantMessageEvent, AssistantMessage]):
280
+ """Event stream of one assistant response.
281
+
282
+ It also times the response: the final message (``done``/``error`` event, or the
283
+ result passed to :meth:`end`) gets ``duration_ms`` measured with a monotonic
284
+ clock from the stream's creation, unless the message already has one or its
285
+ ``timestamp`` predates the stream (a forwarded response that started elsewhere).
286
+ """
287
+
288
+ __slots__ = ("_started_at", "_started_at_monotonic")
289
+
290
+ def __init__(self) -> None:
291
+ super().__init__(_is_terminal_event, _extract_terminal_message)
292
+ self._started_at = now_ms()
293
+ self._started_at_monotonic = time.perf_counter()
294
+
295
+ def push(self, event: AssistantMessageEvent) -> None:
296
+ kind = event.type
297
+ if kind == "done":
298
+ self._time(event.message) # type: ignore[union-attr]
299
+ elif kind == "error":
300
+ self._time(event.error) # type: ignore[union-attr]
301
+ EventStream.push(self, event)
302
+
303
+ def end(self, result: AssistantMessage | None = None) -> None:
304
+ if result is not None:
305
+ self._time(result)
306
+ EventStream.end(self, result)
307
+
308
+ def _time(self, message: AssistantMessage) -> None:
309
+ if self._done or message.duration_ms is not None or message.timestamp < self._started_at:
310
+ return
311
+ message.duration_ms = max(0, round((time.perf_counter() - self._started_at_monotonic) * 1000))
312
+
313
+
314
+ def _is_terminal_event(event: AssistantMessageEvent) -> bool:
315
+ kind = event.type
316
+ return kind == "done" or kind == "error"
317
+
318
+
319
+ def _extract_terminal_message(event: AssistantMessageEvent) -> AssistantMessage:
320
+ if isinstance(event, DoneEvent):
321
+ return event.message
322
+ if isinstance(event, ErrorEvent):
323
+ return event.error
324
+ raise ValueError("Unexpected event type for final result")
325
+
326
+
327
+ def create_assistant_message_event_stream() -> AssistantMessageEventStream:
328
+ """Factory for :class:`AssistantMessageEventStream` (for use in extensions)."""
329
+ return AssistantMessageEventStream()