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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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()
|