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

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