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