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/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared utilities."""
|
pi_ai/utils/callbacks.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Helpers for user callbacks that may be sync or async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
__all__ = ["maybe_await"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def maybe_await(value: Any) -> Any:
|
|
12
|
+
"""Await ``value`` if it is awaitable, otherwise return it unchanged."""
|
|
13
|
+
if inspect.isawaitable(value):
|
|
14
|
+
return await value
|
|
15
|
+
return value
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Redacted provider/runtime diagnostics attached to assistant messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import traceback
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..types import now_ms
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DiagnosticErrorInfo",
|
|
13
|
+
"AssistantMessageDiagnostic",
|
|
14
|
+
"format_thrown_value",
|
|
15
|
+
"extract_diagnostic_error",
|
|
16
|
+
"create_assistant_message_diagnostic",
|
|
17
|
+
"append_assistant_message_diagnostic",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class DiagnosticErrorInfo:
|
|
23
|
+
message: str
|
|
24
|
+
name: str | None = None
|
|
25
|
+
stack: str | None = None
|
|
26
|
+
code: str | int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class AssistantMessageDiagnostic:
|
|
31
|
+
type: str
|
|
32
|
+
timestamp: int = field(default_factory=now_ms)
|
|
33
|
+
error: DiagnosticErrorInfo | None = None
|
|
34
|
+
details: dict[str, Any] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_thrown_value(value: Any) -> str:
|
|
38
|
+
"""Human-readable text for an exception or thrown value."""
|
|
39
|
+
if isinstance(value, BaseException):
|
|
40
|
+
return str(value) or type(value).__name__
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
return value
|
|
43
|
+
return str(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_diagnostic_error(error: Any) -> DiagnosticErrorInfo:
|
|
47
|
+
if not isinstance(error, BaseException):
|
|
48
|
+
return DiagnosticErrorInfo(name="ThrownValue", message=format_thrown_value(error))
|
|
49
|
+
code = getattr(error, "code", None)
|
|
50
|
+
stack = (
|
|
51
|
+
"".join(traceback.format_exception(type(error), error, error.__traceback__)) if error.__traceback__ else None
|
|
52
|
+
)
|
|
53
|
+
return DiagnosticErrorInfo(
|
|
54
|
+
name=type(error).__name__,
|
|
55
|
+
message=str(error) or type(error).__name__,
|
|
56
|
+
stack=stack,
|
|
57
|
+
code=code if isinstance(code, (str, int)) and not isinstance(code, bool) else None,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_assistant_message_diagnostic(
|
|
62
|
+
type: str, error: Any, details: dict[str, Any] | None = None
|
|
63
|
+
) -> AssistantMessageDiagnostic:
|
|
64
|
+
return AssistantMessageDiagnostic(
|
|
65
|
+
type=type, timestamp=now_ms(), error=extract_diagnostic_error(error), details=details
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def append_assistant_message_diagnostic(message: Any, diagnostic: AssistantMessageDiagnostic) -> None:
|
|
70
|
+
message.diagnostics = [*(message.diagnostics or []), diagnostic]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Normalization of provider HTTP error objects into display strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"MAX_PROVIDER_ERROR_BODY_CHARS",
|
|
11
|
+
"NormalizedProviderError",
|
|
12
|
+
"normalize_provider_error",
|
|
13
|
+
"format_provider_error",
|
|
14
|
+
"truncate_error_text",
|
|
15
|
+
"safe_json_stringify",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
MAX_PROVIDER_ERROR_BODY_CHARS = 4000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class NormalizedProviderError:
|
|
23
|
+
message: str
|
|
24
|
+
#: True when ``message`` already contains the body.
|
|
25
|
+
message_carries_body: bool
|
|
26
|
+
status: int | None = None
|
|
27
|
+
body: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def safe_json_stringify(value: Any) -> str:
|
|
31
|
+
try:
|
|
32
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return str(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def truncate_error_text(text: str, max_chars: int) -> str:
|
|
38
|
+
if len(text) <= max_chars:
|
|
39
|
+
return text
|
|
40
|
+
return f"{text[:max_chars]}... [truncated {len(text) - max_chars} chars]"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_status(error: Any) -> int | None:
|
|
44
|
+
for name in ("status_code", "status"):
|
|
45
|
+
value = getattr(error, name, None)
|
|
46
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
47
|
+
return value
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _pick_body_text(error: Any) -> str | None:
|
|
52
|
+
body = getattr(error, "body", None)
|
|
53
|
+
if isinstance(body, str):
|
|
54
|
+
return body
|
|
55
|
+
parsed = getattr(error, "error", None)
|
|
56
|
+
if isinstance(parsed, dict) and parsed:
|
|
57
|
+
return safe_json_stringify(parsed)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_provider_error(error: Any) -> NormalizedProviderError:
|
|
62
|
+
"""Probe an exception for HTTP status and body so the body reason is not lost."""
|
|
63
|
+
if not isinstance(error, BaseException):
|
|
64
|
+
return NormalizedProviderError(message=safe_json_stringify(error), message_carries_body=False)
|
|
65
|
+
message = str(error) or type(error).__name__
|
|
66
|
+
status = _extract_status(error)
|
|
67
|
+
body_text = _pick_body_text(error)
|
|
68
|
+
body: str | None = None
|
|
69
|
+
if body_text is not None:
|
|
70
|
+
trimmed = body_text.strip()
|
|
71
|
+
if trimmed:
|
|
72
|
+
body = truncate_error_text(trimmed, MAX_PROVIDER_ERROR_BODY_CHARS)
|
|
73
|
+
return NormalizedProviderError(
|
|
74
|
+
message=message,
|
|
75
|
+
message_carries_body=body is None or body in message,
|
|
76
|
+
status=status,
|
|
77
|
+
body=body,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def format_provider_error(norm: NormalizedProviderError, prefix: str | None = None) -> str:
|
|
82
|
+
"""Compose a display string: ``"<status>: <body>"`` or ``"<prefix> (<status>): <body>"``."""
|
|
83
|
+
if norm.message_carries_body or norm.status is None or norm.body is None:
|
|
84
|
+
if prefix is not None and norm.status is not None:
|
|
85
|
+
return f"{prefix} ({norm.status}): {norm.message}"
|
|
86
|
+
return norm.message
|
|
87
|
+
if prefix is not None:
|
|
88
|
+
return f"{prefix} ({norm.status}): {norm.body}"
|
|
89
|
+
return f"{norm.status}: {norm.body}"
|
pi_ai/utils/estimate.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Rough token estimates for context accounting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..types import TranscriptContext, Usage
|
|
12
|
+
from .text import get_system_message_text
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ContextUsageEstimate",
|
|
16
|
+
"calculate_context_tokens",
|
|
17
|
+
"estimate_text_tokens",
|
|
18
|
+
"estimate_text_and_image_content_tokens",
|
|
19
|
+
"estimate_message_tokens",
|
|
20
|
+
"estimate_context_tokens",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_CHARS_PER_TOKEN = 3.5
|
|
24
|
+
_ESTIMATED_IMAGE_CHARS = 4800
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class ContextUsageEstimate:
|
|
29
|
+
#: Estimated total context tokens.
|
|
30
|
+
tokens: int
|
|
31
|
+
#: Tokens reported by the most recent applicable assistant usage block.
|
|
32
|
+
usage_tokens: int
|
|
33
|
+
#: Estimated tokens after that usage block.
|
|
34
|
+
trailing_tokens: int
|
|
35
|
+
#: Index of the message that provided usage, or ``None``.
|
|
36
|
+
last_usage_index: int | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def calculate_context_tokens(usage: Usage) -> int:
|
|
40
|
+
return usage.total_tokens or usage.input + usage.output + usage.cache_read + usage.cache_write
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _safe_json(value: Any) -> str:
|
|
44
|
+
try:
|
|
45
|
+
from ..serialization import to_json_value
|
|
46
|
+
|
|
47
|
+
return json.dumps(to_json_value(value), separators=(",", ":"))
|
|
48
|
+
except Exception:
|
|
49
|
+
return "[unserializable]"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _content_chars(content: Any) -> int:
|
|
53
|
+
if isinstance(content, str):
|
|
54
|
+
return len(content)
|
|
55
|
+
chars = 0
|
|
56
|
+
for block in content:
|
|
57
|
+
chars += len(block.text) if block.type == "text" else _ESTIMATED_IMAGE_CHARS
|
|
58
|
+
return chars
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def estimate_text_tokens(text: str) -> int:
|
|
62
|
+
return math.ceil(len(text) / _CHARS_PER_TOKEN)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def estimate_text_and_image_content_tokens(content: Any) -> int:
|
|
66
|
+
return math.ceil(_content_chars(content) / _CHARS_PER_TOKEN)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _estimate_tools_tokens(tools: Sequence[Any] | None) -> int:
|
|
70
|
+
if not tools:
|
|
71
|
+
return 0
|
|
72
|
+
return estimate_text_tokens(_safe_json(list(tools)))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def estimate_message_tokens(message: Any) -> int:
|
|
76
|
+
role = message.role
|
|
77
|
+
if role == "system":
|
|
78
|
+
return (
|
|
79
|
+
estimate_text_tokens(get_system_message_text(message))
|
|
80
|
+
+ _estimate_tools_tokens(message.tools_added)
|
|
81
|
+
+ _estimate_tools_tokens(message.tools_removed)
|
|
82
|
+
)
|
|
83
|
+
if role in ("user", "toolResult"):
|
|
84
|
+
return estimate_text_and_image_content_tokens(message.content)
|
|
85
|
+
if role != "assistant":
|
|
86
|
+
return 0
|
|
87
|
+
chars = 0
|
|
88
|
+
for block in message.content:
|
|
89
|
+
kind = block.type
|
|
90
|
+
if kind == "text":
|
|
91
|
+
chars += len(block.text)
|
|
92
|
+
elif kind == "thinking":
|
|
93
|
+
chars += len(block.thinking)
|
|
94
|
+
else:
|
|
95
|
+
chars += len(block.name) + len(_safe_json(block.arguments))
|
|
96
|
+
return math.ceil(chars / _CHARS_PER_TOKEN)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _last_assistant_usage(messages: Sequence[Any]) -> tuple[Usage, int] | None:
|
|
100
|
+
latest_prefix_timestamp = -math.inf
|
|
101
|
+
info: tuple[Usage, int] | None = None
|
|
102
|
+
for index, message in enumerate(messages):
|
|
103
|
+
if message.role == "assistant":
|
|
104
|
+
applies = message.timestamp >= latest_prefix_timestamp
|
|
105
|
+
if (
|
|
106
|
+
applies
|
|
107
|
+
and message.stop_reason not in ("aborted", "error")
|
|
108
|
+
and calculate_context_tokens(message.usage) > 0
|
|
109
|
+
):
|
|
110
|
+
info = (message.usage, index)
|
|
111
|
+
latest_prefix_timestamp = max(latest_prefix_timestamp, getattr(message, "timestamp", 0))
|
|
112
|
+
return info
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def estimate_context_tokens(context: TranscriptContext | Sequence[Any]) -> ContextUsageEstimate:
|
|
116
|
+
"""Estimate context tokens, anchored on the most recent assistant usage report."""
|
|
117
|
+
messages = context.messages if isinstance(context, TranscriptContext) else context
|
|
118
|
+
usage_info = _last_assistant_usage(messages)
|
|
119
|
+
if usage_info is not None:
|
|
120
|
+
usage, index = usage_info
|
|
121
|
+
usage_tokens = calculate_context_tokens(usage)
|
|
122
|
+
trailing = sum(estimate_message_tokens(message) for message in messages[index + 1 :])
|
|
123
|
+
return ContextUsageEstimate(usage_tokens + trailing, usage_tokens, trailing, index)
|
|
124
|
+
tokens = sum(estimate_message_tokens(message) for message in messages)
|
|
125
|
+
return ContextUsageEstimate(tokens, 0, tokens, None)
|
pi_ai/utils/fastjson.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""JSON encode/decode with an optional ``orjson`` fast path.
|
|
2
|
+
|
|
3
|
+
Install ``pi-ai-python[fast]`` to use orjson for SSE chunk decoding and request bodies.
|
|
4
|
+
Output is compact and UTF-8 in both cases.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__all__ = ["loads", "dumps", "dumps_bytes", "HAS_ORJSON"]
|
|
13
|
+
|
|
14
|
+
try: # pragma: no cover - exercised when orjson is installed
|
|
15
|
+
import orjson as _orjson
|
|
16
|
+
|
|
17
|
+
HAS_ORJSON = True
|
|
18
|
+
except ImportError: # pragma: no cover
|
|
19
|
+
_orjson = None
|
|
20
|
+
HAS_ORJSON = False
|
|
21
|
+
|
|
22
|
+
_decode = json.JSONDecoder(strict=True).decode
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default(value: Any) -> Any:
|
|
26
|
+
from ..serialization import to_json_value
|
|
27
|
+
|
|
28
|
+
return to_json_value(value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if _orjson is not None:
|
|
32
|
+
# Dataclasses go through ``_default`` so they serialize in pi's camelCase format.
|
|
33
|
+
_ORJSON_OPTS = _orjson.OPT_NON_STR_KEYS | _orjson.OPT_PASSTHROUGH_DATACLASS
|
|
34
|
+
|
|
35
|
+
loads = _orjson.loads # pyright: ignore[reportAssignmentType]
|
|
36
|
+
_orjson_dumps = _orjson.dumps
|
|
37
|
+
|
|
38
|
+
def dumps_bytes(value: Any) -> bytes:
|
|
39
|
+
try:
|
|
40
|
+
return _orjson_dumps(value, default=_default, option=_ORJSON_OPTS)
|
|
41
|
+
except TypeError:
|
|
42
|
+
# orjson rejects lone surrogates and integers above 64 bits; the stdlib escapes them.
|
|
43
|
+
return json.dumps(value, ensure_ascii=True, separators=(",", ":"), default=_default).encode()
|
|
44
|
+
|
|
45
|
+
def dumps(value: Any) -> str:
|
|
46
|
+
return dumps_bytes(value).decode()
|
|
47
|
+
|
|
48
|
+
else:
|
|
49
|
+
|
|
50
|
+
def loads(data: str | bytes | bytearray | memoryview) -> Any:
|
|
51
|
+
if not isinstance(data, str):
|
|
52
|
+
data = bytes(data).decode()
|
|
53
|
+
return _decode(data)
|
|
54
|
+
|
|
55
|
+
def dumps(value: Any) -> str:
|
|
56
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=_default)
|
|
57
|
+
|
|
58
|
+
def dumps_bytes(value: Any) -> bytes:
|
|
59
|
+
text = dumps(value)
|
|
60
|
+
try:
|
|
61
|
+
return text.encode()
|
|
62
|
+
except UnicodeEncodeError: # lone surrogates: emit them as \u escapes
|
|
63
|
+
return json.dumps(value, ensure_ascii=True, separators=(",", ":"), default=_default).encode()
|