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
pi_ai/models.py
ADDED
|
@@ -0,0 +1,1191 @@
|
|
|
1
|
+
"""The model runtime: providers, the ``Models`` collection, and model helpers.
|
|
2
|
+
|
|
3
|
+
A :class:`Provider` owns its model catalog, auth, and request behavior. A
|
|
4
|
+
:class:`Models` collection holds providers, resolves auth through the owning
|
|
5
|
+
provider and routes every request to it::
|
|
6
|
+
|
|
7
|
+
models = create_models()
|
|
8
|
+
models.set_provider(anthropic_provider())
|
|
9
|
+
model = models.get_model("anthropic", "claude-sonnet-4-5")
|
|
10
|
+
message = await models.complete(model, Context(messages=[UserMessage("Hi")]))
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import copy
|
|
17
|
+
import dataclasses
|
|
18
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .abort import (
|
|
23
|
+
AbortController,
|
|
24
|
+
AbortError,
|
|
25
|
+
AbortSignal,
|
|
26
|
+
operation_signal,
|
|
27
|
+
race_with_abort_signal,
|
|
28
|
+
)
|
|
29
|
+
from .api.lazy import lazy_stream
|
|
30
|
+
from .auth.context import default_provider_auth_context
|
|
31
|
+
from .auth.credential_store import InMemoryCredentialStore
|
|
32
|
+
from .auth.resolve import AuthResolutionOverrides, refresh_stored_oauth_credential, resolve_provider_auth
|
|
33
|
+
from .auth.types import (
|
|
34
|
+
AuthCheck,
|
|
35
|
+
AuthContext,
|
|
36
|
+
AuthInteraction,
|
|
37
|
+
AuthResult,
|
|
38
|
+
Credential,
|
|
39
|
+
CredentialStore,
|
|
40
|
+
LoginOptions,
|
|
41
|
+
ModelAuth,
|
|
42
|
+
ProviderAuth,
|
|
43
|
+
ProviderAuthInteraction,
|
|
44
|
+
)
|
|
45
|
+
from .event_stream import AssistantMessageEventStream
|
|
46
|
+
from .models_store import InMemoryModelsStore, ModelsStore, ModelsStoreEntry
|
|
47
|
+
from .types import (
|
|
48
|
+
AnyModel,
|
|
49
|
+
AssistantImages,
|
|
50
|
+
AssistantMessage,
|
|
51
|
+
ClassifierContext,
|
|
52
|
+
ClassifierModel,
|
|
53
|
+
ClassifierResult,
|
|
54
|
+
Context,
|
|
55
|
+
DeferredHandle,
|
|
56
|
+
ImageModel,
|
|
57
|
+
ImagesContext,
|
|
58
|
+
Model,
|
|
59
|
+
ModelThinkingLevel,
|
|
60
|
+
TranscriptContext,
|
|
61
|
+
Usage,
|
|
62
|
+
UsageCost,
|
|
63
|
+
now_ms,
|
|
64
|
+
)
|
|
65
|
+
from .utils.callbacks import maybe_await
|
|
66
|
+
from .utils.misc import merge_headers
|
|
67
|
+
from .utils.model_operations import (
|
|
68
|
+
assert_chat_model,
|
|
69
|
+
assert_classifier_input_supported,
|
|
70
|
+
assert_classifier_model,
|
|
71
|
+
assert_image_model,
|
|
72
|
+
classifier_error_result,
|
|
73
|
+
get_model_type,
|
|
74
|
+
image_error_result,
|
|
75
|
+
is_model_type,
|
|
76
|
+
)
|
|
77
|
+
from .utils.models_error import ModelsError, ModelsErrorCode
|
|
78
|
+
from .utils.transcript import normalize_context
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"ModelsError",
|
|
82
|
+
"ModelsErrorCode",
|
|
83
|
+
"get_model_type",
|
|
84
|
+
"is_model_type",
|
|
85
|
+
"UNCHANGED",
|
|
86
|
+
"ModelsPublication",
|
|
87
|
+
"RefreshModelsContext",
|
|
88
|
+
"ModelsRefreshResult",
|
|
89
|
+
"Provider",
|
|
90
|
+
"Models",
|
|
91
|
+
"MutableModels",
|
|
92
|
+
"create_models",
|
|
93
|
+
"create_provider",
|
|
94
|
+
"has_api",
|
|
95
|
+
"calculate_cost",
|
|
96
|
+
"get_supported_thinking_levels",
|
|
97
|
+
"clamp_thinking_level",
|
|
98
|
+
"models_are_equal",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
_KNOWN_MODEL_TYPES = frozenset({"chat", "image", "classifier"})
|
|
102
|
+
|
|
103
|
+
#: ``ModelsPublication.persist`` default: leave persisted storage unchanged.
|
|
104
|
+
UNCHANGED: Any = ...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _has_known_model_type(model: Any) -> bool:
|
|
108
|
+
return get_model_type(model) in _KNOWN_MODEL_TYPES
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(slots=True)
|
|
112
|
+
class ModelsPublication:
|
|
113
|
+
#: Provider-selected persisted catalog. ``UNCHANGED`` leaves storage as is; ``None`` deletes it.
|
|
114
|
+
persist: ModelsStoreEntry | None = UNCHANGED
|
|
115
|
+
#: Optional synchronous update of provider-private in-memory catalog state.
|
|
116
|
+
update: Callable[[], None] | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(slots=True)
|
|
120
|
+
class RefreshModelsContext:
|
|
121
|
+
#: Generation-checked publication; the update runs only after the persistence mutation.
|
|
122
|
+
publish: Callable[[ModelsPublication], Awaitable[bool]]
|
|
123
|
+
#: Always present, including when the public refresh caller omits a signal.
|
|
124
|
+
signal: AbortSignal
|
|
125
|
+
#: False during offline/cache-only initialization.
|
|
126
|
+
allow_network: bool = True
|
|
127
|
+
#: Effective configured credential. OAuth credentials are refreshed before network access.
|
|
128
|
+
credential: Credential | None = None
|
|
129
|
+
#: Provider-scoped catalog snapshot captured before this refresh phase.
|
|
130
|
+
stored: ModelsStoreEntry | None = None
|
|
131
|
+
#: Bypass provider freshness checks.
|
|
132
|
+
force: bool | None = None
|
|
133
|
+
#: Base URL of the registered provider.
|
|
134
|
+
base_url: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(slots=True)
|
|
138
|
+
class ModelsRefreshResult:
|
|
139
|
+
aborted: bool
|
|
140
|
+
errors: dict[str, Exception] = field(default_factory=dict)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Provider:
|
|
144
|
+
"""The concrete runtime unit: identity, auth, model listing, and operations.
|
|
145
|
+
|
|
146
|
+
Subclass it to implement a provider by hand, or use :func:`create_provider`.
|
|
147
|
+
Optional capabilities (``get_all_models``, ``refresh_models``, ``filter_models``,
|
|
148
|
+
``filter_all_models``, ``fetch_deferred``, ``cancel_deferred``, ``generate_images``,
|
|
149
|
+
``classify``) are ``None`` unless implemented.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
id: str
|
|
153
|
+
name: str
|
|
154
|
+
base_url: str | None = None
|
|
155
|
+
headers: dict[str, str | None] | None = None
|
|
156
|
+
auth: ProviderAuth
|
|
157
|
+
|
|
158
|
+
get_all_models: Callable[[], Sequence[AnyModel]] | None = None
|
|
159
|
+
refresh_models: Callable[[RefreshModelsContext], Awaitable[None]] | None = None
|
|
160
|
+
filter_models: Callable[[Sequence[Model], Credential | None], Sequence[Model]] | None = None
|
|
161
|
+
filter_all_models: Callable[[Sequence[AnyModel], Credential | None], Sequence[AnyModel]] | None = None
|
|
162
|
+
fetch_deferred: Callable[..., AssistantMessageEventStream] | None = None
|
|
163
|
+
cancel_deferred: Callable[..., Awaitable[None]] | None = None
|
|
164
|
+
generate_images: Callable[..., Awaitable[AssistantImages]] | None = None
|
|
165
|
+
classify: Callable[..., Awaitable[ClassifierResult]] | None = None
|
|
166
|
+
|
|
167
|
+
def get_models(self) -> Sequence[Model]:
|
|
168
|
+
"""Current known chat models. Must not raise."""
|
|
169
|
+
raise NotImplementedError
|
|
170
|
+
|
|
171
|
+
def stream(
|
|
172
|
+
self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
173
|
+
) -> AssistantMessageEventStream:
|
|
174
|
+
raise NotImplementedError
|
|
175
|
+
|
|
176
|
+
def stream_simple(
|
|
177
|
+
self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
178
|
+
) -> AssistantMessageEventStream:
|
|
179
|
+
raise NotImplementedError
|
|
180
|
+
|
|
181
|
+
def find_model(self, type: str, model_id: str) -> AnyModel | None:
|
|
182
|
+
"""Look up a model by type and id (providers may override with an index)."""
|
|
183
|
+
# Chat reads stay independent of get_all_models, which may fail on its own.
|
|
184
|
+
source = self.get_all_models() if type != "chat" and self.get_all_models is not None else self.get_models()
|
|
185
|
+
for model in source:
|
|
186
|
+
if model.id == model_id and get_model_type(model) == type:
|
|
187
|
+
return model
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
return f"{type(self).__name__}(id={self.id!r})"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _merge_options(options: Mapping[str, Any] | None, kwargs: Mapping[str, Any]) -> dict[str, Any]:
|
|
195
|
+
if options is None:
|
|
196
|
+
return dict(kwargs)
|
|
197
|
+
if not kwargs:
|
|
198
|
+
return dict(options)
|
|
199
|
+
return {**options, **kwargs}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _safe_models(provider: Provider, all_types: bool) -> Sequence[AnyModel]:
|
|
203
|
+
try:
|
|
204
|
+
if all_types and provider.get_all_models is not None:
|
|
205
|
+
return provider.get_all_models()
|
|
206
|
+
return provider.get_models()
|
|
207
|
+
except Exception:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Models:
|
|
212
|
+
"""Runtime collection of providers plus auth application and request convenience.
|
|
213
|
+
|
|
214
|
+
Reads are synchronous and return last-known lists: the unqualified accessors
|
|
215
|
+
(``get_models``, ``get_model``, ``get_available``) return chat models, the
|
|
216
|
+
``*_of_type`` accessors one model type, and ``get_all_models``/``get_all_available``
|
|
217
|
+
every type.
|
|
218
|
+
|
|
219
|
+
Request methods accept options as a mapping and/or keyword arguments::
|
|
220
|
+
|
|
221
|
+
await models.complete(model, context, api_key="sk-...", max_tokens=512)
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
*,
|
|
227
|
+
credentials: CredentialStore | None = None,
|
|
228
|
+
models_store: ModelsStore | None = None,
|
|
229
|
+
auth_context: AuthContext | None = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
self._providers: dict[str, Provider] = {}
|
|
232
|
+
self._credentials: CredentialStore = credentials or InMemoryCredentialStore()
|
|
233
|
+
self._models_store: ModelsStore = models_store or InMemoryModelsStore()
|
|
234
|
+
self._auth_context: AuthContext = auth_context or default_provider_auth_context()
|
|
235
|
+
self._refresh_generations: dict[str, int] = {}
|
|
236
|
+
self._refresh_controllers: dict[str, AbortController] = {}
|
|
237
|
+
self._publication_locks: dict[str, asyncio.Lock] = {}
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def credentials(self) -> CredentialStore:
|
|
241
|
+
return self._credentials
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def models_store(self) -> ModelsStore:
|
|
245
|
+
return self._models_store
|
|
246
|
+
|
|
247
|
+
# -- providers ----------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def set_provider(self, provider: Provider) -> None:
|
|
250
|
+
"""Upsert/replace by ``provider.id``."""
|
|
251
|
+
self._supersede_provider_refresh(provider.id)
|
|
252
|
+
self._providers[provider.id] = provider
|
|
253
|
+
|
|
254
|
+
def delete_provider(self, provider_id: str) -> None:
|
|
255
|
+
self._supersede_provider_refresh(provider_id)
|
|
256
|
+
self._providers.pop(provider_id, None)
|
|
257
|
+
|
|
258
|
+
def clear_providers(self) -> None:
|
|
259
|
+
for provider_id in {*self._providers, *self._refresh_controllers}:
|
|
260
|
+
self._supersede_provider_refresh(provider_id)
|
|
261
|
+
self._providers.clear()
|
|
262
|
+
|
|
263
|
+
def get_providers(self) -> list[Provider]:
|
|
264
|
+
return list(self._providers.values())
|
|
265
|
+
|
|
266
|
+
def get_provider(self, provider_id: str) -> Provider | None:
|
|
267
|
+
return self._providers.get(provider_id)
|
|
268
|
+
|
|
269
|
+
# -- sync reads ---------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def get_models(self, provider: str | None = None) -> list[Model]:
|
|
272
|
+
"""Last-known chat models from one provider or all providers (best-effort)."""
|
|
273
|
+
if provider is not None:
|
|
274
|
+
entry = self._providers.get(provider)
|
|
275
|
+
return list(_safe_models(entry, False)) if entry is not None else [] # type: ignore[arg-type]
|
|
276
|
+
models: list[Model] = []
|
|
277
|
+
for entry in self._providers.values():
|
|
278
|
+
models.extend(_safe_models(entry, False)) # type: ignore[arg-type]
|
|
279
|
+
return models
|
|
280
|
+
|
|
281
|
+
def get_all_models(self, provider: str | None = None) -> list[AnyModel]:
|
|
282
|
+
if provider is not None:
|
|
283
|
+
entry = self._providers.get(provider)
|
|
284
|
+
return list(_safe_models(entry, True)) if entry is not None else []
|
|
285
|
+
models: list[AnyModel] = []
|
|
286
|
+
for entry in self._providers.values():
|
|
287
|
+
models.extend(_safe_models(entry, True))
|
|
288
|
+
return models
|
|
289
|
+
|
|
290
|
+
def get_models_of_type(self, type: str, provider: str | None = None) -> list[AnyModel]:
|
|
291
|
+
return [model for model in self.get_all_models(provider) if get_model_type(model) == type]
|
|
292
|
+
|
|
293
|
+
def get_model(self, provider: str, model_id: str) -> Model | None:
|
|
294
|
+
"""Look up a chat model by provider and id."""
|
|
295
|
+
return self.get_model_of_type("chat", provider, model_id) # type: ignore[return-value]
|
|
296
|
+
|
|
297
|
+
def get_model_of_type(self, type: str, provider: str, model_id: str) -> AnyModel | None:
|
|
298
|
+
entry = self._providers.get(provider)
|
|
299
|
+
if entry is None:
|
|
300
|
+
return None
|
|
301
|
+
try:
|
|
302
|
+
return entry.find_model(type, model_id)
|
|
303
|
+
except Exception:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
# -- dynamic catalogs -----------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def _supersede_provider_refresh(self, provider_id: str) -> int:
|
|
309
|
+
generation = self._refresh_generations.get(provider_id, 0) + 1
|
|
310
|
+
self._refresh_generations[provider_id] = generation
|
|
311
|
+
previous = self._refresh_controllers.pop(provider_id, None)
|
|
312
|
+
if previous is not None:
|
|
313
|
+
previous.abort()
|
|
314
|
+
return generation
|
|
315
|
+
|
|
316
|
+
def _begin_provider_refresh(self, provider_id: str) -> tuple[int, AbortController]:
|
|
317
|
+
generation = self._supersede_provider_refresh(provider_id)
|
|
318
|
+
controller = AbortController()
|
|
319
|
+
self._refresh_controllers[provider_id] = controller
|
|
320
|
+
return generation, controller
|
|
321
|
+
|
|
322
|
+
async def _publish(
|
|
323
|
+
self, provider_id: str, generation: int, signal: AbortSignal, publication: ModelsPublication
|
|
324
|
+
) -> bool:
|
|
325
|
+
lock = self._publication_locks.get(provider_id)
|
|
326
|
+
if lock is None:
|
|
327
|
+
lock = asyncio.Lock()
|
|
328
|
+
self._publication_locks[provider_id] = lock
|
|
329
|
+
|
|
330
|
+
async def queued() -> bool:
|
|
331
|
+
async with lock:
|
|
332
|
+
if signal.aborted or self._refresh_generations.get(provider_id) != generation:
|
|
333
|
+
return False
|
|
334
|
+
if publication.persist is None:
|
|
335
|
+
await self._models_store.delete(provider_id, signal=signal)
|
|
336
|
+
elif publication.persist is not UNCHANGED:
|
|
337
|
+
await self._models_store.write(provider_id, copy.deepcopy(publication.persist), signal=signal)
|
|
338
|
+
if signal.aborted or self._refresh_generations.get(provider_id) != generation:
|
|
339
|
+
return False
|
|
340
|
+
if publication.update is not None:
|
|
341
|
+
publication.update()
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
return await race_with_abort_signal(queued(), signal)
|
|
345
|
+
|
|
346
|
+
async def _run_refresh_phase(
|
|
347
|
+
self,
|
|
348
|
+
provider: Provider,
|
|
349
|
+
credential: Credential | None,
|
|
350
|
+
allow_network: bool,
|
|
351
|
+
force: bool | None,
|
|
352
|
+
generation: int,
|
|
353
|
+
signal: AbortSignal,
|
|
354
|
+
) -> None:
|
|
355
|
+
stored = await self._models_store.read(provider.id, signal=signal)
|
|
356
|
+
if stored is not None:
|
|
357
|
+
stored = dataclasses.replace(stored, models=[m for m in stored.models if _has_known_model_type(m)])
|
|
358
|
+
|
|
359
|
+
async def publish(publication: ModelsPublication) -> bool:
|
|
360
|
+
return await self._publish(provider.id, generation, signal, publication)
|
|
361
|
+
|
|
362
|
+
assert provider.refresh_models is not None
|
|
363
|
+
await provider.refresh_models(
|
|
364
|
+
RefreshModelsContext(
|
|
365
|
+
publish=publish,
|
|
366
|
+
signal=signal,
|
|
367
|
+
allow_network=allow_network,
|
|
368
|
+
credential=credential,
|
|
369
|
+
stored=stored,
|
|
370
|
+
force=force if allow_network else None,
|
|
371
|
+
base_url=provider.base_url,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
async def refresh(
|
|
376
|
+
self,
|
|
377
|
+
*,
|
|
378
|
+
allow_network: bool = True,
|
|
379
|
+
providers: Iterable[str] | None = None,
|
|
380
|
+
force: bool | None = None,
|
|
381
|
+
signal: AbortSignal | None = None,
|
|
382
|
+
) -> ModelsRefreshResult:
|
|
383
|
+
"""Refresh configured dynamic providers concurrently (all when ``providers`` is omitted).
|
|
384
|
+
|
|
385
|
+
Provider errors and cancellation are returned, never raised; static, unknown
|
|
386
|
+
and unconfigured providers are skipped.
|
|
387
|
+
"""
|
|
388
|
+
caller_signal = operation_signal(signal)
|
|
389
|
+
errors: dict[str, Exception] = {}
|
|
390
|
+
if caller_signal.aborted:
|
|
391
|
+
return ModelsRefreshResult(True, errors)
|
|
392
|
+
selected = set(providers) if providers is not None else None
|
|
393
|
+
refreshable = [
|
|
394
|
+
provider
|
|
395
|
+
for provider in self._providers.values()
|
|
396
|
+
if provider.refresh_models is not None and (selected is None or provider.id in selected)
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
async def refresh_one(provider: Provider) -> None:
|
|
400
|
+
generation, controller = self._begin_provider_refresh(provider.id)
|
|
401
|
+
provider_signal = AbortSignal.any([caller_signal, controller.signal])
|
|
402
|
+
|
|
403
|
+
async def operation() -> None:
|
|
404
|
+
stored_credential: Credential | None = None
|
|
405
|
+
credential_error: Exception | None = None
|
|
406
|
+
try:
|
|
407
|
+
stored_credential = await self._read_credential(provider.id, provider_signal)
|
|
408
|
+
except Exception as error:
|
|
409
|
+
credential_error = error
|
|
410
|
+
# Restore cached provider state before auth resolution or network access.
|
|
411
|
+
await self._run_refresh_phase(provider, stored_credential, False, None, generation, provider_signal)
|
|
412
|
+
if credential_error is not None:
|
|
413
|
+
raise credential_error
|
|
414
|
+
if not allow_network or provider_signal.aborted:
|
|
415
|
+
return
|
|
416
|
+
credential = await self._resolve_refresh_credential(provider, stored_credential, provider_signal)
|
|
417
|
+
if credential is None:
|
|
418
|
+
return
|
|
419
|
+
await self._run_refresh_phase(provider, credential, True, force, generation, provider_signal)
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
await race_with_abort_signal(operation(), provider_signal)
|
|
423
|
+
except Exception as error:
|
|
424
|
+
if not provider_signal.aborted:
|
|
425
|
+
errors[provider.id] = error
|
|
426
|
+
finally:
|
|
427
|
+
if self._refresh_controllers.get(provider.id) is controller:
|
|
428
|
+
del self._refresh_controllers[provider.id]
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
await race_with_abort_signal(asyncio.gather(*(refresh_one(p) for p in refreshable)), caller_signal)
|
|
432
|
+
except AbortError:
|
|
433
|
+
if not caller_signal.aborted:
|
|
434
|
+
raise
|
|
435
|
+
return ModelsRefreshResult(caller_signal.aborted, dict(errors))
|
|
436
|
+
|
|
437
|
+
async def _resolve_refresh_credential(
|
|
438
|
+
self, provider: Provider, stored: Credential | None, signal: AbortSignal
|
|
439
|
+
) -> Credential | None:
|
|
440
|
+
if stored is not None and stored.get("type") == "oauth":
|
|
441
|
+
oauth = provider.auth.oauth
|
|
442
|
+
if oauth is None:
|
|
443
|
+
return None
|
|
444
|
+
if now_ms() < int(stored.get("expires") or 0):
|
|
445
|
+
return stored
|
|
446
|
+
if signal.aborted:
|
|
447
|
+
return None
|
|
448
|
+
return await refresh_stored_oauth_credential(
|
|
449
|
+
self._credentials,
|
|
450
|
+
provider.id,
|
|
451
|
+
oauth,
|
|
452
|
+
lambda current: now_ms() >= int(current.get("expires") or 0),
|
|
453
|
+
signal,
|
|
454
|
+
)
|
|
455
|
+
api_key = provider.auth.api_key
|
|
456
|
+
if api_key is None:
|
|
457
|
+
return None
|
|
458
|
+
credential = stored if stored is not None and stored.get("type") == "api_key" else None
|
|
459
|
+
result = await api_key.resolve(ctx=self._auth_context, credential=credential, signal=signal)
|
|
460
|
+
if result is None:
|
|
461
|
+
return None
|
|
462
|
+
resolved: Credential = {"type": "api_key"}
|
|
463
|
+
if result.auth.api_key is not None:
|
|
464
|
+
resolved["key"] = result.auth.api_key
|
|
465
|
+
if result.env:
|
|
466
|
+
resolved["env"] = result.env
|
|
467
|
+
return resolved
|
|
468
|
+
|
|
469
|
+
# -- auth -----------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
async def _read_credential(self, provider_id: str, signal: AbortSignal) -> Credential | None:
|
|
472
|
+
try:
|
|
473
|
+
return await self._credentials.read(provider_id, signal=signal)
|
|
474
|
+
except AbortError:
|
|
475
|
+
raise
|
|
476
|
+
except Exception as error:
|
|
477
|
+
raise ModelsError("auth", f"Credential store read failed for {provider_id}", cause=error) from error
|
|
478
|
+
|
|
479
|
+
async def _check_provider_auth(
|
|
480
|
+
self, provider: Provider, credential: Credential | None, signal: AbortSignal
|
|
481
|
+
) -> AuthCheck | None:
|
|
482
|
+
if credential is not None and credential.get("type") == "oauth":
|
|
483
|
+
return AuthCheck("oauth", "OAuth") if provider.auth.oauth is not None else None
|
|
484
|
+
api_key = provider.auth.api_key
|
|
485
|
+
if api_key is None:
|
|
486
|
+
return None
|
|
487
|
+
if api_key.check is not None:
|
|
488
|
+
try:
|
|
489
|
+
return await api_key.check(
|
|
490
|
+
ctx=self._auth_context,
|
|
491
|
+
credential=credential if credential is not None and credential.get("type") == "api_key" else None,
|
|
492
|
+
signal=signal,
|
|
493
|
+
)
|
|
494
|
+
except AbortError:
|
|
495
|
+
raise
|
|
496
|
+
except Exception as error:
|
|
497
|
+
raise ModelsError(
|
|
498
|
+
"auth", f"API key auth check failed for provider {provider.id}", cause=error
|
|
499
|
+
) from error
|
|
500
|
+
resolution = await resolve_provider_auth(
|
|
501
|
+
provider, self._credentials, self._auth_context, AuthResolutionOverrides(signal=signal)
|
|
502
|
+
)
|
|
503
|
+
return AuthCheck("api_key", resolution.source) if resolution is not None else None
|
|
504
|
+
|
|
505
|
+
async def check_auth(self, provider_id: str, *, signal: AbortSignal | None = None) -> AuthCheck | None:
|
|
506
|
+
"""Whether a provider has complete auth configuration (without refreshing OAuth)."""
|
|
507
|
+
operation = operation_signal(signal)
|
|
508
|
+
|
|
509
|
+
async def check() -> AuthCheck | None:
|
|
510
|
+
operation.throw_if_aborted()
|
|
511
|
+
provider = self._providers.get(provider_id)
|
|
512
|
+
if provider is None:
|
|
513
|
+
return None
|
|
514
|
+
return await self._check_provider_auth(
|
|
515
|
+
provider, await self._read_credential(provider_id, operation), operation
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return await race_with_abort_signal(check(), operation)
|
|
519
|
+
|
|
520
|
+
async def _authenticated_providers(
|
|
521
|
+
self, provider_id: str | None, signal: AbortSignal
|
|
522
|
+
) -> list[tuple[Provider, Credential | None]]:
|
|
523
|
+
signal.throw_if_aborted()
|
|
524
|
+
if provider_id is not None:
|
|
525
|
+
providers = [p for p in (self._providers.get(provider_id),) if p is not None]
|
|
526
|
+
else:
|
|
527
|
+
providers = self.get_providers()
|
|
528
|
+
|
|
529
|
+
async def check(provider: Provider) -> tuple[Provider, Credential | None, AuthCheck | None]:
|
|
530
|
+
credential = await self._read_credential(provider.id, signal)
|
|
531
|
+
return provider, credential, await self._check_provider_auth(provider, credential, signal)
|
|
532
|
+
|
|
533
|
+
checks = await asyncio.gather(*(check(provider) for provider in providers))
|
|
534
|
+
return [(provider, credential) for provider, credential, auth in checks if auth is not None]
|
|
535
|
+
|
|
536
|
+
async def get_available(self, provider_id: str | None = None, *, signal: AbortSignal | None = None) -> list[Model]:
|
|
537
|
+
"""Chat models whose providers have complete auth configuration."""
|
|
538
|
+
operation = operation_signal(signal)
|
|
539
|
+
|
|
540
|
+
async def available() -> list[Model]:
|
|
541
|
+
result: list[Model] = []
|
|
542
|
+
for provider, credential in await self._authenticated_providers(provider_id, operation):
|
|
543
|
+
models = list(_safe_models(provider, False))
|
|
544
|
+
if provider.filter_models is not None:
|
|
545
|
+
models = list(provider.filter_models(models, credential)) # type: ignore[arg-type]
|
|
546
|
+
result.extend(models) # type: ignore[arg-type]
|
|
547
|
+
return result
|
|
548
|
+
|
|
549
|
+
return await race_with_abort_signal(available(), operation)
|
|
550
|
+
|
|
551
|
+
async def get_available_of_type(
|
|
552
|
+
self, type: str, provider_id: str | None = None, *, signal: AbortSignal | None = None
|
|
553
|
+
) -> list[AnyModel]:
|
|
554
|
+
return [
|
|
555
|
+
model for model in await self.get_all_available(provider_id, signal=signal) if get_model_type(model) == type
|
|
556
|
+
]
|
|
557
|
+
|
|
558
|
+
async def get_all_available(
|
|
559
|
+
self, provider_id: str | None = None, *, signal: AbortSignal | None = None
|
|
560
|
+
) -> list[AnyModel]:
|
|
561
|
+
operation = operation_signal(signal)
|
|
562
|
+
|
|
563
|
+
async def available() -> list[AnyModel]:
|
|
564
|
+
result: list[AnyModel] = []
|
|
565
|
+
for provider, credential in await self._authenticated_providers(provider_id, operation):
|
|
566
|
+
models = list(_safe_models(provider, True))
|
|
567
|
+
if provider.filter_all_models is not None:
|
|
568
|
+
result.extend(provider.filter_all_models(models, credential))
|
|
569
|
+
continue
|
|
570
|
+
if provider.filter_models is None:
|
|
571
|
+
result.extend(models)
|
|
572
|
+
continue
|
|
573
|
+
chat_ids = {
|
|
574
|
+
model.id
|
|
575
|
+
for model in provider.filter_models(list(_safe_models(provider, False)), credential) # type: ignore[arg-type]
|
|
576
|
+
}
|
|
577
|
+
result.extend(model for model in models if not is_model_type(model, "chat") or model.id in chat_ids)
|
|
578
|
+
return result
|
|
579
|
+
|
|
580
|
+
return await race_with_abort_signal(available(), operation)
|
|
581
|
+
|
|
582
|
+
async def get_auth(
|
|
583
|
+
self,
|
|
584
|
+
provider_or_model: str | AnyModel,
|
|
585
|
+
*,
|
|
586
|
+
api_key: str | None = None,
|
|
587
|
+
env: Mapping[str, str] | None = None,
|
|
588
|
+
min_oauth_validity_ms: int | None = None,
|
|
589
|
+
signal: AbortSignal | None = None,
|
|
590
|
+
) -> AuthResult | None:
|
|
591
|
+
"""Resolve provider-scoped auth (plus static ``model.headers`` when given a model).
|
|
592
|
+
|
|
593
|
+
Returns ``None`` when the provider is unknown or unconfigured. Raises
|
|
594
|
+
:class:`ModelsError` with code ``"oauth"`` when a token refresh fails and
|
|
595
|
+
``"auth"`` when key resolution or the credential store fails.
|
|
596
|
+
"""
|
|
597
|
+
operation = operation_signal(signal)
|
|
598
|
+
provider_id = provider_or_model if isinstance(provider_or_model, str) else provider_or_model.provider
|
|
599
|
+
provider = self._providers.get(provider_id)
|
|
600
|
+
if provider is None:
|
|
601
|
+
return None
|
|
602
|
+
result = await resolve_provider_auth(
|
|
603
|
+
provider,
|
|
604
|
+
self._credentials,
|
|
605
|
+
self._auth_context,
|
|
606
|
+
AuthResolutionOverrides(
|
|
607
|
+
api_key=api_key,
|
|
608
|
+
env=dict(env) if env else None,
|
|
609
|
+
min_oauth_validity_ms=min_oauth_validity_ms,
|
|
610
|
+
signal=operation,
|
|
611
|
+
),
|
|
612
|
+
)
|
|
613
|
+
if result is None or isinstance(provider_or_model, str) or not provider_or_model.headers:
|
|
614
|
+
return result
|
|
615
|
+
return AuthResult(
|
|
616
|
+
ModelAuth(
|
|
617
|
+
api_key=result.auth.api_key,
|
|
618
|
+
headers=merge_headers(result.auth.headers, provider_or_model.headers),
|
|
619
|
+
base_url=result.auth.base_url,
|
|
620
|
+
),
|
|
621
|
+
env=result.env,
|
|
622
|
+
source=result.source,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
async def login(
|
|
626
|
+
self,
|
|
627
|
+
provider_id: str,
|
|
628
|
+
type: str,
|
|
629
|
+
interaction: AuthInteraction,
|
|
630
|
+
options: LoginOptions | None = None,
|
|
631
|
+
) -> Credential:
|
|
632
|
+
"""Run a provider-owned login flow and persist the returned credential."""
|
|
633
|
+
signal = operation_signal(getattr(interaction, "signal", None))
|
|
634
|
+
signal.throw_if_aborted()
|
|
635
|
+
provider = self._providers.get(provider_id)
|
|
636
|
+
if provider is None:
|
|
637
|
+
raise ModelsError("provider", f"Unknown provider: {provider_id}")
|
|
638
|
+
method: Any = provider.auth.oauth if type == "oauth" else provider.auth.api_key
|
|
639
|
+
login = getattr(method, "login", None) if method is not None else None
|
|
640
|
+
if login is None:
|
|
641
|
+
raise ModelsError("auth", f"{provider.name} does not support {type} login")
|
|
642
|
+
provider_interaction = ProviderAuthInteraction(interaction, signal)
|
|
643
|
+
operation = login(provider_interaction, options) if type == "oauth" else login(provider_interaction)
|
|
644
|
+
credential: Credential = await race_with_abort_signal(operation, signal)
|
|
645
|
+
credential = {"type": type, **{k: v for k, v in credential.items() if k != "type"}}
|
|
646
|
+
|
|
647
|
+
started = asyncio.Event()
|
|
648
|
+
|
|
649
|
+
async def write(_current: Credential | None) -> Credential | None:
|
|
650
|
+
started.set()
|
|
651
|
+
return credential
|
|
652
|
+
|
|
653
|
+
loop = asyncio.get_running_loop()
|
|
654
|
+
mutation = asyncio.ensure_future(self._credentials.modify(provider_id, write, signal=signal))
|
|
655
|
+
started_wait = asyncio.ensure_future(started.wait())
|
|
656
|
+
abort_wait: asyncio.Future[None] = loop.create_future()
|
|
657
|
+
remove = signal.add_listener(
|
|
658
|
+
lambda: loop.call_soon_threadsafe(lambda: abort_wait.done() or abort_wait.set_result(None))
|
|
659
|
+
)
|
|
660
|
+
try:
|
|
661
|
+
done, _ = await asyncio.wait({mutation, started_wait, abort_wait}, return_when=asyncio.FIRST_COMPLETED)
|
|
662
|
+
if abort_wait in done and not started.is_set() and not mutation.done():
|
|
663
|
+
mutation.add_done_callback(lambda task: task.cancelled() or task.exception())
|
|
664
|
+
signal.throw_if_aborted()
|
|
665
|
+
await mutation
|
|
666
|
+
except AbortError:
|
|
667
|
+
raise
|
|
668
|
+
except Exception as error:
|
|
669
|
+
signal.throw_if_aborted()
|
|
670
|
+
raise ModelsError("auth", f"Credential store modify failed for {provider_id}", cause=error) from error
|
|
671
|
+
finally:
|
|
672
|
+
remove()
|
|
673
|
+
started_wait.cancel()
|
|
674
|
+
if not abort_wait.done():
|
|
675
|
+
abort_wait.cancel()
|
|
676
|
+
return credential
|
|
677
|
+
|
|
678
|
+
async def logout(self, provider_id: str, *, signal: AbortSignal | None = None) -> None:
|
|
679
|
+
"""Remove the stored credential for a provider."""
|
|
680
|
+
operation = operation_signal(signal)
|
|
681
|
+
operation.throw_if_aborted()
|
|
682
|
+
try:
|
|
683
|
+
await self._credentials.delete(provider_id, signal=operation)
|
|
684
|
+
except AbortError:
|
|
685
|
+
raise
|
|
686
|
+
except Exception as error:
|
|
687
|
+
operation.throw_if_aborted()
|
|
688
|
+
raise ModelsError("auth", f"Credential store delete failed for {provider_id}", cause=error) from error
|
|
689
|
+
|
|
690
|
+
# -- requests ---------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
def _require_provider(self, model: AnyModel) -> Provider:
|
|
693
|
+
provider = self._providers.get(model.provider)
|
|
694
|
+
if provider is None:
|
|
695
|
+
raise ModelsError("provider", f"Unknown provider: {model.provider}")
|
|
696
|
+
return provider
|
|
697
|
+
|
|
698
|
+
def _require_chat_provider(self, model: Model) -> Provider:
|
|
699
|
+
assert_chat_model(model)
|
|
700
|
+
return self._require_provider(model)
|
|
701
|
+
|
|
702
|
+
async def _apply_auth(self, model: Any, options: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
|
|
703
|
+
self._require_provider(model)
|
|
704
|
+
resolution = await self.get_auth(
|
|
705
|
+
model, api_key=options.get("api_key"), env=options.get("env"), signal=options.get("signal")
|
|
706
|
+
)
|
|
707
|
+
if resolution is None:
|
|
708
|
+
raise ModelsError("auth", f"Provider is not configured: {model.provider}")
|
|
709
|
+
auth = resolution.auth
|
|
710
|
+
explicit_key = options.get("api_key")
|
|
711
|
+
api_key = explicit_key if explicit_key is not None else auth.api_key
|
|
712
|
+
headers = merge_headers(auth.headers, options.get("headers"))
|
|
713
|
+
transform = options.get("transform_headers")
|
|
714
|
+
if transform is not None:
|
|
715
|
+
headers = await maybe_await(transform(dict(headers or {})))
|
|
716
|
+
env = {**(resolution.env or {}), **(options.get("env") or {})} if resolution.env or options.get("env") else None
|
|
717
|
+
request_model = dataclasses.replace(model, base_url=auth.base_url) if auth.base_url else model
|
|
718
|
+
request_options = {key: value for key, value in options.items() if key != "transform_headers"}
|
|
719
|
+
if api_key is not None:
|
|
720
|
+
request_options["api_key"] = api_key
|
|
721
|
+
else:
|
|
722
|
+
request_options.pop("api_key", None)
|
|
723
|
+
if headers is not None:
|
|
724
|
+
request_options["headers"] = headers
|
|
725
|
+
if env is not None:
|
|
726
|
+
request_options["env"] = env
|
|
727
|
+
return request_model, request_options
|
|
728
|
+
|
|
729
|
+
def stream(
|
|
730
|
+
self,
|
|
731
|
+
model: Model,
|
|
732
|
+
context: Context | Mapping[str, Any] | TranscriptContext,
|
|
733
|
+
options: Mapping[str, Any] | None = None,
|
|
734
|
+
/,
|
|
735
|
+
**kwargs: Any,
|
|
736
|
+
) -> AssistantMessageEventStream:
|
|
737
|
+
"""Stream a response with the model API's full option set.
|
|
738
|
+
|
|
739
|
+
Request failures never raise: they end the stream with an ``error`` event and
|
|
740
|
+
an assistant message whose ``stop_reason`` is ``"error"`` or ``"aborted"``.
|
|
741
|
+
"""
|
|
742
|
+
transcript = normalize_context(context)
|
|
743
|
+
opts = _merge_options(options, kwargs)
|
|
744
|
+
|
|
745
|
+
async def setup() -> AssistantMessageEventStream:
|
|
746
|
+
provider = self._require_chat_provider(model)
|
|
747
|
+
request_model, request_options = await self._apply_auth(model, opts)
|
|
748
|
+
return provider.stream(request_model, transcript, request_options)
|
|
749
|
+
|
|
750
|
+
return lazy_stream(model, setup)
|
|
751
|
+
|
|
752
|
+
async def complete(
|
|
753
|
+
self,
|
|
754
|
+
model: Model,
|
|
755
|
+
context: Context | Mapping[str, Any] | TranscriptContext,
|
|
756
|
+
options: Mapping[str, Any] | None = None,
|
|
757
|
+
/,
|
|
758
|
+
**kwargs: Any,
|
|
759
|
+
) -> AssistantMessage:
|
|
760
|
+
"""Like :meth:`stream` but returns only the final assistant message."""
|
|
761
|
+
return await self.stream(model, context, options, **kwargs).result()
|
|
762
|
+
|
|
763
|
+
def stream_simple(
|
|
764
|
+
self,
|
|
765
|
+
model: Model,
|
|
766
|
+
context: Context | Mapping[str, Any] | TranscriptContext,
|
|
767
|
+
options: Mapping[str, Any] | None = None,
|
|
768
|
+
/,
|
|
769
|
+
**kwargs: Any,
|
|
770
|
+
) -> AssistantMessageEventStream:
|
|
771
|
+
"""Stream with provider-neutral options (``reasoning="medium"``, ``tool_choice``...)."""
|
|
772
|
+
transcript = normalize_context(context)
|
|
773
|
+
opts = _merge_options(options, kwargs)
|
|
774
|
+
|
|
775
|
+
async def setup() -> AssistantMessageEventStream:
|
|
776
|
+
provider = self._require_chat_provider(model)
|
|
777
|
+
request_model, request_options = await self._apply_auth(model, opts)
|
|
778
|
+
return provider.stream_simple(request_model, transcript, request_options)
|
|
779
|
+
|
|
780
|
+
return lazy_stream(model, setup)
|
|
781
|
+
|
|
782
|
+
async def complete_simple(
|
|
783
|
+
self,
|
|
784
|
+
model: Model,
|
|
785
|
+
context: Context | Mapping[str, Any] | TranscriptContext,
|
|
786
|
+
options: Mapping[str, Any] | None = None,
|
|
787
|
+
/,
|
|
788
|
+
**kwargs: Any,
|
|
789
|
+
) -> AssistantMessage:
|
|
790
|
+
return await self.stream_simple(model, context, options, **kwargs).result()
|
|
791
|
+
|
|
792
|
+
def stream_deferred(
|
|
793
|
+
self, model: Model, handle: DeferredHandle, options: Mapping[str, Any] | None = None, /, **kwargs: Any
|
|
794
|
+
) -> AssistantMessageEventStream:
|
|
795
|
+
opts = _merge_options(options, kwargs)
|
|
796
|
+
|
|
797
|
+
async def setup() -> AssistantMessageEventStream:
|
|
798
|
+
provider = self._require_chat_provider(model)
|
|
799
|
+
if provider.fetch_deferred is None:
|
|
800
|
+
raise ModelsError("provider", f"Provider {model.provider} does not support deferred responses")
|
|
801
|
+
request_model, request_options = await self._apply_auth(model, opts)
|
|
802
|
+
return provider.fetch_deferred(request_model, handle, request_options)
|
|
803
|
+
|
|
804
|
+
return lazy_stream(model, setup)
|
|
805
|
+
|
|
806
|
+
async def fetch_deferred(
|
|
807
|
+
self, model: Model, handle: DeferredHandle, options: Mapping[str, Any] | None = None, /, **kwargs: Any
|
|
808
|
+
) -> AssistantMessage:
|
|
809
|
+
return await self.stream_deferred(model, handle, options, **kwargs).result()
|
|
810
|
+
|
|
811
|
+
async def cancel_deferred(
|
|
812
|
+
self, model: Model, handle: DeferredHandle, options: Mapping[str, Any] | None = None, /, **kwargs: Any
|
|
813
|
+
) -> None:
|
|
814
|
+
provider = self._require_chat_provider(model)
|
|
815
|
+
if provider.cancel_deferred is None:
|
|
816
|
+
raise ModelsError("provider", f"Provider {model.provider} does not support deferred responses")
|
|
817
|
+
request_model, request_options = await self._apply_auth(model, _merge_options(options, kwargs))
|
|
818
|
+
await provider.cancel_deferred(request_model, handle, request_options)
|
|
819
|
+
|
|
820
|
+
async def generate_images(
|
|
821
|
+
self, model: ImageModel, context: ImagesContext, options: Mapping[str, Any] | None = None, /, **kwargs: Any
|
|
822
|
+
) -> AssistantImages:
|
|
823
|
+
"""Generate images through the owning provider. Never raises."""
|
|
824
|
+
opts = _merge_options(options, kwargs)
|
|
825
|
+
try:
|
|
826
|
+
assert_image_model(model)
|
|
827
|
+
provider = self._require_provider(model)
|
|
828
|
+
if provider.generate_images is None:
|
|
829
|
+
raise ModelsError("provider", f"Provider {model.provider} does not support image generation")
|
|
830
|
+
request_model, request_options = await self._apply_auth(model, opts)
|
|
831
|
+
return await provider.generate_images(request_model, context, request_options)
|
|
832
|
+
except Exception as error:
|
|
833
|
+
signal = opts.get("signal")
|
|
834
|
+
return image_error_result(model, error, bool(signal is not None and signal.aborted))
|
|
835
|
+
|
|
836
|
+
async def classify(
|
|
837
|
+
self,
|
|
838
|
+
model: ClassifierModel,
|
|
839
|
+
context: ClassifierContext,
|
|
840
|
+
options: Mapping[str, Any] | None = None,
|
|
841
|
+
/,
|
|
842
|
+
**kwargs: Any,
|
|
843
|
+
) -> ClassifierResult:
|
|
844
|
+
"""Classify structured state through the owning provider. Never raises."""
|
|
845
|
+
opts = _merge_options(options, kwargs)
|
|
846
|
+
try:
|
|
847
|
+
assert_classifier_model(model)
|
|
848
|
+
assert_classifier_input_supported(model, context)
|
|
849
|
+
provider = self._require_provider(model)
|
|
850
|
+
if provider.classify is None:
|
|
851
|
+
raise ModelsError("provider", f"Provider {model.provider} does not support classification")
|
|
852
|
+
request_model, request_options = await self._apply_auth(model, opts)
|
|
853
|
+
return await provider.classify(request_model, context, request_options)
|
|
854
|
+
except Exception as error:
|
|
855
|
+
signal = opts.get("signal")
|
|
856
|
+
return classifier_error_result(model, error, bool(signal is not None and signal.aborted))
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
#: ``Models`` is mutable in Python (``set_provider``/``delete_provider``/``clear_providers``).
|
|
860
|
+
MutableModels = Models
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def create_models(
|
|
864
|
+
*,
|
|
865
|
+
credentials: CredentialStore | None = None,
|
|
866
|
+
models_store: ModelsStore | None = None,
|
|
867
|
+
auth_context: AuthContext | None = None,
|
|
868
|
+
) -> Models:
|
|
869
|
+
"""Create an empty :class:`Models` collection."""
|
|
870
|
+
return Models(credentials=credentials, models_store=models_store, auth_context=auth_context)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# ---------------------------------------------------------------------------
|
|
874
|
+
# create_provider
|
|
875
|
+
# ---------------------------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
class _ComposedProvider(Provider):
|
|
879
|
+
"""Provider assembled by :func:`create_provider`."""
|
|
880
|
+
|
|
881
|
+
def __init__(
|
|
882
|
+
self,
|
|
883
|
+
*,
|
|
884
|
+
id: str,
|
|
885
|
+
name: str,
|
|
886
|
+
base_url: str | None,
|
|
887
|
+
headers: dict[str, str | None] | None,
|
|
888
|
+
auth: ProviderAuth,
|
|
889
|
+
models: Sequence[AnyModel] | Callable[[], Sequence[AnyModel]],
|
|
890
|
+
api_single: Any,
|
|
891
|
+
api_by_name: Mapping[str, Any] | None,
|
|
892
|
+
images: Mapping[str, Any] | None,
|
|
893
|
+
classifiers: Mapping[str, Any] | None,
|
|
894
|
+
fetch_models: Callable[[RefreshModelsContext], Awaitable[Sequence[AnyModel]]] | None,
|
|
895
|
+
filter_models: Any,
|
|
896
|
+
filter_all_models: Any,
|
|
897
|
+
) -> None:
|
|
898
|
+
self.id = id
|
|
899
|
+
self.name = name
|
|
900
|
+
self.base_url = base_url
|
|
901
|
+
self.headers = headers
|
|
902
|
+
self.auth = auth
|
|
903
|
+
self._baseline_source = models
|
|
904
|
+
self._baseline: list[AnyModel] | None = None if callable(models) else list(models)
|
|
905
|
+
self._dynamic: list[AnyModel] = []
|
|
906
|
+
self._merged: list[AnyModel] | None = None
|
|
907
|
+
self._chat: list[Model] | None = None
|
|
908
|
+
self._index: dict[tuple[str, str], AnyModel] | None = None
|
|
909
|
+
self._api_single = api_single
|
|
910
|
+
self._api_by_name = dict(api_by_name) if api_by_name else None
|
|
911
|
+
self._images = dict(images) if images else None
|
|
912
|
+
self._classifiers = dict(classifiers) if classifiers else None
|
|
913
|
+
self._fetch_models = fetch_models
|
|
914
|
+
self.filter_models = filter_models
|
|
915
|
+
self.filter_all_models = filter_all_models
|
|
916
|
+
self.get_all_models = self._get_all_models
|
|
917
|
+
if fetch_models is not None:
|
|
918
|
+
self.refresh_models = self._refresh_models
|
|
919
|
+
streams = [api_single] if api_single is not None else list((api_by_name or {}).values())
|
|
920
|
+
if any(getattr(entry, "fetch_deferred", None) is not None for entry in streams):
|
|
921
|
+
self.fetch_deferred = self._fetch_deferred
|
|
922
|
+
if any(getattr(entry, "cancel_deferred", None) is not None for entry in streams):
|
|
923
|
+
self.cancel_deferred = self._cancel_deferred
|
|
924
|
+
if self._images:
|
|
925
|
+
self.generate_images = self._generate_images
|
|
926
|
+
if self._classifiers:
|
|
927
|
+
self.classify = self._classify
|
|
928
|
+
|
|
929
|
+
# catalog ------------------------------------------------------------------
|
|
930
|
+
|
|
931
|
+
def _current(self) -> list[AnyModel]:
|
|
932
|
+
merged = self._merged
|
|
933
|
+
if merged is None:
|
|
934
|
+
if self._baseline is None:
|
|
935
|
+
self._baseline = list(self._baseline_source()) # type: ignore[operator]
|
|
936
|
+
merged = list(self._baseline)
|
|
937
|
+
positions = {(get_model_type(model), model.id): index for index, model in enumerate(merged)}
|
|
938
|
+
for model in self._dynamic:
|
|
939
|
+
key = (get_model_type(model), model.id)
|
|
940
|
+
index = positions.get(key)
|
|
941
|
+
if index is not None:
|
|
942
|
+
merged[index] = model
|
|
943
|
+
else:
|
|
944
|
+
positions[key] = len(merged)
|
|
945
|
+
merged.append(model)
|
|
946
|
+
self._merged = merged
|
|
947
|
+
self._chat = [model for model in merged if get_model_type(model) == "chat"] # type: ignore[misc]
|
|
948
|
+
index_map: dict[tuple[str, str], AnyModel] = {}
|
|
949
|
+
for model in merged:
|
|
950
|
+
index_map.setdefault((get_model_type(model), model.id), model)
|
|
951
|
+
self._index = index_map
|
|
952
|
+
return merged
|
|
953
|
+
|
|
954
|
+
def _set_dynamic(self, models: list[AnyModel]) -> None:
|
|
955
|
+
self._dynamic = models
|
|
956
|
+
self._merged = None
|
|
957
|
+
self._chat = None
|
|
958
|
+
self._index = None
|
|
959
|
+
|
|
960
|
+
def get_models(self) -> Sequence[Model]:
|
|
961
|
+
self._current()
|
|
962
|
+
return list(self._chat or [])
|
|
963
|
+
|
|
964
|
+
def _get_all_models(self) -> Sequence[AnyModel]:
|
|
965
|
+
return list(self._current())
|
|
966
|
+
|
|
967
|
+
def find_model(self, type: str, model_id: str) -> AnyModel | None:
|
|
968
|
+
self._current()
|
|
969
|
+
assert self._index is not None
|
|
970
|
+
return self._index.get((type, model_id))
|
|
971
|
+
|
|
972
|
+
async def _refresh_models(self, context: RefreshModelsContext) -> None:
|
|
973
|
+
assert self._fetch_models is not None
|
|
974
|
+
if context.stored is not None:
|
|
975
|
+
restored = [model for model in context.stored.models if model.provider == self.id]
|
|
976
|
+
if not await context.publish(ModelsPublication(update=lambda: self._set_dynamic(restored))):
|
|
977
|
+
return
|
|
978
|
+
if not context.allow_network or context.signal.aborted:
|
|
979
|
+
return
|
|
980
|
+
fetched = await self._fetch_models(context)
|
|
981
|
+
if context.signal.aborted:
|
|
982
|
+
return
|
|
983
|
+
refreshed = [model for model in fetched if _has_known_model_type(model)]
|
|
984
|
+
await context.publish(
|
|
985
|
+
ModelsPublication(
|
|
986
|
+
persist=ModelsStoreEntry(models=refreshed, checked_at=now_ms()),
|
|
987
|
+
update=lambda: self._set_dynamic(refreshed),
|
|
988
|
+
)
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
# operations ---------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
def _api_for(self, model: Model) -> Any:
|
|
994
|
+
if self._api_single is not None:
|
|
995
|
+
return self._api_single
|
|
996
|
+
return (self._api_by_name or {}).get(model.api)
|
|
997
|
+
|
|
998
|
+
def _missing_api(self, model: Model) -> AssistantMessageEventStream:
|
|
999
|
+
async def fail() -> AssistantMessageEventStream:
|
|
1000
|
+
raise ModelsError("stream", f'Provider {self.id} has no API implementation for "{model.api}"')
|
|
1001
|
+
|
|
1002
|
+
return lazy_stream(model, fail)
|
|
1003
|
+
|
|
1004
|
+
def stream(
|
|
1005
|
+
self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
1006
|
+
) -> AssistantMessageEventStream:
|
|
1007
|
+
implementation = self._api_for(model)
|
|
1008
|
+
if implementation is None:
|
|
1009
|
+
return self._missing_api(model)
|
|
1010
|
+
return implementation.stream(model, context, options)
|
|
1011
|
+
|
|
1012
|
+
def stream_simple(
|
|
1013
|
+
self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
1014
|
+
) -> AssistantMessageEventStream:
|
|
1015
|
+
implementation = self._api_for(model)
|
|
1016
|
+
if implementation is None:
|
|
1017
|
+
return self._missing_api(model)
|
|
1018
|
+
return implementation.stream_simple(model, context, options)
|
|
1019
|
+
|
|
1020
|
+
def _fetch_deferred(
|
|
1021
|
+
self, model: Model, handle: DeferredHandle, options: Mapping[str, Any] | None = None
|
|
1022
|
+
) -> AssistantMessageEventStream:
|
|
1023
|
+
implementation = self._api_for(model)
|
|
1024
|
+
fetch = getattr(implementation, "fetch_deferred", None) if implementation is not None else None
|
|
1025
|
+
if fetch is None:
|
|
1026
|
+
|
|
1027
|
+
async def fail() -> AssistantMessageEventStream:
|
|
1028
|
+
raise ModelsError(
|
|
1029
|
+
"provider", f'Provider {self.id} does not support deferred responses for "{model.api}"'
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
return lazy_stream(model, fail)
|
|
1033
|
+
return fetch(model, handle, options)
|
|
1034
|
+
|
|
1035
|
+
async def _cancel_deferred(
|
|
1036
|
+
self, model: Model, handle: DeferredHandle, options: Mapping[str, Any] | None = None
|
|
1037
|
+
) -> None:
|
|
1038
|
+
implementation = self._api_for(model)
|
|
1039
|
+
cancel = getattr(implementation, "cancel_deferred", None) if implementation is not None else None
|
|
1040
|
+
if cancel is None:
|
|
1041
|
+
raise ModelsError("provider", f'Provider {self.id} cannot cancel deferred responses for "{model.api}"')
|
|
1042
|
+
await cancel(model, handle, options)
|
|
1043
|
+
|
|
1044
|
+
async def _generate_images(
|
|
1045
|
+
self, model: ImageModel, context: ImagesContext, options: Mapping[str, Any] | None = None
|
|
1046
|
+
) -> AssistantImages:
|
|
1047
|
+
implementation = (self._images or {}).get(model.api)
|
|
1048
|
+
if implementation is None:
|
|
1049
|
+
return image_error_result(
|
|
1050
|
+
model,
|
|
1051
|
+
ModelsError("provider", f'Provider {self.id} has no image generation implementation for "{model.api}"'),
|
|
1052
|
+
)
|
|
1053
|
+
return await implementation.generate_images(model, context, options)
|
|
1054
|
+
|
|
1055
|
+
async def _classify(
|
|
1056
|
+
self, model: ClassifierModel, context: ClassifierContext, options: Mapping[str, Any] | None = None
|
|
1057
|
+
) -> ClassifierResult:
|
|
1058
|
+
implementation = (self._classifiers or {}).get(model.api)
|
|
1059
|
+
if implementation is None:
|
|
1060
|
+
return classifier_error_result(
|
|
1061
|
+
model, ModelsError("provider", f'Provider {self.id} has no classifier implementation for "{model.api}"')
|
|
1062
|
+
)
|
|
1063
|
+
return await implementation.classify(model, context, options)
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def create_provider(
|
|
1067
|
+
*,
|
|
1068
|
+
id: str,
|
|
1069
|
+
auth: ProviderAuth,
|
|
1070
|
+
models: Sequence[AnyModel] | Callable[[], Sequence[AnyModel]] = (),
|
|
1071
|
+
api: Any = None,
|
|
1072
|
+
images: Mapping[str, Any] | None = None,
|
|
1073
|
+
classifiers: Mapping[str, Any] | None = None,
|
|
1074
|
+
name: str | None = None,
|
|
1075
|
+
base_url: str | None = None,
|
|
1076
|
+
headers: dict[str, str | None] | None = None,
|
|
1077
|
+
fetch_models: Callable[[RefreshModelsContext], Awaitable[Sequence[AnyModel]]] | None = None,
|
|
1078
|
+
filter_models: Callable[[Sequence[Model], Credential | None], Sequence[Model]] | None = None,
|
|
1079
|
+
filter_all_models: Callable[[Sequence[AnyModel], Credential | None], Sequence[AnyModel]] | None = None,
|
|
1080
|
+
) -> Provider:
|
|
1081
|
+
"""Build a provider from parts.
|
|
1082
|
+
|
|
1083
|
+
``models`` is the static baseline catalog (a sequence, or a zero-argument callable
|
|
1084
|
+
evaluated on first access so catalogs load lazily). ``api`` is a single
|
|
1085
|
+
``ProviderStreams`` implementation for all chat models or a mapping keyed by
|
|
1086
|
+
``model.api`` for mixed-API providers. ``images`` and
|
|
1087
|
+
``classifiers`` map ``model.api`` to one-shot implementations. At least one
|
|
1088
|
+
concrete implementation is required.
|
|
1089
|
+
"""
|
|
1090
|
+
single = api if api is not None and callable(getattr(api, "stream", None)) else None
|
|
1091
|
+
by_name = api if single is None and isinstance(api, Mapping) else None
|
|
1092
|
+
stream_impls = (
|
|
1093
|
+
[single] if single is not None else [entry for entry in (by_name or {}).values() if entry is not None]
|
|
1094
|
+
)
|
|
1095
|
+
image_impls = [entry for entry in (images or {}).values() if entry is not None]
|
|
1096
|
+
classifier_impls = [entry for entry in (classifiers or {}).values() if entry is not None]
|
|
1097
|
+
if not stream_impls and not image_impls and not classifier_impls:
|
|
1098
|
+
raise ValueError(f'Provider {id}: at least one of "api", "images", or "classifiers" is required.')
|
|
1099
|
+
return _ComposedProvider(
|
|
1100
|
+
id=id,
|
|
1101
|
+
name=name or id,
|
|
1102
|
+
base_url=base_url,
|
|
1103
|
+
headers=headers,
|
|
1104
|
+
auth=auth,
|
|
1105
|
+
models=models,
|
|
1106
|
+
api_single=single,
|
|
1107
|
+
api_by_name=by_name,
|
|
1108
|
+
images=images,
|
|
1109
|
+
classifiers=classifiers,
|
|
1110
|
+
fetch_models=fetch_models,
|
|
1111
|
+
filter_models=filter_models,
|
|
1112
|
+
filter_all_models=filter_all_models,
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
# ---------------------------------------------------------------------------
|
|
1117
|
+
# Model helpers
|
|
1118
|
+
# ---------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def has_api(model: AnyModel, api: str) -> bool:
|
|
1122
|
+
"""True when ``model`` is a chat model using ``api``. Non-chat models never match."""
|
|
1123
|
+
return is_model_type(model, "chat") and model.api == api
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def calculate_cost(model: AnyModel, usage: Usage) -> UsageCost:
|
|
1127
|
+
"""Fill in ``usage.cost`` from the model's pricing (per million tokens) and return it.
|
|
1128
|
+
|
|
1129
|
+
Request-wide pricing tiers apply when total input exceeds their threshold.
|
|
1130
|
+
Anthropic charges twice the base input rate for 1h cache writes.
|
|
1131
|
+
"""
|
|
1132
|
+
input_tokens = usage.input + usage.cache_read + usage.cache_write
|
|
1133
|
+
rates: Any = model.cost
|
|
1134
|
+
matched_threshold = -1
|
|
1135
|
+
for tier in model.cost.tiers or ():
|
|
1136
|
+
if input_tokens > tier.input_tokens_above and tier.input_tokens_above > matched_threshold:
|
|
1137
|
+
rates = tier
|
|
1138
|
+
matched_threshold = tier.input_tokens_above
|
|
1139
|
+
long_write = usage.cache_write_1h or 0
|
|
1140
|
+
short_write = usage.cache_write - long_write
|
|
1141
|
+
cost = usage.cost
|
|
1142
|
+
cost.input = (rates.input / 1_000_000) * usage.input
|
|
1143
|
+
cost.output = (rates.output / 1_000_000) * usage.output
|
|
1144
|
+
cost.cache_read = (rates.cache_read / 1_000_000) * usage.cache_read
|
|
1145
|
+
cost.cache_write = (rates.cache_write * short_write + rates.input * 2 * long_write) / 1_000_000
|
|
1146
|
+
cost.total = cost.input + cost.output + cost.cache_read + cost.cache_write
|
|
1147
|
+
return cost
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
_EXTENDED_THINKING_LEVELS: tuple[ModelThinkingLevel, ...] = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def get_supported_thinking_levels(model: Model) -> list[ModelThinkingLevel]:
|
|
1154
|
+
"""Thinking levels the model exposes. ``xhigh``/``max`` need an explicit map entry."""
|
|
1155
|
+
if not model.reasoning:
|
|
1156
|
+
return ["off"]
|
|
1157
|
+
level_map = model.thinking_level_map or {}
|
|
1158
|
+
levels: list[ModelThinkingLevel] = []
|
|
1159
|
+
for level in _EXTENDED_THINKING_LEVELS:
|
|
1160
|
+
present = level in level_map
|
|
1161
|
+
mapped = level_map.get(level)
|
|
1162
|
+
if present and mapped is None:
|
|
1163
|
+
continue
|
|
1164
|
+
if level in ("xhigh", "max") and not present:
|
|
1165
|
+
continue
|
|
1166
|
+
levels.append(level)
|
|
1167
|
+
return levels
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def clamp_thinking_level(model: Model, level: ModelThinkingLevel) -> ModelThinkingLevel:
|
|
1171
|
+
"""The closest supported level, preferring higher levels first."""
|
|
1172
|
+
available = get_supported_thinking_levels(model)
|
|
1173
|
+
if level in available:
|
|
1174
|
+
return level
|
|
1175
|
+
if level not in _EXTENDED_THINKING_LEVELS:
|
|
1176
|
+
return available[0] if available else "off"
|
|
1177
|
+
requested = _EXTENDED_THINKING_LEVELS.index(level)
|
|
1178
|
+
for candidate in _EXTENDED_THINKING_LEVELS[requested:]:
|
|
1179
|
+
if candidate in available:
|
|
1180
|
+
return candidate
|
|
1181
|
+
for candidate in reversed(_EXTENDED_THINKING_LEVELS[:requested]):
|
|
1182
|
+
if candidate in available:
|
|
1183
|
+
return candidate
|
|
1184
|
+
return available[0] if available else "off"
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def models_are_equal(a: AnyModel | None, b: AnyModel | None) -> bool:
|
|
1188
|
+
"""Whether two models are the same catalog entry (type, id and provider)."""
|
|
1189
|
+
if a is None or b is None:
|
|
1190
|
+
return False
|
|
1191
|
+
return get_model_type(a) == get_model_type(b) and a.id == b.id and a.provider == b.provider
|