pi-ai-python 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -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)