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/providers/faux.py
ADDED
|
@@ -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
|
+
)
|