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,145 @@
1
+ """OpenRouter provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import math
8
+ import re
9
+ from collections.abc import Sequence
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ..abort import AbortSignal, abort_scope
15
+ from ..api.registry import (
16
+ anthropic_messages_api,
17
+ openai_completions_api,
18
+ openrouter_images_api,
19
+ typesafe_system_one_api,
20
+ )
21
+ from ..auth.helpers import env_api_key_auth, lazy_oauth
22
+ from ..auth.oauth.load import load_openrouter_oauth
23
+ from ..auth.types import ProviderAuth
24
+ from ..models import Provider, RefreshModelsContext, create_provider
25
+ from ..types import AnyModel
26
+ from ..utils.http import get_http_client
27
+ from ..utils.model_operations import is_model_type
28
+ from .catalog import catalog_models
29
+ from .openrouter_api import openrouter_cost
30
+
31
+ __all__ = ["OPENROUTER_BASE_URL", "openrouter_provider", "personalize_openrouter_catalog"]
32
+
33
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
34
+ OPENROUTER_USER_MODELS_TIMEOUT_MS = 15_000
35
+
36
+ _TRAILING_SLASHES = re.compile(r"/+$")
37
+
38
+
39
+ def _truthy(value: Any) -> bool:
40
+ """JavaScript truthiness (``{}`` and ``[]`` are truthy, ``NaN`` is not)."""
41
+ if isinstance(value, float) and math.isnan(value):
42
+ return False
43
+ if isinstance(value, (dict, list)):
44
+ return True
45
+ return bool(value)
46
+
47
+
48
+ def _first_truthy(*values: Any) -> Any:
49
+ for value in values[:-1]:
50
+ if _truthy(value):
51
+ return value
52
+ return values[-1]
53
+
54
+
55
+ async def personalize_openrouter_catalog(
56
+ catalog: Sequence[AnyModel],
57
+ context: RefreshModelsContext,
58
+ *,
59
+ http_client: httpx.AsyncClient | None = None,
60
+ ) -> Sequence[AnyModel]:
61
+ """Restrict an OpenRouter catalog to the chat models the key may use, with the key's limits and prices.
62
+
63
+ Uses ``GET /models/user``. Image and classifier models are returned unchanged and a
64
+ missing limit keeps the catalog value. The request goes to the provider's configured
65
+ base URL, so regional endpoints such as ``https://us.openrouter.ai/api/v1`` apply their
66
+ region's filtering. When the endpoint rejects the key (401/403) or does not exist (404),
67
+ for example with a proxy configured in models.json, ``catalog`` itself is returned.
68
+ """
69
+ credential = context.credential
70
+ if credential is not None and credential.get("type") == "oauth":
71
+ key = credential.get("access")
72
+ else:
73
+ key = credential.get("key") if credential is not None else None
74
+ if not key:
75
+ return catalog
76
+ base_url = context.base_url if context.base_url is not None else OPENROUTER_BASE_URL
77
+ url = f"{_TRAILING_SLASHES.sub('', base_url)}/models/user"
78
+ signal = AbortSignal.any([context.signal, AbortSignal.timeout(OPENROUTER_USER_MODELS_TIMEOUT_MS / 1000)])
79
+ client = http_client if http_client is not None else get_http_client(url)
80
+ async with abort_scope(signal):
81
+ response = await client.get(
82
+ url,
83
+ headers={"accept": "application/json", "authorization": f"Bearer {key}"},
84
+ timeout=OPENROUTER_USER_MODELS_TIMEOUT_MS / 1000,
85
+ )
86
+ raw = await response.aread()
87
+ if response.status_code in (401, 403, 404):
88
+ return catalog
89
+ if not response.is_success:
90
+ raise RuntimeError(f"OpenRouter model list request failed: {response.status_code}")
91
+ body: Any = json.loads(raw)
92
+ data = body.get("data") if isinstance(body, dict) else None
93
+ if not isinstance(data, list):
94
+ raise ValueError("Invalid OpenRouter model list response")
95
+ allowed: dict[str, dict[str, Any]] = {}
96
+ for entry in data:
97
+ if isinstance(entry, dict) and isinstance(entry.get("id"), str):
98
+ allowed[entry["id"]] = entry
99
+
100
+ personalized: list[AnyModel] = []
101
+ for model in catalog:
102
+ if not is_model_type(model, "chat"):
103
+ personalized.append(model)
104
+ continue
105
+ listed = allowed.get(model.id)
106
+ if listed is None:
107
+ continue
108
+ top_provider = listed.get("top_provider")
109
+ top_provider = top_provider if isinstance(top_provider, dict) else {}
110
+ pricing = listed.get("pricing")
111
+ personalized.append(
112
+ dataclasses.replace(
113
+ model, # type: ignore[arg-type]
114
+ # `or`: OpenRouter reports a missing limit as absent, null or 0, like the generator's fallback.
115
+ context_window=_first_truthy(
116
+ top_provider.get("context_length"),
117
+ listed.get("context_length"),
118
+ model.context_window, # type: ignore[union-attr]
119
+ ),
120
+ max_tokens=_first_truthy(top_provider.get("max_completion_tokens"), model.max_tokens), # type: ignore[union-attr]
121
+ cost=openrouter_cost(pricing) if _truthy(pricing) else model.cost,
122
+ )
123
+ )
124
+ return personalized
125
+
126
+
127
+ def openrouter_provider() -> Provider:
128
+ """OpenRouter (chat, image and System One classifier models): ``OPENROUTER_API_KEY`` or OAuth."""
129
+ return create_provider(
130
+ id="openrouter",
131
+ name="OpenRouter",
132
+ base_url=OPENROUTER_BASE_URL,
133
+ auth=ProviderAuth(
134
+ api_key=env_api_key_auth("OpenRouter API key", ["OPENROUTER_API_KEY"]),
135
+ oauth=lazy_oauth("OpenRouter OAuth", load_openrouter_oauth, login_label="Sign in with OpenRouter"),
136
+ ),
137
+ models=lambda: catalog_models("openrouter"),
138
+ api={
139
+ "anthropic-messages": anthropic_messages_api(),
140
+ "openai-completions": openai_completions_api(),
141
+ },
142
+ images={"openrouter-images": openrouter_images_api()},
143
+ # OpenRouter serves TypeSafe's System One protocol at /api/v1/systemone.
144
+ classifiers={"typesafe-system-one": typesafe_system_one_api()},
145
+ )
@@ -0,0 +1,122 @@
1
+ """OpenRouter model-list shapes and price conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from typing import Any, TypedDict
8
+
9
+ from ..types import ModelCost, ModelCostTier
10
+
11
+ __all__ = [
12
+ "OpenRouterPricing",
13
+ "OpenRouterPricingOverride",
14
+ "OpenRouterTopProvider",
15
+ "OpenRouterModel",
16
+ "openrouter_cost",
17
+ ]
18
+
19
+
20
+ class OpenRouterPricingOverride(TypedDict, total=False):
21
+ """A conditional price.
22
+
23
+ ``min_prompt_tokens`` selects prompt-length pricing; ``utc_*`` fields select
24
+ time-of-day or weekday pricing. Missing rates keep the base price.
25
+ """
26
+
27
+ min_prompt_tokens: int
28
+ utc_start: int
29
+ utc_end: int
30
+ utc_days: list[str]
31
+ prompt: str
32
+ completion: str
33
+ input_cache_read: str
34
+ input_cache_write: str
35
+
36
+
37
+ class OpenRouterPricing(TypedDict, total=False):
38
+ """OpenRouter's per-token prices, as decimal strings."""
39
+
40
+ prompt: str
41
+ completion: str
42
+ input_cache_read: str
43
+ input_cache_write: str
44
+ overrides: list[OpenRouterPricingOverride]
45
+
46
+
47
+ class OpenRouterTopProvider(TypedDict, total=False):
48
+ context_length: int | None
49
+ max_completion_tokens: int | None
50
+
51
+
52
+ class OpenRouterModel(TypedDict, total=False):
53
+ """The fields pi reads from a model in OpenRouter's ``GET /models`` and ``GET /models/user``."""
54
+
55
+ id: str
56
+ context_length: int | None
57
+ top_provider: OpenRouterTopProvider
58
+ pricing: OpenRouterPricing
59
+
60
+
61
+ _FLOAT_PREFIX = re.compile(r"\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?Infinity)")
62
+
63
+
64
+ def _parse_float(value: Any) -> float:
65
+ """``Number.parseFloat``: the longest numeric prefix, ``nan`` when there is none."""
66
+ if isinstance(value, bool):
67
+ value = str(value).lower()
68
+ if isinstance(value, (int, float)):
69
+ return float(value)
70
+ match = _FLOAT_PREFIX.match(str(value) if value is not None else "")
71
+ if match is None:
72
+ return math.nan
73
+ return float(match.group(1).replace("Infinity", "inf"))
74
+
75
+
76
+ def _per_million(value: Any, fallback: float) -> float:
77
+ """Convert one $/token price to $/1M tokens. 0 means free.
78
+
79
+ Missing, invalid and negative prices use the fallback: OpenRouter reports -1 for
80
+ router models such as ``openrouter/auto``, whose price depends on the model they pick.
81
+ """
82
+ per_token = _parse_float(value)
83
+ return round(per_token * 1_000_000, 6) if per_token >= 0 else fallback
84
+
85
+
86
+ def openrouter_cost(pricing: OpenRouterPricing | None) -> ModelCost:
87
+ """Convert OpenRouter's $/token prices to pi's $/1M tokens.
88
+
89
+ Missing, invalid and negative base prices are 0. Prompt-length overrides become
90
+ request-wide tiers; a rate a tier does not list keeps the base price. Time-of-day
91
+ overrides are skipped because :class:`ModelCost` cannot express them.
92
+ """
93
+ pricing = pricing or {}
94
+ base_input = _per_million(pricing.get("prompt"), 0)
95
+ base_output = _per_million(pricing.get("completion"), 0)
96
+ base_cache_read = _per_million(pricing.get("input_cache_read"), 0)
97
+ base_cache_write = _per_million(pricing.get("input_cache_write"), 0)
98
+ tiers: list[ModelCostTier] = []
99
+ for override in pricing.get("overrides") or []:
100
+ if (
101
+ override.get("min_prompt_tokens") is None
102
+ or "utc_start" in override
103
+ or "utc_end" in override
104
+ or "utc_days" in override
105
+ ):
106
+ continue
107
+ tiers.append(
108
+ ModelCostTier(
109
+ input_tokens_above=override["min_prompt_tokens"], # type: ignore[typeddict-item]
110
+ input=_per_million(override.get("prompt"), base_input),
111
+ output=_per_million(override.get("completion"), base_output),
112
+ cache_read=_per_million(override.get("input_cache_read"), base_cache_read),
113
+ cache_write=_per_million(override.get("input_cache_write"), base_cache_write),
114
+ )
115
+ )
116
+ return ModelCost(
117
+ input=base_input,
118
+ output=base_output,
119
+ cache_read=base_cache_read,
120
+ cache_write=base_cache_write,
121
+ tiers=tiers or None,
122
+ )
@@ -0,0 +1,23 @@
1
+ """Qwen Token Plan 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__ = ["qwen_token_plan_provider"]
12
+
13
+
14
+ def qwen_token_plan_provider() -> Provider:
15
+ """Qwen Token Plan (OpenAI-compatible Chat Completions), authenticated with ``QWEN_TOKEN_PLAN_API_KEY``."""
16
+ return create_provider(
17
+ id="qwen-token-plan",
18
+ name="Qwen Token Plan",
19
+ base_url="https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Qwen Token Plan API key", ["QWEN_TOKEN_PLAN_API_KEY"])),
21
+ models=lambda: catalog_models("qwen-token-plan"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,23 @@
1
+ """Qwen 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__ = ["qwen_token_plan_cn_provider"]
12
+
13
+
14
+ def qwen_token_plan_cn_provider() -> Provider:
15
+ """Qwen Token Plan CN (OpenAI-compatible Chat Completions), authenticated with ``QWEN_TOKEN_PLAN_CN_API_KEY``."""
16
+ return create_provider(
17
+ id="qwen-token-plan-cn",
18
+ name="Qwen Token Plan CN",
19
+ base_url="https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Qwen Token Plan CN API key", ["QWEN_TOKEN_PLAN_CN_API_KEY"])),
21
+ models=lambda: catalog_models("qwen-token-plan-cn"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,23 @@
1
+ """Qwen Token Plan Individual 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__ = ["qwen_token_plan_individual_provider"]
12
+
13
+
14
+ def qwen_token_plan_individual_provider() -> Provider:
15
+ """Qwen Token Plan Individual (OpenAI-compatible Chat Completions), authenticated with ``QWEN_TOKEN_PLAN_API_KEY``."""
16
+ return create_provider(
17
+ id="qwen-token-plan-individual",
18
+ name="Qwen Token Plan Individual",
19
+ base_url="https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Qwen Token Plan Individual API key", ["QWEN_TOKEN_PLAN_API_KEY"])),
21
+ models=lambda: catalog_models("qwen-token-plan-individual"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,146 @@
1
+ """Radius gateway provider with a persisted, dynamically refreshed catalog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from ..api.registry import pi_messages_api
12
+ from ..auth.helpers import env_api_key_auth, lazy_oauth
13
+ from ..auth.oauth.load import load_radius_oauth
14
+ from ..auth.types import ProviderAuth
15
+ from ..event_stream import AssistantMessageEventStream
16
+ from ..models import ModelsPublication, Provider, RefreshModelsContext
17
+ from ..models_store import ModelsStoreEntry
18
+ from ..types import Model, TranscriptContext, now_ms
19
+ from .catalog import get_builtin_models
20
+ from .radius_config import (
21
+ DEFAULT_RADIUS_GATEWAY,
22
+ get_radius_models,
23
+ get_radius_models_from_config,
24
+ load_radius_gateway_config,
25
+ normalize_radius_gateway_url,
26
+ )
27
+
28
+ __all__ = ["RadiusProvider", "radius_provider"]
29
+
30
+
31
+ class RadiusProvider(Provider):
32
+ """Radius gateway provider.
33
+
34
+ The gateway catalog for the account replaces the shipped baseline once known: Radius
35
+ organization owners can disable models, so disabled models must not reappear from the
36
+ baseline. The baseline (default gateway only) covers the time before any catalog exists.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ id: str = "radius",
43
+ name: str = "Radius",
44
+ gateway: str = DEFAULT_RADIUS_GATEWAY,
45
+ http_client: httpx.AsyncClient | None = None,
46
+ ) -> None:
47
+ self.id = id
48
+ self.name = name
49
+ self.gateway = normalize_radius_gateway_url(gateway)
50
+ self._http_client = http_client
51
+ self._use_public_catalog = self.gateway == normalize_radius_gateway_url(DEFAULT_RADIUS_GATEWAY)
52
+ self._baseline: list[Model] | None = None
53
+ self._dynamic: list[Model] | None = None
54
+ self._streams = pi_messages_api()
55
+ gateway_url = self.gateway
56
+ self.auth = ProviderAuth(
57
+ api_key=env_api_key_auth("Radius API key", ["RADIUS_API_KEY"]),
58
+ oauth=lazy_oauth(name, lambda: load_radius_oauth(name=name, gateway=gateway_url)),
59
+ )
60
+ self.refresh_models = self._refresh_models
61
+
62
+ def _baseline_models(self) -> list[Model]:
63
+ baseline = self._baseline
64
+ if baseline is None:
65
+ baseline = (
66
+ [dataclasses.replace(model, provider=self.id) for model in get_builtin_models("radius")]
67
+ if self._use_public_catalog
68
+ else []
69
+ )
70
+ self._baseline = baseline
71
+ return baseline
72
+
73
+ def get_models(self) -> Sequence[Model]:
74
+ dynamic = self._dynamic
75
+ return list(dynamic if dynamic is not None else self._baseline_models())
76
+
77
+ def _set_dynamic(self, models: list[Model]) -> None:
78
+ self._dynamic = models
79
+
80
+ async def _refresh_models(self, context: RefreshModelsContext) -> None:
81
+ stored = context.stored
82
+ if stored is not None:
83
+ restored: list[Model] = [model for model in stored.models if model.provider == self.id]
84
+ if not await context.publish(ModelsPublication(update=lambda: self._set_dynamic(restored))):
85
+ return
86
+
87
+ credential = context.credential
88
+ is_oauth = credential is not None and credential.get("type") == "oauth"
89
+ # Import catalogs cached by the pre-ModelsStore Radius implementation.
90
+ if stored is None and is_oauth:
91
+ legacy = get_radius_models(self.id, credential)
92
+ if legacy:
93
+ if not await context.publish(
94
+ ModelsPublication(
95
+ persist=ModelsStoreEntry(models=list(legacy), checked_at=now_ms()),
96
+ update=lambda: self._set_dynamic(legacy),
97
+ )
98
+ ):
99
+ return
100
+
101
+ if not context.allow_network or context.signal.aborted:
102
+ return
103
+ if is_oauth:
104
+ api_key = credential.get("access") # type: ignore[union-attr]
105
+ else:
106
+ api_key = credential.get("key") if credential is not None else None
107
+ config = await load_radius_gateway_config(self.gateway, api_key, context.signal, http_client=self._http_client)
108
+ if context.signal.aborted:
109
+ return
110
+ refreshed = get_radius_models_from_config(self.id, config)
111
+ await context.publish(
112
+ ModelsPublication(
113
+ persist=ModelsStoreEntry(models=list(refreshed), checked_at=now_ms()),
114
+ update=lambda: self._set_dynamic(refreshed),
115
+ )
116
+ )
117
+
118
+ def stream(
119
+ self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
120
+ ) -> AssistantMessageEventStream:
121
+ return self._streams.stream(model, context, options)
122
+
123
+ def stream_simple(
124
+ self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
125
+ ) -> AssistantMessageEventStream:
126
+ return self._streams.stream_simple(model, context, options)
127
+
128
+
129
+ def radius_provider(
130
+ *,
131
+ id: str | None = None,
132
+ name: str | None = None,
133
+ gateway: str | None = None,
134
+ http_client: httpx.AsyncClient | None = None,
135
+ ) -> Provider:
136
+ """Radius gateway provider (``RADIUS_API_KEY`` or Radius OAuth).
137
+
138
+ ``gateway`` defaults to ``https://radius.pi.dev``; only the default gateway ships a
139
+ static baseline catalog. ``http_client`` is used for ``/v1/config`` catalog requests.
140
+ """
141
+ return RadiusProvider(
142
+ id=id if id is not None else "radius",
143
+ name=name if name is not None else "Radius",
144
+ gateway=gateway if gateway is not None else DEFAULT_RADIUS_GATEWAY,
145
+ http_client=http_client,
146
+ )
@@ -0,0 +1,136 @@
1
+ """Radius gateway configuration (``GET /v1/config``) and its model list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import urllib.parse
8
+ from typing import Any, TypedDict
9
+
10
+ import httpx
11
+
12
+ from ..abort import AbortSignal, abort_scope
13
+ from ..auth.types import Credential
14
+ from ..serialization import model_from_dict
15
+ from ..types import Model
16
+ from ..utils.http import get_http_client
17
+
18
+ __all__ = [
19
+ "DEFAULT_RADIUS_GATEWAY",
20
+ "RadiusGatewayModel",
21
+ "RadiusGatewayConfig",
22
+ "normalize_radius_gateway_url",
23
+ "get_radius_credential_config",
24
+ "get_radius_models_from_config",
25
+ "get_radius_models",
26
+ "load_radius_gateway_config",
27
+ ]
28
+
29
+ DEFAULT_RADIUS_GATEWAY = "https://radius.pi.dev"
30
+
31
+
32
+ class RadiusGatewayModel(TypedDict, total=False):
33
+ """One gateway model in pi's JSON shape (camelCase keys)."""
34
+
35
+ id: str
36
+ name: str
37
+ reasoning: bool
38
+ thinkingLevelMap: dict[str, str | None]
39
+ input: list[str]
40
+ cost: dict[str, Any]
41
+ contextWindow: int
42
+ maxTokens: int
43
+
44
+
45
+ class RadiusGatewayConfig(TypedDict):
46
+ baseUrl: str
47
+ models: list[RadiusGatewayModel]
48
+
49
+
50
+ def _is_number(value: Any) -> bool:
51
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
52
+
53
+
54
+ def _is_radius_gateway_model(value: Any) -> bool:
55
+ if not isinstance(value, dict):
56
+ return False
57
+ return (
58
+ isinstance(value.get("id"), str)
59
+ and isinstance(value.get("name"), str)
60
+ and isinstance(value.get("reasoning"), bool)
61
+ and isinstance(value.get("input"), list)
62
+ and isinstance(value.get("cost"), dict)
63
+ and _is_number(value.get("contextWindow"))
64
+ and _is_number(value.get("maxTokens"))
65
+ )
66
+
67
+
68
+ def _sanitize_radius_gateway_config(config: Any) -> RadiusGatewayConfig | None:
69
+ if not isinstance(config, dict):
70
+ return None
71
+ base_url = config.get("baseUrl")
72
+ models = config.get("models")
73
+ if not isinstance(base_url, str) or not isinstance(models, list):
74
+ return None
75
+ return {"baseUrl": base_url, "models": [dict(model) for model in models if _is_radius_gateway_model(model)]} # type: ignore[misc]
76
+
77
+
78
+ _SCHEME = re.compile(r"^https?://", re.IGNORECASE)
79
+ _TRAILING_SLASHES = re.compile(r"/+$")
80
+
81
+
82
+ def normalize_radius_gateway_url(value: str) -> str:
83
+ """Add ``https://`` when no scheme is given and drop trailing slashes."""
84
+ with_scheme = value if _SCHEME.match(value) else f"https://{value}"
85
+ return _TRAILING_SLASHES.sub("", with_scheme)
86
+
87
+
88
+ def get_radius_credential_config(credential: Credential | None) -> RadiusGatewayConfig | None:
89
+ """The gateway config cached in an OAuth credential by the pre-ModelsStore Radius implementation."""
90
+ return _sanitize_radius_gateway_config(credential.get("gatewayConfig") if credential else None)
91
+
92
+
93
+ def get_radius_models_from_config(provider_id: str, config: RadiusGatewayConfig) -> list[Model]:
94
+ models: list[Model] = []
95
+ for entry in config["models"]:
96
+ data = {key: value for key, value in entry.items() if key != "type"}
97
+ data.update(api="pi-messages", provider=provider_id, baseUrl=config["baseUrl"])
98
+ models.append(model_from_dict(data))
99
+ return models
100
+
101
+
102
+ def get_radius_models(provider_id: str, credential: Credential | None) -> list[Model]:
103
+ config = get_radius_credential_config(credential)
104
+ return get_radius_models_from_config(provider_id, config) if config is not None else []
105
+
106
+
107
+ def _truncate_http_body(body: str) -> str:
108
+ trimmed = body.strip()
109
+ return f"{trimmed[:512]}…" if len(trimmed) > 512 else trimmed
110
+
111
+
112
+ async def load_radius_gateway_config(
113
+ gateway: str,
114
+ api_key: str | None = None,
115
+ signal: AbortSignal | None = None,
116
+ *,
117
+ http_client: httpx.AsyncClient | None = None,
118
+ ) -> RadiusGatewayConfig:
119
+ """Fetch and validate ``<gateway>/v1/config``. Raises on HTTP errors and invalid configs."""
120
+ headers = {"accept": "application/json"}
121
+ if api_key:
122
+ headers["authorization"] = f"Bearer {api_key}"
123
+ url = urllib.parse.urljoin(gateway, "/v1/config")
124
+ client = http_client if http_client is not None else get_http_client(url)
125
+ async with abort_scope(signal):
126
+ response = await client.get(url, headers=headers)
127
+ raw = await response.aread()
128
+ if not response.is_success:
129
+ text = raw.decode("utf-8", "replace")
130
+ raise RuntimeError(
131
+ f"Could not load Radius config from {gateway}: {response.status_code}: {_truncate_http_body(text)}"
132
+ )
133
+ config = _sanitize_radius_gateway_config(json.loads(raw))
134
+ if config is None:
135
+ raise ValueError(f"Invalid Radius config from {gateway}")
136
+ return config
@@ -0,0 +1,23 @@
1
+ """Together 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__ = ["together_provider"]
12
+
13
+
14
+ def together_provider() -> Provider:
15
+ """Together (OpenAI-compatible Chat Completions), authenticated with ``TOGETHER_API_KEY``."""
16
+ return create_provider(
17
+ id="together",
18
+ name="Together",
19
+ base_url="https://api.together.ai/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Together API key", ["TOGETHER_API_KEY"])),
21
+ models=lambda: catalog_models("together"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,22 @@
1
+ """TypeSafe provider (System One classifiers)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import 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__ = ["typesafe_provider"]
12
+
13
+
14
+ def typesafe_provider() -> Provider:
15
+ """TypeSafe System One classifiers, authenticated with ``TYPESAFE_API_KEY``."""
16
+ return create_provider(
17
+ id="typesafe",
18
+ name="TypeSafe",
19
+ auth=ProviderAuth(api_key=env_api_key_auth("TypeSafe API key", ["TYPESAFE_API_KEY"])),
20
+ models=lambda: catalog_models("typesafe"),
21
+ classifiers={"typesafe-system-one": typesafe_system_one_api()},
22
+ )