pi-ai-python 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,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
|
+
)
|