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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,103 @@
1
+ """Azure OpenAI provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from ..api.lazy import lazy_stream
10
+ from ..api.registry import azure_openai_responses_api, openai_completions_api
11
+ from ..auth.helpers import env_api_key_auth
12
+ from ..auth.types import ProviderAuth
13
+ from ..event_stream import AssistantMessageEventStream
14
+ from ..models import Provider, create_provider
15
+ from ..types import Model, TranscriptContext
16
+ from ..utils.callbacks import maybe_await
17
+ from .catalog import catalog_models
18
+
19
+ __all__ = ["azure_provider"]
20
+
21
+
22
+ def _resolve_azure_model(model: Model, options: Mapping[str, Any] | None) -> Model:
23
+ from ..api.azure_openai_config import resolve_azure_base_url
24
+
25
+ return dataclasses.replace(model, base_url=resolve_azure_base_url(model, options))
26
+
27
+
28
+ def _with_deployment_name(model: Model, options: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
29
+ """Send the deployment name as the request's model, keeping ``model.id`` as the catalog id."""
30
+ from ..api.azure_openai_config import resolve_deployment_name
31
+
32
+ deployment_name = resolve_deployment_name(model, options)
33
+ if deployment_name == model.id:
34
+ return options
35
+ user_on_payload = options.get("on_payload") if options else None
36
+
37
+ async def on_payload(payload: Any, payload_model: Any) -> Any:
38
+ params = {**payload, "model": deployment_name}
39
+ if user_on_payload is not None:
40
+ replaced = await maybe_await(user_on_payload(params, payload_model))
41
+ if replaced is not None:
42
+ return replaced
43
+ return params
44
+
45
+ return {**(options or {}), "on_payload": on_payload}
46
+
47
+
48
+ class _AzureStreams:
49
+ """Resolve the Azure endpoint and deployment before dispatch.
50
+
51
+ Resolution runs inside ``lazy_stream`` so an unconfigured endpoint errors on the
52
+ stream instead of raising out of ``stream()``.
53
+ """
54
+
55
+ __slots__ = ("_streams",)
56
+
57
+ def __init__(self, streams: Any) -> None:
58
+ self._streams = streams
59
+
60
+ def stream(
61
+ self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
62
+ ) -> AssistantMessageEventStream:
63
+ async def setup() -> AssistantMessageEventStream:
64
+ return self._streams.stream(
65
+ _resolve_azure_model(model, options), context, _with_deployment_name(model, options)
66
+ )
67
+
68
+ return lazy_stream(model, setup)
69
+
70
+ def stream_simple(
71
+ self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
72
+ ) -> AssistantMessageEventStream:
73
+ async def setup() -> AssistantMessageEventStream:
74
+ return self._streams.stream_simple(
75
+ _resolve_azure_model(model, options), context, _with_deployment_name(model, options)
76
+ )
77
+
78
+ return lazy_stream(model, setup)
79
+
80
+ def __repr__(self) -> str:
81
+ return f"azure_streams({self._streams!r})"
82
+
83
+
84
+ def azure_streams(streams: Any) -> Any:
85
+ return _AzureStreams(streams)
86
+
87
+
88
+ def azure_provider() -> Provider:
89
+ """Azure OpenAI (Responses and Chat Completions), authenticated with ``AZURE_OPENAI_API_KEY``.
90
+
91
+ Catalog models ship without a base URL: the endpoint comes from ``AZURE_OPENAI_BASE_URL``,
92
+ ``AZURE_OPENAI_RESOURCE_NAME`` or the matching request options.
93
+ """
94
+ return create_provider(
95
+ id="azure",
96
+ name="Azure",
97
+ auth=ProviderAuth(api_key=env_api_key_auth("Azure OpenAI API key", ["AZURE_OPENAI_API_KEY"])),
98
+ models=lambda: catalog_models("azure"),
99
+ api={
100
+ "azure-openai-responses": azure_openai_responses_api(),
101
+ "openai-completions": azure_streams(openai_completions_api()),
102
+ },
103
+ )
@@ -0,0 +1,23 @@
1
+ """Baseten provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import openai_completions_api
6
+ from ..auth.helpers import env_api_key_auth
7
+ from ..auth.types import ProviderAuth
8
+ from ..models import Provider, create_provider
9
+ from .catalog import catalog_models
10
+
11
+ __all__ = ["baseten_provider"]
12
+
13
+
14
+ def baseten_provider() -> Provider:
15
+ """Baseten (OpenAI-compatible Chat Completions), authenticated with ``BASETEN_API_KEY``."""
16
+ return create_provider(
17
+ id="baseten",
18
+ name="Baseten",
19
+ base_url="https://inference.baseten.co/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Baseten API key", ["BASETEN_API_KEY"])),
21
+ models=lambda: catalog_models("baseten"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,167 @@
1
+ """Built-in model catalog data.
2
+
3
+ Catalog files (``data/<provider>.json``) are the generated model data shipped by
4
+ ``@earendil-works/pi-ai``. Each file groups entries by API id and keys them as
5
+ ``"<type>:<model id>"``. Files load on first access and are cached per process.
6
+
7
+ Returned models are shared instances, as in pi. Derive variants with
8
+ ``dataclasses.replace(model, ...)`` instead of mutating them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import threading
15
+ from datetime import datetime
16
+ from functools import lru_cache
17
+ from importlib import resources
18
+ from typing import Any
19
+
20
+ from ..serialization import model_from_dict
21
+ from ..types import AnyModel, ClassifierModel, ImageModel, Model
22
+
23
+ __all__ = [
24
+ "BUILTIN_PROVIDERS",
25
+ "load_catalog",
26
+ "catalog_models",
27
+ "get_builtin_providers",
28
+ "get_builtin_model",
29
+ "get_builtin_image_model",
30
+ "get_builtin_classifier_model",
31
+ "get_builtin_models",
32
+ "get_builtin_image_models",
33
+ "get_builtin_classifier_models",
34
+ "get_all_builtin_models",
35
+ "get_builtin_model_data_generated_at",
36
+ ]
37
+
38
+ #: Providers present in the generated catalog.
39
+ BUILTIN_PROVIDERS: tuple[str, ...] = (
40
+ "amazon-bedrock",
41
+ "ant-ling",
42
+ "anthropic",
43
+ "azure",
44
+ "baseten",
45
+ "cerebras",
46
+ "cloudflare-ai-gateway",
47
+ "cloudflare-workers-ai",
48
+ "deepseek",
49
+ "fireworks",
50
+ "github-copilot",
51
+ "google",
52
+ "google-vertex",
53
+ "groq",
54
+ "huggingface",
55
+ "kimi-coding",
56
+ "meta",
57
+ "minimax",
58
+ "minimax-cn",
59
+ "mistral",
60
+ "moonshotai",
61
+ "moonshotai-cn",
62
+ "nvidia",
63
+ "openai",
64
+ "openai-codex",
65
+ "opencode",
66
+ "opencode-go",
67
+ "openrouter",
68
+ "qwen-token-plan",
69
+ "qwen-token-plan-cn",
70
+ "qwen-token-plan-individual",
71
+ "radius",
72
+ "together",
73
+ "typesafe",
74
+ "vercel-ai-gateway",
75
+ "xai",
76
+ "xiaomi",
77
+ "xiaomi-token-plan-ams",
78
+ "xiaomi-token-plan-cn",
79
+ "xiaomi-token-plan-sgp",
80
+ "zai",
81
+ "zai-coding-cn",
82
+ )
83
+
84
+ _lock = threading.Lock()
85
+ _cache: dict[str, dict[str, dict[str, AnyModel]]] = {}
86
+
87
+
88
+ def _read_json(name: str) -> Any:
89
+ data_dir = resources.files("pi_ai.providers").joinpath("data")
90
+ with data_dir.joinpath(name).open("r", encoding="utf-8") as handle:
91
+ return json.load(handle)
92
+
93
+
94
+ def load_catalog(provider: str) -> dict[str, dict[str, AnyModel]]:
95
+ """Catalog of ``provider`` as ``{"chat": {id: model}, "image": {...}, "classifier": {...}}``."""
96
+ cached = _cache.get(provider)
97
+ if cached is not None:
98
+ return cached
99
+ with _lock:
100
+ cached = _cache.get(provider)
101
+ if cached is not None:
102
+ return cached
103
+ groups: dict[str, dict[str, AnyModel]] = {"chat": {}, "image": {}, "classifier": {}}
104
+ try:
105
+ raw = _read_json(f"{provider}.json")
106
+ except FileNotFoundError:
107
+ raw = {}
108
+ for entries in raw.values():
109
+ for key, value in entries.items():
110
+ model_type, _, _ = key.partition(":")
111
+ group = groups.get(model_type or "chat")
112
+ if group is None:
113
+ continue
114
+ model = model_from_dict(value)
115
+ group[model.id] = model
116
+ _cache[provider] = groups
117
+ return groups
118
+
119
+
120
+ def catalog_models(provider: str) -> list[AnyModel]:
121
+ """Every built-in model of ``provider`` (chat, then image, then classifier)."""
122
+ groups = load_catalog(provider)
123
+ return [*groups["chat"].values(), *groups["image"].values(), *groups["classifier"].values()]
124
+
125
+
126
+ def get_builtin_providers() -> list[str]:
127
+ return list(BUILTIN_PROVIDERS)
128
+
129
+
130
+ def get_builtin_model(provider: str, model_id: str) -> Model | None:
131
+ """One generated built-in chat model, independent of any ``Models`` collection."""
132
+ return load_catalog(provider)["chat"].get(model_id) # type: ignore[return-value]
133
+
134
+
135
+ def get_builtin_image_model(provider: str, model_id: str) -> ImageModel | None:
136
+ return load_catalog(provider)["image"].get(model_id) # type: ignore[return-value]
137
+
138
+
139
+ def get_builtin_classifier_model(provider: str, model_id: str) -> ClassifierModel | None:
140
+ return load_catalog(provider)["classifier"].get(model_id) # type: ignore[return-value]
141
+
142
+
143
+ def get_builtin_models(provider: str) -> list[Model]:
144
+ return list(load_catalog(provider)["chat"].values()) # type: ignore[arg-type]
145
+
146
+
147
+ def get_builtin_image_models(provider: str) -> list[ImageModel]:
148
+ return list(load_catalog(provider)["image"].values()) # type: ignore[arg-type]
149
+
150
+
151
+ def get_builtin_classifier_models(provider: str) -> list[ClassifierModel]:
152
+ return list(load_catalog(provider)["classifier"].values()) # type: ignore[arg-type]
153
+
154
+
155
+ def get_all_builtin_models(provider: str) -> list[AnyModel]:
156
+ return catalog_models(provider)
157
+
158
+
159
+ @lru_cache(maxsize=1)
160
+ def get_builtin_model_data_generated_at() -> int | None:
161
+ """Generation timestamp (ms) shared by all built-in provider catalogs."""
162
+ try:
163
+ manifest = _read_json("manifest.json")
164
+ stamp = datetime.fromisoformat(manifest["generatedAt"].replace("Z", "+00:00"))
165
+ except (FileNotFoundError, KeyError, ValueError):
166
+ return None
167
+ return int(stamp.timestamp() * 1000)
@@ -0,0 +1,23 @@
1
+ """Cerebras provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import openai_completions_api
6
+ from ..auth.helpers import env_api_key_auth
7
+ from ..auth.types import ProviderAuth
8
+ from ..models import Provider, create_provider
9
+ from .catalog import catalog_models
10
+
11
+ __all__ = ["cerebras_provider"]
12
+
13
+
14
+ def cerebras_provider() -> Provider:
15
+ """Cerebras (OpenAI-compatible Chat Completions), authenticated with ``CEREBRAS_API_KEY``."""
16
+ return create_provider(
17
+ id="cerebras",
18
+ name="Cerebras",
19
+ base_url="https://api.cerebras.ai/v1",
20
+ auth=ProviderAuth(api_key=env_api_key_auth("Cerebras API key", ["CEREBRAS_API_KEY"])),
21
+ models=lambda: catalog_models("cerebras"),
22
+ api=openai_completions_api(),
23
+ )
@@ -0,0 +1,30 @@
1
+ """Cloudflare AI Gateway provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import anthropic_messages_api, openai_completions_api, openai_responses_api
6
+ from ..auth.types import ProviderAuth
7
+ from ..models import Provider, create_provider
8
+ from .catalog import catalog_models
9
+ from .cloudflare_auth import cloudflare_ai_gateway_auth
10
+ from .cloudflare_stream import cloudflare_streams
11
+
12
+ __all__ = ["cloudflare_ai_gateway_provider"]
13
+
14
+
15
+ def cloudflare_ai_gateway_provider() -> Provider:
16
+ """Cloudflare AI Gateway: ``CLOUDFLARE_API_KEY``, ``CLOUDFLARE_ACCOUNT_ID`` and ``CLOUDFLARE_GATEWAY_ID``."""
17
+ # The api map is pinned to all three APIs: models.dev's gateway catalog drops and
18
+ # restores `workers-ai/*` (openai-completions) entries over time, so the map must
19
+ # not depend on which APIs the generated catalog happens to contain.
20
+ return create_provider(
21
+ id="cloudflare-ai-gateway",
22
+ name="Cloudflare AI Gateway",
23
+ auth=ProviderAuth(api_key=cloudflare_ai_gateway_auth()),
24
+ models=lambda: catalog_models("cloudflare-ai-gateway"),
25
+ api={
26
+ "anthropic-messages": cloudflare_streams(anthropic_messages_api()),
27
+ "openai-completions": cloudflare_streams(openai_completions_api()),
28
+ "openai-responses": cloudflare_streams(openai_responses_api()),
29
+ },
30
+ )
@@ -0,0 +1,112 @@
1
+ """Cloudflare Workers AI / AI Gateway auth: API key plus account (and gateway) ids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ..abort import AbortSignal
8
+ from ..auth.types import (
9
+ ApiKeyAuth,
10
+ AuthContext,
11
+ AuthPrompt,
12
+ AuthResult,
13
+ Credential,
14
+ ModelAuth,
15
+ ProviderAuthInteraction,
16
+ )
17
+
18
+ __all__ = ["cloudflare_workers_ai_auth", "cloudflare_ai_gateway_auth"]
19
+
20
+ CLOUDFLARE_API_KEY = "CLOUDFLARE_API_KEY"
21
+ CLOUDFLARE_ACCOUNT_ID = "CLOUDFLARE_ACCOUNT_ID"
22
+ CLOUDFLARE_GATEWAY_ID = "CLOUDFLARE_GATEWAY_ID"
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class _ResolvedCloudflare:
27
+ api_key: str
28
+ env: dict[str, str]
29
+ source: str
30
+
31
+
32
+ async def _resolve_value(name: str, ctx: AuthContext, credential: Credential | None, signal: AbortSignal) -> str | None:
33
+ # Per-field merge: prefer the credential value, fall back to ambient env.
34
+ # A credential carrying only the API key must still pick up the account /
35
+ # gateway id from the environment.
36
+ from_credential: str | None = None
37
+ if credential is not None:
38
+ if name == CLOUDFLARE_API_KEY:
39
+ from_credential = credential.get("key")
40
+ else:
41
+ from_credential = (credential.get("env") or {}).get(name)
42
+ if from_credential is not None:
43
+ return from_credential
44
+ signal.throw_if_aborted()
45
+ value = await ctx.env(name)
46
+ signal.throw_if_aborted()
47
+ return value
48
+
49
+
50
+ async def _resolve_cloudflare_env(
51
+ kind: str, ctx: AuthContext, credential: Credential | None, signal: AbortSignal
52
+ ) -> _ResolvedCloudflare | None:
53
+ api_key = await _resolve_value(CLOUDFLARE_API_KEY, ctx, credential, signal)
54
+ account_id = await _resolve_value(CLOUDFLARE_ACCOUNT_ID, ctx, credential, signal)
55
+ gateway_id = await _resolve_value(CLOUDFLARE_GATEWAY_ID, ctx, credential, signal) if kind == "ai-gateway" else None
56
+
57
+ if not api_key or not account_id or (kind == "ai-gateway" and not gateway_id):
58
+ return None
59
+
60
+ env = {CLOUDFLARE_ACCOUNT_ID: account_id}
61
+ if gateway_id:
62
+ env[CLOUDFLARE_GATEWAY_ID] = gateway_id
63
+ return _ResolvedCloudflare(api_key, env, "stored credential" if credential is not None else CLOUDFLARE_API_KEY)
64
+
65
+
66
+ def cloudflare_workers_ai_auth() -> ApiKeyAuth:
67
+ """API key auth that also requires ``CLOUDFLARE_ACCOUNT_ID`` (returned as provider env)."""
68
+
69
+ async def login(interaction: ProviderAuthInteraction) -> Credential:
70
+ key = await interaction.prompt(AuthPrompt(type="secret", message="Enter Cloudflare API key"))
71
+ account_id = await interaction.prompt(AuthPrompt(type="text", message="Enter Cloudflare account ID"))
72
+ return {"type": "api_key", "key": key, "env": {CLOUDFLARE_ACCOUNT_ID: account_id}}
73
+
74
+ async def resolve(*, ctx: AuthContext, credential: Credential | None, signal: AbortSignal) -> AuthResult | None:
75
+ resolved = await _resolve_cloudflare_env("workers-ai", ctx, credential, signal)
76
+ if resolved is None:
77
+ return None
78
+ return AuthResult(ModelAuth(api_key=resolved.api_key), env=resolved.env, source=resolved.source)
79
+
80
+ return ApiKeyAuth("Cloudflare API key", resolve, login=login)
81
+
82
+
83
+ def cloudflare_ai_gateway_auth() -> ApiKeyAuth:
84
+ """AI Gateway auth: the key travels as ``cf-aig-authorization``; account and gateway ids as provider env."""
85
+
86
+ async def login(interaction: ProviderAuthInteraction) -> Credential:
87
+ key = await interaction.prompt(AuthPrompt(type="secret", message="Enter Cloudflare API key"))
88
+ account_id = await interaction.prompt(AuthPrompt(type="text", message="Enter Cloudflare account ID"))
89
+ gateway_id = await interaction.prompt(AuthPrompt(type="text", message="Enter Cloudflare AI Gateway ID"))
90
+ return {
91
+ "type": "api_key",
92
+ "key": key,
93
+ "env": {CLOUDFLARE_ACCOUNT_ID: account_id, CLOUDFLARE_GATEWAY_ID: gateway_id},
94
+ }
95
+
96
+ async def resolve(*, ctx: AuthContext, credential: Credential | None, signal: AbortSignal) -> AuthResult | None:
97
+ resolved = await _resolve_cloudflare_env("ai-gateway", ctx, credential, signal)
98
+ if resolved is None:
99
+ return None
100
+ return AuthResult(
101
+ ModelAuth(
102
+ headers={
103
+ "cf-aig-authorization": f"Bearer {resolved.api_key}",
104
+ "Authorization": None,
105
+ "x-api-key": None,
106
+ }
107
+ ),
108
+ env=resolved.env,
109
+ source=resolved.source,
110
+ )
111
+
112
+ return ApiKeyAuth("Cloudflare API key", resolve, login=login)
@@ -0,0 +1,84 @@
1
+ """Materialize Cloudflare account/gateway placeholders in model base URLs before dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from collections.abc import Mapping
7
+ from typing import Any, TypeVar
8
+
9
+ from ..event_stream import AssistantMessageEventStream
10
+ from ..types import ClassifierContext, ClassifierModel, ClassifierResult, Model, TranscriptContext
11
+
12
+ __all__ = ["resolve_cloudflare_model", "cloudflare_streams", "cloudflare_classifier"]
13
+
14
+ CLOUDFLARE_ACCOUNT_ID = "CLOUDFLARE_ACCOUNT_ID"
15
+ CLOUDFLARE_GATEWAY_ID = "CLOUDFLARE_GATEWAY_ID"
16
+
17
+ _ModelT = TypeVar("_ModelT")
18
+
19
+
20
+ def resolve_cloudflare_model(model: _ModelT, env: Mapping[str, str] | None) -> _ModelT:
21
+ """Replace ``{CLOUDFLARE_ACCOUNT_ID}``/``{CLOUDFLARE_GATEWAY_ID}`` in ``model.base_url`` from ``env``.
22
+
23
+ Unresolved placeholders are kept; the model is returned unchanged when nothing changes.
24
+ """
25
+ if not env:
26
+ return model
27
+ base_url: str = model.base_url # type: ignore[attr-defined]
28
+ resolved = base_url
29
+ for name in (CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID):
30
+ value = env.get(name)
31
+ placeholder = "{" + name + "}"
32
+ resolved = resolved.replace(placeholder, value if value is not None else placeholder)
33
+ if resolved == base_url:
34
+ return model
35
+ return dataclasses.replace(model, base_url=resolved) # type: ignore[type-var]
36
+
37
+
38
+ def _env(options: Mapping[str, Any] | None) -> Mapping[str, str] | None:
39
+ return options.get("env") if options else None
40
+
41
+
42
+ class _CloudflareStreams:
43
+ __slots__ = ("_streams",)
44
+
45
+ def __init__(self, streams: Any) -> None:
46
+ self._streams = streams
47
+
48
+ def stream(
49
+ self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
50
+ ) -> AssistantMessageEventStream:
51
+ return self._streams.stream(resolve_cloudflare_model(model, _env(options)), context, options)
52
+
53
+ def stream_simple(
54
+ self, model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
55
+ ) -> AssistantMessageEventStream:
56
+ return self._streams.stream_simple(resolve_cloudflare_model(model, _env(options)), context, options)
57
+
58
+ def __repr__(self) -> str:
59
+ return f"cloudflare_streams({self._streams!r})"
60
+
61
+
62
+ class _CloudflareClassifier:
63
+ __slots__ = ("_classifier",)
64
+
65
+ def __init__(self, classifier: Any) -> None:
66
+ self._classifier = classifier
67
+
68
+ async def classify(
69
+ self, model: ClassifierModel, context: ClassifierContext, options: Mapping[str, Any] | None = None
70
+ ) -> ClassifierResult:
71
+ return await self._classifier.classify(resolve_cloudflare_model(model, _env(options)), context, options)
72
+
73
+ def __repr__(self) -> str:
74
+ return f"cloudflare_classifier({self._classifier!r})"
75
+
76
+
77
+ def cloudflare_streams(streams: Any) -> Any:
78
+ """Wrap an API implementation so Cloudflare endpoint placeholders materialize from the resolved provider env."""
79
+ return _CloudflareStreams(streams)
80
+
81
+
82
+ def cloudflare_classifier(classifier: Any) -> Any:
83
+ """Classifier counterpart of :func:`cloudflare_streams`."""
84
+ return _CloudflareClassifier(classifier)
@@ -0,0 +1,26 @@
1
+ """Cloudflare Workers AI provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.registry import cloudflare_workers_ai_system_one_api, openai_completions_api
6
+ from ..auth.types import ProviderAuth
7
+ from ..models import Provider, create_provider
8
+ from .catalog import catalog_models
9
+ from .cloudflare_auth import cloudflare_workers_ai_auth
10
+ from .cloudflare_stream import cloudflare_classifier, cloudflare_streams
11
+
12
+ __all__ = ["cloudflare_workers_ai_provider"]
13
+
14
+
15
+ def cloudflare_workers_ai_provider() -> Provider:
16
+ """Cloudflare Workers AI: ``CLOUDFLARE_API_KEY`` plus ``CLOUDFLARE_ACCOUNT_ID``."""
17
+ return create_provider(
18
+ id="cloudflare-workers-ai",
19
+ name="Cloudflare Workers AI",
20
+ auth=ProviderAuth(api_key=cloudflare_workers_ai_auth()),
21
+ models=lambda: catalog_models("cloudflare-workers-ai"),
22
+ api=cloudflare_streams(openai_completions_api()),
23
+ classifiers={
24
+ "cloudflare-workers-ai-system-one": cloudflare_classifier(cloudflare_workers_ai_system_one_api()),
25
+ },
26
+ )