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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""TypeSafe System One protocol shared by the services that serve System One models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, TypedDict
|
|
8
|
+
|
|
9
|
+
from ..serialization import to_json_value
|
|
10
|
+
from ..types import (
|
|
11
|
+
ClassifierAnswer,
|
|
12
|
+
ClassifierBoolAnswer,
|
|
13
|
+
ClassifierChoiceAnswer,
|
|
14
|
+
ClassifierContext,
|
|
15
|
+
ClassifierModel,
|
|
16
|
+
ClassifierQuestion,
|
|
17
|
+
ClassifierResult,
|
|
18
|
+
ClassifierScoreAnswer,
|
|
19
|
+
now_ms,
|
|
20
|
+
)
|
|
21
|
+
from ..utils.error_body import format_provider_error, normalize_provider_error
|
|
22
|
+
from ..utils.model_operations import classifier_error_result
|
|
23
|
+
from .classifier_shared import (
|
|
24
|
+
classifier_questions,
|
|
25
|
+
is_record,
|
|
26
|
+
parse_classifier_usage,
|
|
27
|
+
post_classifier_request,
|
|
28
|
+
required_number,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = ["SystemOneWireRequest", "SystemOneTransport", "wire_request", "classify_system_one"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SystemOneWireRequest(TypedDict):
|
|
35
|
+
"""TypeSafe System One request body without the transport-specific envelope."""
|
|
36
|
+
|
|
37
|
+
state: dict[str, Any]
|
|
38
|
+
questions: dict[str, Any]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class SystemOneTransport:
|
|
43
|
+
"""Differences between services that serve System One models."""
|
|
44
|
+
|
|
45
|
+
#: Classifier API implemented by this transport.
|
|
46
|
+
api: str
|
|
47
|
+
#: Service name used in error messages.
|
|
48
|
+
label: str
|
|
49
|
+
#: Absolute request URL.
|
|
50
|
+
url: Callable[[ClassifierModel], str]
|
|
51
|
+
#: Wraps the System One request in the service's request envelope.
|
|
52
|
+
payload: Callable[[ClassifierModel, SystemOneWireRequest], Any]
|
|
53
|
+
#: Extracts the System One output (``{answers, usage}``) from the service's response envelope.
|
|
54
|
+
output: Callable[[Any], Mapping[str, Any]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _probabilities(label: str, value: Any, question_id: str) -> dict[str, float]:
|
|
58
|
+
if not is_record(value):
|
|
59
|
+
raise ValueError(f"{label} returned invalid probabilities for {question_id}")
|
|
60
|
+
return {
|
|
61
|
+
key: required_number(label, probability, f"probability for {question_id}.{key}")
|
|
62
|
+
for key, probability in value.items()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_answers(label: str, value: Any, questions: Mapping[str, ClassifierQuestion]) -> dict[str, ClassifierAnswer]:
|
|
67
|
+
if not is_record(value):
|
|
68
|
+
raise ValueError(f"{label} returned an unexpected response")
|
|
69
|
+
answers: dict[str, ClassifierAnswer] = {}
|
|
70
|
+
for question_id, question in questions.items():
|
|
71
|
+
answer = value.get(question_id)
|
|
72
|
+
if not is_record(answer):
|
|
73
|
+
raise ValueError(f"{label} did not return an answer for {question_id}")
|
|
74
|
+
if question.type == "choice":
|
|
75
|
+
choice = answer.get("choice")
|
|
76
|
+
if answer.get("type") != "choice" or not isinstance(choice, str):
|
|
77
|
+
raise ValueError(f"{label} did not return a choice answer for {question_id}")
|
|
78
|
+
answers[question_id] = ClassifierChoiceAnswer(
|
|
79
|
+
choice=choice,
|
|
80
|
+
probabilities=_probabilities(label, answer.get("probabilities"), question_id),
|
|
81
|
+
confidence=required_number(label, answer.get("confidence"), f"confidence for {question_id}"),
|
|
82
|
+
)
|
|
83
|
+
elif question.type == "score":
|
|
84
|
+
if answer.get("type") != "score":
|
|
85
|
+
raise ValueError(f"{label} did not return a score answer for {question_id}")
|
|
86
|
+
answers[question_id] = ClassifierScoreAnswer(
|
|
87
|
+
score=required_number(label, answer.get("score"), f"score for {question_id}"),
|
|
88
|
+
confidence=required_number(label, answer.get("confidence"), f"confidence for {question_id}"),
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
if answer.get("type") != "noul":
|
|
92
|
+
raise ValueError(f"{label} did not return a bool answer for {question_id}")
|
|
93
|
+
answers[question_id] = ClassifierBoolAnswer(
|
|
94
|
+
probability=required_number(label, answer.get("noul"), f"probability for {question_id}")
|
|
95
|
+
)
|
|
96
|
+
return answers
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def wire_request(state: dict[str, Any], questions: Mapping[str, ClassifierQuestion]) -> SystemOneWireRequest:
|
|
100
|
+
"""Map public ``bool`` questions to TypeSafe's wire-level ``noul`` type."""
|
|
101
|
+
wire_questions: dict[str, Any] = {}
|
|
102
|
+
for question_id, question in questions.items():
|
|
103
|
+
encoded = to_json_value(question)
|
|
104
|
+
wire_questions[question_id] = {**encoded, "type": "noul"} if question.type == "bool" else encoded
|
|
105
|
+
return {"state": state, "questions": wire_questions}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def classify_system_one(
|
|
109
|
+
transport: SystemOneTransport,
|
|
110
|
+
model: ClassifierModel,
|
|
111
|
+
context: ClassifierContext,
|
|
112
|
+
options: Mapping[str, Any] | None,
|
|
113
|
+
) -> ClassifierResult:
|
|
114
|
+
"""Run one System One classification over ``transport``. Never raises."""
|
|
115
|
+
options = options or {}
|
|
116
|
+
output = ClassifierResult(
|
|
117
|
+
api=model.api, provider=model.provider, model=model.id, answers={}, stop_reason="stop", timestamp=now_ms()
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
if model.api != transport.api:
|
|
121
|
+
raise ValueError(f"Unsupported classifier API: {model.api}")
|
|
122
|
+
if context.images:
|
|
123
|
+
raise ValueError(f"{transport.label} does not support image input")
|
|
124
|
+
questions = classifier_questions(context)
|
|
125
|
+
body = await post_classifier_request(
|
|
126
|
+
transport.label,
|
|
127
|
+
transport.url(model),
|
|
128
|
+
model,
|
|
129
|
+
transport.payload(model, wire_request(context.state, questions)),
|
|
130
|
+
options,
|
|
131
|
+
)
|
|
132
|
+
result = transport.output(body)
|
|
133
|
+
# Set before parsing answers: a request with malformed answers was still billed.
|
|
134
|
+
usage = parse_classifier_usage(result.get("usage"), model)
|
|
135
|
+
if usage is not None:
|
|
136
|
+
output.usage = usage
|
|
137
|
+
output.answers = _parse_answers(transport.label, result.get("answers"), questions)
|
|
138
|
+
return output
|
|
139
|
+
except Exception as error:
|
|
140
|
+
signal = options.get("signal")
|
|
141
|
+
failed = classifier_error_result(
|
|
142
|
+
model,
|
|
143
|
+
format_provider_error(normalize_provider_error(error), f"{transport.label} error"),
|
|
144
|
+
aborted=signal is not None and signal.aborted,
|
|
145
|
+
)
|
|
146
|
+
failed.usage = output.usage
|
|
147
|
+
failed.timestamp = output.timestamp
|
|
148
|
+
return failed
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Cross-provider message normalization shared by API implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..types import (
|
|
11
|
+
AssistantMessage,
|
|
12
|
+
ImageContent,
|
|
13
|
+
Message,
|
|
14
|
+
Model,
|
|
15
|
+
TextContent,
|
|
16
|
+
ToolCall,
|
|
17
|
+
ToolResultMessage,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["SplitErrorToolResult", "split_error_tool_result_images", "transform_messages"]
|
|
21
|
+
|
|
22
|
+
_NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)"
|
|
23
|
+
_NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)"
|
|
24
|
+
_ERROR_TOOL_IMAGE_PLACEHOLDER = "(image attached after the tool results)"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class SplitErrorToolResult:
|
|
29
|
+
"""An error tool result split into text-only content and images to send after the tool results."""
|
|
30
|
+
|
|
31
|
+
content: list[TextContent]
|
|
32
|
+
caption: str
|
|
33
|
+
images: list[ImageContent]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def split_error_tool_result_images(message: ToolResultMessage) -> SplitErrorToolResult | None:
|
|
37
|
+
"""Split the images out of an error tool result (for APIs that reject images in error results)."""
|
|
38
|
+
if not message.is_error:
|
|
39
|
+
return None
|
|
40
|
+
images = [block for block in message.content if block.type == "image"]
|
|
41
|
+
if not images:
|
|
42
|
+
return None
|
|
43
|
+
return SplitErrorToolResult(
|
|
44
|
+
content=_replace_images(message.content, _ERROR_TOOL_IMAGE_PLACEHOLDER),
|
|
45
|
+
caption=f"Images from the error result of tool call {message.tool_call_id} ({message.tool_name}):",
|
|
46
|
+
images=images, # type: ignore[arg-type]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _replace_images(content: list[Any], placeholder: str) -> list[Any]:
|
|
51
|
+
result: list[Any] = []
|
|
52
|
+
previous_was_placeholder = False
|
|
53
|
+
for block in content:
|
|
54
|
+
if block.type == "image":
|
|
55
|
+
if not previous_was_placeholder:
|
|
56
|
+
result.append(TextContent(placeholder))
|
|
57
|
+
previous_was_placeholder = True
|
|
58
|
+
continue
|
|
59
|
+
result.append(block)
|
|
60
|
+
previous_was_placeholder = block.text == placeholder
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _has_images(content: Any) -> bool:
|
|
65
|
+
return not isinstance(content, str) and any(block.type == "image" for block in content)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _downgrade_unsupported_images(messages: list[Any], model: Model) -> list[Any]:
|
|
69
|
+
if "image" in model.input:
|
|
70
|
+
return messages
|
|
71
|
+
result: list[Any] = []
|
|
72
|
+
for message in messages:
|
|
73
|
+
role = message.role
|
|
74
|
+
if role == "user" and _has_images(message.content):
|
|
75
|
+
message = dataclasses.replace(
|
|
76
|
+
message, content=_replace_images(message.content, _NON_VISION_USER_IMAGE_PLACEHOLDER)
|
|
77
|
+
)
|
|
78
|
+
elif role == "toolResult" and _has_images(message.content):
|
|
79
|
+
message = dataclasses.replace(
|
|
80
|
+
message, content=_replace_images(message.content, _NON_VISION_TOOL_IMAGE_PLACEHOLDER)
|
|
81
|
+
)
|
|
82
|
+
result.append(message)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def transform_messages(
|
|
87
|
+
messages: list[Message],
|
|
88
|
+
model: Model,
|
|
89
|
+
normalize_tool_call_id: Callable[[str, Model, AssistantMessage], str] | None = None,
|
|
90
|
+
) -> list[Message]:
|
|
91
|
+
"""Prepare a transcript for ``model``.
|
|
92
|
+
|
|
93
|
+
* Images are replaced by placeholders for models without image input.
|
|
94
|
+
* Thinking from another model is dropped; same-model thinking is kept for replay.
|
|
95
|
+
* Tool call ids are normalized for the target API, and tool results follow.
|
|
96
|
+
* Errored/aborted assistant turns are skipped.
|
|
97
|
+
* Orphaned tool calls get synthetic error results so APIs accept the history.
|
|
98
|
+
"""
|
|
99
|
+
tool_call_id_map: dict[str, str] = {}
|
|
100
|
+
normalized: list[Any] = [
|
|
101
|
+
dataclasses.replace(message, content=[]) if getattr(message, "content", []) is None else message
|
|
102
|
+
for message in messages
|
|
103
|
+
]
|
|
104
|
+
image_aware = _downgrade_unsupported_images(normalized, model)
|
|
105
|
+
|
|
106
|
+
transformed: list[Any] = []
|
|
107
|
+
for message in image_aware:
|
|
108
|
+
role = message.role
|
|
109
|
+
if role in ("system", "user"):
|
|
110
|
+
transformed.append(message)
|
|
111
|
+
continue
|
|
112
|
+
if role == "toolResult":
|
|
113
|
+
mapped = tool_call_id_map.get(message.tool_call_id)
|
|
114
|
+
if mapped and mapped != message.tool_call_id:
|
|
115
|
+
message = dataclasses.replace(message, tool_call_id=mapped)
|
|
116
|
+
transformed.append(message)
|
|
117
|
+
continue
|
|
118
|
+
if role != "assistant":
|
|
119
|
+
transformed.append(message)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
is_same_model = message.provider == model.provider and message.api == model.api and message.model == model.id
|
|
123
|
+
content: list[Any] = []
|
|
124
|
+
changed = False
|
|
125
|
+
for block in message.content:
|
|
126
|
+
kind = block.type
|
|
127
|
+
if kind == "thinking":
|
|
128
|
+
if not is_same_model:
|
|
129
|
+
changed = True
|
|
130
|
+
continue
|
|
131
|
+
if block.redacted or block.thinking_signature:
|
|
132
|
+
content.append(block)
|
|
133
|
+
continue
|
|
134
|
+
if not block.thinking or not block.thinking.strip():
|
|
135
|
+
changed = True
|
|
136
|
+
continue
|
|
137
|
+
content.append(block)
|
|
138
|
+
elif kind == "text":
|
|
139
|
+
if is_same_model or block.text_signature is None:
|
|
140
|
+
content.append(block)
|
|
141
|
+
else:
|
|
142
|
+
content.append(TextContent(block.text))
|
|
143
|
+
changed = True
|
|
144
|
+
elif kind == "toolCall":
|
|
145
|
+
tool_call: ToolCall = block
|
|
146
|
+
if not is_same_model and tool_call.thought_signature:
|
|
147
|
+
tool_call = dataclasses.replace(tool_call, thought_signature=None)
|
|
148
|
+
changed = True
|
|
149
|
+
if not is_same_model and normalize_tool_call_id is not None:
|
|
150
|
+
normalized_id = normalize_tool_call_id(tool_call.id, model, message)
|
|
151
|
+
if normalized_id != tool_call.id:
|
|
152
|
+
tool_call_id_map[tool_call.id] = normalized_id
|
|
153
|
+
tool_call = dataclasses.replace(tool_call, id=normalized_id)
|
|
154
|
+
changed = True
|
|
155
|
+
content.append(tool_call)
|
|
156
|
+
else:
|
|
157
|
+
content.append(block)
|
|
158
|
+
if changed:
|
|
159
|
+
message = message.shallow_copy()
|
|
160
|
+
message.content = content
|
|
161
|
+
transformed.append(message)
|
|
162
|
+
|
|
163
|
+
result: list[Any] = []
|
|
164
|
+
pending_tool_calls: list[ToolCall] = []
|
|
165
|
+
existing_result_ids: set[str] = set()
|
|
166
|
+
held_system: list[Any] = []
|
|
167
|
+
|
|
168
|
+
def close_pending() -> None:
|
|
169
|
+
nonlocal pending_tool_calls, existing_result_ids
|
|
170
|
+
if pending_tool_calls:
|
|
171
|
+
for tool_call in pending_tool_calls:
|
|
172
|
+
if tool_call.id not in existing_result_ids:
|
|
173
|
+
result.append(
|
|
174
|
+
ToolResultMessage(
|
|
175
|
+
tool_call.id,
|
|
176
|
+
tool_call.name,
|
|
177
|
+
[TextContent("No result provided")],
|
|
178
|
+
True,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
pending_tool_calls = []
|
|
182
|
+
existing_result_ids = set()
|
|
183
|
+
result.extend(held_system)
|
|
184
|
+
held_system.clear()
|
|
185
|
+
|
|
186
|
+
for message in transformed:
|
|
187
|
+
role = message.role
|
|
188
|
+
if role == "assistant":
|
|
189
|
+
close_pending()
|
|
190
|
+
if message.stop_reason in ("error", "aborted"):
|
|
191
|
+
continue
|
|
192
|
+
tool_calls = [block for block in message.content if block.type == "toolCall"]
|
|
193
|
+
if tool_calls:
|
|
194
|
+
pending_tool_calls = tool_calls
|
|
195
|
+
existing_result_ids = set()
|
|
196
|
+
result.append(message)
|
|
197
|
+
elif role == "toolResult":
|
|
198
|
+
existing_result_ids.add(message.tool_call_id)
|
|
199
|
+
result.append(message)
|
|
200
|
+
elif role == "system":
|
|
201
|
+
if pending_tool_calls:
|
|
202
|
+
held_system.append(message)
|
|
203
|
+
else:
|
|
204
|
+
result.append(message)
|
|
205
|
+
elif role == "user":
|
|
206
|
+
close_pending()
|
|
207
|
+
result.append(message)
|
|
208
|
+
else:
|
|
209
|
+
result.append(message)
|
|
210
|
+
close_pending()
|
|
211
|
+
return result
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""TypeSafe's native System One protocol.
|
|
2
|
+
|
|
3
|
+
OpenRouter (and other gateways) serve the same protocol, so those providers use
|
|
4
|
+
this API with different base URLs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urljoin
|
|
12
|
+
|
|
13
|
+
from ..types import ClassifierContext, ClassifierModel, ClassifierResult
|
|
14
|
+
from .classifier_shared import is_record
|
|
15
|
+
from .system_one_shared import SystemOneTransport, SystemOneWireRequest, classify_system_one
|
|
16
|
+
|
|
17
|
+
__all__ = ["classify"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _url(model: ClassifierModel) -> str:
|
|
21
|
+
return urljoin(f"{model.base_url.rstrip('/')}/", "systemone")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _payload(model: ClassifierModel, request: SystemOneWireRequest) -> Any:
|
|
25
|
+
return {"model": model.id, **request}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _output(body: Any) -> Mapping[str, Any]:
|
|
29
|
+
if not is_record(body):
|
|
30
|
+
raise ValueError("System One API returned an unexpected response")
|
|
31
|
+
return body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_TRANSPORT = SystemOneTransport(
|
|
35
|
+
api="typesafe-system-one", label="System One API", url=_url, payload=_payload, output=_output
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def classify(
|
|
40
|
+
model: ClassifierModel, context: ClassifierContext, options: Mapping[str, Any] | None = None
|
|
41
|
+
) -> ClassifierResult:
|
|
42
|
+
"""TypeSafe System One classification with public ``bool`` values mapped to wire-level ``noul``."""
|
|
43
|
+
return await classify_system_one(_TRANSPORT, model, context, options)
|
pi_ai/auth/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication: credentials, stores, resolution and OAuth flows."""
|
pi_ai/auth/context.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Default auth context backed by ``os.environ`` and the local filesystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from .types import AuthContext
|
|
9
|
+
|
|
10
|
+
__all__ = ["DefaultAuthContext", "default_provider_auth_context", "overlay_env_auth_context"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DefaultAuthContext:
|
|
14
|
+
"""Env vars from ``os.environ`` (blank values count as unset); file checks via ``os.path``."""
|
|
15
|
+
|
|
16
|
+
async def env(self, name: str) -> str | None:
|
|
17
|
+
value = os.environ.get(name)
|
|
18
|
+
return value if value and value.strip() else None
|
|
19
|
+
|
|
20
|
+
async def file_exists(self, path: str) -> bool:
|
|
21
|
+
return os.path.exists(os.path.expanduser(path))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_provider_auth_context() -> AuthContext:
|
|
25
|
+
return DefaultAuthContext()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _OverlayAuthContext:
|
|
29
|
+
__slots__ = ("_base", "_env")
|
|
30
|
+
|
|
31
|
+
def __init__(self, base: AuthContext, env: Mapping[str, str]) -> None:
|
|
32
|
+
self._base = base
|
|
33
|
+
self._env = env
|
|
34
|
+
|
|
35
|
+
async def env(self, name: str) -> str | None:
|
|
36
|
+
value = self._env.get(name)
|
|
37
|
+
if value:
|
|
38
|
+
return value
|
|
39
|
+
return await self._base.env(name)
|
|
40
|
+
|
|
41
|
+
async def file_exists(self, path: str) -> bool:
|
|
42
|
+
return await self._base.file_exists(path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def overlay_env_auth_context(base: AuthContext, env: Mapping[str, str]) -> AuthContext:
|
|
46
|
+
"""An auth context where ``env`` values take precedence over ``base``."""
|
|
47
|
+
return _OverlayAuthContext(base, env)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Credential stores: in-memory (default) and ``auth.json`` file-backed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import copy
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections.abc import Awaitable, Callable, Iterator, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..abort import AbortSignal, race_with_abort_signal
|
|
16
|
+
from .types import Credential, CredentialInfo
|
|
17
|
+
|
|
18
|
+
__all__ = ["InMemoryCredentialStore", "FileCredentialStore"]
|
|
19
|
+
|
|
20
|
+
try: # POSIX advisory locks for cross-process serialization
|
|
21
|
+
import fcntl as _fcntl
|
|
22
|
+
except ImportError: # pragma: no cover - Windows
|
|
23
|
+
_fcntl = None # type: ignore[assignment]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _ProviderLocks:
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
29
|
+
|
|
30
|
+
def get(self, provider_id: str) -> asyncio.Lock:
|
|
31
|
+
lock = self._locks.get(provider_id)
|
|
32
|
+
if lock is None:
|
|
33
|
+
lock = asyncio.Lock()
|
|
34
|
+
self._locks[provider_id] = lock
|
|
35
|
+
return lock
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _enqueue(
|
|
39
|
+
locks: _ProviderLocks,
|
|
40
|
+
provider_id: str,
|
|
41
|
+
task: Callable[[], Awaitable[Any]],
|
|
42
|
+
signal: AbortSignal | None,
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""Serialize ``task`` per provider. ``signal`` stops the caller waiting; started work completes."""
|
|
45
|
+
lock = locks.get(provider_id)
|
|
46
|
+
|
|
47
|
+
async def run() -> Any:
|
|
48
|
+
async with lock:
|
|
49
|
+
if signal is not None:
|
|
50
|
+
signal.throw_if_aborted()
|
|
51
|
+
return await task()
|
|
52
|
+
|
|
53
|
+
return await race_with_abort_signal(run(), signal)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InMemoryCredentialStore:
|
|
57
|
+
"""Default in-memory credential store; writes are serialized per provider id."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, initial: dict[str, Credential] | None = None) -> None:
|
|
60
|
+
self._credentials: dict[str, Credential] = copy.deepcopy(initial) if initial else {}
|
|
61
|
+
self._locks = _ProviderLocks()
|
|
62
|
+
|
|
63
|
+
async def read(self, provider_id: str, *, signal: AbortSignal | None = None) -> Credential | None:
|
|
64
|
+
if signal is not None:
|
|
65
|
+
signal.throw_if_aborted()
|
|
66
|
+
credential = self._credentials.get(provider_id)
|
|
67
|
+
return copy.deepcopy(credential) if credential is not None else None
|
|
68
|
+
|
|
69
|
+
async def list(self, *, signal: AbortSignal | None = None) -> Sequence[CredentialInfo]:
|
|
70
|
+
if signal is not None:
|
|
71
|
+
signal.throw_if_aborted()
|
|
72
|
+
return [
|
|
73
|
+
CredentialInfo(provider_id, credential.get("type", "api_key"))
|
|
74
|
+
for provider_id, credential in self._credentials.items()
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
async def modify(
|
|
78
|
+
self,
|
|
79
|
+
provider_id: str,
|
|
80
|
+
fn: Callable[[Credential | None], Awaitable[Credential | None]],
|
|
81
|
+
*,
|
|
82
|
+
signal: AbortSignal | None = None,
|
|
83
|
+
) -> Credential | None:
|
|
84
|
+
async def task() -> Credential | None:
|
|
85
|
+
current = self._credentials.get(provider_id)
|
|
86
|
+
following = await fn(copy.deepcopy(current) if current is not None else None)
|
|
87
|
+
if signal is not None:
|
|
88
|
+
signal.throw_if_aborted()
|
|
89
|
+
if following is not None:
|
|
90
|
+
self._credentials[provider_id] = copy.deepcopy(following)
|
|
91
|
+
return following
|
|
92
|
+
return current
|
|
93
|
+
|
|
94
|
+
return await _enqueue(self._locks, provider_id, task, signal)
|
|
95
|
+
|
|
96
|
+
async def delete(self, provider_id: str, *, signal: AbortSignal | None = None) -> None:
|
|
97
|
+
async def task() -> None:
|
|
98
|
+
self._credentials.pop(provider_id, None)
|
|
99
|
+
|
|
100
|
+
await _enqueue(self._locks, provider_id, task, signal)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FileCredentialStore:
|
|
104
|
+
"""Credentials in an ``auth.json`` file shared with TypeScript pi.
|
|
105
|
+
|
|
106
|
+
The file maps provider ids to credentials (``{"anthropic": {"type": "oauth", ...}}``)
|
|
107
|
+
and is written atomically with ``0600`` permissions. On POSIX, ``modify`` also takes
|
|
108
|
+
an advisory file lock so concurrent processes cannot double-refresh OAuth tokens.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, path: str | os.PathLike[str] = "auth.json") -> None:
|
|
112
|
+
self.path = Path(path).expanduser()
|
|
113
|
+
self._locks = _ProviderLocks()
|
|
114
|
+
|
|
115
|
+
def _load(self) -> dict[str, Credential]:
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(self.path.read_text("utf-8"))
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
return {}
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
raise ValueError(f"{self.path} does not contain a JSON object")
|
|
122
|
+
return data
|
|
123
|
+
|
|
124
|
+
def _save(self, data: dict[str, Credential]) -> None:
|
|
125
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".auth-", suffix=".json")
|
|
127
|
+
try:
|
|
128
|
+
os.chmod(tmp, 0o600)
|
|
129
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
130
|
+
json.dump(data, handle, ensure_ascii=False, indent=2)
|
|
131
|
+
handle.write("\n")
|
|
132
|
+
os.replace(tmp, self.path)
|
|
133
|
+
except BaseException:
|
|
134
|
+
with contextlib.suppress(FileNotFoundError):
|
|
135
|
+
os.unlink(tmp)
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
@contextlib.contextmanager
|
|
139
|
+
def _file_lock(self) -> Iterator[None]:
|
|
140
|
+
if _fcntl is None:
|
|
141
|
+
yield
|
|
142
|
+
return
|
|
143
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
lock_path = self.path.with_name(self.path.name + ".lock")
|
|
145
|
+
with open(lock_path, "a+") as handle:
|
|
146
|
+
_fcntl.flock(handle.fileno(), _fcntl.LOCK_EX)
|
|
147
|
+
try:
|
|
148
|
+
yield
|
|
149
|
+
finally:
|
|
150
|
+
_fcntl.flock(handle.fileno(), _fcntl.LOCK_UN)
|
|
151
|
+
|
|
152
|
+
async def read(self, provider_id: str, *, signal: AbortSignal | None = None) -> Credential | None:
|
|
153
|
+
if signal is not None:
|
|
154
|
+
signal.throw_if_aborted()
|
|
155
|
+
data = await asyncio.to_thread(self._load)
|
|
156
|
+
credential = data.get(provider_id)
|
|
157
|
+
return credential if isinstance(credential, dict) else None
|
|
158
|
+
|
|
159
|
+
async def list(self, *, signal: AbortSignal | None = None) -> Sequence[CredentialInfo]:
|
|
160
|
+
if signal is not None:
|
|
161
|
+
signal.throw_if_aborted()
|
|
162
|
+
data = await asyncio.to_thread(self._load)
|
|
163
|
+
return [
|
|
164
|
+
CredentialInfo(provider_id, credential.get("type", "api_key"))
|
|
165
|
+
for provider_id, credential in data.items()
|
|
166
|
+
if isinstance(credential, dict)
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
async def modify(
|
|
170
|
+
self,
|
|
171
|
+
provider_id: str,
|
|
172
|
+
fn: Callable[[Credential | None], Awaitable[Credential | None]],
|
|
173
|
+
*,
|
|
174
|
+
signal: AbortSignal | None = None,
|
|
175
|
+
) -> Credential | None:
|
|
176
|
+
loop = asyncio.get_running_loop()
|
|
177
|
+
|
|
178
|
+
async def task() -> Credential | None:
|
|
179
|
+
lock = self._file_lock()
|
|
180
|
+
await asyncio.to_thread(lock.__enter__)
|
|
181
|
+
try:
|
|
182
|
+
data = await asyncio.to_thread(self._load)
|
|
183
|
+
current = data.get(provider_id)
|
|
184
|
+
following = await fn(copy.deepcopy(current) if isinstance(current, dict) else None)
|
|
185
|
+
if signal is not None:
|
|
186
|
+
signal.throw_if_aborted()
|
|
187
|
+
if following is None:
|
|
188
|
+
return current if isinstance(current, dict) else None
|
|
189
|
+
data[provider_id] = following
|
|
190
|
+
await asyncio.to_thread(self._save, data)
|
|
191
|
+
return following
|
|
192
|
+
finally:
|
|
193
|
+
await loop.run_in_executor(None, lock.__exit__, None, None, None)
|
|
194
|
+
|
|
195
|
+
return await _enqueue(self._locks, provider_id, task, signal)
|
|
196
|
+
|
|
197
|
+
async def delete(self, provider_id: str, *, signal: AbortSignal | None = None) -> None:
|
|
198
|
+
async def task() -> None:
|
|
199
|
+
lock = self._file_lock()
|
|
200
|
+
await asyncio.to_thread(lock.__enter__)
|
|
201
|
+
try:
|
|
202
|
+
data = await asyncio.to_thread(self._load)
|
|
203
|
+
if data.pop(provider_id, None) is not None:
|
|
204
|
+
await asyncio.to_thread(self._save, data)
|
|
205
|
+
finally:
|
|
206
|
+
await asyncio.to_thread(lock.__exit__, None, None, None)
|
|
207
|
+
|
|
208
|
+
await _enqueue(self._locks, provider_id, task, signal)
|