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,25 @@
1
+ """Vercel AI Gateway provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import anthropic_messages_api, typesafe_system_one_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__ = ["vercel_ai_gateway_provider"]
12
+
13
+
14
+ def vercel_ai_gateway_provider() -> Provider:
15
+ """Vercel AI Gateway (Anthropic Messages API), authenticated with ``AI_GATEWAY_API_KEY``."""
16
+ return create_provider(
17
+ id="vercel-ai-gateway",
18
+ name="Vercel AI Gateway",
19
+ base_url="https://ai-gateway.vercel.sh",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Vercel AI Gateway API key", ["AI_GATEWAY_API_KEY"])),
21
+ models=lambda: catalog_models("vercel-ai-gateway"),
22
+ api=anthropic_messages_api(),
23
+ # AI Gateway serves TypeSafe's System One protocol at /typesafe/v1/systemone.
24
+ classifiers={"typesafe-system-one": typesafe_system_one_api()},
25
+ )
pi_ai/providers/xai.py ADDED
@@ -0,0 +1,32 @@
1
+ """xAI provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import openai_responses_api
6
+ from ..auth.helpers import env_api_key_auth, lazy_oauth
7
+ from ..auth.oauth.load import load_xai_oauth
8
+ from ..auth.types import ProviderAuth
9
+ from ..models import Provider, create_provider
10
+ from .catalog import catalog_models
11
+
12
+ __all__ = ["xai_provider"]
13
+
14
+
15
+ def xai_provider() -> Provider:
16
+ """xAI (OpenAI Responses API): ``XAI_API_KEY`` or a SuperGrok / X Premium login."""
17
+ return create_provider(
18
+ id="xai",
19
+ name="xAI",
20
+ base_url="https://api.x.ai/v1",
21
+ auth=ProviderAuth(
22
+ api_key=env_api_key_auth("xAI API key", ["XAI_API_KEY"]),
23
+ oauth=lazy_oauth(
24
+ "xAI (Grok/X subscription)",
25
+ load_xai_oauth,
26
+ is_subscription=True,
27
+ login_label="Sign in with SuperGrok or X Premium",
28
+ ),
29
+ ),
30
+ models=lambda: catalog_models("xai"),
31
+ api=openai_responses_api(),
32
+ )
@@ -0,0 +1,23 @@
1
+ """Xiaomi provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import 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__ = ["xiaomi_provider"]
12
+
13
+
14
+ def xiaomi_provider() -> Provider:
15
+ """Xiaomi (OpenAI-compatible Chat Completions), authenticated with ``XIAOMI_API_KEY``."""
16
+ return create_provider(
17
+ id="xiaomi",
18
+ name="Xiaomi",
19
+ base_url="https://api.xiaomimimo.com/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Xiaomi API key", ["XIAOMI_API_KEY"])),
21
+ models=lambda: catalog_models("xiaomi"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,23 @@
1
+ """Xiaomi Token Plan AMS provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import 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__ = ["xiaomi_token_plan_ams_provider"]
12
+
13
+
14
+ def xiaomi_token_plan_ams_provider() -> Provider:
15
+ """Xiaomi Token Plan AMS (OpenAI-compatible Chat Completions), authenticated with ``XIAOMI_TOKEN_PLAN_AMS_API_KEY``."""
16
+ return create_provider(
17
+ id="xiaomi-token-plan-ams",
18
+ name="Xiaomi Token Plan AMS",
19
+ base_url="https://token-plan-ams.xiaomimimo.com/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Xiaomi Token Plan AMS API key", ["XIAOMI_TOKEN_PLAN_AMS_API_KEY"])),
21
+ models=lambda: catalog_models("xiaomi-token-plan-ams"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,23 @@
1
+ """Xiaomi Token Plan CN provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import 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__ = ["xiaomi_token_plan_cn_provider"]
12
+
13
+
14
+ def xiaomi_token_plan_cn_provider() -> Provider:
15
+ """Xiaomi Token Plan CN (OpenAI-compatible Chat Completions), authenticated with ``XIAOMI_TOKEN_PLAN_CN_API_KEY``."""
16
+ return create_provider(
17
+ id="xiaomi-token-plan-cn",
18
+ name="Xiaomi Token Plan CN",
19
+ base_url="https://token-plan-cn.xiaomimimo.com/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Xiaomi Token Plan CN API key", ["XIAOMI_TOKEN_PLAN_CN_API_KEY"])),
21
+ models=lambda: catalog_models("xiaomi-token-plan-cn"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,23 @@
1
+ """Xiaomi Token Plan SGP provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import 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__ = ["xiaomi_token_plan_sgp_provider"]
12
+
13
+
14
+ def xiaomi_token_plan_sgp_provider() -> Provider:
15
+ """Xiaomi Token Plan SGP (OpenAI-compatible Chat Completions), authenticated with ``XIAOMI_TOKEN_PLAN_SGP_API_KEY``."""
16
+ return create_provider(
17
+ id="xiaomi-token-plan-sgp",
18
+ name="Xiaomi Token Plan SGP",
19
+ base_url="https://token-plan-sgp.xiaomimimo.com/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Xiaomi Token Plan SGP API key", ["XIAOMI_TOKEN_PLAN_SGP_API_KEY"])),
21
+ models=lambda: catalog_models("xiaomi-token-plan-sgp"),
22
+ api=openai_completions_api(),
23
+ )
pi_ai/providers/zai.py ADDED
@@ -0,0 +1,23 @@
1
+ """Z.AI provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import 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__ = ["zai_provider"]
12
+
13
+
14
+ def zai_provider() -> Provider:
15
+ """Z.AI (OpenAI-compatible Chat Completions), authenticated with ``ZAI_API_KEY``."""
16
+ return create_provider(
17
+ id="zai",
18
+ name="Z.AI",
19
+ base_url="https://api.z.ai/api/coding/paas/v4",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Z.AI API key", ["ZAI_API_KEY"])),
21
+ models=lambda: catalog_models("zai"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,23 @@
1
+ """Z.AI Coding CN provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import 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__ = ["zai_coding_cn_provider"]
12
+
13
+
14
+ def zai_coding_cn_provider() -> Provider:
15
+ """Z.AI Coding CN (OpenAI-compatible Chat Completions), authenticated with ``ZAI_CODING_CN_API_KEY``."""
16
+ return create_provider(
17
+ id="zai-coding-cn",
18
+ name="Z.AI Coding CN",
19
+ base_url="https://open.bigmodel.cn/api/coding/paas/v4",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Z.AI Coding CN API key", ["ZAI_CODING_CN_API_KEY"])),
21
+ models=lambda: catalog_models("zai-coding-cn"),
22
+ api=openai_completions_api(),
23
+ )
pi_ai/py.typed ADDED
File without changes
pi_ai/serialization.py ADDED
@@ -0,0 +1,477 @@
1
+ """Conversion between pi-ai data classes and pi's JSON representation.
2
+
3
+ The TypeScript implementation stores contexts, messages and models as plain JSON
4
+ objects with camelCase keys. These helpers produce and read exactly that format,
5
+ so a session written by the Python port can be resumed by TypeScript pi and vice
6
+ versa::
7
+
8
+ from pi_ai.serialization import context_to_dict, context_from_dict, dumps, loads
9
+
10
+ text = dumps(context) # JSON string in pi's format
11
+ restored = loads(text, Context) # back to data classes
12
+
13
+ Free-form JSON values (tool arguments, tool result ``details``, headers, model
14
+ ``compat`` settings, ``metadata``) are passed through untouched.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import json
21
+ import re
22
+ from collections.abc import Callable, Mapping
23
+ from typing import Any, TypeVar
24
+
25
+ from . import types as t
26
+ from .utils.diagnostics import AssistantMessageDiagnostic, DiagnosticErrorInfo
27
+
28
+ __all__ = [
29
+ "to_dict",
30
+ "to_json_value",
31
+ "from_dict",
32
+ "dumps",
33
+ "loads",
34
+ "message_from_dict",
35
+ "messages_from_list",
36
+ "content_from_dict",
37
+ "tool_from_dict",
38
+ "model_from_dict",
39
+ "event_from_dict",
40
+ "context_from_dict",
41
+ "context_to_dict",
42
+ "camel_case",
43
+ "snake_case",
44
+ ]
45
+
46
+ T = TypeVar("T")
47
+
48
+ _CAMEL_RE = re.compile(r"_([a-z0-9])")
49
+ _SNAKE_RE = re.compile(r"(?<=[a-z0-9])([A-Z])")
50
+
51
+
52
+ def camel_case(name: str) -> str:
53
+ """``tool_call_id`` -> ``toolCallId`` (``cache_write_1h`` -> ``cacheWrite1h``)."""
54
+ return _CAMEL_RE.sub(lambda match: match.group(1).upper(), name)
55
+
56
+
57
+ def snake_case(name: str) -> str:
58
+ """``toolCallId`` -> ``tool_call_id`` (``cacheWrite1h`` -> ``cache_write_1h``)."""
59
+ converted = _SNAKE_RE.sub(lambda match: "_" + match.group(1).lower(), name)
60
+ return re.sub(r"(?<=[a-z])(?=\d)", "_", converted)
61
+
62
+
63
+ # Fields whose values are free-form JSON: copied verbatim, keys never converted.
64
+ _RAW_FIELDS = frozenset(
65
+ {
66
+ "arguments",
67
+ "details",
68
+ "headers",
69
+ "compat",
70
+ "metadata",
71
+ "sections",
72
+ "thinking_level_map",
73
+ "prompt_cache",
74
+ "sampling_params",
75
+ "sampling_params_by_thinking_level",
76
+ "parameters",
77
+ "constrained_sampling",
78
+ "data",
79
+ "state",
80
+ "criteria",
81
+ "probabilities",
82
+ "env",
83
+ }
84
+ )
85
+
86
+
87
+ def _decode_list(decoder: Callable[[Any], Any]) -> Callable[[Any], Any]:
88
+ return lambda values: [decoder(value) for value in values] if isinstance(values, list) else values
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Encoding
93
+ # ---------------------------------------------------------------------------
94
+
95
+ _field_cache: dict[type, tuple[tuple[str, str], ...]] = {}
96
+
97
+
98
+ def _fields(cls: type) -> tuple[tuple[str, str], ...]:
99
+ cached = _field_cache.get(cls)
100
+ if cached is None:
101
+ cached = tuple((f.name, camel_case(f.name)) for f in dataclasses.fields(cls) if not f.name.startswith("_"))
102
+ _field_cache[cls] = cached
103
+ return cached
104
+
105
+
106
+ def _discriminator(obj: Any) -> tuple[str, str] | None:
107
+ cls = type(obj)
108
+ for key in ("type", "role"):
109
+ value = cls.__dict__.get(key)
110
+ if value is None:
111
+ for base in cls.__mro__[1:]:
112
+ if key in base.__dict__:
113
+ value = base.__dict__[key]
114
+ break
115
+ if isinstance(value, str):
116
+ return (key, value)
117
+ return None
118
+
119
+
120
+ def to_dict(obj: Any) -> Any:
121
+ """Encode a pi-ai data class (or a list/dict of them) as pi JSON with camelCase keys.
122
+
123
+ ``None`` fields are omitted, like ``undefined`` properties in TypeScript.
124
+ """
125
+ return to_json_value(obj)
126
+
127
+
128
+ def to_json_value(obj: Any) -> Any:
129
+ if obj is None or isinstance(obj, (str, int, float, bool)):
130
+ return obj
131
+ if isinstance(obj, (list, tuple)):
132
+ return [to_json_value(item) for item in obj]
133
+ if isinstance(obj, Mapping):
134
+ return {key: to_json_value(value) for key, value in obj.items()}
135
+ if isinstance(obj, t.TranscriptContext):
136
+ return {"messages": to_json_value(obj.messages)}
137
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
138
+ result: dict[str, Any] = {}
139
+ discriminator = _discriminator(obj)
140
+ if discriminator is not None:
141
+ result[discriminator[0]] = discriminator[1]
142
+ for name, key in _fields(type(obj)):
143
+ value = getattr(obj, name)
144
+ if value is None:
145
+ continue
146
+ if name in _RAW_FIELDS:
147
+ if name == "parameters":
148
+ from .utils.schema import parameters_schema
149
+
150
+ value = parameters_schema(value)
151
+ result[key] = _raw_json(value)
152
+ else:
153
+ result[key] = to_json_value(value)
154
+ return result
155
+ if isinstance(obj, set | frozenset):
156
+ return [to_json_value(item) for item in obj]
157
+ if isinstance(obj, BaseException):
158
+ return str(obj)
159
+ model_dump = getattr(obj, "model_dump", None)
160
+ if callable(model_dump): # pydantic instances in tool details
161
+ return model_dump(mode="json")
162
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
163
+
164
+
165
+ def _raw_json(value: Any) -> Any:
166
+ if isinstance(value, (dict, list, str, int, float, bool)) or value is None:
167
+ if isinstance(value, dict):
168
+ return {key: _raw_json(item) for key, item in value.items()}
169
+ if isinstance(value, list):
170
+ return [_raw_json(item) for item in value]
171
+ return value
172
+ return to_json_value(value)
173
+
174
+
175
+ def dumps(obj: Any, *, indent: int | None = None) -> str:
176
+ """Serialize a data class (context, message, model, ...) to a pi-compatible JSON string."""
177
+ return json.dumps(to_json_value(obj), ensure_ascii=False, indent=indent, separators=None if indent else (",", ":"))
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Decoding
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ def _build(cls: type[T], data: Mapping[str, Any], *, nested: Mapping[str, Callable[[Any], Any]] | None = None) -> T:
186
+ kwargs: dict[str, Any] = {}
187
+ for name, key in _fields(cls):
188
+ if key in data:
189
+ value = data[key]
190
+ elif name in data:
191
+ value = data[name]
192
+ else:
193
+ continue
194
+ if value is None:
195
+ continue
196
+ decoder = nested.get(name) if nested else None
197
+ if decoder is not None:
198
+ value = decoder(value)
199
+ kwargs[name] = value
200
+ return cls(**kwargs)
201
+
202
+
203
+ def _usage_cost(data: Any) -> t.UsageCost:
204
+ return data if isinstance(data, t.UsageCost) else _build(t.UsageCost, data)
205
+
206
+
207
+ def _usage(data: Any) -> t.Usage:
208
+ if isinstance(data, t.Usage):
209
+ return data
210
+ return _build(t.Usage, data, nested={"cost": _usage_cost})
211
+
212
+
213
+ _CONTENT_TYPES: dict[str, type] = {
214
+ "text": t.TextContent,
215
+ "thinking": t.ThinkingContent,
216
+ "image": t.ImageContent,
217
+ "toolCall": t.ToolCall,
218
+ }
219
+
220
+
221
+ def content_from_dict(data: Any) -> Any:
222
+ """Decode one content block (``{"type": "text", "text": ...}`` etc.)."""
223
+ if not isinstance(data, Mapping):
224
+ return data
225
+ cls = _CONTENT_TYPES.get(data.get("type", ""))
226
+ if cls is None:
227
+ return dict(data)
228
+ return _build(cls, data)
229
+
230
+
231
+ def _content_list(data: Any) -> Any:
232
+ if isinstance(data, str) or data is None:
233
+ return data if data is not None else []
234
+ return [content_from_dict(item) for item in data]
235
+
236
+
237
+ def tool_from_dict(data: Any) -> t.Tool:
238
+ if isinstance(data, t.Tool):
239
+ return data
240
+ return _build(t.Tool, data)
241
+
242
+
243
+ def _tool_reference(data: Any) -> t.ToolReference:
244
+ return data if isinstance(data, t.ToolReference) else t.ToolReference(data["name"])
245
+
246
+
247
+ def _diagnostic(data: Any) -> Any:
248
+ if not isinstance(data, Mapping):
249
+ return data
250
+ return _build(
251
+ AssistantMessageDiagnostic,
252
+ data,
253
+ nested={"error": lambda value: _build(DiagnosticErrorInfo, value) if isinstance(value, Mapping) else value},
254
+ )
255
+
256
+
257
+ def _deferred(data: Any) -> Any:
258
+ return _build(t.DeferredHandle, data) if isinstance(data, Mapping) else data
259
+
260
+
261
+ def _nested_calls(data: Any) -> Any:
262
+ if not isinstance(data, Mapping):
263
+ return data
264
+ return _build(
265
+ t.NestedToolCalls,
266
+ data,
267
+ nested={"calls": _decode_list(lambda item: _build(t.NestedToolCallRecord, item))},
268
+ )
269
+
270
+
271
+ _MESSAGE_NESTED: dict[str, dict[str, Callable[[Any], Any]]] = {
272
+ "system": {
273
+ "content": lambda value: value if isinstance(value, str) else _content_list(value),
274
+ "tools_added": _decode_list(tool_from_dict),
275
+ "tools_removed": _decode_list(_tool_reference),
276
+ },
277
+ "user": {"content": _content_list},
278
+ "assistant": {
279
+ "content": _content_list,
280
+ "usage": _usage,
281
+ "diagnostics": _decode_list(_diagnostic),
282
+ "deferred": _deferred,
283
+ },
284
+ "toolResult": {
285
+ "content": _content_list,
286
+ "usage": _usage,
287
+ "nested_calls": _nested_calls,
288
+ },
289
+ }
290
+
291
+ _MESSAGE_TYPES: dict[str, type] = {
292
+ "system": t.SystemMessage,
293
+ "user": t.UserMessage,
294
+ "assistant": t.AssistantMessage,
295
+ "toolResult": t.ToolResultMessage,
296
+ }
297
+
298
+
299
+ def message_from_dict(data: Any) -> Any:
300
+ """Decode a message by its ``role``. Unknown roles (custom agent messages) are returned unchanged."""
301
+ if not isinstance(data, Mapping):
302
+ return data
303
+ role = data.get("role")
304
+ cls = _MESSAGE_TYPES.get(role) if isinstance(role, str) else None
305
+ if cls is None:
306
+ return data
307
+ return _build(cls, data, nested=_MESSAGE_NESTED[role]) # type: ignore[index]
308
+
309
+
310
+ def messages_from_list(data: list[Any]) -> list[Any]:
311
+ return [message_from_dict(item) for item in data]
312
+
313
+
314
+ def _cost(data: Any) -> t.ModelCost:
315
+ if isinstance(data, t.ModelCost):
316
+ return data
317
+ return _build(t.ModelCost, data, nested={"tiers": _decode_list(lambda tier: _build(t.ModelCostTier, tier))})
318
+
319
+
320
+ def _input_limits(data: Any) -> Any:
321
+ if not isinstance(data, Mapping):
322
+ return data
323
+
324
+ def images(value: Any) -> Any:
325
+ if not isinstance(value, Mapping):
326
+ return value
327
+ return _build(
328
+ t.ModelImageInputLimits,
329
+ value,
330
+ nested={"resize": lambda r: _build(t.ModelImageResizeOptions, r) if isinstance(r, Mapping) else r},
331
+ )
332
+
333
+ return _build(t.ModelInputLimits, data, nested={"images": images})
334
+
335
+
336
+ _MODEL_NESTED: dict[str, Callable[[Any], Any]] = {"cost": _cost, "input_limits": _input_limits}
337
+ _MODEL_TYPES: dict[str, type] = {"chat": t.Model, "image": t.ImageModel, "classifier": t.ClassifierModel}
338
+
339
+
340
+ def model_from_dict(data: Any) -> Any:
341
+ """Decode a catalog entry. A missing ``type`` means a chat model."""
342
+ if not isinstance(data, Mapping):
343
+ return data
344
+ cls = _MODEL_TYPES.get(data.get("type") or "chat")
345
+ if cls is None:
346
+ raise ValueError(f"Unknown model type: {data.get('type')!r}")
347
+ return _build(cls, data, nested=_MODEL_NESTED)
348
+
349
+
350
+ _EVENT_TYPES: dict[str, type] = {
351
+ cls.type: cls # type: ignore[attr-defined]
352
+ for cls in (
353
+ t.StartEvent,
354
+ t.TextStartEvent,
355
+ t.TextDeltaEvent,
356
+ t.TextEndEvent,
357
+ t.ThinkingStartEvent,
358
+ t.ThinkingDeltaEvent,
359
+ t.ThinkingEndEvent,
360
+ t.ToolCallStartEvent,
361
+ t.ToolCallDeltaEvent,
362
+ t.ToolCallEndEvent,
363
+ t.DoneEvent,
364
+ t.ErrorEvent,
365
+ )
366
+ }
367
+
368
+
369
+ def event_from_dict(data: Mapping[str, Any]) -> Any:
370
+ cls = _EVENT_TYPES[data["type"]]
371
+ return _build(
372
+ cls,
373
+ data,
374
+ nested={
375
+ "partial": message_from_dict,
376
+ "message": message_from_dict,
377
+ "error": message_from_dict,
378
+ "tool_call": content_from_dict,
379
+ },
380
+ )
381
+
382
+
383
+ def context_from_dict(data: Mapping[str, Any]) -> t.Context:
384
+ tools = data.get("tools")
385
+ return t.Context(
386
+ messages=messages_from_list(list(data.get("messages") or [])),
387
+ system_prompt=data.get("systemPrompt", data.get("system_prompt")),
388
+ tools=[tool_from_dict(tool) for tool in tools] if tools else None,
389
+ )
390
+
391
+
392
+ def context_to_dict(context: t.Context | t.TranscriptContext) -> dict[str, Any]:
393
+ return to_json_value(context)
394
+
395
+
396
+ _QUESTION_TYPES = {
397
+ "choice": t.ClassifierChoiceQuestion,
398
+ "score": t.ClassifierScoreQuestion,
399
+ "bool": t.ClassifierBoolQuestion,
400
+ }
401
+ _ANSWER_TYPES = {"choice": t.ClassifierChoiceAnswer, "score": t.ClassifierScoreAnswer, "bool": t.ClassifierBoolAnswer}
402
+
403
+
404
+ def classifier_question_from_dict(data: Any) -> Any:
405
+ if not isinstance(data, Mapping):
406
+ return data
407
+ return _build(_QUESTION_TYPES[data["type"]], data)
408
+
409
+
410
+ def classifier_answer_from_dict(data: Any) -> Any:
411
+ if not isinstance(data, Mapping):
412
+ return data
413
+ return _build(_ANSWER_TYPES[data["type"]], data)
414
+
415
+
416
+ _DECODERS: dict[type, Callable[[Any], Any]] = {
417
+ t.Context: context_from_dict,
418
+ t.Model: model_from_dict,
419
+ t.ImageModel: model_from_dict,
420
+ t.ClassifierModel: model_from_dict,
421
+ t.Tool: tool_from_dict,
422
+ t.Usage: _usage,
423
+ t.ModelCost: _cost,
424
+ t.DeferredHandle: _deferred,
425
+ t.SystemMessage: message_from_dict,
426
+ t.UserMessage: message_from_dict,
427
+ t.AssistantMessage: message_from_dict,
428
+ t.ToolResultMessage: message_from_dict,
429
+ t.TextContent: content_from_dict,
430
+ t.ThinkingContent: content_from_dict,
431
+ t.ImageContent: content_from_dict,
432
+ t.ToolCall: content_from_dict,
433
+ t.ImagesContext: lambda data: t.ImagesContext([content_from_dict(item) for item in data.get("input", [])]),
434
+ t.ClassifierContext: lambda data: t.ClassifierContext(
435
+ state=data["state"],
436
+ questions={name: classifier_question_from_dict(q) for name, q in data.get("questions", {}).items()},
437
+ images=[content_from_dict(image) for image in data["images"]] if data.get("images") else None,
438
+ ),
439
+ t.ClassifierResult: lambda data: _build(
440
+ t.ClassifierResult,
441
+ data,
442
+ nested={
443
+ "answers": lambda answers: {name: classifier_answer_from_dict(a) for name, a in answers.items()},
444
+ "usage": _usage,
445
+ },
446
+ ),
447
+ t.AssistantImages: lambda data: _build(t.AssistantImages, data, nested={"output": _content_list, "usage": _usage}),
448
+ }
449
+
450
+
451
+ def from_dict(cls: type[T] | str, data: Any) -> T:
452
+ """Decode pi JSON into data class ``cls``.
453
+
454
+ ``cls`` may also be ``"message"``, ``"content"``, ``"model"``, ``"event"`` or
455
+ ``"context"`` to decode by discriminator.
456
+ """
457
+ if isinstance(cls, str):
458
+ decoder = {
459
+ "message": message_from_dict,
460
+ "content": content_from_dict,
461
+ "model": model_from_dict,
462
+ "event": event_from_dict,
463
+ "context": context_from_dict,
464
+ "messages": messages_from_list,
465
+ }[cls]
466
+ return decoder(data)
467
+ decoder = _DECODERS.get(cls)
468
+ if decoder is not None:
469
+ return decoder(data)
470
+ if dataclasses.is_dataclass(cls):
471
+ return _build(cls, data) # type: ignore[return-value]
472
+ raise TypeError(f"Cannot decode {cls!r}")
473
+
474
+
475
+ def loads(text: str | bytes, cls: type[T] | str = "context") -> T:
476
+ """Parse a JSON string produced by :func:`dumps` (or TypeScript pi) into ``cls``."""
477
+ return from_dict(cls, json.loads(text))