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,211 @@
1
+ """HTTP and parsing helpers shared by the classifier APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any, TypeGuard
9
+
10
+ import httpx
11
+
12
+ from ..abort import AbortError, abort_scope
13
+ from ..models import calculate_cost
14
+ from ..serialization import classifier_question_from_dict
15
+ from ..types import ClassifierContext, ClassifierModel, ClassifierQuestion, ProviderResponse, Usage
16
+ from ..utils import fastjson
17
+ from ..utils.callbacks import maybe_await
18
+ from ..utils.http import DEFAULT_TIMEOUT_MS, ProviderHttpError, get_http_client, retry_provider_request
19
+ from ..utils.misc import headers_to_record, provider_headers_to_record
20
+
21
+ __all__ = [
22
+ "ClassifierHttpError",
23
+ "is_record",
24
+ "is_number",
25
+ "required_number",
26
+ "format_js_number",
27
+ "classifier_questions",
28
+ "coerce_question",
29
+ "post_json",
30
+ "post_classifier_request",
31
+ "parse_classifier_usage",
32
+ ]
33
+
34
+ #: Default client-side retries for classifier requests.
35
+ DEFAULT_CLASSIFIER_MAX_RETRIES = 2
36
+
37
+
38
+ class ClassifierHttpError(ProviderHttpError):
39
+ """An HTTP failure in the shape ``retry_provider_request`` and ``normalize_provider_error`` understand.
40
+
41
+ ``body`` holds the raw response text, so error formatting can show the provider's reason.
42
+ """
43
+
44
+ def __init__(self, message: str, *, status: int | None, headers: httpx.Headers | None, body: str) -> None:
45
+ super().__init__(message, status=status, headers=headers, body=body, include_body=True)
46
+
47
+
48
+ def _http_error(label: str, response: httpx.Response) -> ClassifierHttpError:
49
+ return ClassifierHttpError(
50
+ f"{label} returned {response.status_code}",
51
+ status=response.status_code,
52
+ headers=response.headers,
53
+ body=response.text,
54
+ )
55
+
56
+
57
+ def _timeout_error(timeout_ms: float) -> ClassifierHttpError:
58
+ return ClassifierHttpError(
59
+ f"Request timed out after {format_js_number(timeout_ms)}ms", status=None, headers=None, body=""
60
+ )
61
+
62
+
63
+ def is_record(value: Any) -> TypeGuard[Mapping[str, Any]]:
64
+ """Whether ``value`` is a JSON object."""
65
+ return isinstance(value, Mapping)
66
+
67
+
68
+ def is_number(value: Any) -> bool:
69
+ """Whether ``value`` is a finite JSON number (booleans excluded)."""
70
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
71
+
72
+
73
+ def required_number(label: str, value: Any, field: str) -> float:
74
+ if not is_number(value):
75
+ raise ValueError(f"{label} returned an invalid {field}")
76
+ return value
77
+
78
+
79
+ def format_js_number(value: float) -> str:
80
+ """Render a number like JavaScript's ``String(n)`` for integral values (``5`` rather than ``5.0``)."""
81
+ if isinstance(value, float) and value.is_integer():
82
+ return str(int(value))
83
+ return str(value)
84
+
85
+
86
+ def coerce_question(question: Any) -> ClassifierQuestion:
87
+ """Accept a question dataclass or its pi JSON form (``{"type": "bool", ...}``)."""
88
+ if isinstance(question, Mapping):
89
+ return classifier_question_from_dict(question)
90
+ return question
91
+
92
+
93
+ def classifier_questions(context: ClassifierContext) -> dict[str, ClassifierQuestion]:
94
+ """The context's questions as dataclasses, in request order."""
95
+ return {name: coerce_question(question) for name, question in context.questions.items()}
96
+
97
+
98
+ async def post_json(
99
+ label: str,
100
+ url: str,
101
+ *,
102
+ headers: Mapping[str, str],
103
+ payload: Any,
104
+ options: Mapping[str, Any],
105
+ no_retry_statuses: Sequence[int] = (),
106
+ ) -> tuple[httpx.Response, Any]:
107
+ """POST ``payload`` as JSON and return the response with its parsed body.
108
+
109
+ Each attempt gets a fresh ``timeout_ms`` deadline covering the whole exchange.
110
+ Retryable failures are retried ``max_retries`` times (default 2). Caller
111
+ cancellation raises :class:`AbortError` ("Request aborted").
112
+ """
113
+ client = get_http_client(url, options)
114
+ content = fastjson.dumps_bytes(payload)
115
+ timeout_ms = options.get("timeout_ms")
116
+ signal = options.get("signal")
117
+ http_timeout = httpx.Timeout((timeout_ms if timeout_ms is not None else DEFAULT_TIMEOUT_MS) / 1000)
118
+
119
+ async def attempt() -> tuple[httpx.Response, Any]:
120
+ request = client.build_request("POST", url, headers=headers, content=content, timeout=http_timeout)
121
+ try:
122
+ async with asyncio.timeout(timeout_ms / 1000 if timeout_ms is not None else None):
123
+ response = await client.send(request)
124
+ except (TimeoutError, httpx.TimeoutException) as error:
125
+ if timeout_ms is not None and not (signal is not None and signal.aborted):
126
+ raise _timeout_error(timeout_ms) from error
127
+ raise
128
+ if not response.is_success:
129
+ raise _http_error(label, response)
130
+ return response, fastjson.loads(response.content)
131
+
132
+ max_retries = options.get("max_retries")
133
+ try:
134
+ async with abort_scope(signal):
135
+ return await retry_provider_request(
136
+ attempt,
137
+ max_retries=max_retries if max_retries is not None else DEFAULT_CLASSIFIER_MAX_RETRIES,
138
+ max_retry_delay_ms=options.get("max_retry_delay_ms"),
139
+ signal=signal,
140
+ no_retry_statuses=no_retry_statuses,
141
+ )
142
+ except AbortError as error:
143
+ raise AbortError("Request aborted") from error
144
+
145
+
146
+ def _request_headers(model: ClassifierModel, api_key: str, options_headers: Any) -> dict[str, str]:
147
+ return (
148
+ provider_headers_to_record(
149
+ {"authorization": f"Bearer {api_key}", "content-type": "application/json"},
150
+ model.headers,
151
+ options_headers,
152
+ )
153
+ or {}
154
+ )
155
+
156
+
157
+ async def post_classifier_request(
158
+ label: str,
159
+ url: str,
160
+ model: ClassifierModel,
161
+ body: Any,
162
+ options: Mapping[str, Any] | None,
163
+ no_retry_statuses: Sequence[int] = (),
164
+ ) -> Any:
165
+ """POST one JSON classifier request with bearer auth, hooks, per-attempt timeouts and retries.
166
+
167
+ Returns the parsed response body; raises on failure. ``no_retry_statuses`` lists
168
+ HTTP statuses that fail at once although they are normally retried.
169
+ """
170
+ options = options or {}
171
+ api_key = options.get("api_key")
172
+ if not api_key:
173
+ raise ValueError(f"No API key for provider: {model.provider}")
174
+ payload = body
175
+ on_payload = options.get("on_payload")
176
+ if on_payload is not None:
177
+ transformed = await maybe_await(on_payload(payload, model))
178
+ if transformed is not None:
179
+ payload = transformed
180
+ response, json_body = await post_json(
181
+ label,
182
+ url,
183
+ headers=_request_headers(model, api_key, options.get("headers")),
184
+ payload=payload,
185
+ options=options,
186
+ no_retry_statuses=no_retry_statuses,
187
+ )
188
+ on_response = options.get("on_response")
189
+ if on_response is not None:
190
+ await maybe_await(
191
+ on_response(ProviderResponse(response.status_code, headers_to_record(response.headers)), model)
192
+ )
193
+ return json_body
194
+
195
+
196
+ def _token_count(value: Any) -> Any:
197
+ return value if is_number(value) and value > 0 else 0
198
+
199
+
200
+ def parse_classifier_usage(value: Any, model: ClassifierModel) -> Usage | None:
201
+ """Usage from an ``{input_tokens, output_tokens}`` object, priced from the model catalog like chat usage.
202
+
203
+ A missing or malformed usage object leaves the result without usage instead of failing it.
204
+ """
205
+ if not is_record(value) or ("input_tokens" not in value and "output_tokens" not in value):
206
+ return None
207
+ input_tokens = _token_count(value.get("input_tokens"))
208
+ output_tokens = _token_count(value.get("output_tokens"))
209
+ usage = Usage(input_tokens, output_tokens, 0, 0, input_tokens + output_tokens)
210
+ calculate_cost(model, usage)
211
+ return usage
@@ -0,0 +1,36 @@
1
+ """Cloudflare endpoint templates.
2
+
3
+ ``{CLOUDFLARE_ACCOUNT_ID}`` and ``{CLOUDFLARE_GATEWAY_ID}`` are placeholders that
4
+ the Cloudflare providers fill from the resolved provider env before dispatch.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = [
10
+ "CLOUDFLARE_WORKERS_AI_BASE_URL",
11
+ "CLOUDFLARE_WORKERS_AI_REST_BASE_URL",
12
+ "CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL",
13
+ "CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL",
14
+ "CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL",
15
+ ]
16
+
17
+ #: Workers AI direct endpoint.
18
+ CLOUDFLARE_WORKERS_AI_BASE_URL = "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1"
19
+
20
+ #: Workers AI REST endpoint root; model runs are posted to ``run``.
21
+ CLOUDFLARE_WORKERS_AI_REST_BASE_URL = "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai"
22
+
23
+ #: AI Gateway Unified API. https://developers.cloudflare.com/ai-gateway/usage/unified-api/
24
+ CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL = (
25
+ "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat"
26
+ )
27
+
28
+ #: AI Gateway -> OpenAI passthrough. Used until /compat supports /v1/responses.
29
+ CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL = (
30
+ "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai"
31
+ )
32
+
33
+ #: AI Gateway -> Anthropic passthrough.
34
+ CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL = (
35
+ "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic"
36
+ )
@@ -0,0 +1,80 @@
1
+ """System One models on the Cloudflare Workers AI REST endpoint.
2
+
3
+ ``POST /accounts/{account}/ai/run`` with ``{model, input}``. The REST API wraps the
4
+ model output in Cloudflare's API envelope. Third-party models such as
5
+ ``typesafe/jev`` add a run record:
6
+ ``{success, result: {state: "Completed", result: {answers, usage}}}``
7
+ (https://developers.cloudflare.com/ai/models/typesafe/jev/). Cloudflare-hosted
8
+ models such as ``@cf/cloudflare/clef`` return the output directly:
9
+ ``{success, result: {model, answers, usage}}``
10
+ (https://developers.cloudflare.com/workers-ai/models/clef/).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping
16
+ from typing import Any
17
+ from urllib.parse import urljoin
18
+
19
+ from ..types import ClassifierContext, ClassifierModel, ClassifierResult
20
+ from .classifier_shared import is_record
21
+ from .system_one_shared import SystemOneTransport, SystemOneWireRequest, classify_system_one
22
+
23
+ __all__ = ["classify"]
24
+
25
+ LABEL = "Cloudflare Workers AI"
26
+
27
+
28
+ def _cloudflare_error_message(errors: Any) -> str:
29
+ if isinstance(errors, list):
30
+ messages = [error["message"] for error in errors if is_record(error) and isinstance(error.get("message"), str)]
31
+ if messages:
32
+ return f"{LABEL} error: {'; '.join(messages)}"
33
+ return f"{LABEL} request failed"
34
+
35
+
36
+ def _js_string(value: Any) -> str:
37
+ if value is None:
38
+ return "null"
39
+ if isinstance(value, bool):
40
+ return "true" if value else "false"
41
+ return str(value)
42
+
43
+
44
+ def _url(model: ClassifierModel) -> str:
45
+ return urljoin(f"{model.base_url.rstrip('/')}/", "run")
46
+
47
+
48
+ def _payload(model: ClassifierModel, request: SystemOneWireRequest) -> Any:
49
+ return {"model": model.id, "input": request}
50
+
51
+
52
+ def _output(body: Any) -> Mapping[str, Any]:
53
+ if not is_record(body):
54
+ raise ValueError(f"{LABEL} returned an unexpected response")
55
+ if body.get("success") is False:
56
+ raise ValueError(_cloudflare_error_message(body.get("errors")))
57
+ result = body.get("result")
58
+ if not is_record(result):
59
+ raise ValueError(f"{LABEL} returned an unexpected response")
60
+ if "answers" in result:
61
+ return result
62
+ if result.get("state") != "Completed":
63
+ state = _js_string(result["state"]) if "state" in result else "undefined"
64
+ raise ValueError(f"{LABEL} run did not complete (state: {state})")
65
+ run_result = result.get("result")
66
+ if not is_record(run_result):
67
+ raise ValueError(f"{LABEL} returned an unexpected response")
68
+ return run_result
69
+
70
+
71
+ _TRANSPORT = SystemOneTransport(
72
+ api="cloudflare-workers-ai-system-one", label=LABEL, url=_url, payload=_payload, output=_output
73
+ )
74
+
75
+
76
+ async def classify(
77
+ model: ClassifierModel, context: ClassifierContext, options: Mapping[str, Any] | None = None
78
+ ) -> ClassifierResult:
79
+ """Cloudflare Workers AI System One classification with public ``bool`` values mapped to ``noul``."""
80
+ return await classify_system_one(_TRANSPORT, model, context, options)
@@ -0,0 +1,279 @@
1
+ """Provider-side constrained sampling: strict JSON-schema tools and OpenAI grammar tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass
9
+ from typing import Any, Literal
10
+
11
+ from ..types import Tool
12
+ from ..utils.schema import parameters_schema
13
+
14
+ __all__ = [
15
+ "UnsupportedStrictJsonSchemaError",
16
+ "UnsupportedStrictSchemaKeywordCheck",
17
+ "make_strict_json_schema",
18
+ "get_json_schema_tool_parameters",
19
+ "GrammarConstrainedSampling",
20
+ "GrammarToolInputJsonBuffer",
21
+ "get_grammar_tool_input",
22
+ "append_grammar_tool_input_json_delta",
23
+ "resolve_json_schema_strict_sampling",
24
+ "resolve_grammar_constrained_sampling",
25
+ "create_grammar_tool_input_properties",
26
+ ]
27
+
28
+
29
+ class UnsupportedStrictJsonSchemaError(Exception):
30
+ pass
31
+
32
+
33
+ #: Returns True when a provider's strict mode rejects this schema keyword with this value.
34
+ UnsupportedStrictSchemaKeywordCheck = Callable[[str, Any], bool]
35
+
36
+ _UNSUPPORTED_STRICT_SCHEMA_KEYS = (
37
+ "$ref",
38
+ "$defs",
39
+ "definitions",
40
+ "allOf",
41
+ "oneOf",
42
+ "patternProperties",
43
+ "dependentSchemas",
44
+ "dependencies",
45
+ "unevaluatedProperties",
46
+ "propertyNames",
47
+ "contains",
48
+ "prefixItems",
49
+ "not",
50
+ "if",
51
+ "then",
52
+ "else",
53
+ )
54
+
55
+
56
+ def _is_schema_object(value: Any) -> bool:
57
+ return isinstance(value, dict)
58
+
59
+
60
+ def _is_structured_schema(schema: Any) -> bool:
61
+ if not isinstance(schema, dict):
62
+ return False
63
+ raw = schema.get("type")
64
+ types = [raw] if isinstance(raw, str) else raw if isinstance(raw, list) else []
65
+ return "object" in types or "array" in types or "properties" in schema or "items" in schema
66
+
67
+
68
+ def _schema_allows_null(schema: Any) -> bool:
69
+ if not isinstance(schema, dict):
70
+ return False
71
+ raw = schema.get("type")
72
+ if raw == "null" or (isinstance(raw, list) and "null" in raw):
73
+ return True
74
+ if "const" in schema and schema["const"] is None:
75
+ return True
76
+ if isinstance(schema.get("enum"), list) and None in schema["enum"]:
77
+ return True
78
+ any_of = schema.get("anyOf")
79
+ return isinstance(any_of, list) and any(_schema_allows_null(variant) for variant in any_of)
80
+
81
+
82
+ def _make_node_strict(schema: Any, is_unsupported: UnsupportedStrictSchemaKeywordCheck | None) -> None:
83
+ if not isinstance(schema, dict):
84
+ raise UnsupportedStrictJsonSchemaError("boolean schemas are unsupported")
85
+ for key in _UNSUPPORTED_STRICT_SCHEMA_KEYS:
86
+ if key in schema:
87
+ raise UnsupportedStrictJsonSchemaError(f"{key} schemas are unsupported")
88
+ if is_unsupported is not None:
89
+ for key, value in schema.items():
90
+ if is_unsupported(key, value):
91
+ raise UnsupportedStrictJsonSchemaError(f"{key}: {json.dumps(value)} is unsupported")
92
+
93
+ if "anyOf" in schema:
94
+ any_of = schema["anyOf"]
95
+ if not isinstance(any_of, list) or not any_of:
96
+ raise UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema")
97
+ for variant in any_of:
98
+ if _is_structured_schema(variant):
99
+ raise UnsupportedStrictJsonSchemaError("object and array unions are unsupported")
100
+ _make_node_strict(variant, is_unsupported)
101
+
102
+ if "items" in schema:
103
+ if isinstance(schema["items"], list):
104
+ raise UnsupportedStrictJsonSchemaError("tuple schemas are unsupported")
105
+ _make_node_strict(schema["items"], is_unsupported)
106
+
107
+ is_object = schema.get("type") == "object"
108
+ if "properties" in schema and not is_object:
109
+ raise UnsupportedStrictJsonSchemaError("properties require type object")
110
+ if not is_object:
111
+ return
112
+ if "additionalProperties" in schema and schema["additionalProperties"] is not False:
113
+ raise UnsupportedStrictJsonSchemaError("schema-valued or true additionalProperties is unsupported")
114
+ if "properties" in schema and not isinstance(schema["properties"], dict):
115
+ raise UnsupportedStrictJsonSchemaError("object properties must be a schema map")
116
+ required_raw = schema.get("required")
117
+ if required_raw is not None and (
118
+ not isinstance(required_raw, list) or any(not isinstance(key, str) for key in required_raw)
119
+ ):
120
+ raise UnsupportedStrictJsonSchemaError("object required must be a string array")
121
+
122
+ properties: dict[str, Any] = schema.get("properties") or {}
123
+ names = list(properties)
124
+ required = set(required_raw or [])
125
+ if any(key not in properties for key in required):
126
+ raise UnsupportedStrictJsonSchemaError("required contains an unknown property")
127
+ for key, prop in list(properties.items()):
128
+ _make_node_strict(prop, is_unsupported)
129
+ if key not in required and not _schema_allows_null(prop):
130
+ properties[key] = {"anyOf": [prop, {"type": "null"}]}
131
+ if "properties" in schema:
132
+ schema["properties"] = properties
133
+ schema["required"] = names
134
+ schema["additionalProperties"] = False
135
+
136
+
137
+ def make_strict_json_schema(
138
+ schema: Any, is_unsupported: UnsupportedStrictSchemaKeywordCheck | None = None
139
+ ) -> dict[str, Any]:
140
+ """Convert a tool schema to the strict subset expected by provider constrained sampling."""
141
+ cloned = copy.deepcopy(parameters_schema(schema)) if not isinstance(schema, dict) else copy.deepcopy(dict(schema))
142
+ cloned = json.loads(json.dumps(cloned))
143
+ if not isinstance(cloned, dict):
144
+ raise UnsupportedStrictJsonSchemaError("root schema must have type object")
145
+ _make_node_strict(cloned, is_unsupported)
146
+ if cloned.get("type") != "object":
147
+ raise UnsupportedStrictJsonSchemaError("root schema must have type object")
148
+ return cloned
149
+
150
+
151
+ def get_json_schema_tool_parameters(tool: Tool, strict: bool | None) -> dict[str, Any]:
152
+ """Tool parameters as sent to the provider (made strict when ``strict`` is True)."""
153
+ return make_strict_json_schema(tool.parameters) if strict is True else parameters_schema(tool.parameters)
154
+
155
+
156
+ @dataclass(slots=True)
157
+ class GrammarConstrainedSampling:
158
+ format: Literal["lark", "regex"]
159
+ definition: str
160
+ input_property: str
161
+
162
+
163
+ @dataclass(slots=True)
164
+ class GrammarToolInputJsonBuffer:
165
+ input: str = ""
166
+ started: bool = False
167
+ closed: bool = False
168
+
169
+
170
+ def get_grammar_tool_input(tool_name: str, arguments: dict[str, Any], input_property: str) -> str:
171
+ value = arguments.get(input_property)
172
+ if not isinstance(value, str):
173
+ raise ValueError(f'Grammar tool call "{tool_name}" requires argument "{input_property}" to be a string.')
174
+ return value
175
+
176
+
177
+ def append_grammar_tool_input_json_delta(
178
+ buffer: GrammarToolInputJsonBuffer, input_property: str, next_input: str, close: bool
179
+ ) -> str | None:
180
+ """Translate a grammar tool's raw input growth into a JSON-arguments delta."""
181
+ if buffer.closed:
182
+ if close and next_input == buffer.input:
183
+ return None
184
+ raise ValueError(f'grammar tool input for property "{input_property}" changed after it was closed')
185
+ if not next_input.startswith(buffer.input):
186
+ raise ValueError(f'grammar tool input for property "{input_property}" changed non-monotonically')
187
+ input_delta = next_input[len(buffer.input) :]
188
+ if not close and not input_delta:
189
+ return None
190
+ delta = ""
191
+ if not buffer.started:
192
+ delta += "{" + json.dumps(input_property) + ':"'
193
+ buffer.started = True
194
+ delta += json.dumps(input_delta, ensure_ascii=False)[1:-1]
195
+ buffer.input = next_input
196
+ if close:
197
+ delta += '"}'
198
+ buffer.closed = True
199
+ return delta
200
+
201
+
202
+ def _infer_grammar_input_property(tool: Tool) -> str:
203
+ schema = parameters_schema(tool.parameters)
204
+ if schema.get("type") != "object":
205
+ raise ValueError("grammar constrained sampling requires an object parameter schema")
206
+ required = schema.get("required")
207
+ if not isinstance(required, list) or len(required) != 1 or not isinstance(required[0], str):
208
+ raise ValueError("grammar constrained sampling requires exactly one required string property")
209
+ name = required[0]
210
+ properties = schema.get("properties") or {}
211
+ if name not in properties:
212
+ raise ValueError(f"grammar constrained sampling requires a properties entry for {name}")
213
+ if (properties[name] or {}).get("type") != "string":
214
+ raise ValueError(f"grammar constrained sampling property {name} must have type string")
215
+ return name
216
+
217
+
218
+ def resolve_json_schema_strict_sampling(
219
+ tool: Tool, supports_strict_mode: bool, is_unsupported: UnsupportedStrictSchemaKeywordCheck | None = None
220
+ ) -> bool | None:
221
+ """Whether a JSON-schema tool is sent in strict mode (``None`` = not strict).
222
+
223
+ Raises ``ValueError`` when the tool requires strict sampling the provider/schema cannot honor.
224
+ """
225
+ config = tool.constrained_sampling
226
+ if not config or config.get("type") != "json_schema":
227
+ return None
228
+ if supports_strict_mode:
229
+ try:
230
+ make_strict_json_schema(tool.parameters, is_unsupported)
231
+ return True
232
+ except UnsupportedStrictJsonSchemaError as error:
233
+ if config.get("strict") != "require":
234
+ return None
235
+ raise ValueError(f'Tool "{tool.name}" requires JSON-schema constrained sampling, but {error}.') from error
236
+ if config.get("strict") == "require":
237
+ raise ValueError(
238
+ f'Tool "{tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.'
239
+ )
240
+ return None
241
+
242
+
243
+ def resolve_grammar_constrained_sampling(
244
+ tool: Tool, supports_openai_grammar_tools: bool
245
+ ) -> GrammarConstrainedSampling | None:
246
+ config = tool.constrained_sampling
247
+ if not config or config.get("type") != "grammar":
248
+ return None
249
+ if not supports_openai_grammar_tools:
250
+ return None
251
+ variants = config.get("variants") or {}
252
+ lark = variants.get("openai_lark")
253
+ regex = variants.get("openai_regex")
254
+ has_lark = isinstance(lark, str) and bool(lark.strip())
255
+ has_regex = isinstance(regex, str) and bool(regex.strip())
256
+ if not has_lark and not has_regex:
257
+ raise ValueError(
258
+ f'Tool "{tool.name}" cannot use grammar constrained sampling: no supported grammar variant was provided.'
259
+ )
260
+ try:
261
+ return GrammarConstrainedSampling(
262
+ format="lark" if has_lark else "regex",
263
+ definition=lark if has_lark else regex, # type: ignore[arg-type]
264
+ input_property=_infer_grammar_input_property(tool),
265
+ )
266
+ except ValueError as error:
267
+ raise ValueError(f'Tool "{tool.name}" cannot use grammar constrained sampling: {error}.') from error
268
+
269
+
270
+ def create_grammar_tool_input_properties(
271
+ tools: Sequence[Tool] | None, supports_openai_grammar_tools: bool
272
+ ) -> dict[str, str]:
273
+ """Map grammar tool names to their single string input property."""
274
+ properties: dict[str, str] = {}
275
+ for tool in tools or ():
276
+ grammar = resolve_grammar_constrained_sampling(tool, supports_openai_grammar_tools)
277
+ if grammar is not None:
278
+ properties[tool.name] = grammar.input_property
279
+ return properties
@@ -0,0 +1,30 @@
1
+ """Dynamic request headers required by GitHub Copilot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ __all__ = ["infer_copilot_initiator", "has_copilot_vision_input", "build_copilot_dynamic_headers"]
9
+
10
+
11
+ def infer_copilot_initiator(messages: Sequence[Any]) -> str:
12
+ """``"user"`` for user-initiated requests, ``"agent"`` for follow-ups after assistant/tool messages."""
13
+ last = messages[-1] if messages else None
14
+ return "agent" if last is not None and last.role != "user" else "user"
15
+
16
+
17
+ def has_copilot_vision_input(messages: Sequence[Any]) -> bool:
18
+ """Copilot requires ``Copilot-Vision-Request`` when any user or tool-result message carries images."""
19
+ for message in messages:
20
+ if message.role in ("user", "toolResult") and not isinstance(message.content, str):
21
+ if any(block.type == "image" for block in message.content):
22
+ return True
23
+ return False
24
+
25
+
26
+ def build_copilot_dynamic_headers(*, messages: Sequence[Any], has_images: bool) -> dict[str, str]:
27
+ headers = {"X-Initiator": infer_copilot_initiator(messages), "Openai-Intent": "conversation-edits"}
28
+ if has_images:
29
+ headers["Copilot-Vision-Request"] = "true"
30
+ return headers