Spaces:
Running
Running
Delete _providers
Browse files- _providers/__init__.py +0 -60
- _providers/anthropic.py +0 -511
- _providers/artifact_output.py +0 -502
- _providers/base.py +0 -62
- _providers/custom.py +0 -10
- _providers/executor.py +0 -377
- _providers/gemini.py +0 -1167
- _providers/huggingface.py +0 -479
- _providers/openai.py +0 -553
- _providers/policy.py +0 -256
- _providers/registry.py +0 -124
- _providers/self_hosted.py +0 -10
_providers/__init__.py
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
"""Provider adapters for first-class chat resources."""
|
| 4 |
-
|
| 5 |
-
from .anthropic import AnthropicAdapter, AnthropicResourceExecutor
|
| 6 |
-
from .artifact_output import (
|
| 7 |
-
GeneratedProviderArtifact,
|
| 8 |
-
OpenAIProviderArtifactOutputExecutor,
|
| 9 |
-
ProviderArtifactOutputExecutor,
|
| 10 |
-
ProviderArtifactOutputRegistry,
|
| 11 |
-
StubProviderArtifactOutputExecutor,
|
| 12 |
-
)
|
| 13 |
-
from .base import ProviderAdapter, ProviderResourceCapabilities, ResourceRoute
|
| 14 |
-
from .custom import CustomAdapter
|
| 15 |
-
from .executor import (
|
| 16 |
-
PendingProviderExecutor,
|
| 17 |
-
ProviderChatExecutor,
|
| 18 |
-
ProviderExecutorRegistry,
|
| 19 |
-
ProviderPrivateResource,
|
| 20 |
-
ProviderResourceExecutor,
|
| 21 |
-
ResourceExecutionError,
|
| 22 |
-
ResourceExecutionReceipt,
|
| 23 |
-
ResourceExecutionSession,
|
| 24 |
-
)
|
| 25 |
-
from .gemini import GeminiAdapter, GeminiResourceExecutor
|
| 26 |
-
from .huggingface import HuggingFaceAdapter, HuggingFaceResourceExecutor
|
| 27 |
-
from .openai import OpenAIAdapter, OpenAIResourceExecutor
|
| 28 |
-
from .registry import ProviderRegistry, StaticProviderAdapter
|
| 29 |
-
from .self_hosted import SelfHostedAdapter
|
| 30 |
-
|
| 31 |
-
__all__ = [
|
| 32 |
-
"AnthropicAdapter",
|
| 33 |
-
"AnthropicResourceExecutor",
|
| 34 |
-
"CustomAdapter",
|
| 35 |
-
"GeminiAdapter",
|
| 36 |
-
"GeminiResourceExecutor",
|
| 37 |
-
"GeneratedProviderArtifact",
|
| 38 |
-
"HuggingFaceAdapter",
|
| 39 |
-
"HuggingFaceResourceExecutor",
|
| 40 |
-
"OpenAIAdapter",
|
| 41 |
-
"OpenAIProviderArtifactOutputExecutor",
|
| 42 |
-
"OpenAIResourceExecutor",
|
| 43 |
-
"PendingProviderExecutor",
|
| 44 |
-
"ProviderAdapter",
|
| 45 |
-
"ProviderArtifactOutputExecutor",
|
| 46 |
-
"ProviderArtifactOutputRegistry",
|
| 47 |
-
"ProviderChatExecutor",
|
| 48 |
-
"ProviderExecutorRegistry",
|
| 49 |
-
"ProviderPrivateResource",
|
| 50 |
-
"ProviderRegistry",
|
| 51 |
-
"ProviderResourceCapabilities",
|
| 52 |
-
"ProviderResourceExecutor",
|
| 53 |
-
"ResourceExecutionError",
|
| 54 |
-
"ResourceExecutionReceipt",
|
| 55 |
-
"ResourceExecutionSession",
|
| 56 |
-
"ResourceRoute",
|
| 57 |
-
"SelfHostedAdapter",
|
| 58 |
-
"StaticProviderAdapter",
|
| 59 |
-
"StubProviderArtifactOutputExecutor",
|
| 60 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/anthropic.py
DELETED
|
@@ -1,511 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
Anthropic Files + Messages resource executor.
|
| 6 |
-
|
| 7 |
-
Uploaded file identifiers are workspace-level provider capabilities and remain
|
| 8 |
-
strictly request-private. They never enter browser responses, health metadata,
|
| 9 |
-
logs, receipts, persistence, or exports.
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
import json
|
| 15 |
-
import secrets
|
| 16 |
-
from collections.abc import AsyncIterator, Sequence
|
| 17 |
-
from urllib.parse import quote, urlparse
|
| 18 |
-
|
| 19 |
-
import httpx
|
| 20 |
-
|
| 21 |
-
from .executor import ProviderPrivateResource, ResourceExecutionError
|
| 22 |
-
from .policy import anthropic_code_execution_supported_model
|
| 23 |
-
from .registry import StaticProviderAdapter
|
| 24 |
-
|
| 25 |
-
try:
|
| 26 |
-
from .._utils._chat_contract import ChatRequest, build_upstream_payload
|
| 27 |
-
from .._utils._resource_transport import ResourceUpload
|
| 28 |
-
except ImportError: # standalone HF Space: _providers is top-level
|
| 29 |
-
from _utils._chat_contract import ChatRequest, build_upstream_payload
|
| 30 |
-
from _utils._resource_transport import ResourceUpload
|
| 31 |
-
|
| 32 |
-
_ANTHROPIC_BASE = "https://api.anthropic.com"
|
| 33 |
-
_ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages"
|
| 34 |
-
_ANTHROPIC_VERSION = "2023-06-01"
|
| 35 |
-
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
| 36 |
-
_MIN_EXPIRES_SECONDS = 3600
|
| 37 |
-
_MAX_EXPIRES_SECONDS = 90 * 24 * 60 * 60
|
| 38 |
-
_MAX_FILE_BYTES = 500_000_000
|
| 39 |
-
_MAX_SSE_EVENT_BYTES = 1024 * 1024
|
| 40 |
-
_CODE_EXECUTION_TOOL = "code_execution_20260521"
|
| 41 |
-
_IMAGE_MIMES = frozenset({"image/jpeg", "image/png", "image/gif", "image/webp"})
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
class AnthropicAdapter(StaticProviderAdapter):
|
| 45 |
-
def __init__(self, **kwargs):
|
| 46 |
-
super().__init__("anthropic", **kwargs)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def _validated_anthropic_base(value: str) -> str:
|
| 50 |
-
raw = str(value or _ANTHROPIC_BASE).rstrip("/")
|
| 51 |
-
parsed = urlparse(raw)
|
| 52 |
-
if (
|
| 53 |
-
parsed.scheme != "https"
|
| 54 |
-
or parsed.hostname != "api.anthropic.com"
|
| 55 |
-
or parsed.username is not None
|
| 56 |
-
or parsed.password is not None
|
| 57 |
-
or parsed.port not in {None, 443}
|
| 58 |
-
or parsed.query
|
| 59 |
-
or parsed.fragment
|
| 60 |
-
or parsed.path not in {"", "/"}
|
| 61 |
-
):
|
| 62 |
-
raise ValueError(
|
| 63 |
-
"Anthropic resource executor requires the official HTTPS API origin"
|
| 64 |
-
)
|
| 65 |
-
return _ANTHROPIC_BASE
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def official_anthropic_messages_backend(value: str) -> bool:
|
| 69 |
-
"""Return whether Path-1 points exactly at the official Messages URL."""
|
| 70 |
-
raw = str(value or "").strip().rstrip("/")
|
| 71 |
-
try:
|
| 72 |
-
parsed = urlparse(raw)
|
| 73 |
-
except ValueError:
|
| 74 |
-
return False
|
| 75 |
-
return bool(
|
| 76 |
-
parsed.scheme == "https"
|
| 77 |
-
and parsed.hostname == "api.anthropic.com"
|
| 78 |
-
and parsed.username is None
|
| 79 |
-
and parsed.password is None
|
| 80 |
-
and parsed.port in {None, 443}
|
| 81 |
-
and not parsed.query
|
| 82 |
-
and not parsed.fragment
|
| 83 |
-
and parsed.path.rstrip("/") == "/v1/messages"
|
| 84 |
-
and raw == _ANTHROPIC_MESSAGES_URL
|
| 85 |
-
)
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def _safe_provider_file_id(value: object) -> str:
|
| 89 |
-
"""Validate one opaque provider file ID without assuming a prefix/format."""
|
| 90 |
-
if (
|
| 91 |
-
not isinstance(value, str)
|
| 92 |
-
or not value
|
| 93 |
-
or value != value.strip()
|
| 94 |
-
or len(value) > 512 # ruff: ignore[magic-value-comparison]
|
| 95 |
-
):
|
| 96 |
-
raise ResourceExecutionError("Anthropic private file handle is invalid")
|
| 97 |
-
if any(
|
| 98 |
-
ord(ch) < 0x20 or ord(ch) == 0x7F # ruff: ignore[magic-value-comparison]
|
| 99 |
-
for ch in value
|
| 100 |
-
):
|
| 101 |
-
raise ResourceExecutionError("Anthropic private file handle is invalid")
|
| 102 |
-
if any(ch in value for ch in "/\\?#&"):
|
| 103 |
-
raise ResourceExecutionError("Anthropic private file handle is invalid")
|
| 104 |
-
return value
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def _opaque_file_id(handle: ProviderPrivateResource) -> str:
|
| 108 |
-
opaque = handle.opaque if isinstance(handle.opaque, dict) else {}
|
| 109 |
-
return _safe_provider_file_id(opaque.get("file_id"))
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def _upload_mime(upload: ResourceUpload) -> str:
|
| 113 |
-
verified = upload.verified
|
| 114 |
-
if verified.detected_modality == "text":
|
| 115 |
-
return "text/plain"
|
| 116 |
-
return verified.detected_mime or "application/octet-stream"
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def _multipart_header(
|
| 120 |
-
boundary: str, *, seconds: int, upload: ResourceUpload
|
| 121 |
-
) -> tuple[bytes, bytes]:
|
| 122 |
-
verified = upload.verified
|
| 123 |
-
safe_name = (
|
| 124 |
-
verified.name.replace('"', "_").replace("\r", " ").replace("\n", " ")[:500]
|
| 125 |
-
or "resource"
|
| 126 |
-
)
|
| 127 |
-
prefix = (
|
| 128 |
-
f"--{boundary}\r\n"
|
| 129 |
-
'Content-Disposition: form-data; name="expires_in_seconds"\r\n\r\n'
|
| 130 |
-
f"{seconds}\r\n"
|
| 131 |
-
f"--{boundary}\r\n"
|
| 132 |
-
f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'
|
| 133 |
-
f"Content-Type: {_upload_mime(upload)}\r\n\r\n"
|
| 134 |
-
).encode()
|
| 135 |
-
suffix = f"\r\n--{boundary}--\r\n".encode("ascii")
|
| 136 |
-
return prefix, suffix
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def _bounded_json_bytes(payload: dict) -> bytes:
|
| 140 |
-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
|
| 141 |
-
"utf-8"
|
| 142 |
-
)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def _message_text(payload: object) -> str:
|
| 146 |
-
if not isinstance(payload, dict):
|
| 147 |
-
raise ResourceExecutionError("Anthropic Messages returned invalid metadata")
|
| 148 |
-
if payload.get("type") == "error" or payload.get("error"):
|
| 149 |
-
raise ResourceExecutionError("Anthropic Messages reported a provider failure")
|
| 150 |
-
pieces: list[str] = []
|
| 151 |
-
content = payload.get("content")
|
| 152 |
-
if isinstance(content, list):
|
| 153 |
-
for block in content:
|
| 154 |
-
if (
|
| 155 |
-
isinstance(block, dict)
|
| 156 |
-
and block.get("type") == "text"
|
| 157 |
-
and isinstance(block.get("text"), str)
|
| 158 |
-
):
|
| 159 |
-
pieces.append(block["text"])
|
| 160 |
-
text = "".join(pieces)
|
| 161 |
-
if not text:
|
| 162 |
-
raise ResourceExecutionError("Anthropic Messages returned no assistant text")
|
| 163 |
-
return text
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
class AnthropicResourceExecutor:
|
| 167 |
-
"""Upload verified resources to Files and execute Claude Messages."""
|
| 168 |
-
|
| 169 |
-
name = "anthropic"
|
| 170 |
-
enabled = True
|
| 171 |
-
|
| 172 |
-
def __init__(
|
| 173 |
-
self,
|
| 174 |
-
*,
|
| 175 |
-
api_key: str,
|
| 176 |
-
client: httpx.AsyncClient,
|
| 177 |
-
base_url: str = _ANTHROPIC_BASE,
|
| 178 |
-
expires_seconds: int = _MIN_EXPIRES_SECONDS,
|
| 179 |
-
response_timeout_seconds: float = 600.0,
|
| 180 |
-
) -> None:
|
| 181 |
-
key = str(api_key or "").strip()
|
| 182 |
-
if not key:
|
| 183 |
-
raise ValueError("Anthropic API key is required")
|
| 184 |
-
self._api_key = key
|
| 185 |
-
self._client = client
|
| 186 |
-
self._base = _validated_anthropic_base(base_url)
|
| 187 |
-
self._expires = max(
|
| 188 |
-
_MIN_EXPIRES_SECONDS, min(int(expires_seconds), _MAX_EXPIRES_SECONDS)
|
| 189 |
-
)
|
| 190 |
-
self._response_timeout = httpx.Timeout(
|
| 191 |
-
connect=20.0,
|
| 192 |
-
read=max(30.0, min(float(response_timeout_seconds), 1800.0)),
|
| 193 |
-
write=120.0,
|
| 194 |
-
pool=20.0,
|
| 195 |
-
)
|
| 196 |
-
|
| 197 |
-
def executable_routes(self, model: str) -> dict[str, tuple[str, ...]]:
|
| 198 |
-
routes: dict[str, tuple[str, ...]] = {
|
| 199 |
-
"text": ("native",),
|
| 200 |
-
"image": ("native",),
|
| 201 |
-
"animated_image": ("native",),
|
| 202 |
-
"document": ("native",),
|
| 203 |
-
}
|
| 204 |
-
if anthropic_code_execution_supported_model(model):
|
| 205 |
-
for modality in ("vector_image", "data", "archive", "binary"):
|
| 206 |
-
routes[modality] = ("tool",)
|
| 207 |
-
return routes
|
| 208 |
-
|
| 209 |
-
def _headers(self) -> dict[str, str]:
|
| 210 |
-
return {
|
| 211 |
-
"x-api-key": self._api_key,
|
| 212 |
-
"anthropic-version": _ANTHROPIC_VERSION,
|
| 213 |
-
"Accept": "application/json",
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
async def _body(self, prefix: bytes, suffix: bytes, upload: ResourceUpload):
|
| 217 |
-
await upload.upload.seek(0)
|
| 218 |
-
try:
|
| 219 |
-
yield prefix
|
| 220 |
-
while True:
|
| 221 |
-
chunk = await upload.upload.read(_UPLOAD_CHUNK_BYTES)
|
| 222 |
-
if not chunk:
|
| 223 |
-
break
|
| 224 |
-
yield chunk
|
| 225 |
-
yield suffix
|
| 226 |
-
finally:
|
| 227 |
-
await upload.upload.seek(0)
|
| 228 |
-
|
| 229 |
-
async def prepare(
|
| 230 |
-
self, *, model: str, route, upload: ResourceUpload
|
| 231 |
-
) -> ProviderPrivateResource:
|
| 232 |
-
del model
|
| 233 |
-
verified = upload.verified
|
| 234 |
-
if route.resource_id != verified.id or route.route not in {"native", "tool"}:
|
| 235 |
-
raise ResourceExecutionError(
|
| 236 |
-
"Anthropic executor received an invalid resource route"
|
| 237 |
-
)
|
| 238 |
-
if int(verified.actual_size) > _MAX_FILE_BYTES:
|
| 239 |
-
raise ResourceExecutionError(
|
| 240 |
-
"Anthropic file exceeds the bounded 500 MB provider limit"
|
| 241 |
-
)
|
| 242 |
-
boundary = "scikitplots-" + secrets.token_hex(16)
|
| 243 |
-
prefix, suffix = _multipart_header(
|
| 244 |
-
boundary, seconds=self._expires, upload=upload
|
| 245 |
-
)
|
| 246 |
-
headers = self._headers()
|
| 247 |
-
headers.update(
|
| 248 |
-
{
|
| 249 |
-
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
| 250 |
-
"Content-Length": str(len(prefix) + verified.actual_size + len(suffix)),
|
| 251 |
-
}
|
| 252 |
-
)
|
| 253 |
-
try:
|
| 254 |
-
response = await self._client.post(
|
| 255 |
-
f"{self._base}/v1/files",
|
| 256 |
-
headers=headers,
|
| 257 |
-
content=self._body(prefix, suffix, upload),
|
| 258 |
-
)
|
| 259 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 260 |
-
raise ResourceExecutionError(
|
| 261 |
-
"Anthropic temporary file upload failed"
|
| 262 |
-
) from exc
|
| 263 |
-
if (
|
| 264 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 265 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 266 |
-
):
|
| 267 |
-
raise ResourceExecutionError("Anthropic temporary file upload was rejected")
|
| 268 |
-
try:
|
| 269 |
-
payload = response.json()
|
| 270 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 271 |
-
raise ResourceExecutionError(
|
| 272 |
-
"Anthropic temporary file upload returned invalid metadata"
|
| 273 |
-
) from exc
|
| 274 |
-
if not isinstance(payload, dict):
|
| 275 |
-
raise ResourceExecutionError(
|
| 276 |
-
"Anthropic temporary file upload returned invalid metadata"
|
| 277 |
-
)
|
| 278 |
-
file_id = _safe_provider_file_id(payload.get("id"))
|
| 279 |
-
returned_bytes = payload.get("size_bytes")
|
| 280 |
-
if returned_bytes is not None:
|
| 281 |
-
try:
|
| 282 |
-
if int(returned_bytes) != int(verified.actual_size):
|
| 283 |
-
raise ResourceExecutionError(
|
| 284 |
-
"Anthropic temporary file size did not match source bytes"
|
| 285 |
-
)
|
| 286 |
-
except (TypeError, ValueError) as exc:
|
| 287 |
-
raise ResourceExecutionError(
|
| 288 |
-
"Anthropic temporary file size metadata was invalid"
|
| 289 |
-
) from exc
|
| 290 |
-
return ProviderPrivateResource(
|
| 291 |
-
resource_id=verified.id,
|
| 292 |
-
route=route.route,
|
| 293 |
-
provider=self.name,
|
| 294 |
-
source_sha256=verified.sha256,
|
| 295 |
-
source_size=verified.actual_size,
|
| 296 |
-
opaque={
|
| 297 |
-
"file_id": file_id,
|
| 298 |
-
"modality": verified.detected_modality,
|
| 299 |
-
"mime": _upload_mime(upload),
|
| 300 |
-
"name": verified.name,
|
| 301 |
-
},
|
| 302 |
-
cleanup_required=True,
|
| 303 |
-
)
|
| 304 |
-
|
| 305 |
-
async def release(self, handle: ProviderPrivateResource) -> None:
|
| 306 |
-
file_id = _opaque_file_id(handle)
|
| 307 |
-
try:
|
| 308 |
-
response = await self._client.delete(
|
| 309 |
-
f"{self._base}/v1/files/{quote(file_id, safe='')}",
|
| 310 |
-
headers=self._headers(),
|
| 311 |
-
)
|
| 312 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 313 |
-
raise ResourceExecutionError(
|
| 314 |
-
"Anthropic temporary file cleanup failed"
|
| 315 |
-
) from exc
|
| 316 |
-
if response.status_code == 404: # ruff: ignore[magic-value-comparison]
|
| 317 |
-
handle.released = True
|
| 318 |
-
return
|
| 319 |
-
if (
|
| 320 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 321 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 322 |
-
):
|
| 323 |
-
raise ResourceExecutionError(
|
| 324 |
-
"Anthropic temporary file cleanup was rejected"
|
| 325 |
-
)
|
| 326 |
-
try:
|
| 327 |
-
payload = response.json()
|
| 328 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 329 |
-
raise ResourceExecutionError(
|
| 330 |
-
"Anthropic temporary file cleanup returned invalid metadata"
|
| 331 |
-
) from exc
|
| 332 |
-
if not isinstance(payload, dict) or payload.get("type") != "file_deleted":
|
| 333 |
-
raise ResourceExecutionError(
|
| 334 |
-
"Anthropic temporary file cleanup was not confirmed"
|
| 335 |
-
)
|
| 336 |
-
handle.released = True
|
| 337 |
-
|
| 338 |
-
def messages_payload(
|
| 339 |
-
self, chat: ChatRequest, handles: Sequence[ProviderPrivateResource]
|
| 340 |
-
) -> dict:
|
| 341 |
-
provider_body = build_upstream_payload(chat, reasoning_enabled=False)
|
| 342 |
-
messages = provider_body.get("messages", [])
|
| 343 |
-
system = str(messages[0].get("content", "")) if len(messages) > 0 else ""
|
| 344 |
-
user_text = (
|
| 345 |
-
str(messages[1].get("content", ""))
|
| 346 |
-
if len(messages) > 1
|
| 347 |
-
else chat.user_message
|
| 348 |
-
)
|
| 349 |
-
content: list[dict] = [{"type": "text", "text": user_text}]
|
| 350 |
-
tool_needed = False
|
| 351 |
-
|
| 352 |
-
for handle in handles:
|
| 353 |
-
file_id = _opaque_file_id(handle)
|
| 354 |
-
opaque = handle.opaque if isinstance(handle.opaque, dict) else {}
|
| 355 |
-
modality = str(opaque.get("modality", ""))
|
| 356 |
-
mime = str(opaque.get("mime", ""))
|
| 357 |
-
if handle.route == "tool":
|
| 358 |
-
if not anthropic_code_execution_supported_model(chat.model):
|
| 359 |
-
raise ResourceExecutionError(
|
| 360 |
-
"Anthropic Code Execution is not enabled for this model"
|
| 361 |
-
)
|
| 362 |
-
content.append({"type": "container_upload", "file_id": file_id})
|
| 363 |
-
tool_needed = True
|
| 364 |
-
continue
|
| 365 |
-
if handle.route != "native":
|
| 366 |
-
raise ResourceExecutionError(
|
| 367 |
-
"Anthropic Messages received a non-executable route"
|
| 368 |
-
)
|
| 369 |
-
if modality in {"text", "document"}:
|
| 370 |
-
if modality == "document" and mime != "application/pdf":
|
| 371 |
-
raise ResourceExecutionError(
|
| 372 |
-
"Anthropic native document route supports PDF or plain text only"
|
| 373 |
-
)
|
| 374 |
-
content.append(
|
| 375 |
-
{"type": "document", "source": {"type": "file", "file_id": file_id}}
|
| 376 |
-
)
|
| 377 |
-
continue
|
| 378 |
-
if modality in {"image", "animated_image"} and mime in _IMAGE_MIMES:
|
| 379 |
-
content.append(
|
| 380 |
-
{"type": "image", "source": {"type": "file", "file_id": file_id}}
|
| 381 |
-
)
|
| 382 |
-
continue
|
| 383 |
-
raise ResourceExecutionError(
|
| 384 |
-
"Anthropic native route does not support this modality"
|
| 385 |
-
)
|
| 386 |
-
|
| 387 |
-
payload: dict = {
|
| 388 |
-
"model": chat.model,
|
| 389 |
-
"system": system,
|
| 390 |
-
"messages": [{"role": "user", "content": content}],
|
| 391 |
-
"max_tokens": chat.max_tokens,
|
| 392 |
-
"stream": bool(chat.stream),
|
| 393 |
-
}
|
| 394 |
-
if tool_needed:
|
| 395 |
-
payload["tools"] = [
|
| 396 |
-
{"type": _CODE_EXECUTION_TOOL, "name": "code_execution"}
|
| 397 |
-
]
|
| 398 |
-
return payload
|
| 399 |
-
|
| 400 |
-
async def open_chat(
|
| 401 |
-
self, *, chat: ChatRequest, handles: Sequence[ProviderPrivateResource]
|
| 402 |
-
) -> httpx.Response:
|
| 403 |
-
payload = self.messages_payload(chat, handles)
|
| 404 |
-
headers = self._headers()
|
| 405 |
-
headers["Content-Type"] = "application/json"
|
| 406 |
-
request = self._client.build_request(
|
| 407 |
-
"POST",
|
| 408 |
-
f"{self._base}/v1/messages",
|
| 409 |
-
headers=headers,
|
| 410 |
-
content=_bounded_json_bytes(payload),
|
| 411 |
-
timeout=self._response_timeout,
|
| 412 |
-
)
|
| 413 |
-
try:
|
| 414 |
-
return await self._client.send(request, stream=True)
|
| 415 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 416 |
-
raise ResourceExecutionError("Anthropic Messages request failed") from exc
|
| 417 |
-
|
| 418 |
-
async def buffered_chat_response(
|
| 419 |
-
self, upstream: httpx.Response, *, maximum: int
|
| 420 |
-
) -> dict:
|
| 421 |
-
total = 0
|
| 422 |
-
chunks: list[bytes] = []
|
| 423 |
-
async for chunk in upstream.aiter_bytes():
|
| 424 |
-
total += len(chunk)
|
| 425 |
-
if total > maximum:
|
| 426 |
-
raise ResourceExecutionError(
|
| 427 |
-
"Anthropic Messages body exceeded the configured response limit"
|
| 428 |
-
)
|
| 429 |
-
chunks.append(chunk)
|
| 430 |
-
try:
|
| 431 |
-
payload = json.loads(b"".join(chunks))
|
| 432 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 433 |
-
raise ResourceExecutionError(
|
| 434 |
-
"Anthropic Messages returned invalid JSON"
|
| 435 |
-
) from exc
|
| 436 |
-
text = _message_text(payload)
|
| 437 |
-
return {
|
| 438 |
-
"choices": [{"message": {"role": "assistant", "content": text}}],
|
| 439 |
-
"model": (
|
| 440 |
-
str(payload.get("model", "") or "") if isinstance(payload, dict) else ""
|
| 441 |
-
),
|
| 442 |
-
}
|
| 443 |
-
|
| 444 |
-
async def chat_sse( # ruff: ignore[too-many-branches]
|
| 445 |
-
self, upstream: httpx.Response, *, maximum: int
|
| 446 |
-
) -> AsyncIterator[bytes]:
|
| 447 |
-
total = 0
|
| 448 |
-
buffer = bytearray()
|
| 449 |
-
completed = False
|
| 450 |
-
async for chunk in upstream.aiter_bytes():
|
| 451 |
-
total += len(chunk)
|
| 452 |
-
if total > maximum:
|
| 453 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_RESPONSE_TOO_LARGE","message":"The upstream response exceeded the configured limit."}}\n\n'
|
| 454 |
-
return
|
| 455 |
-
buffer.extend(chunk)
|
| 456 |
-
if (
|
| 457 |
-
len(buffer) > _MAX_SSE_EVENT_BYTES
|
| 458 |
-
and b"\n\n" not in buffer
|
| 459 |
-
and b"\r\n\r\n" not in buffer
|
| 460 |
-
):
|
| 461 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_EVENT_TOO_LARGE","message":"The upstream stream event exceeded the configured limit."}}\n\n'
|
| 462 |
-
return
|
| 463 |
-
while True:
|
| 464 |
-
pos_lf = buffer.find(b"\n\n")
|
| 465 |
-
pos_crlf = buffer.find(b"\r\n\r\n")
|
| 466 |
-
positions = [(pos_lf, 2), (pos_crlf, 4)]
|
| 467 |
-
positions = [(pos, width) for pos, width in positions if pos >= 0]
|
| 468 |
-
if not positions:
|
| 469 |
-
break
|
| 470 |
-
pos, width = min(positions, key=lambda row: row[0])
|
| 471 |
-
if pos > _MAX_SSE_EVENT_BYTES:
|
| 472 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_EVENT_TOO_LARGE","message":"The upstream stream event exceeded the configured limit."}}\n\n'
|
| 473 |
-
return
|
| 474 |
-
raw = bytes(buffer[:pos])
|
| 475 |
-
del buffer[: pos + width]
|
| 476 |
-
event = ""
|
| 477 |
-
data_lines: list[bytes] = []
|
| 478 |
-
for line in raw.replace(b"\r\n", b"\n").split(b"\n"):
|
| 479 |
-
if line.startswith(b"event:"):
|
| 480 |
-
event = line[6:].strip().decode("utf-8", "replace")
|
| 481 |
-
elif line.startswith(b"data:"):
|
| 482 |
-
data_lines.append(line[5:].lstrip())
|
| 483 |
-
if event == "message_stop":
|
| 484 |
-
completed = True
|
| 485 |
-
yield b"data: [DONE]\n\n"
|
| 486 |
-
return
|
| 487 |
-
if event == "error":
|
| 488 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROVIDER_ERROR","message":"The upstream provider stream failed."}}\n\n'
|
| 489 |
-
return
|
| 490 |
-
if not data_lines:
|
| 491 |
-
continue
|
| 492 |
-
try:
|
| 493 |
-
payload = json.loads(b"\n".join(data_lines))
|
| 494 |
-
except (json.JSONDecodeError, ValueError):
|
| 495 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_STREAM_INVALID","message":"The upstream provider stream was malformed."}}\n\n'
|
| 496 |
-
return
|
| 497 |
-
if event == "content_block_delta" and isinstance(payload, dict):
|
| 498 |
-
delta = payload.get("delta")
|
| 499 |
-
if (
|
| 500 |
-
isinstance(delta, dict)
|
| 501 |
-
and delta.get("type") == "text_delta"
|
| 502 |
-
and isinstance(delta.get("text"), str)
|
| 503 |
-
):
|
| 504 |
-
frame = json.dumps(
|
| 505 |
-
{"choices": [{"delta": {"content": delta["text"]}}]},
|
| 506 |
-
ensure_ascii=False,
|
| 507 |
-
separators=(",", ":"),
|
| 508 |
-
).encode("utf-8")
|
| 509 |
-
yield b"data: " + frame + b"\n\n"
|
| 510 |
-
if not completed:
|
| 511 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_STREAM_INCOMPLETE","message":"The upstream provider stream ended before completion."}}\n\n'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/artifact_output.py
DELETED
|
@@ -1,502 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
First-class provider binary artifact output executors.
|
| 6 |
-
|
| 7 |
-
Output generation is intentionally separate from chat/resource input routing.
|
| 8 |
-
Input modality support never implies write/output authority. Only executors
|
| 9 |
-
registered here may produce bytes for ``/v1/artifacts/provider-output``.
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
import base64
|
| 15 |
-
import hashlib
|
| 16 |
-
import io
|
| 17 |
-
import json
|
| 18 |
-
import math
|
| 19 |
-
import struct
|
| 20 |
-
import tempfile
|
| 21 |
-
import wave
|
| 22 |
-
import zlib
|
| 23 |
-
from dataclasses import dataclass
|
| 24 |
-
from typing import Protocol, Sequence
|
| 25 |
-
|
| 26 |
-
import httpx
|
| 27 |
-
|
| 28 |
-
try:
|
| 29 |
-
from .._utils._provider_artifact import (
|
| 30 |
-
PROVIDER_ARTIFACT_MAX_OUTPUT_BYTES,
|
| 31 |
-
ProviderArtifactError,
|
| 32 |
-
ProviderArtifactGeneratorSpec,
|
| 33 |
-
ProviderArtifactReceipt,
|
| 34 |
-
ProviderArtifactRequest,
|
| 35 |
-
build_provider_artifact_receipt_from_digest,
|
| 36 |
-
validate_provider_artifact_signature,
|
| 37 |
-
)
|
| 38 |
-
except ImportError: # standalone HF Space: _providers is top-level
|
| 39 |
-
from _utils._provider_artifact import (
|
| 40 |
-
PROVIDER_ARTIFACT_MAX_OUTPUT_BYTES,
|
| 41 |
-
ProviderArtifactError,
|
| 42 |
-
ProviderArtifactGeneratorSpec,
|
| 43 |
-
ProviderArtifactReceipt,
|
| 44 |
-
ProviderArtifactRequest,
|
| 45 |
-
build_provider_artifact_receipt_from_digest,
|
| 46 |
-
validate_provider_artifact_signature,
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
-
_OPENAI_IMAGE_URL = "https://api.openai.com/v1/images/generations"
|
| 50 |
-
_OPENAI_SPEECH_URL = "https://api.openai.com/v1/audio/speech"
|
| 51 |
-
_OPENAI_IMAGE_MODEL = "gpt-image-2"
|
| 52 |
-
_OPENAI_TTS_MODEL = "gpt-4o-mini-tts"
|
| 53 |
-
_IMAGE_MAX_OUTPUT = 16 * 1024 * 1024
|
| 54 |
-
_AUDIO_MAX_OUTPUT = 32 * 1024 * 1024
|
| 55 |
-
_IMAGE_JSON_MAX = 24 * 1024 * 1024
|
| 56 |
-
_COPY_CHUNK = 1024 * 1024
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
@dataclass
|
| 60 |
-
class GeneratedProviderArtifact:
|
| 61 |
-
file: tempfile.SpooledTemporaryFile
|
| 62 |
-
receipt: ProviderArtifactReceipt
|
| 63 |
-
filename: str
|
| 64 |
-
|
| 65 |
-
def close(self) -> None:
|
| 66 |
-
self.file.close()
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
class ProviderArtifactOutputExecutor(Protocol):
|
| 70 |
-
name: str
|
| 71 |
-
enabled: bool
|
| 72 |
-
|
| 73 |
-
def specs(self) -> Sequence[ProviderArtifactGeneratorSpec]: ...
|
| 74 |
-
|
| 75 |
-
async def generate(
|
| 76 |
-
self, request: ProviderArtifactRequest
|
| 77 |
-
) -> GeneratedProviderArtifact: ...
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
class ProviderArtifactOutputRegistry:
|
| 81 |
-
"""Generator-id registry separated from resource/chat execution authority."""
|
| 82 |
-
|
| 83 |
-
def __init__(self) -> None:
|
| 84 |
-
self._executors: dict[str, ProviderArtifactOutputExecutor] = {}
|
| 85 |
-
self._specs: dict[str, ProviderArtifactGeneratorSpec] = {}
|
| 86 |
-
|
| 87 |
-
def clear(self) -> None:
|
| 88 |
-
self._executors.clear()
|
| 89 |
-
self._specs.clear()
|
| 90 |
-
|
| 91 |
-
def register(self, executor: ProviderArtifactOutputExecutor) -> None:
|
| 92 |
-
if not bool(getattr(executor, "enabled", False)):
|
| 93 |
-
raise ValueError("provider artifact executor is disabled")
|
| 94 |
-
rows = tuple(executor.specs())
|
| 95 |
-
if not rows:
|
| 96 |
-
raise ValueError("provider artifact executor exposes no generators")
|
| 97 |
-
for spec in rows:
|
| 98 |
-
if spec.id in self._specs:
|
| 99 |
-
raise ValueError("duplicate provider artifact generator id")
|
| 100 |
-
self._specs[spec.id] = spec
|
| 101 |
-
self._executors[spec.id] = executor
|
| 102 |
-
|
| 103 |
-
def public_specs(self) -> tuple[ProviderArtifactGeneratorSpec, ...]:
|
| 104 |
-
return tuple(self._specs[key] for key in sorted(self._specs))
|
| 105 |
-
|
| 106 |
-
def spec(self, generator_id: str) -> ProviderArtifactGeneratorSpec | None:
|
| 107 |
-
return self._specs.get(str(generator_id or ""))
|
| 108 |
-
|
| 109 |
-
async def generate(
|
| 110 |
-
self, request: ProviderArtifactRequest
|
| 111 |
-
) -> GeneratedProviderArtifact:
|
| 112 |
-
executor = self._executors.get(request.generator_id)
|
| 113 |
-
spec = self._specs.get(request.generator_id)
|
| 114 |
-
if executor is None or spec is None:
|
| 115 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_GENERATOR_UNAVAILABLE")
|
| 116 |
-
if request.kind != spec.kind or request.mime_type not in spec.mime_types:
|
| 117 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_CAPABILITY_MISMATCH")
|
| 118 |
-
artifact = await executor.generate(request)
|
| 119 |
-
try:
|
| 120 |
-
validate_provider_artifact_signature(
|
| 121 |
-
artifact.file, request.mime_type, artifact.receipt.output_size
|
| 122 |
-
)
|
| 123 |
-
except Exception:
|
| 124 |
-
artifact.close()
|
| 125 |
-
raise
|
| 126 |
-
artifact.file.seek(0)
|
| 127 |
-
return artifact
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def _png_bytes(prompt: str) -> bytes:
|
| 131 |
-
"""Return a deterministic 32x32 RGB PNG without third-party image libraries."""
|
| 132 |
-
digest = hashlib.sha256(prompt.encode("utf-8")).digest()
|
| 133 |
-
width = height = 32
|
| 134 |
-
rows = bytearray()
|
| 135 |
-
for y in range(height):
|
| 136 |
-
rows.append(0)
|
| 137 |
-
for x in range(width):
|
| 138 |
-
rows.extend(
|
| 139 |
-
(
|
| 140 |
-
digest[(x + y) % 32],
|
| 141 |
-
digest[(x * 3 + y) % 32],
|
| 142 |
-
digest[(x + y * 5) % 32],
|
| 143 |
-
)
|
| 144 |
-
)
|
| 145 |
-
raw = zlib.compress(bytes(rows), level=9)
|
| 146 |
-
|
| 147 |
-
def chunk(kind: bytes, payload: bytes) -> bytes:
|
| 148 |
-
return (
|
| 149 |
-
struct.pack(">I", len(payload))
|
| 150 |
-
+ kind
|
| 151 |
-
+ payload
|
| 152 |
-
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
return (
|
| 156 |
-
b"\x89PNG\r\n\x1a\n"
|
| 157 |
-
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
|
| 158 |
-
+ chunk(b"IDAT", raw)
|
| 159 |
-
+ chunk(b"IEND", b"")
|
| 160 |
-
)
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
def _wav_bytes(prompt: str) -> bytes:
|
| 164 |
-
"""Return a deterministic short PCM WAV fixture derived from the prompt."""
|
| 165 |
-
digest = hashlib.sha256(prompt.encode("utf-8")).digest()
|
| 166 |
-
rate = 8000
|
| 167 |
-
duration = 0.20
|
| 168 |
-
frames = int(rate * duration)
|
| 169 |
-
freq = 300 + digest[0]
|
| 170 |
-
out = io.BytesIO()
|
| 171 |
-
with wave.open(out, "wb") as wav:
|
| 172 |
-
wav.setnchannels(1)
|
| 173 |
-
wav.setsampwidth(2)
|
| 174 |
-
wav.setframerate(rate)
|
| 175 |
-
samples = bytearray()
|
| 176 |
-
for i in range(frames):
|
| 177 |
-
value = int(8000 * math.sin(2 * math.pi * freq * i / rate))
|
| 178 |
-
samples.extend(struct.pack("<h", value))
|
| 179 |
-
wav.writeframes(bytes(samples))
|
| 180 |
-
return out.getvalue()
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
def _artifact_from_bytes(
|
| 184 |
-
*,
|
| 185 |
-
request: ProviderArtifactRequest,
|
| 186 |
-
spec: ProviderArtifactGeneratorSpec,
|
| 187 |
-
payload: bytes,
|
| 188 |
-
filename: str,
|
| 189 |
-
) -> GeneratedProviderArtifact:
|
| 190 |
-
if not payload or len(payload) > min(
|
| 191 |
-
spec.max_output_bytes, PROVIDER_ARTIFACT_MAX_OUTPUT_BYTES
|
| 192 |
-
):
|
| 193 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_OUTPUT_TOO_LARGE")
|
| 194 |
-
spool = (
|
| 195 |
-
# lint
|
| 196 |
-
tempfile.SpooledTemporaryFile( # ruff: ignore[open-file-with-context-handler]
|
| 197 |
-
max_size=8 * 1024 * 1024,
|
| 198 |
-
mode="w+b",
|
| 199 |
-
)
|
| 200 |
-
)
|
| 201 |
-
spool.write(payload)
|
| 202 |
-
spool.seek(0)
|
| 203 |
-
receipt = build_provider_artifact_receipt_from_digest(
|
| 204 |
-
request=request,
|
| 205 |
-
spec=spec,
|
| 206 |
-
output_size=len(payload),
|
| 207 |
-
output_sha256=hashlib.sha256(payload).hexdigest(),
|
| 208 |
-
)
|
| 209 |
-
return GeneratedProviderArtifact(file=spool, receipt=receipt, filename=filename)
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
class StubProviderArtifactOutputExecutor:
|
| 213 |
-
"""Deterministic credential-free generator used only when the stub rig is enabled."""
|
| 214 |
-
|
| 215 |
-
name = "stub"
|
| 216 |
-
enabled = True
|
| 217 |
-
|
| 218 |
-
_SPECS = (
|
| 219 |
-
ProviderArtifactGeneratorSpec(
|
| 220 |
-
id="stub/generated-png",
|
| 221 |
-
provider="stub",
|
| 222 |
-
model="deterministic-png",
|
| 223 |
-
kind="image",
|
| 224 |
-
diagnostic=True,
|
| 225 |
-
mime_types=("image/png",),
|
| 226 |
-
max_output_bytes=1024 * 1024,
|
| 227 |
-
option_schema={},
|
| 228 |
-
),
|
| 229 |
-
ProviderArtifactGeneratorSpec(
|
| 230 |
-
id="stub/generated-wav",
|
| 231 |
-
provider="stub",
|
| 232 |
-
model="deterministic-wav",
|
| 233 |
-
kind="audio",
|
| 234 |
-
diagnostic=True,
|
| 235 |
-
mime_types=("audio/wav",),
|
| 236 |
-
max_output_bytes=1024 * 1024,
|
| 237 |
-
option_schema={},
|
| 238 |
-
),
|
| 239 |
-
)
|
| 240 |
-
|
| 241 |
-
def specs(self) -> Sequence[ProviderArtifactGeneratorSpec]:
|
| 242 |
-
return self._SPECS
|
| 243 |
-
|
| 244 |
-
async def generate(
|
| 245 |
-
self, request: ProviderArtifactRequest
|
| 246 |
-
) -> GeneratedProviderArtifact:
|
| 247 |
-
spec = next(
|
| 248 |
-
(row for row in self._SPECS if row.id == request.generator_id), None
|
| 249 |
-
)
|
| 250 |
-
if spec is None or request.mime_type not in spec.mime_types:
|
| 251 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_CAPABILITY_MISMATCH")
|
| 252 |
-
if spec.kind == "image":
|
| 253 |
-
return _artifact_from_bytes(
|
| 254 |
-
request=request,
|
| 255 |
-
spec=spec,
|
| 256 |
-
payload=_png_bytes(request.prompt),
|
| 257 |
-
filename="generated.png",
|
| 258 |
-
)
|
| 259 |
-
return _artifact_from_bytes(
|
| 260 |
-
request=request,
|
| 261 |
-
spec=spec,
|
| 262 |
-
payload=_wav_bytes(request.prompt),
|
| 263 |
-
filename="generated.wav",
|
| 264 |
-
)
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
async def _read_limited(response: httpx.Response, maximum: int) -> bytes:
|
| 268 |
-
declared = response.headers.get("content-length")
|
| 269 |
-
if declared:
|
| 270 |
-
try:
|
| 271 |
-
if int(declared) > maximum:
|
| 272 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_UPSTREAM_TOO_LARGE")
|
| 273 |
-
except ValueError:
|
| 274 |
-
pass
|
| 275 |
-
out = bytearray()
|
| 276 |
-
async for chunk in response.aiter_bytes():
|
| 277 |
-
out.extend(chunk)
|
| 278 |
-
if len(out) > maximum:
|
| 279 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_UPSTREAM_TOO_LARGE")
|
| 280 |
-
return bytes(out)
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
async def _stream_limited_to_spool(
|
| 284 |
-
response: httpx.Response, maximum: int
|
| 285 |
-
) -> tuple[tempfile.SpooledTemporaryFile, int, str]:
|
| 286 |
-
declared = response.headers.get("content-length")
|
| 287 |
-
if declared:
|
| 288 |
-
try:
|
| 289 |
-
if int(declared) > maximum:
|
| 290 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_OUTPUT_TOO_LARGE")
|
| 291 |
-
except ValueError:
|
| 292 |
-
pass
|
| 293 |
-
spool = (
|
| 294 |
-
# lint
|
| 295 |
-
tempfile.SpooledTemporaryFile( # ruff: ignore[open-file-with-context-handler]
|
| 296 |
-
max_size=8 * 1024 * 1024,
|
| 297 |
-
mode="w+b",
|
| 298 |
-
)
|
| 299 |
-
)
|
| 300 |
-
digest = hashlib.sha256()
|
| 301 |
-
total = 0
|
| 302 |
-
try:
|
| 303 |
-
async for chunk in response.aiter_bytes(chunk_size=_COPY_CHUNK):
|
| 304 |
-
total += len(chunk)
|
| 305 |
-
if total > maximum:
|
| 306 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_OUTPUT_TOO_LARGE")
|
| 307 |
-
digest.update(chunk)
|
| 308 |
-
spool.write(chunk)
|
| 309 |
-
if total <= 0:
|
| 310 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_OUTPUT_INVALID")
|
| 311 |
-
spool.seek(0)
|
| 312 |
-
return spool, total, digest.hexdigest()
|
| 313 |
-
except Exception:
|
| 314 |
-
spool.close()
|
| 315 |
-
raise
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
class OpenAIProviderArtifactOutputExecutor:
|
| 319 |
-
"""Official OpenAI GPT Image / Speech API output executor."""
|
| 320 |
-
|
| 321 |
-
name = "openai"
|
| 322 |
-
enabled = True
|
| 323 |
-
|
| 324 |
-
_SPECS = (
|
| 325 |
-
ProviderArtifactGeneratorSpec(
|
| 326 |
-
id="openai/gpt-image-2",
|
| 327 |
-
provider="openai",
|
| 328 |
-
model=_OPENAI_IMAGE_MODEL,
|
| 329 |
-
kind="image",
|
| 330 |
-
mime_types=("image/png", "image/jpeg", "image/webp"),
|
| 331 |
-
max_output_bytes=_IMAGE_MAX_OUTPUT,
|
| 332 |
-
option_schema={
|
| 333 |
-
"size": [
|
| 334 |
-
"auto",
|
| 335 |
-
"1024x1024",
|
| 336 |
-
"1536x1024",
|
| 337 |
-
"1024x1536",
|
| 338 |
-
"2048x2048",
|
| 339 |
-
"2048x1152",
|
| 340 |
-
"3840x2160",
|
| 341 |
-
"2160x3840",
|
| 342 |
-
],
|
| 343 |
-
"quality": ["auto", "low", "medium", "high"],
|
| 344 |
-
},
|
| 345 |
-
),
|
| 346 |
-
ProviderArtifactGeneratorSpec(
|
| 347 |
-
id="openai/gpt-4o-mini-tts",
|
| 348 |
-
provider="openai",
|
| 349 |
-
model=_OPENAI_TTS_MODEL,
|
| 350 |
-
kind="audio",
|
| 351 |
-
mime_types=("audio/mpeg", "audio/wav"),
|
| 352 |
-
max_output_bytes=_AUDIO_MAX_OUTPUT,
|
| 353 |
-
option_schema={
|
| 354 |
-
"voice": [
|
| 355 |
-
"alloy",
|
| 356 |
-
"ash",
|
| 357 |
-
"ballad",
|
| 358 |
-
"coral",
|
| 359 |
-
"echo",
|
| 360 |
-
"fable",
|
| 361 |
-
"nova",
|
| 362 |
-
"onyx",
|
| 363 |
-
"sage",
|
| 364 |
-
"shimmer",
|
| 365 |
-
"verse",
|
| 366 |
-
"marin",
|
| 367 |
-
"cedar",
|
| 368 |
-
],
|
| 369 |
-
"instructions": "string",
|
| 370 |
-
},
|
| 371 |
-
),
|
| 372 |
-
)
|
| 373 |
-
|
| 374 |
-
def __init__(
|
| 375 |
-
self, *, api_key: str, client: httpx.AsyncClient, timeout_seconds: float
|
| 376 |
-
) -> None:
|
| 377 |
-
token = str(api_key or "").strip()
|
| 378 |
-
if not token:
|
| 379 |
-
raise ValueError("OpenAI artifact output requires a non-empty API key")
|
| 380 |
-
self._api_key = token
|
| 381 |
-
self._client = client
|
| 382 |
-
self._timeout = max(10.0, min(float(timeout_seconds), 600.0))
|
| 383 |
-
|
| 384 |
-
def specs(self) -> Sequence[ProviderArtifactGeneratorSpec]:
|
| 385 |
-
return self._SPECS
|
| 386 |
-
|
| 387 |
-
def _spec(self, request: ProviderArtifactRequest) -> ProviderArtifactGeneratorSpec:
|
| 388 |
-
spec = next(
|
| 389 |
-
(row for row in self._SPECS if row.id == request.generator_id), None
|
| 390 |
-
)
|
| 391 |
-
if (
|
| 392 |
-
spec is None
|
| 393 |
-
or request.mime_type not in spec.mime_types
|
| 394 |
-
or request.kind != spec.kind
|
| 395 |
-
):
|
| 396 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_CAPABILITY_MISMATCH")
|
| 397 |
-
return spec
|
| 398 |
-
|
| 399 |
-
async def generate(
|
| 400 |
-
self, request: ProviderArtifactRequest
|
| 401 |
-
) -> GeneratedProviderArtifact:
|
| 402 |
-
spec = self._spec(request)
|
| 403 |
-
headers = {
|
| 404 |
-
"Authorization": f"Bearer {self._api_key}",
|
| 405 |
-
"Content-Type": "application/json",
|
| 406 |
-
}
|
| 407 |
-
timeout = httpx.Timeout(connect=10.0, read=self._timeout, write=30.0, pool=10.0)
|
| 408 |
-
if spec.kind == "image":
|
| 409 |
-
fmt = {"image/png": "png", "image/jpeg": "jpeg", "image/webp": "webp"}[
|
| 410 |
-
request.mime_type
|
| 411 |
-
]
|
| 412 |
-
payload = {
|
| 413 |
-
"model": _OPENAI_IMAGE_MODEL,
|
| 414 |
-
"prompt": request.prompt,
|
| 415 |
-
"size": request.options.get("size", "auto"),
|
| 416 |
-
"quality": request.options.get("quality", "auto"),
|
| 417 |
-
"output_format": fmt,
|
| 418 |
-
}
|
| 419 |
-
try:
|
| 420 |
-
response = await self._client.post(
|
| 421 |
-
_OPENAI_IMAGE_URL, headers=headers, json=payload, timeout=timeout
|
| 422 |
-
)
|
| 423 |
-
except httpx.RequestError as exc:
|
| 424 |
-
raise ProviderArtifactError(
|
| 425 |
-
"PROVIDER_ARTIFACT_UPSTREAM_UNAVAILABLE"
|
| 426 |
-
) from exc
|
| 427 |
-
try:
|
| 428 |
-
if response.status_code != 200: # ruff: ignore[magic-value-comparison]
|
| 429 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_UPSTREAM_REJECTED")
|
| 430 |
-
raw = await _read_limited(response, _IMAGE_JSON_MAX)
|
| 431 |
-
finally:
|
| 432 |
-
await response.aclose()
|
| 433 |
-
try:
|
| 434 |
-
doc = json.loads(raw)
|
| 435 |
-
encoded = doc["data"][0]["b64_json"]
|
| 436 |
-
if not isinstance(encoded, str) or not encoded:
|
| 437 |
-
raise ValueError
|
| 438 |
-
# Refuse impossible base64 lengths before decoding into process memory.
|
| 439 |
-
if len(encoded) > ((_IMAGE_MAX_OUTPUT + 2) // 3) * 4 + 16:
|
| 440 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_OUTPUT_TOO_LARGE")
|
| 441 |
-
binary = base64.b64decode(encoded, validate=True)
|
| 442 |
-
except ProviderArtifactError:
|
| 443 |
-
raise
|
| 444 |
-
except (
|
| 445 |
-
KeyError,
|
| 446 |
-
IndexError,
|
| 447 |
-
TypeError,
|
| 448 |
-
ValueError,
|
| 449 |
-
json.JSONDecodeError,
|
| 450 |
-
) as exc:
|
| 451 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_OUTPUT_INVALID") from exc
|
| 452 |
-
suffix = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}[
|
| 453 |
-
request.mime_type
|
| 454 |
-
]
|
| 455 |
-
return _artifact_from_bytes(
|
| 456 |
-
request=request,
|
| 457 |
-
spec=spec,
|
| 458 |
-
payload=binary,
|
| 459 |
-
filename="generated" + suffix,
|
| 460 |
-
)
|
| 461 |
-
|
| 462 |
-
response_format = "wav" if request.mime_type == "audio/wav" else "mp3"
|
| 463 |
-
payload = {
|
| 464 |
-
"model": _OPENAI_TTS_MODEL,
|
| 465 |
-
"input": request.prompt,
|
| 466 |
-
"voice": request.options.get("voice", "marin"),
|
| 467 |
-
"response_format": response_format,
|
| 468 |
-
}
|
| 469 |
-
instructions = request.options.get("instructions", "")
|
| 470 |
-
if instructions:
|
| 471 |
-
payload["instructions"] = instructions
|
| 472 |
-
try:
|
| 473 |
-
req = self._client.build_request(
|
| 474 |
-
"POST",
|
| 475 |
-
_OPENAI_SPEECH_URL,
|
| 476 |
-
headers=headers,
|
| 477 |
-
json=payload,
|
| 478 |
-
timeout=timeout,
|
| 479 |
-
)
|
| 480 |
-
response = await self._client.send(req, stream=True)
|
| 481 |
-
except httpx.RequestError as exc:
|
| 482 |
-
raise ProviderArtifactError(
|
| 483 |
-
"PROVIDER_ARTIFACT_UPSTREAM_UNAVAILABLE"
|
| 484 |
-
) from exc
|
| 485 |
-
try:
|
| 486 |
-
if response.status_code != 200: # ruff: ignore[magic-value-comparison]
|
| 487 |
-
raise ProviderArtifactError("PROVIDER_ARTIFACT_UPSTREAM_REJECTED")
|
| 488 |
-
spool, size, digest = await _stream_limited_to_spool(
|
| 489 |
-
response, _AUDIO_MAX_OUTPUT
|
| 490 |
-
)
|
| 491 |
-
finally:
|
| 492 |
-
await response.aclose()
|
| 493 |
-
receipt = build_provider_artifact_receipt_from_digest(
|
| 494 |
-
request=request,
|
| 495 |
-
spec=spec,
|
| 496 |
-
output_size=size,
|
| 497 |
-
output_sha256=digest,
|
| 498 |
-
)
|
| 499 |
-
suffix = ".wav" if request.mime_type == "audio/wav" else ".mp3"
|
| 500 |
-
return GeneratedProviderArtifact(
|
| 501 |
-
file=spool, receipt=receipt, filename="generated" + suffix
|
| 502 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/base.py
DELETED
|
@@ -1,62 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
Provider-neutral resource routing interfaces.
|
| 6 |
-
|
| 7 |
-
Run 127 defines the provider-routing authority boundary before enabling any
|
| 8 |
-
credential-bearing executor. Adapters added in later runs must explicitly map each
|
| 9 |
-
verified modality; an unknown modality fails closed instead of being silently
|
| 10 |
-
flattened or dropped.
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
|
| 15 |
-
from dataclasses import dataclass, field
|
| 16 |
-
from typing import Any, Protocol, Sequence
|
| 17 |
-
|
| 18 |
-
try:
|
| 19 |
-
from .._utils._resource_transport import ResourceUpload
|
| 20 |
-
except ImportError: # standalone HF Space: _providers is top-level
|
| 21 |
-
from _utils._resource_transport import ResourceUpload
|
| 22 |
-
|
| 23 |
-
RESOURCE_ROUTES = frozenset({"native", "tool", "extract", "context", "unsupported"})
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
@dataclass(frozen=True)
|
| 27 |
-
class ResourceRoute:
|
| 28 |
-
resource_id: str
|
| 29 |
-
route: str
|
| 30 |
-
reason: str = ""
|
| 31 |
-
metadata: dict[str, Any] = field(default_factory=dict)
|
| 32 |
-
|
| 33 |
-
def __post_init__(self) -> None:
|
| 34 |
-
if self.route not in RESOURCE_ROUTES:
|
| 35 |
-
raise ValueError("invalid resource route")
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
@dataclass(frozen=True)
|
| 39 |
-
class ProviderResourceCapabilities:
|
| 40 |
-
provider: str
|
| 41 |
-
model: str
|
| 42 |
-
modality_routes: dict[str, tuple[str, ...]]
|
| 43 |
-
max_files: int | None = None
|
| 44 |
-
max_file_bytes: int | None = None
|
| 45 |
-
max_total_bytes: int | None = None
|
| 46 |
-
|
| 47 |
-
def routes_for(self, modality: str) -> tuple[str, ...]:
|
| 48 |
-
return tuple(self.modality_routes.get(modality, ("unsupported",)))
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
class ProviderAdapter(Protocol):
|
| 52 |
-
"""Minimal contract every provider adapter must implement."""
|
| 53 |
-
|
| 54 |
-
name: str
|
| 55 |
-
|
| 56 |
-
def capabilities(self, model: str) -> ProviderResourceCapabilities: ...
|
| 57 |
-
|
| 58 |
-
async def route_resources(
|
| 59 |
-
self, model: str, resources: Sequence[ResourceUpload]
|
| 60 |
-
) -> Sequence[ResourceRoute]: ...
|
| 61 |
-
|
| 62 |
-
async def close(self) -> None: ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/custom.py
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
"""Explicit custom-backend resource adapter marker."""
|
| 4 |
-
|
| 5 |
-
from .registry import StaticProviderAdapter
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class CustomAdapter(StaticProviderAdapter):
|
| 9 |
-
def __init__(self, **kwargs):
|
| 10 |
-
super().__init__("custom", **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/executor.py
DELETED
|
@@ -1,377 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
Credential-neutral provider resource execution lifecycle.
|
| 6 |
-
|
| 7 |
-
Run 128 defines the state machine every later provider executor must obey. It
|
| 8 |
-
contains no provider credentials and performs no provider I/O by itself. The
|
| 9 |
-
important invariant is ownership: once a provider-side resource is created it
|
| 10 |
-
is registered for cleanup immediately, and cleanup is attempted in reverse
|
| 11 |
-
creation order on success, error, timeout, or cancellation.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import asyncio
|
| 17 |
-
from dataclasses import dataclass, field
|
| 18 |
-
from typing import Any, AsyncIterator, Protocol, Sequence
|
| 19 |
-
|
| 20 |
-
from typing_extensions import Self
|
| 21 |
-
|
| 22 |
-
from .base import ResourceRoute
|
| 23 |
-
|
| 24 |
-
try:
|
| 25 |
-
from .._utils._resource_transport import ResourceUpload
|
| 26 |
-
except ImportError: # standalone HF Space: _providers is top-level
|
| 27 |
-
from _utils._resource_transport import ResourceUpload
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class ResourceExecutionError(RuntimeError):
|
| 31 |
-
"""A provider resource could not enter a safe executable state."""
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
@dataclass
|
| 35 |
-
class ProviderPrivateResource:
|
| 36 |
-
"""Provider-owned resource handle that must never be serialized publicly."""
|
| 37 |
-
|
| 38 |
-
resource_id: str
|
| 39 |
-
route: str
|
| 40 |
-
provider: str
|
| 41 |
-
source_sha256: str
|
| 42 |
-
source_size: int
|
| 43 |
-
opaque: Any = None
|
| 44 |
-
cleanup_required: bool = True
|
| 45 |
-
released: bool = False
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@dataclass
|
| 49 |
-
class ResourceExecutionReceipt:
|
| 50 |
-
"""Sanitized chain-of-custody record for one resource lifecycle."""
|
| 51 |
-
|
| 52 |
-
resource_id: str
|
| 53 |
-
provider: str
|
| 54 |
-
model: str
|
| 55 |
-
route: str
|
| 56 |
-
source_sha256: str
|
| 57 |
-
source_size: int
|
| 58 |
-
state: str = "prepared"
|
| 59 |
-
cleanup_required: bool = True
|
| 60 |
-
cleanup_error: bool = False
|
| 61 |
-
metadata: dict[str, Any] = field(default_factory=dict)
|
| 62 |
-
|
| 63 |
-
def as_public_dict(self) -> dict[str, Any]:
|
| 64 |
-
"""Return a bounded receipt that contains no provider opaque identifier."""
|
| 65 |
-
return {
|
| 66 |
-
"resource_id": self.resource_id,
|
| 67 |
-
"provider": self.provider,
|
| 68 |
-
"model": self.model,
|
| 69 |
-
"route": self.route,
|
| 70 |
-
"source_sha256": self.source_sha256,
|
| 71 |
-
"source_size": self.source_size,
|
| 72 |
-
"state": self.state,
|
| 73 |
-
"cleanup_required": self.cleanup_required,
|
| 74 |
-
"cleanup_error": self.cleanup_error,
|
| 75 |
-
"metadata": dict(self.metadata),
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
class ProviderResourceExecutor(Protocol):
|
| 80 |
-
"""Provider-specific prepare/release boundary with route-level authority."""
|
| 81 |
-
|
| 82 |
-
name: str
|
| 83 |
-
enabled: bool
|
| 84 |
-
|
| 85 |
-
def executable_routes(self, model: str) -> dict[str, tuple[str, ...]]:
|
| 86 |
-
"""Return exact modality routes this executor can perform for *model*."""
|
| 87 |
-
...
|
| 88 |
-
|
| 89 |
-
async def prepare(
|
| 90 |
-
self,
|
| 91 |
-
*,
|
| 92 |
-
model: str,
|
| 93 |
-
route: ResourceRoute,
|
| 94 |
-
upload: ResourceUpload,
|
| 95 |
-
) -> ProviderPrivateResource: ...
|
| 96 |
-
|
| 97 |
-
async def release(self, handle: ProviderPrivateResource) -> None: ...
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
class ProviderChatExecutor(ProviderResourceExecutor, Protocol):
|
| 101 |
-
"""
|
| 102 |
-
Executable provider boundary for one prepared resource chat request.
|
| 103 |
-
|
| 104 |
-
Provider planning and temporary-resource ownership remain generic. Only an
|
| 105 |
-
executor registered in the separate execution registry may implement this
|
| 106 |
-
credential-bearing boundary. ``chat`` and ``upstream`` deliberately stay
|
| 107 |
-
provider-neutral here so the common proxy lifecycle does not depend on one
|
| 108 |
-
SDK's public types.
|
| 109 |
-
"""
|
| 110 |
-
|
| 111 |
-
async def open_chat(
|
| 112 |
-
self,
|
| 113 |
-
*,
|
| 114 |
-
chat: Any,
|
| 115 |
-
handles: Sequence[ProviderPrivateResource],
|
| 116 |
-
) -> Any: ...
|
| 117 |
-
|
| 118 |
-
async def buffered_chat_response(self, upstream: Any, *, maximum: int) -> dict: ...
|
| 119 |
-
|
| 120 |
-
def chat_sse(self, upstream: Any, *, maximum: int) -> AsyncIterator[bytes]: ...
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
@dataclass
|
| 124 |
-
class PendingProviderExecutor:
|
| 125 |
-
"""Explicit non-executor used until a credential-bearing adapter is enabled."""
|
| 126 |
-
|
| 127 |
-
name: str
|
| 128 |
-
enabled: bool = False
|
| 129 |
-
|
| 130 |
-
def executable_routes(self, model: str) -> dict[str, tuple[str, ...]]:
|
| 131 |
-
del model
|
| 132 |
-
return {}
|
| 133 |
-
|
| 134 |
-
async def prepare(
|
| 135 |
-
self,
|
| 136 |
-
*,
|
| 137 |
-
model: str,
|
| 138 |
-
route: ResourceRoute,
|
| 139 |
-
upload: ResourceUpload,
|
| 140 |
-
) -> ProviderPrivateResource:
|
| 141 |
-
del model, route, upload
|
| 142 |
-
raise ResourceExecutionError(f"{self.name} resource executor is not enabled")
|
| 143 |
-
|
| 144 |
-
async def release(self, handle: ProviderPrivateResource) -> None:
|
| 145 |
-
del handle
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
class ProviderExecutorRegistry:
|
| 149 |
-
"""Execution registry separate from provider capability/route planning."""
|
| 150 |
-
|
| 151 |
-
def __init__(self, names: Sequence[str] = ()) -> None:
|
| 152 |
-
names = [str(name).strip().lower() for name in names]
|
| 153 |
-
self._executors: dict[str, ProviderResourceExecutor] = {
|
| 154 |
-
name: PendingProviderExecutor(name) for name in names if str(name).strip()
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
def register(self, name: str, executor: ProviderResourceExecutor) -> None:
|
| 158 |
-
key = str(name or "").strip().lower()
|
| 159 |
-
if not key or key != str(executor.name or "").strip().lower():
|
| 160 |
-
raise ValueError("executor registry name mismatch")
|
| 161 |
-
self._executors[key] = executor
|
| 162 |
-
|
| 163 |
-
def unregister(self, name: str) -> None:
|
| 164 |
-
"""Return one provider to explicit plan-only state."""
|
| 165 |
-
key = str(name or "").strip().lower()
|
| 166 |
-
if key:
|
| 167 |
-
self._executors[key] = PendingProviderExecutor(key)
|
| 168 |
-
|
| 169 |
-
def get(self, name: str) -> ProviderResourceExecutor:
|
| 170 |
-
key = str(name or "custom").strip().lower() or "custom"
|
| 171 |
-
return self._executors.get(key, PendingProviderExecutor(key))
|
| 172 |
-
|
| 173 |
-
def execution_state(self, name: str) -> str:
|
| 174 |
-
return "enabled" if bool(self.get(name).enabled) else "plan-only"
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
class ResourceExecutionSession:
|
| 178 |
-
"""Sequential prepare + shielded LIFO cleanup for one provider request."""
|
| 179 |
-
|
| 180 |
-
def __init__(
|
| 181 |
-
self,
|
| 182 |
-
*,
|
| 183 |
-
provider: str,
|
| 184 |
-
model: str,
|
| 185 |
-
executor: ProviderResourceExecutor,
|
| 186 |
-
routes: Sequence[ResourceRoute],
|
| 187 |
-
) -> None:
|
| 188 |
-
self.provider = str(provider or "").strip().lower()
|
| 189 |
-
self.model = str(model or "")
|
| 190 |
-
self.executor = executor
|
| 191 |
-
self.routes = tuple(routes)
|
| 192 |
-
self._handles: list[ProviderPrivateResource] = []
|
| 193 |
-
self._receipts: list[ResourceExecutionReceipt] = []
|
| 194 |
-
self._closed = False
|
| 195 |
-
self._close_lock = asyncio.Lock()
|
| 196 |
-
|
| 197 |
-
@property
|
| 198 |
-
def receipts(self) -> tuple[ResourceExecutionReceipt, ...]:
|
| 199 |
-
return tuple(self._receipts)
|
| 200 |
-
|
| 201 |
-
@property
|
| 202 |
-
def private_handles(self) -> tuple[ProviderPrivateResource, ...]:
|
| 203 |
-
"""
|
| 204 |
-
Return request-private provider handles for the in-process executor only.
|
| 205 |
-
|
| 206 |
-
These handles are intentionally never exposed by receipts, health, logs,
|
| 207 |
-
persistence, or browser responses. Provider execution consumes them only
|
| 208 |
-
after :meth:`prepare` has validated the full upload/route identity.
|
| 209 |
-
"""
|
| 210 |
-
return tuple(self._handles)
|
| 211 |
-
|
| 212 |
-
def public_receipts(self) -> tuple[dict[str, Any], ...]:
|
| 213 |
-
return tuple(row.as_public_dict() for row in self._receipts)
|
| 214 |
-
|
| 215 |
-
def _validate_plan(
|
| 216 |
-
self, uploads: Sequence[ResourceUpload]
|
| 217 |
-
) -> dict[str, ResourceUpload]:
|
| 218 |
-
if not bool(getattr(self.executor, "enabled", False)):
|
| 219 |
-
raise ResourceExecutionError(
|
| 220 |
-
f"{self.provider} resource executor is not enabled"
|
| 221 |
-
)
|
| 222 |
-
by_id: dict[str, ResourceUpload] = {}
|
| 223 |
-
for upload in uploads:
|
| 224 |
-
rid = upload.verified.id
|
| 225 |
-
if rid in by_id:
|
| 226 |
-
raise ResourceExecutionError("duplicate resource upload id")
|
| 227 |
-
by_id[rid] = upload
|
| 228 |
-
route_ids: set[str] = set()
|
| 229 |
-
for route in self.routes:
|
| 230 |
-
if route.resource_id in route_ids:
|
| 231 |
-
raise ResourceExecutionError("duplicate resource route id")
|
| 232 |
-
route_ids.add(route.resource_id)
|
| 233 |
-
if route.route == "unsupported":
|
| 234 |
-
raise ResourceExecutionError("resource plan contains unsupported route")
|
| 235 |
-
if route_ids != set(by_id):
|
| 236 |
-
raise ResourceExecutionError("resource plan/upload ids do not match")
|
| 237 |
-
return by_id
|
| 238 |
-
|
| 239 |
-
def _register_handle(
|
| 240 |
-
self,
|
| 241 |
-
*,
|
| 242 |
-
route: ResourceRoute,
|
| 243 |
-
upload: ResourceUpload,
|
| 244 |
-
handle: ProviderPrivateResource,
|
| 245 |
-
) -> None:
|
| 246 |
-
"""
|
| 247 |
-
Validate and register one private handle before it can be used.
|
| 248 |
-
|
| 249 |
-
Batch-capable executors may create shared provider resources (for
|
| 250 |
-
example one retrieval store for many files). The common lifecycle
|
| 251 |
-
still validates every returned per-resource handle against the exact
|
| 252 |
-
verified upload and route so batch preparation cannot weaken identity
|
| 253 |
-
or receipt authority.
|
| 254 |
-
"""
|
| 255 |
-
if (
|
| 256 |
-
handle.resource_id != route.resource_id
|
| 257 |
-
or handle.route != route.route
|
| 258 |
-
or handle.provider != self.provider
|
| 259 |
-
or handle.source_sha256 != upload.verified.sha256
|
| 260 |
-
or int(handle.source_size) != int(upload.verified.actual_size)
|
| 261 |
-
):
|
| 262 |
-
# Register first so even a malformed provider response is released
|
| 263 |
-
# before the validation error escapes.
|
| 264 |
-
self._handles.append(handle)
|
| 265 |
-
raise ResourceExecutionError(
|
| 266 |
-
"provider resource handle failed identity validation"
|
| 267 |
-
)
|
| 268 |
-
self._handles.append(handle)
|
| 269 |
-
self._receipts.append(
|
| 270 |
-
ResourceExecutionReceipt(
|
| 271 |
-
resource_id=route.resource_id,
|
| 272 |
-
provider=self.provider,
|
| 273 |
-
model=self.model,
|
| 274 |
-
route=route.route,
|
| 275 |
-
source_sha256=upload.verified.sha256,
|
| 276 |
-
source_size=upload.verified.actual_size,
|
| 277 |
-
cleanup_required=bool(handle.cleanup_required),
|
| 278 |
-
metadata={
|
| 279 |
-
"modality": route.metadata.get("modality", ""),
|
| 280 |
-
"intent": route.metadata.get("intent", ""),
|
| 281 |
-
},
|
| 282 |
-
)
|
| 283 |
-
)
|
| 284 |
-
|
| 285 |
-
async def prepare(
|
| 286 |
-
self, uploads: Sequence[ResourceUpload]
|
| 287 |
-
) -> tuple[ResourceExecutionReceipt, ...]:
|
| 288 |
-
by_id = self._validate_plan(uploads)
|
| 289 |
-
ordered_uploads = tuple(by_id[route.resource_id] for route in self.routes)
|
| 290 |
-
try:
|
| 291 |
-
prepare_many = getattr(self.executor, "prepare_many", None)
|
| 292 |
-
if callable(prepare_many):
|
| 293 |
-
# A batch executor owns rollback for provider objects created
|
| 294 |
-
# before this method returns. Once returned, the common
|
| 295 |
-
# session immediately registers every handle and owns cleanup.
|
| 296 |
-
handles = tuple(
|
| 297 |
-
await prepare_many(
|
| 298 |
-
model=self.model,
|
| 299 |
-
routes=self.routes,
|
| 300 |
-
uploads=ordered_uploads,
|
| 301 |
-
)
|
| 302 |
-
)
|
| 303 |
-
if len(handles) != len(self.routes):
|
| 304 |
-
# Returned handles are the only provider cleanup authority
|
| 305 |
-
# available to the session. Register all of them so a bad
|
| 306 |
-
# batch count cannot silently leak those that are usable.
|
| 307 |
-
self._handles.extend(handles)
|
| 308 |
-
raise ResourceExecutionError(
|
| 309 |
-
"provider batch prepare returned the wrong handle count"
|
| 310 |
-
)
|
| 311 |
-
for route, upload, handle in zip(self.routes, ordered_uploads, handles):
|
| 312 |
-
self._register_handle(route=route, upload=upload, handle=handle)
|
| 313 |
-
else:
|
| 314 |
-
for route, upload in zip(self.routes, ordered_uploads):
|
| 315 |
-
handle = await self.executor.prepare(
|
| 316 |
-
model=self.model,
|
| 317 |
-
route=route,
|
| 318 |
-
upload=upload,
|
| 319 |
-
)
|
| 320 |
-
self._register_handle(route=route, upload=upload, handle=handle)
|
| 321 |
-
return self.receipts
|
| 322 |
-
except BaseException:
|
| 323 |
-
await self.close_shielded()
|
| 324 |
-
raise
|
| 325 |
-
|
| 326 |
-
async def _release_all(self) -> None:
|
| 327 |
-
# Receipts and handles are aligned only for handles that passed identity
|
| 328 |
-
# validation. A malformed last handle may therefore have no receipt.
|
| 329 |
-
receipt_by_id = {row.resource_id: row for row in self._receipts}
|
| 330 |
-
for handle in reversed(self._handles):
|
| 331 |
-
if handle.released or not handle.cleanup_required:
|
| 332 |
-
handle.released = True
|
| 333 |
-
receipt = receipt_by_id.get(handle.resource_id)
|
| 334 |
-
if receipt is not None:
|
| 335 |
-
receipt.state = "released"
|
| 336 |
-
continue
|
| 337 |
-
try:
|
| 338 |
-
await self.executor.release(handle)
|
| 339 |
-
except Exception: # noqa: BLE001
|
| 340 |
-
receipt = receipt_by_id.get(handle.resource_id)
|
| 341 |
-
if receipt is not None:
|
| 342 |
-
receipt.state = "cleanup_failed"
|
| 343 |
-
receipt.cleanup_error = True
|
| 344 |
-
else:
|
| 345 |
-
handle.released = True
|
| 346 |
-
receipt = receipt_by_id.get(handle.resource_id)
|
| 347 |
-
if receipt is not None:
|
| 348 |
-
receipt.state = "released"
|
| 349 |
-
|
| 350 |
-
async def close(self) -> tuple[ResourceExecutionReceipt, ...]:
|
| 351 |
-
async with self._close_lock:
|
| 352 |
-
if self._closed:
|
| 353 |
-
return self.receipts
|
| 354 |
-
await self._release_all()
|
| 355 |
-
self._closed = True
|
| 356 |
-
return self.receipts
|
| 357 |
-
|
| 358 |
-
async def close_shielded(self) -> tuple[ResourceExecutionReceipt, ...]:
|
| 359 |
-
"""Finish cleanup even when the caller task is being cancelled."""
|
| 360 |
-
task = asyncio.create_task(self.close())
|
| 361 |
-
try:
|
| 362 |
-
return await asyncio.shield(task)
|
| 363 |
-
except asyncio.CancelledError:
|
| 364 |
-
# Do not abandon provider cleanup. Preserve cancellation only after
|
| 365 |
-
# the cleanup task has reached its terminal state.
|
| 366 |
-
try:
|
| 367 |
-
await task
|
| 368 |
-
finally:
|
| 369 |
-
raise
|
| 370 |
-
|
| 371 |
-
async def __aenter__(self) -> Self:
|
| 372 |
-
return self
|
| 373 |
-
|
| 374 |
-
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
| 375 |
-
del exc_type, exc, tb
|
| 376 |
-
await self.close_shielded()
|
| 377 |
-
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/gemini.py
DELETED
|
@@ -1,1167 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
Gemini Files + Interactions first-class resource executor.
|
| 6 |
-
|
| 7 |
-
Verified browser resources are uploaded byte-for-byte through Gemini's provider
|
| 8 |
-
facilities. Provider names/URIs/store names are request-private capabilities,
|
| 9 |
-
never public receipts. Image/GIF/audio/video/PDF use native Files + Interactions
|
| 10 |
-
content blocks; reviewed text/ZIP/XLS/XLSX resources may use one request-scoped
|
| 11 |
-
File Search store with mandatory cleanup and route-specific MIME/size limits.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import asyncio
|
| 17 |
-
import base64
|
| 18 |
-
import binascii
|
| 19 |
-
import json
|
| 20 |
-
import re
|
| 21 |
-
from collections.abc import AsyncIterator, Sequence
|
| 22 |
-
from urllib.parse import quote, urlparse
|
| 23 |
-
|
| 24 |
-
import httpx
|
| 25 |
-
|
| 26 |
-
from .executor import ProviderPrivateResource, ResourceExecutionError
|
| 27 |
-
from .policy import (
|
| 28 |
-
gemini_file_search_supported_model,
|
| 29 |
-
gemini_native_media_supported_model,
|
| 30 |
-
)
|
| 31 |
-
from .registry import StaticProviderAdapter
|
| 32 |
-
|
| 33 |
-
try:
|
| 34 |
-
from .._utils._chat_contract import ChatRequest, build_upstream_payload
|
| 35 |
-
from .._utils._resource_transport import ResourceUpload
|
| 36 |
-
except ImportError: # standalone HF Space: _providers is top-level
|
| 37 |
-
from _utils._chat_contract import ChatRequest, build_upstream_payload
|
| 38 |
-
from _utils._resource_transport import ResourceUpload
|
| 39 |
-
|
| 40 |
-
_GEMINI_BASE = "https://generativelanguage.googleapis.com"
|
| 41 |
-
_GEMINI_INTERACTIONS_URL = (
|
| 42 |
-
"https://generativelanguage.googleapis.com/v1beta/interactions"
|
| 43 |
-
)
|
| 44 |
-
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
| 45 |
-
_MAX_PDF_BYTES = 50 * 1024 * 1024
|
| 46 |
-
_FILE_SEARCH_MAX_BYTES = 100 * 1024 * 1024
|
| 47 |
-
_MAX_SSE_EVENT_BYTES = 1024 * 1024
|
| 48 |
-
_FILE_NAME_RE = re.compile(r"^files/[A-Za-z0-9._~-]{1,240}$")
|
| 49 |
-
_FILE_SEARCH_STORE_RE = re.compile(
|
| 50 |
-
r"^fileSearchStores/[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$"
|
| 51 |
-
)
|
| 52 |
-
_FILE_SEARCH_OPERATION_RE = re.compile(
|
| 53 |
-
r"^fileSearchStores/[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?/upload/operations/[A-Za-z0-9._~-]{1,240}$"
|
| 54 |
-
)
|
| 55 |
-
_FILE_SEARCH_MIMES = {
|
| 56 |
-
"archive": {"application/zip"},
|
| 57 |
-
"data": {
|
| 58 |
-
"application/vnd.ms-excel",
|
| 59 |
-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 60 |
-
},
|
| 61 |
-
}
|
| 62 |
-
_NATIVE_MIMES = {
|
| 63 |
-
"image": {
|
| 64 |
-
"image/png",
|
| 65 |
-
"image/jpeg",
|
| 66 |
-
"image/webp",
|
| 67 |
-
"image/heic",
|
| 68 |
-
"image/heif",
|
| 69 |
-
"image/bmp",
|
| 70 |
-
"image/tiff",
|
| 71 |
-
},
|
| 72 |
-
"animated_image": {"image/gif"},
|
| 73 |
-
"audio": {
|
| 74 |
-
"audio/wav",
|
| 75 |
-
"audio/mp3",
|
| 76 |
-
"audio/aiff",
|
| 77 |
-
"audio/aac",
|
| 78 |
-
"audio/ogg",
|
| 79 |
-
"audio/flac",
|
| 80 |
-
"audio/mpeg",
|
| 81 |
-
"audio/m4a",
|
| 82 |
-
"audio/l16",
|
| 83 |
-
"audio/opus",
|
| 84 |
-
"audio/alaw",
|
| 85 |
-
"audio/mulaw",
|
| 86 |
-
"audio/webm",
|
| 87 |
-
},
|
| 88 |
-
"video": {
|
| 89 |
-
"video/mp4",
|
| 90 |
-
"video/mpeg",
|
| 91 |
-
"video/mpg",
|
| 92 |
-
"video/mov",
|
| 93 |
-
"video/avi",
|
| 94 |
-
"video/x-flv",
|
| 95 |
-
"video/webm",
|
| 96 |
-
"video/wmv",
|
| 97 |
-
"video/3gpp",
|
| 98 |
-
},
|
| 99 |
-
"document": {"application/pdf"},
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
_MIME_ALIASES = {
|
| 103 |
-
"image/jpg": "image/jpeg",
|
| 104 |
-
"video/quicktime": "video/mov",
|
| 105 |
-
"video/x-msvideo": "video/avi",
|
| 106 |
-
"video/x-ms-wmv": "video/wmv",
|
| 107 |
-
"audio/x-wav": "audio/wav",
|
| 108 |
-
"audio/x-aiff": "audio/aiff",
|
| 109 |
-
"audio/x-flac": "audio/flac",
|
| 110 |
-
"audio/x-m4a": "audio/m4a",
|
| 111 |
-
"audio/mp4": "audio/m4a",
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _gemini_mime(upload: ResourceUpload) -> str:
|
| 116 |
-
raw = str(upload.verified.detected_mime or "application/octet-stream").lower()
|
| 117 |
-
return _MIME_ALIASES.get(raw, raw)
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
class GeminiAdapter(StaticProviderAdapter):
|
| 121 |
-
def __init__(self, **kwargs):
|
| 122 |
-
super().__init__("gemini", **kwargs)
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def _validated_gemini_base(value: str) -> str:
|
| 126 |
-
raw = str(value or _GEMINI_BASE).rstrip("/")
|
| 127 |
-
parsed = urlparse(raw)
|
| 128 |
-
if (
|
| 129 |
-
parsed.scheme != "https"
|
| 130 |
-
or parsed.hostname != "generativelanguage.googleapis.com"
|
| 131 |
-
or parsed.port not in {None, 443}
|
| 132 |
-
or parsed.username is not None
|
| 133 |
-
or parsed.password is not None
|
| 134 |
-
or parsed.query
|
| 135 |
-
or parsed.fragment
|
| 136 |
-
or parsed.path not in {"", "/"}
|
| 137 |
-
):
|
| 138 |
-
raise ValueError("Gemini executor requires the official HTTPS API origin")
|
| 139 |
-
return _GEMINI_BASE
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def official_gemini_interactions_backend(value: str) -> bool:
|
| 143 |
-
raw = str(value or "").strip().rstrip("/")
|
| 144 |
-
try:
|
| 145 |
-
parsed = urlparse(raw)
|
| 146 |
-
except ValueError:
|
| 147 |
-
return False
|
| 148 |
-
return bool(
|
| 149 |
-
parsed.scheme == "https"
|
| 150 |
-
and parsed.hostname == "generativelanguage.googleapis.com"
|
| 151 |
-
and parsed.port in {None, 443}
|
| 152 |
-
and parsed.username is None
|
| 153 |
-
and parsed.password is None
|
| 154 |
-
and not parsed.query
|
| 155 |
-
and not parsed.fragment
|
| 156 |
-
and parsed.path.rstrip("/") == "/v1beta/interactions"
|
| 157 |
-
and raw == _GEMINI_INTERACTIONS_URL
|
| 158 |
-
)
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
def _validated_upload_url(value: str) -> str:
|
| 162 |
-
raw = str(value or "").strip()
|
| 163 |
-
if len(raw) > 4096 or any( # ruff: ignore[magic-value-comparison]
|
| 164 |
-
ord(ch) < 32 # ruff: ignore[magic-value-comparison]
|
| 165 |
-
or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 166 |
-
for ch in raw
|
| 167 |
-
):
|
| 168 |
-
raise ResourceExecutionError("Gemini resumable upload URL was invalid")
|
| 169 |
-
try:
|
| 170 |
-
parsed = urlparse(raw)
|
| 171 |
-
except ValueError as exc:
|
| 172 |
-
raise ResourceExecutionError("Gemini resumable upload URL was invalid") from exc
|
| 173 |
-
if (
|
| 174 |
-
parsed.scheme != "https"
|
| 175 |
-
or parsed.hostname != "generativelanguage.googleapis.com"
|
| 176 |
-
or parsed.port not in {None, 443}
|
| 177 |
-
or parsed.username is not None
|
| 178 |
-
or parsed.password is not None
|
| 179 |
-
or parsed.fragment
|
| 180 |
-
or not parsed.path.startswith("/upload/v1beta/files")
|
| 181 |
-
):
|
| 182 |
-
raise ResourceExecutionError("Gemini resumable upload URL was invalid")
|
| 183 |
-
return raw
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
def _safe_file_name(value: object) -> str:
|
| 187 |
-
name = str(value or "").strip()
|
| 188 |
-
if not _FILE_NAME_RE.fullmatch(name):
|
| 189 |
-
raise ResourceExecutionError("Gemini Files returned an invalid file name")
|
| 190 |
-
return name
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
def _safe_file_uri(value: object) -> str:
|
| 194 |
-
raw = str(value or "").strip()
|
| 195 |
-
if len(raw) > 4096 or any( # ruff: ignore[magic-value-comparison]
|
| 196 |
-
ord(ch) < 32 # ruff: ignore[magic-value-comparison]
|
| 197 |
-
or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 198 |
-
for ch in raw
|
| 199 |
-
):
|
| 200 |
-
raise ResourceExecutionError("Gemini Files returned an invalid file URI")
|
| 201 |
-
try:
|
| 202 |
-
parsed = urlparse(raw)
|
| 203 |
-
except ValueError as exc:
|
| 204 |
-
raise ResourceExecutionError(
|
| 205 |
-
"Gemini Files returned an invalid file URI"
|
| 206 |
-
) from exc
|
| 207 |
-
if (
|
| 208 |
-
parsed.scheme != "https"
|
| 209 |
-
or parsed.hostname != "generativelanguage.googleapis.com"
|
| 210 |
-
or parsed.port not in {None, 443}
|
| 211 |
-
or parsed.username is not None
|
| 212 |
-
or parsed.password is not None
|
| 213 |
-
or parsed.query
|
| 214 |
-
or parsed.fragment
|
| 215 |
-
or not parsed.path.startswith("/v1beta/files/")
|
| 216 |
-
):
|
| 217 |
-
raise ResourceExecutionError("Gemini Files returned an invalid file URI")
|
| 218 |
-
return raw
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def _native_opaque(handle: ProviderPrivateResource) -> dict:
|
| 222 |
-
data = handle.opaque if isinstance(handle.opaque, dict) else {}
|
| 223 |
-
name = _safe_file_name(data.get("file_name"))
|
| 224 |
-
uri = _safe_file_uri(data.get("uri"))
|
| 225 |
-
return {**data, "file_name": name, "uri": uri}
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
def _safe_store_name(value: object) -> str:
|
| 229 |
-
name = str(value or "").strip()
|
| 230 |
-
if not _FILE_SEARCH_STORE_RE.fullmatch(name):
|
| 231 |
-
raise ResourceExecutionError(
|
| 232 |
-
"Gemini File Search returned an invalid store name"
|
| 233 |
-
)
|
| 234 |
-
return name
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
def _safe_operation_name(value: object, store_name: str) -> str:
|
| 238 |
-
name = str(value or "").strip()
|
| 239 |
-
if not _FILE_SEARCH_OPERATION_RE.fullmatch(name) or not name.startswith(
|
| 240 |
-
store_name + "/upload/operations/"
|
| 241 |
-
):
|
| 242 |
-
raise ResourceExecutionError(
|
| 243 |
-
"Gemini File Search returned an invalid operation name"
|
| 244 |
-
)
|
| 245 |
-
return name
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
def _validated_file_search_upload_url(value: str, store_name: str) -> str:
|
| 249 |
-
raw = str(value or "").strip()
|
| 250 |
-
if len(raw) > 4096 or any( # ruff: ignore[magic-value-comparison]
|
| 251 |
-
ord(ch) < 32 # ruff: ignore[magic-value-comparison]
|
| 252 |
-
or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 253 |
-
for ch in raw
|
| 254 |
-
):
|
| 255 |
-
raise ResourceExecutionError("Gemini File Search upload URL was invalid")
|
| 256 |
-
try:
|
| 257 |
-
parsed = urlparse(raw)
|
| 258 |
-
except ValueError as exc:
|
| 259 |
-
raise ResourceExecutionError(
|
| 260 |
-
"Gemini File Search upload URL was invalid"
|
| 261 |
-
) from exc
|
| 262 |
-
expected = "/upload/v1beta/" + store_name + ":uploadToFileSearchStore"
|
| 263 |
-
if (
|
| 264 |
-
parsed.scheme != "https"
|
| 265 |
-
or parsed.hostname != "generativelanguage.googleapis.com"
|
| 266 |
-
or parsed.port not in {None, 443}
|
| 267 |
-
or parsed.username is not None
|
| 268 |
-
or parsed.password is not None
|
| 269 |
-
or parsed.fragment
|
| 270 |
-
or parsed.path != expected
|
| 271 |
-
):
|
| 272 |
-
raise ResourceExecutionError("Gemini File Search upload URL was invalid")
|
| 273 |
-
return raw
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
def _tool_opaque(handle: ProviderPrivateResource) -> dict:
|
| 277 |
-
data = handle.opaque if isinstance(handle.opaque, dict) else {}
|
| 278 |
-
return {**data, "store_name": _safe_store_name(data.get("store_name"))}
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
def _file_search_mime(upload: ResourceUpload) -> str:
|
| 282 |
-
modality = str(upload.verified.detected_modality or "")
|
| 283 |
-
raw = str(upload.verified.detected_mime or "application/octet-stream").lower()
|
| 284 |
-
if modality == "text":
|
| 285 |
-
if raw.startswith("text/") or raw in {"application/json", "application/xml"}:
|
| 286 |
-
return raw
|
| 287 |
-
# The verifier already classified the resource as text. Normalizing
|
| 288 |
-
# uncommon textual application/* labels to text/plain changes only
|
| 289 |
-
# provider metadata, never the source bytes.
|
| 290 |
-
return "text/plain"
|
| 291 |
-
return raw
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
def _provider_hash_hex(value: object) -> str:
|
| 295 |
-
raw = str(value or "").strip()
|
| 296 |
-
if not raw:
|
| 297 |
-
return ""
|
| 298 |
-
try:
|
| 299 |
-
decoded = base64.b64decode(raw, validate=True)
|
| 300 |
-
except (ValueError, binascii.Error) as exc:
|
| 301 |
-
raise ResourceExecutionError(
|
| 302 |
-
"Gemini Files returned invalid SHA-256 metadata"
|
| 303 |
-
) from exc
|
| 304 |
-
if len(decoded) != 32: # ruff: ignore[magic-value-comparison]
|
| 305 |
-
raise ResourceExecutionError("Gemini Files returned invalid SHA-256 metadata")
|
| 306 |
-
return decoded.hex()
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
def _file_metadata(payload: object) -> dict:
|
| 310 |
-
if isinstance(payload, dict) and isinstance(payload.get("file"), dict):
|
| 311 |
-
return payload["file"]
|
| 312 |
-
if isinstance(payload, dict):
|
| 313 |
-
return payload
|
| 314 |
-
raise ResourceExecutionError("Gemini Files returned invalid metadata")
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
def _response_text(payload: object) -> str:
|
| 318 |
-
if not isinstance(payload, dict):
|
| 319 |
-
raise ResourceExecutionError("Gemini Interactions returned invalid metadata")
|
| 320 |
-
status = str(payload.get("status", "") or "")
|
| 321 |
-
if status != "completed" or payload.get("error"):
|
| 322 |
-
raise ResourceExecutionError(
|
| 323 |
-
"Gemini Interactions did not complete successfully"
|
| 324 |
-
)
|
| 325 |
-
pieces: list[str] = []
|
| 326 |
-
steps = payload.get("steps")
|
| 327 |
-
if isinstance(steps, list):
|
| 328 |
-
for step in steps:
|
| 329 |
-
if not isinstance(step, dict) or step.get("type") != "model_output":
|
| 330 |
-
continue
|
| 331 |
-
content = step.get("content")
|
| 332 |
-
if not isinstance(content, list):
|
| 333 |
-
continue
|
| 334 |
-
for row in content:
|
| 335 |
-
if (
|
| 336 |
-
isinstance(row, dict)
|
| 337 |
-
and row.get("type") == "text"
|
| 338 |
-
and isinstance(row.get("text"), str)
|
| 339 |
-
):
|
| 340 |
-
pieces.append(row["text"])
|
| 341 |
-
text = "".join(pieces)
|
| 342 |
-
if not text:
|
| 343 |
-
raise ResourceExecutionError("Gemini Interactions returned no assistant text")
|
| 344 |
-
return text
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
class GeminiResourceExecutor:
|
| 348 |
-
"""Upload verified native media to Gemini Files and execute Interactions."""
|
| 349 |
-
|
| 350 |
-
name = "gemini"
|
| 351 |
-
enabled = True
|
| 352 |
-
|
| 353 |
-
def __init__(
|
| 354 |
-
self,
|
| 355 |
-
*,
|
| 356 |
-
api_key: str,
|
| 357 |
-
client: httpx.AsyncClient,
|
| 358 |
-
base_url: str = _GEMINI_BASE,
|
| 359 |
-
response_timeout_seconds: float = 600.0,
|
| 360 |
-
processing_timeout_seconds: float = 300.0,
|
| 361 |
-
poll_interval_seconds: float = 1.0,
|
| 362 |
-
) -> None:
|
| 363 |
-
key = str(api_key or "").strip()
|
| 364 |
-
if not key:
|
| 365 |
-
raise ValueError("Gemini API key is required")
|
| 366 |
-
self._api_key = key
|
| 367 |
-
self._client = client
|
| 368 |
-
self._base = _validated_gemini_base(base_url)
|
| 369 |
-
self._response_timeout = httpx.Timeout(
|
| 370 |
-
connect=20.0,
|
| 371 |
-
read=max(30.0, min(float(response_timeout_seconds), 1800.0)),
|
| 372 |
-
write=180.0,
|
| 373 |
-
pool=20.0,
|
| 374 |
-
)
|
| 375 |
-
self._processing_timeout = max(
|
| 376 |
-
1.0, min(float(processing_timeout_seconds), 900.0)
|
| 377 |
-
)
|
| 378 |
-
self._poll_interval = max(0.05, min(float(poll_interval_seconds), 5.0))
|
| 379 |
-
|
| 380 |
-
def executable_routes(self, model: str) -> dict[str, tuple[str, ...]]:
|
| 381 |
-
routes: dict[str, tuple[str, ...]] = {}
|
| 382 |
-
if gemini_native_media_supported_model(model):
|
| 383 |
-
routes.update(
|
| 384 |
-
{
|
| 385 |
-
"image": ("native",),
|
| 386 |
-
"animated_image": ("native",),
|
| 387 |
-
"audio": ("native",),
|
| 388 |
-
"video": ("native",),
|
| 389 |
-
"document": ("native",),
|
| 390 |
-
}
|
| 391 |
-
)
|
| 392 |
-
if gemini_file_search_supported_model(model):
|
| 393 |
-
routes.update(
|
| 394 |
-
{
|
| 395 |
-
"text": ("tool",),
|
| 396 |
-
"archive": ("tool",),
|
| 397 |
-
"data": ("tool",),
|
| 398 |
-
}
|
| 399 |
-
)
|
| 400 |
-
return routes
|
| 401 |
-
|
| 402 |
-
def route_constraints(self, model: str) -> dict[str, dict[str, dict]]:
|
| 403 |
-
"""
|
| 404 |
-
Return browser-visible limits for exact executable routes.
|
| 405 |
-
|
| 406 |
-
Limits are route-specific because Gemini native media and File Search
|
| 407 |
-
have different provider ceilings and MIME vocabularies. This data is
|
| 408 |
-
non-secret and is re-checked server-side before provider I/O.
|
| 409 |
-
"""
|
| 410 |
-
constraints: dict[str, dict[str, dict]] = {}
|
| 411 |
-
if gemini_native_media_supported_model(model):
|
| 412 |
-
constraints["document"] = {
|
| 413 |
-
"native": {
|
| 414 |
-
"max_file_bytes": _MAX_PDF_BYTES,
|
| 415 |
-
"mime_types": ["application/pdf"],
|
| 416 |
-
}
|
| 417 |
-
}
|
| 418 |
-
if gemini_file_search_supported_model(model):
|
| 419 |
-
constraints["text"] = {"tool": {"max_file_bytes": _FILE_SEARCH_MAX_BYTES}}
|
| 420 |
-
constraints["archive"] = {
|
| 421 |
-
"tool": {
|
| 422 |
-
"max_file_bytes": _FILE_SEARCH_MAX_BYTES,
|
| 423 |
-
"mime_types": sorted(_FILE_SEARCH_MIMES["archive"]),
|
| 424 |
-
}
|
| 425 |
-
}
|
| 426 |
-
constraints["data"] = {
|
| 427 |
-
"tool": {
|
| 428 |
-
"max_file_bytes": _FILE_SEARCH_MAX_BYTES,
|
| 429 |
-
"mime_types": sorted(_FILE_SEARCH_MIMES["data"]),
|
| 430 |
-
}
|
| 431 |
-
}
|
| 432 |
-
return constraints
|
| 433 |
-
|
| 434 |
-
def _headers(self) -> dict[str, str]:
|
| 435 |
-
return {"x-goog-api-key": self._api_key, "Accept": "application/json"}
|
| 436 |
-
|
| 437 |
-
async def _raw_body(self, upload: ResourceUpload):
|
| 438 |
-
await upload.upload.seek(0)
|
| 439 |
-
try:
|
| 440 |
-
while True:
|
| 441 |
-
chunk = await upload.upload.read(_UPLOAD_CHUNK_BYTES)
|
| 442 |
-
if not chunk:
|
| 443 |
-
break
|
| 444 |
-
yield chunk
|
| 445 |
-
finally:
|
| 446 |
-
await upload.upload.seek(0)
|
| 447 |
-
|
| 448 |
-
def _validate_metadata(
|
| 449 |
-
self, meta: dict, upload: ResourceUpload
|
| 450 |
-
) -> tuple[str, str, str]:
|
| 451 |
-
verified = upload.verified
|
| 452 |
-
name = _safe_file_name(meta.get("name"))
|
| 453 |
-
uri = _safe_file_uri(meta.get("uri"))
|
| 454 |
-
state = str(meta.get("state", "") or "").upper()
|
| 455 |
-
size = meta.get("sizeBytes", meta.get("size_bytes"))
|
| 456 |
-
if size is not None:
|
| 457 |
-
try:
|
| 458 |
-
if int(size) != int(verified.actual_size):
|
| 459 |
-
raise ResourceExecutionError(
|
| 460 |
-
"Gemini Files size did not match source bytes"
|
| 461 |
-
)
|
| 462 |
-
except (TypeError, ValueError) as exc:
|
| 463 |
-
raise ResourceExecutionError(
|
| 464 |
-
"Gemini Files returned invalid size metadata"
|
| 465 |
-
) from exc
|
| 466 |
-
provider_hash = _provider_hash_hex(
|
| 467 |
-
meta.get("sha256Hash", meta.get("sha256_hash"))
|
| 468 |
-
)
|
| 469 |
-
if provider_hash and provider_hash != verified.sha256:
|
| 470 |
-
raise ResourceExecutionError(
|
| 471 |
-
"Gemini Files SHA-256 did not match source bytes"
|
| 472 |
-
)
|
| 473 |
-
returned_mime = str(
|
| 474 |
-
meta.get("mimeType", meta.get("mime_type", "")) or ""
|
| 475 |
-
).lower()
|
| 476 |
-
expected_mime = _gemini_mime(upload)
|
| 477 |
-
if returned_mime and returned_mime != expected_mime:
|
| 478 |
-
raise ResourceExecutionError(
|
| 479 |
-
"Gemini Files MIME metadata did not match verified bytes"
|
| 480 |
-
)
|
| 481 |
-
return name, uri, state
|
| 482 |
-
|
| 483 |
-
async def _delete_name(self, name: str) -> None:
|
| 484 |
-
safe = _safe_file_name(name)
|
| 485 |
-
try:
|
| 486 |
-
response = await self._client.delete(
|
| 487 |
-
f"{self._base}/v1beta/{quote(safe, safe='/')}", headers=self._headers()
|
| 488 |
-
)
|
| 489 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 490 |
-
raise ResourceExecutionError(
|
| 491 |
-
"Gemini temporary file cleanup failed"
|
| 492 |
-
) from exc
|
| 493 |
-
if response.status_code == 404: # ruff: ignore[magic-value-comparison]
|
| 494 |
-
return
|
| 495 |
-
if (
|
| 496 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 497 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 498 |
-
):
|
| 499 |
-
raise ResourceExecutionError("Gemini temporary file cleanup was rejected")
|
| 500 |
-
|
| 501 |
-
async def _wait_active(
|
| 502 |
-
self, *, name: str, upload: ResourceUpload, initial: dict
|
| 503 |
-
) -> dict:
|
| 504 |
-
meta = initial
|
| 505 |
-
deadline = asyncio.get_running_loop().time() + self._processing_timeout
|
| 506 |
-
while True:
|
| 507 |
-
got_name, _uri, state = self._validate_metadata(meta, upload)
|
| 508 |
-
if got_name != name:
|
| 509 |
-
raise ResourceExecutionError(
|
| 510 |
-
"Gemini Files identity changed while processing"
|
| 511 |
-
)
|
| 512 |
-
if state == "ACTIVE":
|
| 513 |
-
return meta
|
| 514 |
-
if state == "FAILED":
|
| 515 |
-
raise ResourceExecutionError("Gemini Files processing failed")
|
| 516 |
-
if state not in {"PROCESSING", "STATE_UNSPECIFIED", ""}:
|
| 517 |
-
raise ResourceExecutionError(
|
| 518 |
-
"Gemini Files returned an unexpected processing state"
|
| 519 |
-
)
|
| 520 |
-
if asyncio.get_running_loop().time() >= deadline:
|
| 521 |
-
raise ResourceExecutionError("Gemini Files processing timed out")
|
| 522 |
-
await asyncio.sleep(self._poll_interval)
|
| 523 |
-
try:
|
| 524 |
-
response = await self._client.get(
|
| 525 |
-
f"{self._base}/v1beta/{quote(name, safe='/')}",
|
| 526 |
-
headers=self._headers(),
|
| 527 |
-
)
|
| 528 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 529 |
-
raise ResourceExecutionError(
|
| 530 |
-
"Gemini Files processing check failed"
|
| 531 |
-
) from exc
|
| 532 |
-
if (
|
| 533 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 534 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 535 |
-
):
|
| 536 |
-
raise ResourceExecutionError(
|
| 537 |
-
"Gemini Files processing check was rejected"
|
| 538 |
-
)
|
| 539 |
-
try:
|
| 540 |
-
meta = _file_metadata(response.json())
|
| 541 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 542 |
-
raise ResourceExecutionError(
|
| 543 |
-
"Gemini Files processing metadata was invalid"
|
| 544 |
-
) from exc
|
| 545 |
-
|
| 546 |
-
async def _create_file_search_store(self) -> str:
|
| 547 |
-
payload = {
|
| 548 |
-
"displayName": "scikit-plots-ephemeral",
|
| 549 |
-
"embeddingModel": "models/gemini-embedding-2",
|
| 550 |
-
}
|
| 551 |
-
headers = self._headers()
|
| 552 |
-
headers["Content-Type"] = "application/json"
|
| 553 |
-
try:
|
| 554 |
-
response = await self._client.post(
|
| 555 |
-
f"{self._base}/v1beta/fileSearchStores",
|
| 556 |
-
headers=headers,
|
| 557 |
-
content=json.dumps(
|
| 558 |
-
payload, ensure_ascii=False, separators=(",", ":")
|
| 559 |
-
).encode("utf-8"),
|
| 560 |
-
)
|
| 561 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 562 |
-
raise ResourceExecutionError(
|
| 563 |
-
"Gemini File Search store creation failed"
|
| 564 |
-
) from exc
|
| 565 |
-
if (
|
| 566 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 567 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 568 |
-
):
|
| 569 |
-
raise ResourceExecutionError(
|
| 570 |
-
"Gemini File Search store creation was rejected"
|
| 571 |
-
)
|
| 572 |
-
try:
|
| 573 |
-
data = response.json()
|
| 574 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 575 |
-
raise ResourceExecutionError(
|
| 576 |
-
"Gemini File Search store metadata was invalid"
|
| 577 |
-
) from exc
|
| 578 |
-
if not isinstance(data, dict):
|
| 579 |
-
raise ResourceExecutionError(
|
| 580 |
-
"Gemini File Search store metadata was invalid"
|
| 581 |
-
)
|
| 582 |
-
return _safe_store_name(data.get("name"))
|
| 583 |
-
|
| 584 |
-
async def _delete_store(self, store_name: str) -> None:
|
| 585 |
-
safe = _safe_store_name(store_name)
|
| 586 |
-
try:
|
| 587 |
-
response = await self._client.delete(
|
| 588 |
-
f"{self._base}/v1beta/{quote(safe, safe='/')}?force=true",
|
| 589 |
-
headers=self._headers(),
|
| 590 |
-
)
|
| 591 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 592 |
-
raise ResourceExecutionError(
|
| 593 |
-
"Gemini File Search store cleanup failed"
|
| 594 |
-
) from exc
|
| 595 |
-
if response.status_code == 404: # ruff: ignore[magic-value-comparison]
|
| 596 |
-
return
|
| 597 |
-
if (
|
| 598 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 599 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 600 |
-
):
|
| 601 |
-
raise ResourceExecutionError(
|
| 602 |
-
"Gemini File Search store cleanup was rejected"
|
| 603 |
-
)
|
| 604 |
-
|
| 605 |
-
async def _wait_file_search_operation(
|
| 606 |
-
self, *, store_name: str, payload: object
|
| 607 |
-
) -> None:
|
| 608 |
-
if not isinstance(payload, dict):
|
| 609 |
-
raise ResourceExecutionError(
|
| 610 |
-
"Gemini File Search upload returned invalid operation metadata"
|
| 611 |
-
)
|
| 612 |
-
operation_name = _safe_operation_name(payload.get("name"), store_name)
|
| 613 |
-
operation = payload
|
| 614 |
-
deadline = asyncio.get_running_loop().time() + self._processing_timeout
|
| 615 |
-
while True:
|
| 616 |
-
if operation.get("error"):
|
| 617 |
-
raise ResourceExecutionError("Gemini File Search indexing failed")
|
| 618 |
-
if operation.get("done") is True:
|
| 619 |
-
return
|
| 620 |
-
if asyncio.get_running_loop().time() >= deadline:
|
| 621 |
-
raise ResourceExecutionError("Gemini File Search indexing timed out")
|
| 622 |
-
await asyncio.sleep(self._poll_interval)
|
| 623 |
-
try:
|
| 624 |
-
response = await self._client.get(
|
| 625 |
-
f"{self._base}/v1beta/{quote(operation_name, safe='/')}",
|
| 626 |
-
headers=self._headers(),
|
| 627 |
-
)
|
| 628 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 629 |
-
raise ResourceExecutionError(
|
| 630 |
-
"Gemini File Search indexing check failed"
|
| 631 |
-
) from exc
|
| 632 |
-
if (
|
| 633 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 634 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 635 |
-
):
|
| 636 |
-
raise ResourceExecutionError(
|
| 637 |
-
"Gemini File Search indexing check was rejected"
|
| 638 |
-
)
|
| 639 |
-
try:
|
| 640 |
-
operation = response.json()
|
| 641 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 642 |
-
raise ResourceExecutionError(
|
| 643 |
-
"Gemini File Search operation metadata was invalid"
|
| 644 |
-
) from exc
|
| 645 |
-
if (
|
| 646 |
-
not isinstance(operation, dict)
|
| 647 |
-
or _safe_operation_name(operation.get("name"), store_name)
|
| 648 |
-
!= operation_name
|
| 649 |
-
):
|
| 650 |
-
raise ResourceExecutionError(
|
| 651 |
-
"Gemini File Search operation identity changed"
|
| 652 |
-
)
|
| 653 |
-
|
| 654 |
-
async def _upload_to_file_search_store(
|
| 655 |
-
self, *, store_name: str, upload: ResourceUpload
|
| 656 |
-
) -> None:
|
| 657 |
-
verified = upload.verified
|
| 658 |
-
modality = str(verified.detected_modality or "")
|
| 659 |
-
mime = _file_search_mime(upload)
|
| 660 |
-
if int(verified.actual_size) > _FILE_SEARCH_MAX_BYTES:
|
| 661 |
-
raise ResourceExecutionError(
|
| 662 |
-
"Gemini File Search resource exceeds the 100 MB document limit"
|
| 663 |
-
)
|
| 664 |
-
if modality == "archive":
|
| 665 |
-
if mime not in _FILE_SEARCH_MIMES["archive"]:
|
| 666 |
-
raise ResourceExecutionError(
|
| 667 |
-
"Gemini File Search does not support this archive MIME type"
|
| 668 |
-
)
|
| 669 |
-
elif modality == "data":
|
| 670 |
-
if mime not in _FILE_SEARCH_MIMES["data"]:
|
| 671 |
-
raise ResourceExecutionError(
|
| 672 |
-
"Gemini File Search does not support this data MIME type"
|
| 673 |
-
)
|
| 674 |
-
elif modality != "text":
|
| 675 |
-
raise ResourceExecutionError(
|
| 676 |
-
"Gemini File Search does not support this resource modality"
|
| 677 |
-
)
|
| 678 |
-
|
| 679 |
-
safe_store = _safe_store_name(store_name)
|
| 680 |
-
start_headers = self._headers()
|
| 681 |
-
start_headers.update(
|
| 682 |
-
{
|
| 683 |
-
"X-Goog-Upload-Protocol": "resumable",
|
| 684 |
-
"X-Goog-Upload-Command": "start",
|
| 685 |
-
"X-Goog-Upload-Header-Content-Length": str(verified.actual_size),
|
| 686 |
-
"X-Goog-Upload-Header-Content-Type": mime,
|
| 687 |
-
"Content-Type": "application/json",
|
| 688 |
-
}
|
| 689 |
-
)
|
| 690 |
-
display_name = str(verified.name or "resource")[:512]
|
| 691 |
-
try:
|
| 692 |
-
start = await self._client.post(
|
| 693 |
-
f"{self._base}/upload/v1beta/{quote(safe_store, safe='/')}:uploadToFileSearchStore",
|
| 694 |
-
headers=start_headers,
|
| 695 |
-
content=json.dumps(
|
| 696 |
-
{"displayName": display_name},
|
| 697 |
-
ensure_ascii=False,
|
| 698 |
-
separators=(",", ":"),
|
| 699 |
-
).encode("utf-8"),
|
| 700 |
-
)
|
| 701 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 702 |
-
raise ResourceExecutionError(
|
| 703 |
-
"Gemini File Search resumable upload could not start"
|
| 704 |
-
) from exc
|
| 705 |
-
if (
|
| 706 |
-
start.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 707 |
-
or start.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 708 |
-
):
|
| 709 |
-
raise ResourceExecutionError(
|
| 710 |
-
"Gemini File Search resumable upload start was rejected"
|
| 711 |
-
)
|
| 712 |
-
upload_url = _validated_file_search_upload_url(
|
| 713 |
-
start.headers.get("x-goog-upload-url", ""), safe_store
|
| 714 |
-
)
|
| 715 |
-
try:
|
| 716 |
-
finish = await self._client.post(
|
| 717 |
-
upload_url,
|
| 718 |
-
headers={
|
| 719 |
-
"Content-Length": str(verified.actual_size),
|
| 720 |
-
"X-Goog-Upload-Offset": "0",
|
| 721 |
-
"X-Goog-Upload-Command": "upload, finalize",
|
| 722 |
-
"Content-Type": mime,
|
| 723 |
-
},
|
| 724 |
-
content=self._raw_body(upload),
|
| 725 |
-
)
|
| 726 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 727 |
-
raise ResourceExecutionError(
|
| 728 |
-
"Gemini File Search resource upload failed"
|
| 729 |
-
) from exc
|
| 730 |
-
if (
|
| 731 |
-
finish.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 732 |
-
or finish.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 733 |
-
):
|
| 734 |
-
raise ResourceExecutionError(
|
| 735 |
-
"Gemini File Search resource upload was rejected"
|
| 736 |
-
)
|
| 737 |
-
try:
|
| 738 |
-
operation = finish.json()
|
| 739 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 740 |
-
raise ResourceExecutionError(
|
| 741 |
-
"Gemini File Search upload returned invalid operation metadata"
|
| 742 |
-
) from exc
|
| 743 |
-
await self._wait_file_search_operation(store_name=safe_store, payload=operation)
|
| 744 |
-
|
| 745 |
-
async def prepare_many( # ruff: ignore[too-many-branches]
|
| 746 |
-
self, *, model: str, routes: Sequence, uploads: Sequence[ResourceUpload]
|
| 747 |
-
):
|
| 748 |
-
if len(routes) != len(uploads):
|
| 749 |
-
raise ResourceExecutionError(
|
| 750 |
-
"Gemini batch prepare route/upload count mismatch"
|
| 751 |
-
)
|
| 752 |
-
pairs = tuple(zip(routes, uploads))
|
| 753 |
-
tool_pairs = []
|
| 754 |
-
for route, upload in pairs:
|
| 755 |
-
if route.resource_id != upload.verified.id:
|
| 756 |
-
raise ResourceExecutionError(
|
| 757 |
-
"Gemini batch prepare resource identity mismatch"
|
| 758 |
-
)
|
| 759 |
-
modality = str(upload.verified.detected_modality or "")
|
| 760 |
-
allowed = self.executable_routes(model).get(modality, ())
|
| 761 |
-
if route.route not in allowed:
|
| 762 |
-
raise ResourceExecutionError(
|
| 763 |
-
"Gemini executor does not implement this modality/route"
|
| 764 |
-
)
|
| 765 |
-
if route.route == "tool":
|
| 766 |
-
# Validate provider limits/MIME before any provider object is
|
| 767 |
-
# created. The upload helper repeats these checks defensively.
|
| 768 |
-
mime = _file_search_mime(upload)
|
| 769 |
-
if int(upload.verified.actual_size) > _FILE_SEARCH_MAX_BYTES:
|
| 770 |
-
raise ResourceExecutionError(
|
| 771 |
-
"Gemini File Search resource exceeds the 100 MB document limit"
|
| 772 |
-
)
|
| 773 |
-
if modality == "archive" and mime not in _FILE_SEARCH_MIMES["archive"]:
|
| 774 |
-
raise ResourceExecutionError(
|
| 775 |
-
"Gemini File Search does not support this archive MIME type"
|
| 776 |
-
)
|
| 777 |
-
if modality == "data" and mime not in _FILE_SEARCH_MIMES["data"]:
|
| 778 |
-
raise ResourceExecutionError(
|
| 779 |
-
"Gemini File Search does not support this data MIME type"
|
| 780 |
-
)
|
| 781 |
-
tool_pairs.append((route, upload))
|
| 782 |
-
|
| 783 |
-
store_name = ""
|
| 784 |
-
native_handles: list[ProviderPrivateResource] = []
|
| 785 |
-
output: dict[str, ProviderPrivateResource] = {}
|
| 786 |
-
try:
|
| 787 |
-
if tool_pairs:
|
| 788 |
-
if not gemini_file_search_supported_model(model):
|
| 789 |
-
raise ResourceExecutionError(
|
| 790 |
-
"Gemini model does not support File Search"
|
| 791 |
-
)
|
| 792 |
-
store_name = await self._create_file_search_store()
|
| 793 |
-
for _route, upload in tool_pairs:
|
| 794 |
-
await self._upload_to_file_search_store(
|
| 795 |
-
store_name=store_name, upload=upload
|
| 796 |
-
)
|
| 797 |
-
|
| 798 |
-
owner_id = tool_pairs[0][0].resource_id if tool_pairs else ""
|
| 799 |
-
for route, upload in pairs:
|
| 800 |
-
if route.route == "native":
|
| 801 |
-
handle = await self.prepare(model=model, route=route, upload=upload)
|
| 802 |
-
native_handles.append(handle)
|
| 803 |
-
output[route.resource_id] = handle
|
| 804 |
-
else:
|
| 805 |
-
output[route.resource_id] = ProviderPrivateResource(
|
| 806 |
-
resource_id=upload.verified.id,
|
| 807 |
-
route="tool",
|
| 808 |
-
provider=self.name,
|
| 809 |
-
source_sha256=upload.verified.sha256,
|
| 810 |
-
source_size=upload.verified.actual_size,
|
| 811 |
-
opaque={
|
| 812 |
-
"store_name": store_name,
|
| 813 |
-
"modality": upload.verified.detected_modality,
|
| 814 |
-
"mime": _file_search_mime(upload),
|
| 815 |
-
"name": upload.verified.name,
|
| 816 |
-
},
|
| 817 |
-
cleanup_required=(route.resource_id == owner_id),
|
| 818 |
-
)
|
| 819 |
-
return tuple(output[route.resource_id] for route, _upload in pairs)
|
| 820 |
-
except BaseException:
|
| 821 |
-
for handle in reversed(native_handles):
|
| 822 |
-
try: # ruff: ignore[suppressible-exception]
|
| 823 |
-
await asyncio.shield(self.release(handle))
|
| 824 |
-
except Exception: # ruff: ignore[blind-except]
|
| 825 |
-
pass
|
| 826 |
-
if store_name:
|
| 827 |
-
try: # ruff: ignore[suppressible-exception]
|
| 828 |
-
await asyncio.shield(self._delete_store(store_name))
|
| 829 |
-
except Exception: # ruff: ignore[blind-except]
|
| 830 |
-
pass
|
| 831 |
-
raise
|
| 832 |
-
|
| 833 |
-
async def prepare( # ruff: ignore[too-many-branches]
|
| 834 |
-
self, *, model: str, route, upload: ResourceUpload
|
| 835 |
-
) -> ProviderPrivateResource:
|
| 836 |
-
verified = upload.verified
|
| 837 |
-
modality = verified.detected_modality
|
| 838 |
-
mime = _gemini_mime(upload)
|
| 839 |
-
if route.resource_id != verified.id or route.route != "native":
|
| 840 |
-
raise ResourceExecutionError(
|
| 841 |
-
"Gemini executor received an invalid resource route"
|
| 842 |
-
)
|
| 843 |
-
if modality not in self.executable_routes(model):
|
| 844 |
-
raise ResourceExecutionError(
|
| 845 |
-
"Gemini native route does not support this modality"
|
| 846 |
-
)
|
| 847 |
-
if mime not in _NATIVE_MIMES.get(modality, set()):
|
| 848 |
-
raise ResourceExecutionError(
|
| 849 |
-
"Gemini native route does not support this MIME type"
|
| 850 |
-
)
|
| 851 |
-
if (
|
| 852 |
-
modality == "document"
|
| 853 |
-
and mime == "application/pdf"
|
| 854 |
-
and int(verified.actual_size) > _MAX_PDF_BYTES
|
| 855 |
-
):
|
| 856 |
-
raise ResourceExecutionError(
|
| 857 |
-
"Gemini PDF exceeds the bounded 50 MB provider limit"
|
| 858 |
-
)
|
| 859 |
-
|
| 860 |
-
start_headers = self._headers()
|
| 861 |
-
start_headers.update(
|
| 862 |
-
{
|
| 863 |
-
"X-Goog-Upload-Protocol": "resumable",
|
| 864 |
-
"X-Goog-Upload-Command": "start",
|
| 865 |
-
"X-Goog-Upload-Header-Content-Length": str(verified.actual_size),
|
| 866 |
-
"X-Goog-Upload-Header-Content-Type": mime,
|
| 867 |
-
"Content-Type": "application/json",
|
| 868 |
-
}
|
| 869 |
-
)
|
| 870 |
-
display_name = str(verified.name or "resource")[:512]
|
| 871 |
-
try:
|
| 872 |
-
start = await self._client.post(
|
| 873 |
-
f"{self._base}/upload/v1beta/files",
|
| 874 |
-
headers=start_headers,
|
| 875 |
-
content=json.dumps(
|
| 876 |
-
{"file": {"display_name": display_name}},
|
| 877 |
-
ensure_ascii=False,
|
| 878 |
-
separators=(",", ":"),
|
| 879 |
-
).encode("utf-8"),
|
| 880 |
-
)
|
| 881 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 882 |
-
raise ResourceExecutionError(
|
| 883 |
-
"Gemini resumable upload could not start"
|
| 884 |
-
) from exc
|
| 885 |
-
if (
|
| 886 |
-
start.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 887 |
-
or start.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 888 |
-
):
|
| 889 |
-
raise ResourceExecutionError("Gemini resumable upload start was rejected")
|
| 890 |
-
upload_url = _validated_upload_url(start.headers.get("x-goog-upload-url", ""))
|
| 891 |
-
|
| 892 |
-
provider_name = ""
|
| 893 |
-
try:
|
| 894 |
-
try:
|
| 895 |
-
finish = await self._client.post(
|
| 896 |
-
upload_url,
|
| 897 |
-
headers={
|
| 898 |
-
"Content-Length": str(verified.actual_size),
|
| 899 |
-
"X-Goog-Upload-Offset": "0",
|
| 900 |
-
"X-Goog-Upload-Command": "upload, finalize",
|
| 901 |
-
"Content-Type": mime,
|
| 902 |
-
},
|
| 903 |
-
content=self._raw_body(upload),
|
| 904 |
-
)
|
| 905 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 906 |
-
raise ResourceExecutionError("Gemini resource upload failed") from exc
|
| 907 |
-
if (
|
| 908 |
-
finish.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 909 |
-
or finish.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 910 |
-
):
|
| 911 |
-
raise ResourceExecutionError("Gemini resource upload was rejected")
|
| 912 |
-
try:
|
| 913 |
-
meta = _file_metadata(finish.json())
|
| 914 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 915 |
-
raise ResourceExecutionError(
|
| 916 |
-
"Gemini resource upload returned invalid metadata"
|
| 917 |
-
) from exc
|
| 918 |
-
# Capture the provider cleanup authority before validating optional
|
| 919 |
-
# metadata such as URI/hash/state. Any later rejection can now roll
|
| 920 |
-
# back the already-created provider file.
|
| 921 |
-
provider_name = _safe_file_name(meta.get("name"))
|
| 922 |
-
checked_name, _uri, _state = self._validate_metadata(meta, upload)
|
| 923 |
-
if checked_name != provider_name:
|
| 924 |
-
raise ResourceExecutionError(
|
| 925 |
-
"Gemini Files identity changed after upload"
|
| 926 |
-
)
|
| 927 |
-
meta = await self._wait_active(
|
| 928 |
-
name=provider_name, upload=upload, initial=meta
|
| 929 |
-
)
|
| 930 |
-
provider_name, provider_uri, state = self._validate_metadata(meta, upload)
|
| 931 |
-
if state != "ACTIVE":
|
| 932 |
-
raise ResourceExecutionError("Gemini resource did not become active")
|
| 933 |
-
except BaseException:
|
| 934 |
-
if provider_name:
|
| 935 |
-
try: # ruff: ignore[suppressible-exception]
|
| 936 |
-
await asyncio.shield(self._delete_name(provider_name))
|
| 937 |
-
except Exception: # ruff: ignore[blind-except]
|
| 938 |
-
pass
|
| 939 |
-
raise
|
| 940 |
-
|
| 941 |
-
return ProviderPrivateResource(
|
| 942 |
-
resource_id=verified.id,
|
| 943 |
-
route=route.route,
|
| 944 |
-
provider=self.name,
|
| 945 |
-
source_sha256=verified.sha256,
|
| 946 |
-
source_size=verified.actual_size,
|
| 947 |
-
opaque={
|
| 948 |
-
"file_name": provider_name,
|
| 949 |
-
"uri": provider_uri,
|
| 950 |
-
"modality": modality,
|
| 951 |
-
"mime": mime,
|
| 952 |
-
"name": verified.name,
|
| 953 |
-
},
|
| 954 |
-
cleanup_required=True,
|
| 955 |
-
)
|
| 956 |
-
|
| 957 |
-
async def release(self, handle: ProviderPrivateResource) -> None:
|
| 958 |
-
if handle.route == "tool":
|
| 959 |
-
data = _tool_opaque(handle)
|
| 960 |
-
if handle.cleanup_required:
|
| 961 |
-
await self._delete_store(data["store_name"])
|
| 962 |
-
handle.released = True
|
| 963 |
-
return
|
| 964 |
-
data = _native_opaque(handle)
|
| 965 |
-
await self._delete_name(data["file_name"])
|
| 966 |
-
handle.released = True
|
| 967 |
-
|
| 968 |
-
def interactions_payload(
|
| 969 |
-
self, chat: ChatRequest, handles: Sequence[ProviderPrivateResource]
|
| 970 |
-
) -> dict:
|
| 971 |
-
upstream = build_upstream_payload(chat, reasoning_enabled=False)
|
| 972 |
-
messages = upstream.get("messages", [])
|
| 973 |
-
system = str(messages[0].get("content", "")) if len(messages) > 0 else ""
|
| 974 |
-
user_text = (
|
| 975 |
-
str(messages[1].get("content", ""))
|
| 976 |
-
if len(messages) > 1
|
| 977 |
-
else chat.user_message
|
| 978 |
-
)
|
| 979 |
-
content: list[dict] = [{"type": "text", "text": user_text}]
|
| 980 |
-
file_search_stores: list[str] = []
|
| 981 |
-
for handle in handles:
|
| 982 |
-
if handle.route == "native":
|
| 983 |
-
data = _native_opaque(handle)
|
| 984 |
-
modality = str(data.get("modality", ""))
|
| 985 |
-
mime = str(data.get("mime", ""))
|
| 986 |
-
block_type = (
|
| 987 |
-
"image" if modality in {"image", "animated_image"} else modality
|
| 988 |
-
)
|
| 989 |
-
if block_type not in {"image", "audio", "video", "document"}:
|
| 990 |
-
raise ResourceExecutionError(
|
| 991 |
-
"Gemini Interactions cannot map this resource modality"
|
| 992 |
-
)
|
| 993 |
-
block = {"type": block_type, "uri": data["uri"], "mime_type": mime}
|
| 994 |
-
content.append(block)
|
| 995 |
-
elif handle.route == "tool":
|
| 996 |
-
store = _tool_opaque(handle)["store_name"]
|
| 997 |
-
if store not in file_search_stores:
|
| 998 |
-
file_search_stores.append(store)
|
| 999 |
-
else:
|
| 1000 |
-
raise ResourceExecutionError(
|
| 1001 |
-
"Gemini Interactions received an unsupported resource route"
|
| 1002 |
-
)
|
| 1003 |
-
generation: dict = {
|
| 1004 |
-
"max_output_tokens": chat.max_tokens,
|
| 1005 |
-
"thinking_summaries": "none",
|
| 1006 |
-
}
|
| 1007 |
-
if chat.effort:
|
| 1008 |
-
effort_map = {
|
| 1009 |
-
"low": "low",
|
| 1010 |
-
"medium": "medium",
|
| 1011 |
-
"high": "high",
|
| 1012 |
-
"extra": "high",
|
| 1013 |
-
"max": "high",
|
| 1014 |
-
}
|
| 1015 |
-
generation["thinking_level"] = effort_map.get(chat.effort, "medium")
|
| 1016 |
-
payload: dict = {
|
| 1017 |
-
"model": chat.model,
|
| 1018 |
-
"input": content,
|
| 1019 |
-
"system_instruction": system,
|
| 1020 |
-
"stream": bool(chat.stream),
|
| 1021 |
-
"store": False,
|
| 1022 |
-
"generation_config": generation,
|
| 1023 |
-
}
|
| 1024 |
-
if file_search_stores:
|
| 1025 |
-
payload["tools"] = [
|
| 1026 |
-
{
|
| 1027 |
-
"type": "file_search",
|
| 1028 |
-
"file_search_store_names": file_search_stores,
|
| 1029 |
-
}
|
| 1030 |
-
]
|
| 1031 |
-
return payload
|
| 1032 |
-
|
| 1033 |
-
async def open_chat(
|
| 1034 |
-
self, *, chat: ChatRequest, handles: Sequence[ProviderPrivateResource]
|
| 1035 |
-
) -> httpx.Response:
|
| 1036 |
-
payload = self.interactions_payload(chat, handles)
|
| 1037 |
-
headers = self._headers()
|
| 1038 |
-
headers["Content-Type"] = "application/json"
|
| 1039 |
-
if chat.stream:
|
| 1040 |
-
headers["Accept"] = "text/event-stream"
|
| 1041 |
-
request = self._client.build_request(
|
| 1042 |
-
"POST",
|
| 1043 |
-
f"{self._base}/v1beta/interactions",
|
| 1044 |
-
headers=headers,
|
| 1045 |
-
content=json.dumps(
|
| 1046 |
-
payload, ensure_ascii=False, separators=(",", ":")
|
| 1047 |
-
).encode("utf-8"),
|
| 1048 |
-
timeout=self._response_timeout,
|
| 1049 |
-
)
|
| 1050 |
-
try:
|
| 1051 |
-
return await self._client.send(request, stream=True)
|
| 1052 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 1053 |
-
raise ResourceExecutionError("Gemini Interactions request failed") from exc
|
| 1054 |
-
|
| 1055 |
-
async def buffered_chat_response(
|
| 1056 |
-
self, upstream: httpx.Response, *, maximum: int
|
| 1057 |
-
) -> dict:
|
| 1058 |
-
total = 0
|
| 1059 |
-
chunks: list[bytes] = []
|
| 1060 |
-
async for chunk in upstream.aiter_bytes():
|
| 1061 |
-
total += len(chunk)
|
| 1062 |
-
if total > maximum:
|
| 1063 |
-
raise ResourceExecutionError(
|
| 1064 |
-
"Gemini Interactions body exceeded the configured response limit"
|
| 1065 |
-
)
|
| 1066 |
-
chunks.append(chunk)
|
| 1067 |
-
try:
|
| 1068 |
-
payload = json.loads(b"".join(chunks))
|
| 1069 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 1070 |
-
raise ResourceExecutionError(
|
| 1071 |
-
"Gemini Interactions returned invalid JSON"
|
| 1072 |
-
) from exc
|
| 1073 |
-
text = _response_text(payload)
|
| 1074 |
-
return {
|
| 1075 |
-
"choices": [{"message": {"role": "assistant", "content": text}}],
|
| 1076 |
-
"model": (
|
| 1077 |
-
str(payload.get("model", "") or "") if isinstance(payload, dict) else ""
|
| 1078 |
-
),
|
| 1079 |
-
}
|
| 1080 |
-
|
| 1081 |
-
async def chat_sse( # ruff: ignore[too-many-branches, too-many-return-statements]
|
| 1082 |
-
self, upstream: httpx.Response, *, maximum: int
|
| 1083 |
-
) -> AsyncIterator[bytes]:
|
| 1084 |
-
total = 0
|
| 1085 |
-
buffer = bytearray()
|
| 1086 |
-
completed = False
|
| 1087 |
-
async for chunk in upstream.aiter_bytes():
|
| 1088 |
-
total += len(chunk)
|
| 1089 |
-
if total > maximum:
|
| 1090 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_RESPONSE_TOO_LARGE","message":"The upstream response exceeded the configured limit."}}\n\n'
|
| 1091 |
-
return
|
| 1092 |
-
buffer.extend(chunk)
|
| 1093 |
-
if (
|
| 1094 |
-
len(buffer) > _MAX_SSE_EVENT_BYTES
|
| 1095 |
-
and b"\n\n" not in buffer
|
| 1096 |
-
and b"\r\n\r\n" not in buffer
|
| 1097 |
-
):
|
| 1098 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_EVENT_TOO_LARGE","message":"The upstream stream event exceeded the configured limit."}}\n\n'
|
| 1099 |
-
return
|
| 1100 |
-
while True:
|
| 1101 |
-
pos_lf = buffer.find(b"\n\n")
|
| 1102 |
-
pos_crlf = buffer.find(b"\r\n\r\n")
|
| 1103 |
-
candidates = [(pos_lf, 2), (pos_crlf, 4)]
|
| 1104 |
-
candidates = [(pos, width) for pos, width in candidates if pos >= 0]
|
| 1105 |
-
if not candidates:
|
| 1106 |
-
break
|
| 1107 |
-
pos, width = min(candidates, key=lambda row: row[0])
|
| 1108 |
-
if pos > _MAX_SSE_EVENT_BYTES:
|
| 1109 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_EVENT_TOO_LARGE","message":"The upstream stream event exceeded the configured limit."}}\n\n'
|
| 1110 |
-
return
|
| 1111 |
-
raw = bytes(buffer[:pos])
|
| 1112 |
-
del buffer[: pos + width]
|
| 1113 |
-
data_lines: list[bytes] = []
|
| 1114 |
-
event_name = ""
|
| 1115 |
-
for line in raw.replace(b"\r\n", b"\n").split(b"\n"):
|
| 1116 |
-
if line.startswith(b"event:"):
|
| 1117 |
-
event_name = line[6:].strip().decode("utf-8", "replace")
|
| 1118 |
-
elif line.startswith(b"data:"):
|
| 1119 |
-
data_lines.append(line[5:].lstrip())
|
| 1120 |
-
if not data_lines:
|
| 1121 |
-
continue
|
| 1122 |
-
joined = b"\n".join(data_lines)
|
| 1123 |
-
if joined == b"[DONE]":
|
| 1124 |
-
continue
|
| 1125 |
-
try:
|
| 1126 |
-
payload = json.loads(joined)
|
| 1127 |
-
except (json.JSONDecodeError, ValueError):
|
| 1128 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_STREAM_INVALID","message":"The upstream provider stream was malformed."}}\n\n'
|
| 1129 |
-
return
|
| 1130 |
-
if not isinstance(payload, dict):
|
| 1131 |
-
continue
|
| 1132 |
-
event_type = str(payload.get("event_type", "") or event_name)
|
| 1133 |
-
if event_type == "interaction.completed":
|
| 1134 |
-
interaction = payload.get("interaction")
|
| 1135 |
-
if (
|
| 1136 |
-
isinstance(interaction, dict)
|
| 1137 |
-
and interaction.get("status") == "completed"
|
| 1138 |
-
):
|
| 1139 |
-
completed = True
|
| 1140 |
-
yield b"data: [DONE]\n\n"
|
| 1141 |
-
return
|
| 1142 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_STREAM_INCOMPLETE","message":"The upstream interaction did not complete successfully."}}\n\n'
|
| 1143 |
-
return
|
| 1144 |
-
if event_type in {
|
| 1145 |
-
"interaction.failed",
|
| 1146 |
-
"interaction.cancelled",
|
| 1147 |
-
"interaction.incomplete",
|
| 1148 |
-
"interaction.budget_exceeded",
|
| 1149 |
-
"error",
|
| 1150 |
-
}:
|
| 1151 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROVIDER_ERROR","message":"The upstream provider stream failed."}}\n\n'
|
| 1152 |
-
return
|
| 1153 |
-
if event_type == "step.delta":
|
| 1154 |
-
delta = payload.get("delta")
|
| 1155 |
-
if (
|
| 1156 |
-
isinstance(delta, dict)
|
| 1157 |
-
and delta.get("type") == "text"
|
| 1158 |
-
and isinstance(delta.get("text"), str)
|
| 1159 |
-
):
|
| 1160 |
-
frame = json.dumps(
|
| 1161 |
-
{"choices": [{"delta": {"content": delta["text"]}}]},
|
| 1162 |
-
ensure_ascii=False,
|
| 1163 |
-
separators=(",", ":"),
|
| 1164 |
-
).encode("utf-8")
|
| 1165 |
-
yield b"data: " + frame + b"\n\n"
|
| 1166 |
-
if not completed:
|
| 1167 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_STREAM_INCOMPLETE","message":"The upstream provider stream ended before completion."}}\n\n'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/huggingface.py
DELETED
|
@@ -1,479 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
Hugging Face task-aware Chat Completion resource executor.
|
| 6 |
-
|
| 7 |
-
Inference Providers are heterogeneous: the OpenAI-compatible Chat Completion
|
| 8 |
-
surface covers conversational LLMs and VLMs, while audio/video/other media
|
| 9 |
-
families use separate task APIs. This executor therefore owns ordinary Path-3
|
| 10 |
-
chat, but enables raw resources only for exact reviewed/opted-in VLMs and
|
| 11 |
-
raster-image inputs. Browser-to-proxy transport remains raw multipart; the
|
| 12 |
-
provider-private ``data:`` URL exists only inside this adapter lifecycle.
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
from __future__ import annotations
|
| 16 |
-
|
| 17 |
-
import base64
|
| 18 |
-
import json
|
| 19 |
-
from collections.abc import AsyncIterator, Sequence
|
| 20 |
-
from urllib.parse import quote, urlparse
|
| 21 |
-
|
| 22 |
-
import httpx
|
| 23 |
-
|
| 24 |
-
from .executor import ProviderPrivateResource, ResourceExecutionError
|
| 25 |
-
from .policy import huggingface_asr_supported_model, huggingface_vlm_supported_model
|
| 26 |
-
from .registry import StaticProviderAdapter
|
| 27 |
-
|
| 28 |
-
try:
|
| 29 |
-
from .._utils._chat_contract import ChatRequest, build_upstream_payload
|
| 30 |
-
from .._utils._resource_transport import ResourceUpload
|
| 31 |
-
except ImportError: # standalone HF Space: _providers is top-level
|
| 32 |
-
from _utils._chat_contract import ChatRequest, build_upstream_payload
|
| 33 |
-
from _utils._resource_transport import ResourceUpload
|
| 34 |
-
|
| 35 |
-
_HF_ROUTER_BASE = "https://router.huggingface.co"
|
| 36 |
-
_HF_CHAT_URL = "https://router.huggingface.co/v1/chat/completions"
|
| 37 |
-
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
| 38 |
-
_MAX_TOTAL_IMAGE_BYTES = 24 * 1024 * 1024
|
| 39 |
-
_MAX_SSE_EVENT_BYTES = 1024 * 1024
|
| 40 |
-
_MAX_ASR_AUDIO_BYTES = 25 * 1024 * 1024
|
| 41 |
-
_AUDIO_MIMES = frozenset(
|
| 42 |
-
{
|
| 43 |
-
"audio/wav",
|
| 44 |
-
"audio/mpeg",
|
| 45 |
-
"audio/flac",
|
| 46 |
-
"audio/ogg",
|
| 47 |
-
"audio/mp4",
|
| 48 |
-
"audio/aac",
|
| 49 |
-
"audio/webm",
|
| 50 |
-
}
|
| 51 |
-
)
|
| 52 |
-
_MODEL_ID_RE = __import__("re").compile(
|
| 53 |
-
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,159}$"
|
| 54 |
-
)
|
| 55 |
-
_IMAGE_MIMES = frozenset({"image/jpeg", "image/png", "image/webp"})
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
class HuggingFaceAdapter(StaticProviderAdapter):
|
| 59 |
-
def __init__(self, **kwargs):
|
| 60 |
-
super().__init__("huggingface", **kwargs)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def official_huggingface_router_base(value: str) -> bool:
|
| 64 |
-
"""Return whether *value* is exactly the official credential destination."""
|
| 65 |
-
raw = str(value or "").strip().rstrip("/")
|
| 66 |
-
try:
|
| 67 |
-
parsed = urlparse(raw)
|
| 68 |
-
except ValueError:
|
| 69 |
-
return False
|
| 70 |
-
return bool(
|
| 71 |
-
raw == _HF_ROUTER_BASE
|
| 72 |
-
and parsed.scheme == "https"
|
| 73 |
-
and parsed.hostname == "router.huggingface.co"
|
| 74 |
-
and parsed.username is None
|
| 75 |
-
and parsed.password is None
|
| 76 |
-
and parsed.port in {None, 443}
|
| 77 |
-
and parsed.path in {"", "/"}
|
| 78 |
-
and not parsed.query
|
| 79 |
-
and not parsed.fragment
|
| 80 |
-
)
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def _bounded_json_bytes(payload: dict) -> bytes:
|
| 84 |
-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
|
| 85 |
-
"utf-8"
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def _chat_text(payload: object) -> str:
|
| 90 |
-
if not isinstance(payload, dict) or payload.get("error"):
|
| 91 |
-
raise ResourceExecutionError(
|
| 92 |
-
"Hugging Face Chat Completion reported a provider failure"
|
| 93 |
-
)
|
| 94 |
-
choices = payload.get("choices")
|
| 95 |
-
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
|
| 96 |
-
raise ResourceExecutionError(
|
| 97 |
-
"Hugging Face Chat Completion returned invalid metadata"
|
| 98 |
-
)
|
| 99 |
-
message = choices[0].get("message")
|
| 100 |
-
if not isinstance(message, dict) or not isinstance(message.get("content"), str):
|
| 101 |
-
raise ResourceExecutionError(
|
| 102 |
-
"Hugging Face Chat Completion returned no assistant text"
|
| 103 |
-
)
|
| 104 |
-
text = message["content"]
|
| 105 |
-
if not text:
|
| 106 |
-
raise ResourceExecutionError(
|
| 107 |
-
"Hugging Face Chat Completion returned no assistant text"
|
| 108 |
-
)
|
| 109 |
-
return text
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def _private_image(handle: ProviderPrivateResource) -> tuple[str, str]:
|
| 113 |
-
opaque = handle.opaque if isinstance(handle.opaque, dict) else {}
|
| 114 |
-
data_url = opaque.get("data_url")
|
| 115 |
-
mime = opaque.get("mime")
|
| 116 |
-
if (
|
| 117 |
-
handle.route != "native"
|
| 118 |
-
or not isinstance(data_url, str)
|
| 119 |
-
or not data_url.startswith("data:image/")
|
| 120 |
-
or not isinstance(mime, str)
|
| 121 |
-
or mime not in _IMAGE_MIMES
|
| 122 |
-
):
|
| 123 |
-
raise ResourceExecutionError("Hugging Face private VLM image handle is invalid")
|
| 124 |
-
return data_url, mime
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _private_audio(handle: ProviderPrivateResource) -> tuple[bytes, str]:
|
| 128 |
-
opaque = handle.opaque if isinstance(handle.opaque, dict) else {}
|
| 129 |
-
raw = opaque.get("audio")
|
| 130 |
-
mime = opaque.get("mime")
|
| 131 |
-
if (
|
| 132 |
-
handle.route != "native"
|
| 133 |
-
or opaque.get("task") != "asr"
|
| 134 |
-
or not isinstance(raw, (bytes, bytearray))
|
| 135 |
-
or not isinstance(mime, str)
|
| 136 |
-
or mime not in _AUDIO_MIMES
|
| 137 |
-
):
|
| 138 |
-
raise ResourceExecutionError("Hugging Face private ASR audio handle is invalid")
|
| 139 |
-
return bytes(raw), mime
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def _asr_url(model: str) -> str:
|
| 143 |
-
raw = str(model or "").strip()
|
| 144 |
-
if not _MODEL_ID_RE.fullmatch(raw):
|
| 145 |
-
raise ResourceExecutionError("Hugging Face ASR model id is invalid")
|
| 146 |
-
encoded = "/".join(quote(part, safe="-._~") for part in raw.split("/", 1))
|
| 147 |
-
return f"{_HF_ROUTER_BASE}/hf-inference/models/{encoded}"
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
class HuggingFaceResourceExecutor:
|
| 151 |
-
"""Execute Path-3 HF chat and exact VLM raster-image resources."""
|
| 152 |
-
|
| 153 |
-
name = "huggingface"
|
| 154 |
-
enabled = True
|
| 155 |
-
|
| 156 |
-
def __init__(
|
| 157 |
-
self,
|
| 158 |
-
*,
|
| 159 |
-
api_key: str,
|
| 160 |
-
client: httpx.AsyncClient,
|
| 161 |
-
base_url: str = _HF_ROUTER_BASE,
|
| 162 |
-
response_timeout_seconds: float = 120.0,
|
| 163 |
-
vlm_models: Sequence[str] = (),
|
| 164 |
-
asr_models: Sequence[str] = (),
|
| 165 |
-
) -> None:
|
| 166 |
-
if not api_key:
|
| 167 |
-
raise ValueError(
|
| 168 |
-
"Hugging Face resource executor requires an inference token"
|
| 169 |
-
)
|
| 170 |
-
if not official_huggingface_router_base(base_url):
|
| 171 |
-
raise ValueError(
|
| 172 |
-
"Hugging Face resource executor requires the official router origin"
|
| 173 |
-
)
|
| 174 |
-
self._api_key = str(api_key)
|
| 175 |
-
self._client = client
|
| 176 |
-
self._response_timeout = max(1.0, float(response_timeout_seconds))
|
| 177 |
-
self._vlm_models = frozenset(
|
| 178 |
-
str(row).strip() for row in vlm_models if str(row).strip()
|
| 179 |
-
)
|
| 180 |
-
self._asr_models = frozenset(
|
| 181 |
-
str(row).strip() for row in asr_models if str(row).strip()
|
| 182 |
-
)
|
| 183 |
-
|
| 184 |
-
def _vlm(self, model: str) -> bool:
|
| 185 |
-
return huggingface_vlm_supported_model(model, extra_models=self._vlm_models)
|
| 186 |
-
|
| 187 |
-
def _asr(self, model: str) -> bool:
|
| 188 |
-
return huggingface_asr_supported_model(model, extra_models=self._asr_models)
|
| 189 |
-
|
| 190 |
-
def executable_routes(self, model: str) -> dict[str, tuple[str, ...]]:
|
| 191 |
-
out: dict[str, tuple[str, ...]] = {}
|
| 192 |
-
if self._vlm(model):
|
| 193 |
-
out["image"] = ("native",)
|
| 194 |
-
if self._asr(model):
|
| 195 |
-
out["audio"] = ("native",)
|
| 196 |
-
return out
|
| 197 |
-
|
| 198 |
-
def route_constraints(self, model: str) -> dict[str, dict[str, dict]]:
|
| 199 |
-
out: dict[str, dict[str, dict]] = {}
|
| 200 |
-
if self._vlm(model):
|
| 201 |
-
out["image"] = {
|
| 202 |
-
"native": {
|
| 203 |
-
"max_file_bytes": _MAX_IMAGE_BYTES,
|
| 204 |
-
"mime_types": sorted(_IMAGE_MIMES),
|
| 205 |
-
}
|
| 206 |
-
}
|
| 207 |
-
if self._asr(model):
|
| 208 |
-
out["audio"] = {
|
| 209 |
-
"native": {
|
| 210 |
-
"max_files": 1,
|
| 211 |
-
"max_file_bytes": _MAX_ASR_AUDIO_BYTES,
|
| 212 |
-
"mime_types": sorted(_AUDIO_MIMES),
|
| 213 |
-
}
|
| 214 |
-
}
|
| 215 |
-
return out
|
| 216 |
-
|
| 217 |
-
async def prepare(
|
| 218 |
-
self, *, model: str, route, upload: ResourceUpload
|
| 219 |
-
) -> ProviderPrivateResource:
|
| 220 |
-
verified = upload.verified
|
| 221 |
-
mime = str(verified.detected_mime or "").lower()
|
| 222 |
-
size = int(verified.actual_size)
|
| 223 |
-
if route.route != "native":
|
| 224 |
-
raise ResourceExecutionError("Hugging Face resource route is unsupported")
|
| 225 |
-
if verified.detected_modality == "audio":
|
| 226 |
-
if not self._asr(model) or mime not in _AUDIO_MIMES:
|
| 227 |
-
raise ResourceExecutionError(
|
| 228 |
-
"Hugging Face ASR audio route is unsupported"
|
| 229 |
-
)
|
| 230 |
-
if size <= 0 or size > _MAX_ASR_AUDIO_BYTES:
|
| 231 |
-
raise ResourceExecutionError(
|
| 232 |
-
"Hugging Face ASR audio exceeds the bounded 25 MiB limit"
|
| 233 |
-
)
|
| 234 |
-
await upload.upload.seek(0)
|
| 235 |
-
raw = await upload.upload.read(_MAX_ASR_AUDIO_BYTES + 1)
|
| 236 |
-
await upload.upload.seek(0)
|
| 237 |
-
if len(raw) != size or len(raw) > _MAX_ASR_AUDIO_BYTES:
|
| 238 |
-
raise ResourceExecutionError(
|
| 239 |
-
"Hugging Face ASR audio bytes failed bounded verification"
|
| 240 |
-
)
|
| 241 |
-
return ProviderPrivateResource(
|
| 242 |
-
resource_id=verified.id,
|
| 243 |
-
route="native",
|
| 244 |
-
provider=self.name,
|
| 245 |
-
source_sha256=verified.sha256,
|
| 246 |
-
source_size=size,
|
| 247 |
-
opaque={
|
| 248 |
-
"task": "asr",
|
| 249 |
-
"audio": bytes(raw),
|
| 250 |
-
"mime": mime,
|
| 251 |
-
"modality": "audio",
|
| 252 |
-
},
|
| 253 |
-
cleanup_required=True,
|
| 254 |
-
)
|
| 255 |
-
if not self._vlm(model) or verified.detected_modality != "image":
|
| 256 |
-
raise ResourceExecutionError("Hugging Face VLM image route is unsupported")
|
| 257 |
-
if mime not in _IMAGE_MIMES:
|
| 258 |
-
raise ResourceExecutionError("Hugging Face VLM image MIME is unsupported")
|
| 259 |
-
if size <= 0 or size > _MAX_IMAGE_BYTES:
|
| 260 |
-
raise ResourceExecutionError(
|
| 261 |
-
"Hugging Face VLM image exceeds the bounded 8 MiB limit"
|
| 262 |
-
)
|
| 263 |
-
await upload.upload.seek(0)
|
| 264 |
-
raw = await upload.upload.read(_MAX_IMAGE_BYTES + 1)
|
| 265 |
-
await upload.upload.seek(0)
|
| 266 |
-
if len(raw) != size or len(raw) > _MAX_IMAGE_BYTES:
|
| 267 |
-
raise ResourceExecutionError(
|
| 268 |
-
"Hugging Face VLM image bytes failed bounded verification"
|
| 269 |
-
)
|
| 270 |
-
data_url = f"data:{mime};base64," + base64.b64encode(raw).decode("ascii")
|
| 271 |
-
return ProviderPrivateResource(
|
| 272 |
-
resource_id=verified.id,
|
| 273 |
-
route="native",
|
| 274 |
-
provider=self.name,
|
| 275 |
-
source_sha256=verified.sha256,
|
| 276 |
-
source_size=size,
|
| 277 |
-
opaque={
|
| 278 |
-
"task": "vlm",
|
| 279 |
-
"data_url": data_url,
|
| 280 |
-
"mime": mime,
|
| 281 |
-
"modality": "image",
|
| 282 |
-
},
|
| 283 |
-
cleanup_required=True,
|
| 284 |
-
)
|
| 285 |
-
|
| 286 |
-
async def release(self, handle: ProviderPrivateResource) -> None:
|
| 287 |
-
if handle.released:
|
| 288 |
-
return
|
| 289 |
-
if isinstance(handle.opaque, dict):
|
| 290 |
-
handle.opaque.clear()
|
| 291 |
-
handle.released = True
|
| 292 |
-
|
| 293 |
-
def chat_payload(
|
| 294 |
-
self, chat: ChatRequest, handles: Sequence[ProviderPrivateResource]
|
| 295 |
-
) -> dict:
|
| 296 |
-
payload = build_upstream_payload(chat, reasoning_enabled=False)
|
| 297 |
-
if handles:
|
| 298 |
-
total = sum(int(row.source_size) for row in handles)
|
| 299 |
-
if total > _MAX_TOTAL_IMAGE_BYTES:
|
| 300 |
-
raise ResourceExecutionError(
|
| 301 |
-
"Hugging Face VLM images exceed the bounded 24 MiB request limit"
|
| 302 |
-
)
|
| 303 |
-
messages = payload.get("messages")
|
| 304 |
-
if (
|
| 305 |
-
not isinstance(messages, list)
|
| 306 |
-
or len(messages) < 2 # ruff: ignore[magic-value-comparison]
|
| 307 |
-
or not isinstance(messages[1], dict)
|
| 308 |
-
):
|
| 309 |
-
raise ResourceExecutionError(
|
| 310 |
-
"Hugging Face Chat payload authority is invalid"
|
| 311 |
-
)
|
| 312 |
-
user_text = str(messages[1].get("content", ""))
|
| 313 |
-
content: list[dict] = [{"type": "text", "text": user_text}]
|
| 314 |
-
for handle in handles:
|
| 315 |
-
data_url, _mime = _private_image(handle)
|
| 316 |
-
content.append({"type": "image_url", "image_url": {"url": data_url}})
|
| 317 |
-
messages[1]["content"] = content
|
| 318 |
-
return payload
|
| 319 |
-
|
| 320 |
-
async def open_chat(
|
| 321 |
-
self, *, chat: ChatRequest, handles: Sequence[ProviderPrivateResource]
|
| 322 |
-
) -> httpx.Response:
|
| 323 |
-
audio_handles = [
|
| 324 |
-
row
|
| 325 |
-
for row in handles
|
| 326 |
-
if isinstance(row.opaque, dict) and row.opaque.get("task") == "asr"
|
| 327 |
-
]
|
| 328 |
-
if audio_handles:
|
| 329 |
-
if (
|
| 330 |
-
len(handles) != 1
|
| 331 |
-
or len(audio_handles) != 1
|
| 332 |
-
or not self._asr(chat.model)
|
| 333 |
-
):
|
| 334 |
-
raise ResourceExecutionError(
|
| 335 |
-
"Hugging Face ASR accepts exactly one audio resource"
|
| 336 |
-
)
|
| 337 |
-
raw, mime = _private_audio(audio_handles[0])
|
| 338 |
-
request = self._client.build_request(
|
| 339 |
-
"POST",
|
| 340 |
-
_asr_url(chat.model),
|
| 341 |
-
headers={
|
| 342 |
-
"Authorization": f"Bearer {self._api_key}",
|
| 343 |
-
"Content-Type": mime,
|
| 344 |
-
"Accept": "application/json",
|
| 345 |
-
},
|
| 346 |
-
content=raw,
|
| 347 |
-
timeout=self._response_timeout,
|
| 348 |
-
)
|
| 349 |
-
try:
|
| 350 |
-
return await self._client.send(request, stream=True)
|
| 351 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 352 |
-
raise ResourceExecutionError("Hugging Face ASR request failed") from exc
|
| 353 |
-
if self._asr(chat.model):
|
| 354 |
-
raise ResourceExecutionError(
|
| 355 |
-
"Hugging Face ASR requires exactly one audio resource"
|
| 356 |
-
)
|
| 357 |
-
payload = self.chat_payload(chat, handles)
|
| 358 |
-
request = self._client.build_request(
|
| 359 |
-
"POST",
|
| 360 |
-
_HF_CHAT_URL,
|
| 361 |
-
headers={
|
| 362 |
-
"Authorization": f"Bearer {self._api_key}",
|
| 363 |
-
"Content-Type": "application/json",
|
| 364 |
-
"Accept": "text/event-stream" if chat.stream else "application/json",
|
| 365 |
-
},
|
| 366 |
-
content=_bounded_json_bytes(payload),
|
| 367 |
-
timeout=self._response_timeout,
|
| 368 |
-
)
|
| 369 |
-
try:
|
| 370 |
-
return await self._client.send(request, stream=True)
|
| 371 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 372 |
-
raise ResourceExecutionError(
|
| 373 |
-
"Hugging Face Chat Completion request failed"
|
| 374 |
-
) from exc
|
| 375 |
-
|
| 376 |
-
async def buffered_chat_response(
|
| 377 |
-
self, upstream: httpx.Response, *, maximum: int
|
| 378 |
-
) -> dict:
|
| 379 |
-
total = 0
|
| 380 |
-
chunks: list[bytes] = []
|
| 381 |
-
async for chunk in upstream.aiter_bytes():
|
| 382 |
-
total += len(chunk)
|
| 383 |
-
if total > maximum:
|
| 384 |
-
raise ResourceExecutionError(
|
| 385 |
-
"Hugging Face Chat Completion body exceeded the response limit"
|
| 386 |
-
)
|
| 387 |
-
chunks.append(chunk)
|
| 388 |
-
try:
|
| 389 |
-
payload = json.loads(b"".join(chunks))
|
| 390 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 391 |
-
raise ResourceExecutionError(
|
| 392 |
-
"Hugging Face Chat Completion returned invalid JSON"
|
| 393 |
-
) from exc
|
| 394 |
-
if isinstance(payload, dict) and isinstance(payload.get("text"), str):
|
| 395 |
-
text = payload["text"]
|
| 396 |
-
if not text:
|
| 397 |
-
raise ResourceExecutionError(
|
| 398 |
-
"Hugging Face ASR returned no transcript text"
|
| 399 |
-
)
|
| 400 |
-
else:
|
| 401 |
-
text = _chat_text(payload)
|
| 402 |
-
return {
|
| 403 |
-
"choices": [{"message": {"role": "assistant", "content": text}}],
|
| 404 |
-
"model": (
|
| 405 |
-
str(payload.get("model", "") or "") if isinstance(payload, dict) else ""
|
| 406 |
-
),
|
| 407 |
-
}
|
| 408 |
-
|
| 409 |
-
async def chat_sse( # ruff: ignore[too-many-branches]
|
| 410 |
-
self, upstream: httpx.Response, *, maximum: int
|
| 411 |
-
) -> AsyncIterator[bytes]:
|
| 412 |
-
total = 0
|
| 413 |
-
buffer = bytearray()
|
| 414 |
-
done = False
|
| 415 |
-
async for chunk in upstream.aiter_bytes():
|
| 416 |
-
total += len(chunk)
|
| 417 |
-
if total > maximum:
|
| 418 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_RESPONSE_TOO_LARGE","message":"The upstream response exceeded the configured limit."}}\n\n'
|
| 419 |
-
return
|
| 420 |
-
buffer.extend(chunk)
|
| 421 |
-
if (
|
| 422 |
-
len(buffer) > _MAX_SSE_EVENT_BYTES
|
| 423 |
-
and b"\n\n" not in buffer
|
| 424 |
-
and b"\r\n\r\n" not in buffer
|
| 425 |
-
):
|
| 426 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROTOCOL_ERROR","message":"The upstream stream event exceeded the configured limit."}}\n\n'
|
| 427 |
-
return
|
| 428 |
-
while True:
|
| 429 |
-
marker = buffer.find(b"\n\n")
|
| 430 |
-
marker_len = 2
|
| 431 |
-
if marker < 0:
|
| 432 |
-
marker = buffer.find(b"\r\n\r\n")
|
| 433 |
-
marker_len = 4
|
| 434 |
-
if marker < 0:
|
| 435 |
-
break
|
| 436 |
-
raw = bytes(buffer[:marker])
|
| 437 |
-
del buffer[: marker + marker_len]
|
| 438 |
-
if len(raw) > _MAX_SSE_EVENT_BYTES:
|
| 439 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROTOCOL_ERROR","message":"The upstream stream event exceeded the configured limit."}}\n\n'
|
| 440 |
-
return
|
| 441 |
-
data_lines = [
|
| 442 |
-
line[5:].lstrip()
|
| 443 |
-
for line in raw.replace(b"\r\n", b"\n").split(b"\n")
|
| 444 |
-
if line.startswith(b"data:")
|
| 445 |
-
]
|
| 446 |
-
if not data_lines:
|
| 447 |
-
continue
|
| 448 |
-
data = b"\n".join(data_lines)
|
| 449 |
-
if data == b"[DONE]":
|
| 450 |
-
done = True
|
| 451 |
-
break
|
| 452 |
-
try:
|
| 453 |
-
doc = json.loads(data)
|
| 454 |
-
except (json.JSONDecodeError, ValueError):
|
| 455 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROTOCOL_ERROR","message":"The upstream stream was malformed."}}\n\n'
|
| 456 |
-
return
|
| 457 |
-
if not isinstance(doc, dict) or doc.get("error"):
|
| 458 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_SERVICE_ERROR","message":"The upstream AI provider did not complete the streamed response."}}\n\n'
|
| 459 |
-
return
|
| 460 |
-
choices = doc.get("choices")
|
| 461 |
-
if (
|
| 462 |
-
isinstance(choices, list)
|
| 463 |
-
and choices
|
| 464 |
-
and isinstance(choices[0], dict)
|
| 465 |
-
):
|
| 466 |
-
delta = choices[0].get("delta")
|
| 467 |
-
if (
|
| 468 |
-
isinstance(delta, dict)
|
| 469 |
-
and isinstance(delta.get("content"), str)
|
| 470 |
-
and delta["content"]
|
| 471 |
-
):
|
| 472 |
-
frame = {"choices": [{"delta": {"content": delta["content"]}}]}
|
| 473 |
-
yield b"data: " + _bounded_json_bytes(frame) + b"\n\n"
|
| 474 |
-
if done:
|
| 475 |
-
break
|
| 476 |
-
if not done:
|
| 477 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROTOCOL_ERROR","message":"The upstream stream ended before completion."}}\n\n'
|
| 478 |
-
return
|
| 479 |
-
yield b"data: [DONE]\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/openai.py
DELETED
|
@@ -1,553 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
OpenAI resource planning, temporary files, and Responses execution.
|
| 6 |
-
|
| 7 |
-
The provider file identifier is request-private capability state. Browser
|
| 8 |
-
metadata, health documents, logs, receipts, and persisted transcripts never
|
| 9 |
-
contain it. Run 130 adds the full execution boundary: verified resources are
|
| 10 |
-
uploaded first, a server-owned Responses request consumes those private handles,
|
| 11 |
-
and temporary provider files are deleted after buffered or streaming completion.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import json
|
| 17 |
-
import secrets
|
| 18 |
-
from collections.abc import AsyncIterator, Sequence
|
| 19 |
-
from urllib.parse import quote, urlparse
|
| 20 |
-
|
| 21 |
-
import httpx
|
| 22 |
-
|
| 23 |
-
from .executor import ProviderPrivateResource, ResourceExecutionError
|
| 24 |
-
from .registry import StaticProviderAdapter
|
| 25 |
-
|
| 26 |
-
try:
|
| 27 |
-
from .._utils._chat_contract import ChatRequest, build_upstream_payload
|
| 28 |
-
from .._utils._resource_transport import ResourceUpload
|
| 29 |
-
except ImportError: # standalone HF Space: _providers is top-level
|
| 30 |
-
from _utils._chat_contract import ChatRequest, build_upstream_payload
|
| 31 |
-
from _utils._resource_transport import ResourceUpload
|
| 32 |
-
|
| 33 |
-
_OPENAI_BASE = "https://api.openai.com"
|
| 34 |
-
_OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"
|
| 35 |
-
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
| 36 |
-
_MIN_EXPIRES_SECONDS = 3600
|
| 37 |
-
_MAX_EXPIRES_SECONDS = 30 * 24 * 60 * 60
|
| 38 |
-
_DIRECT_FILE_LIMIT_BYTES = 50 * 1024 * 1024
|
| 39 |
-
_MAX_SSE_EVENT_BYTES = 1024 * 1024
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
class OpenAIAdapter(StaticProviderAdapter):
|
| 43 |
-
def __init__(self, **kwargs):
|
| 44 |
-
super().__init__("openai", **kwargs)
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _validated_openai_base(value: str) -> str:
|
| 48 |
-
raw = str(value or _OPENAI_BASE).rstrip("/")
|
| 49 |
-
parsed = urlparse(raw)
|
| 50 |
-
if (
|
| 51 |
-
parsed.scheme != "https"
|
| 52 |
-
or parsed.hostname != "api.openai.com"
|
| 53 |
-
or parsed.username is not None
|
| 54 |
-
or parsed.password is not None
|
| 55 |
-
or parsed.query
|
| 56 |
-
or parsed.fragment
|
| 57 |
-
or parsed.path not in {"", "/"}
|
| 58 |
-
):
|
| 59 |
-
raise ValueError(
|
| 60 |
-
"OpenAI resource executor requires the official HTTPS API origin"
|
| 61 |
-
)
|
| 62 |
-
return _OPENAI_BASE
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def official_openai_chat_backend(value: str) -> bool:
|
| 66 |
-
"""Return whether Path-1 points exactly at the official Chat Completions URL."""
|
| 67 |
-
raw = str(value or "").strip().rstrip("/")
|
| 68 |
-
try:
|
| 69 |
-
parsed = urlparse(raw)
|
| 70 |
-
except ValueError:
|
| 71 |
-
return False
|
| 72 |
-
return bool(
|
| 73 |
-
parsed.scheme == "https"
|
| 74 |
-
and parsed.hostname == "api.openai.com"
|
| 75 |
-
and parsed.username is None
|
| 76 |
-
and parsed.password is None
|
| 77 |
-
and not parsed.query
|
| 78 |
-
and not parsed.fragment
|
| 79 |
-
and parsed.port in {None, 443}
|
| 80 |
-
and parsed.path.rstrip("/") == "/v1/chat/completions"
|
| 81 |
-
and raw == _OPENAI_CHAT_URL
|
| 82 |
-
)
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def _multipart_header(
|
| 86 |
-
boundary: str, *, purpose: str, seconds: int, upload: ResourceUpload
|
| 87 |
-
) -> tuple[bytes, bytes]:
|
| 88 |
-
verified = upload.verified
|
| 89 |
-
safe_name = verified.name.replace('"', "_").replace("\r", " ").replace("\n", " ")
|
| 90 |
-
mime = verified.detected_mime or "application/octet-stream"
|
| 91 |
-
prefix = (
|
| 92 |
-
f"--{boundary}\r\n"
|
| 93 |
-
'Content-Disposition: form-data; name="purpose"\r\n\r\n'
|
| 94 |
-
f"{purpose}\r\n"
|
| 95 |
-
f"--{boundary}\r\n"
|
| 96 |
-
'Content-Disposition: form-data; name="expires_after[anchor]"\r\n\r\n'
|
| 97 |
-
"created_at\r\n"
|
| 98 |
-
f"--{boundary}\r\n"
|
| 99 |
-
'Content-Disposition: form-data; name="expires_after[seconds]"\r\n\r\n'
|
| 100 |
-
f"{seconds}\r\n"
|
| 101 |
-
f"--{boundary}\r\n"
|
| 102 |
-
f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'
|
| 103 |
-
f"Content-Type: {mime}\r\n\r\n"
|
| 104 |
-
).encode()
|
| 105 |
-
suffix = f"\r\n--{boundary}--\r\n".encode("ascii")
|
| 106 |
-
return prefix, suffix
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def _opaque_file_id(handle: ProviderPrivateResource) -> str:
|
| 110 |
-
opaque = handle.opaque if isinstance(handle.opaque, dict) else {}
|
| 111 |
-
file_id = str(opaque.get("file_id", "")).strip()
|
| 112 |
-
if (
|
| 113 |
-
not file_id
|
| 114 |
-
or len(file_id) > 256 # ruff: ignore[magic-value-comparison]
|
| 115 |
-
or not file_id.startswith("file-")
|
| 116 |
-
):
|
| 117 |
-
raise ResourceExecutionError("OpenAI private file handle is invalid")
|
| 118 |
-
return file_id
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def _bounded_json_bytes(payload: dict) -> bytes:
|
| 122 |
-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
|
| 123 |
-
"utf-8"
|
| 124 |
-
)
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _response_text(payload: object) -> str:
|
| 128 |
-
"""Extract only assistant-visible text from one bounded Responses JSON body."""
|
| 129 |
-
if not isinstance(payload, dict):
|
| 130 |
-
raise ResourceExecutionError("OpenAI Responses returned invalid metadata")
|
| 131 |
-
status = str(payload.get("status", "") or "")
|
| 132 |
-
if status in {"failed", "incomplete", "cancelled"} or payload.get("error"):
|
| 133 |
-
raise ResourceExecutionError("OpenAI Responses reported a provider failure")
|
| 134 |
-
top = payload.get("output_text")
|
| 135 |
-
if isinstance(top, str) and top:
|
| 136 |
-
return top
|
| 137 |
-
pieces: list[str] = []
|
| 138 |
-
output = payload.get("output")
|
| 139 |
-
if isinstance(output, list):
|
| 140 |
-
for item in output:
|
| 141 |
-
if not isinstance(item, dict) or item.get("type") != "message":
|
| 142 |
-
continue
|
| 143 |
-
content = item.get("content")
|
| 144 |
-
if not isinstance(content, list):
|
| 145 |
-
continue
|
| 146 |
-
for row in content:
|
| 147 |
-
if not isinstance(row, dict):
|
| 148 |
-
continue
|
| 149 |
-
if row.get("type") == "output_text" and isinstance(
|
| 150 |
-
row.get("text"), str
|
| 151 |
-
):
|
| 152 |
-
pieces.append(row["text"])
|
| 153 |
-
elif row.get("type") == "refusal" and isinstance(
|
| 154 |
-
row.get("refusal"), str
|
| 155 |
-
):
|
| 156 |
-
pieces.append(row["refusal"])
|
| 157 |
-
text = "".join(pieces)
|
| 158 |
-
if not text:
|
| 159 |
-
raise ResourceExecutionError("OpenAI Responses returned no assistant text")
|
| 160 |
-
return text
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
class OpenAIResourceExecutor:
|
| 164 |
-
"""Stream verified resources to OpenAI Files and consume them with Responses."""
|
| 165 |
-
|
| 166 |
-
name = "openai"
|
| 167 |
-
enabled = True
|
| 168 |
-
|
| 169 |
-
def __init__(
|
| 170 |
-
self,
|
| 171 |
-
*,
|
| 172 |
-
api_key: str,
|
| 173 |
-
client: httpx.AsyncClient,
|
| 174 |
-
base_url: str = _OPENAI_BASE,
|
| 175 |
-
expires_seconds: int = _MIN_EXPIRES_SECONDS,
|
| 176 |
-
response_timeout_seconds: float = 600.0,
|
| 177 |
-
) -> None:
|
| 178 |
-
key = str(api_key or "").strip()
|
| 179 |
-
if not key:
|
| 180 |
-
raise ValueError("OpenAI API key is required")
|
| 181 |
-
self._api_key = key
|
| 182 |
-
self._client = client
|
| 183 |
-
self._base = _validated_openai_base(base_url)
|
| 184 |
-
self._expires = max(
|
| 185 |
-
_MIN_EXPIRES_SECONDS,
|
| 186 |
-
min(int(expires_seconds), _MAX_EXPIRES_SECONDS),
|
| 187 |
-
)
|
| 188 |
-
self._response_timeout = httpx.Timeout(
|
| 189 |
-
connect=20.0,
|
| 190 |
-
read=max(30.0, min(float(response_timeout_seconds), 1800.0)),
|
| 191 |
-
write=120.0,
|
| 192 |
-
pool=20.0,
|
| 193 |
-
)
|
| 194 |
-
|
| 195 |
-
def executable_routes(self, model: str) -> dict[str, tuple[str, ...]]:
|
| 196 |
-
del model
|
| 197 |
-
return {
|
| 198 |
-
"text": ("native",),
|
| 199 |
-
"image": ("native",),
|
| 200 |
-
"document": ("native",),
|
| 201 |
-
"vector_image": ("tool",),
|
| 202 |
-
"data": ("tool",),
|
| 203 |
-
"archive": ("tool",),
|
| 204 |
-
"binary": ("tool",),
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
def _headers(self) -> dict[str, str]:
|
| 208 |
-
return {
|
| 209 |
-
"Authorization": f"Bearer {self._api_key}",
|
| 210 |
-
"Accept": "application/json",
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
async def _body(self, prefix: bytes, suffix: bytes, upload: ResourceUpload):
|
| 214 |
-
await upload.upload.seek(0)
|
| 215 |
-
try:
|
| 216 |
-
yield prefix
|
| 217 |
-
while True:
|
| 218 |
-
chunk = await upload.upload.read(_UPLOAD_CHUNK_BYTES)
|
| 219 |
-
if not chunk:
|
| 220 |
-
break
|
| 221 |
-
yield chunk
|
| 222 |
-
yield suffix
|
| 223 |
-
finally:
|
| 224 |
-
await upload.upload.seek(0)
|
| 225 |
-
|
| 226 |
-
async def prepare(
|
| 227 |
-
self, *, model: str, route, upload: ResourceUpload
|
| 228 |
-
) -> ProviderPrivateResource:
|
| 229 |
-
del model
|
| 230 |
-
verified = upload.verified
|
| 231 |
-
if route.resource_id != verified.id or route.route not in {"native", "tool"}:
|
| 232 |
-
raise ResourceExecutionError(
|
| 233 |
-
"OpenAI executor received an invalid resource route"
|
| 234 |
-
)
|
| 235 |
-
purpose = "vision" if verified.detected_modality == "image" else "user_data"
|
| 236 |
-
boundary = "scikitplots-" + secrets.token_hex(16)
|
| 237 |
-
prefix, suffix = _multipart_header(
|
| 238 |
-
boundary,
|
| 239 |
-
purpose=purpose,
|
| 240 |
-
seconds=self._expires,
|
| 241 |
-
upload=upload,
|
| 242 |
-
)
|
| 243 |
-
headers = self._headers()
|
| 244 |
-
headers.update(
|
| 245 |
-
{
|
| 246 |
-
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
| 247 |
-
"Content-Length": str(len(prefix) + verified.actual_size + len(suffix)),
|
| 248 |
-
}
|
| 249 |
-
)
|
| 250 |
-
try:
|
| 251 |
-
response = await self._client.post(
|
| 252 |
-
f"{self._base}/v1/files",
|
| 253 |
-
headers=headers,
|
| 254 |
-
content=self._body(prefix, suffix, upload),
|
| 255 |
-
)
|
| 256 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 257 |
-
raise ResourceExecutionError("OpenAI temporary file upload failed") from exc
|
| 258 |
-
if (
|
| 259 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 260 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 261 |
-
):
|
| 262 |
-
raise ResourceExecutionError("OpenAI temporary file upload was rejected")
|
| 263 |
-
try:
|
| 264 |
-
payload = response.json()
|
| 265 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 266 |
-
raise ResourceExecutionError(
|
| 267 |
-
"OpenAI temporary file upload returned invalid metadata"
|
| 268 |
-
) from exc
|
| 269 |
-
file_id = (
|
| 270 |
-
str(payload.get("id", "")).strip() if isinstance(payload, dict) else ""
|
| 271 |
-
)
|
| 272 |
-
returned_bytes = payload.get("bytes") if isinstance(payload, dict) else None
|
| 273 |
-
if (
|
| 274 |
-
not file_id
|
| 275 |
-
or len(file_id) > 256 # ruff: ignore[magic-value-comparison]
|
| 276 |
-
or not file_id.startswith("file-")
|
| 277 |
-
):
|
| 278 |
-
raise ResourceExecutionError(
|
| 279 |
-
"OpenAI temporary file upload returned an invalid file identifier"
|
| 280 |
-
)
|
| 281 |
-
if returned_bytes is not None:
|
| 282 |
-
try:
|
| 283 |
-
if int(returned_bytes) != int(verified.actual_size):
|
| 284 |
-
raise ResourceExecutionError(
|
| 285 |
-
"OpenAI temporary file size did not match source bytes"
|
| 286 |
-
)
|
| 287 |
-
except (TypeError, ValueError) as exc:
|
| 288 |
-
raise ResourceExecutionError(
|
| 289 |
-
"OpenAI temporary file size metadata was invalid"
|
| 290 |
-
) from exc
|
| 291 |
-
return ProviderPrivateResource(
|
| 292 |
-
resource_id=verified.id,
|
| 293 |
-
route=route.route,
|
| 294 |
-
provider=self.name,
|
| 295 |
-
source_sha256=verified.sha256,
|
| 296 |
-
source_size=verified.actual_size,
|
| 297 |
-
opaque={
|
| 298 |
-
"file_id": file_id,
|
| 299 |
-
"purpose": purpose,
|
| 300 |
-
"modality": verified.detected_modality,
|
| 301 |
-
"mime": verified.detected_mime,
|
| 302 |
-
"name": verified.name,
|
| 303 |
-
},
|
| 304 |
-
cleanup_required=True,
|
| 305 |
-
)
|
| 306 |
-
|
| 307 |
-
async def release(self, handle: ProviderPrivateResource) -> None:
|
| 308 |
-
file_id = _opaque_file_id(handle)
|
| 309 |
-
try:
|
| 310 |
-
response = await self._client.delete(
|
| 311 |
-
f"{self._base}/v1/files/{quote(file_id, safe='-_.~')}",
|
| 312 |
-
headers=self._headers(),
|
| 313 |
-
)
|
| 314 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 315 |
-
raise ResourceExecutionError(
|
| 316 |
-
"OpenAI temporary file cleanup failed"
|
| 317 |
-
) from exc
|
| 318 |
-
if response.status_code == 404: # ruff: ignore[magic-value-comparison]
|
| 319 |
-
handle.released = True
|
| 320 |
-
return
|
| 321 |
-
if (
|
| 322 |
-
response.status_code < 200 # ruff: ignore[magic-value-comparison]
|
| 323 |
-
or response.status_code >= 300 # ruff: ignore[magic-value-comparison]
|
| 324 |
-
):
|
| 325 |
-
raise ResourceExecutionError("OpenAI temporary file cleanup was rejected")
|
| 326 |
-
try:
|
| 327 |
-
payload = response.json()
|
| 328 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 329 |
-
raise ResourceExecutionError(
|
| 330 |
-
"OpenAI temporary file cleanup returned invalid metadata"
|
| 331 |
-
) from exc
|
| 332 |
-
if not isinstance(payload, dict) or payload.get("deleted") is not True:
|
| 333 |
-
raise ResourceExecutionError(
|
| 334 |
-
"OpenAI temporary file cleanup was not confirmed"
|
| 335 |
-
)
|
| 336 |
-
handle.released = True
|
| 337 |
-
|
| 338 |
-
def responses_payload(
|
| 339 |
-
self,
|
| 340 |
-
chat: ChatRequest,
|
| 341 |
-
handles: Sequence[ProviderPrivateResource],
|
| 342 |
-
) -> dict:
|
| 343 |
-
"""Build one server-authoritative Responses request from private file handles."""
|
| 344 |
-
provider_body = build_upstream_payload(chat, reasoning_enabled=False)
|
| 345 |
-
messages = provider_body.get("messages", [])
|
| 346 |
-
instructions = str(messages[0].get("content", "")) if len(messages) > 0 else ""
|
| 347 |
-
user_text = (
|
| 348 |
-
str(messages[1].get("content", ""))
|
| 349 |
-
if len(messages) > 1
|
| 350 |
-
else chat.user_message
|
| 351 |
-
)
|
| 352 |
-
content: list[dict] = [{"type": "input_text", "text": user_text}]
|
| 353 |
-
tool_file_ids: list[str] = []
|
| 354 |
-
direct_file_bytes = 0
|
| 355 |
-
|
| 356 |
-
for handle in handles:
|
| 357 |
-
file_id = _opaque_file_id(handle)
|
| 358 |
-
opaque = handle.opaque if isinstance(handle.opaque, dict) else {}
|
| 359 |
-
modality = str(opaque.get("modality", ""))
|
| 360 |
-
if handle.route == "tool":
|
| 361 |
-
tool_file_ids.append(file_id)
|
| 362 |
-
continue
|
| 363 |
-
if handle.route != "native":
|
| 364 |
-
raise ResourceExecutionError(
|
| 365 |
-
"OpenAI Responses received a non-executable route"
|
| 366 |
-
)
|
| 367 |
-
if modality == "image":
|
| 368 |
-
content.append(
|
| 369 |
-
{"type": "input_image", "file_id": file_id, "detail": "auto"}
|
| 370 |
-
)
|
| 371 |
-
continue
|
| 372 |
-
if modality not in {"text", "document"}:
|
| 373 |
-
raise ResourceExecutionError(
|
| 374 |
-
"OpenAI direct file route does not support this modality"
|
| 375 |
-
)
|
| 376 |
-
direct_file_bytes += int(handle.source_size)
|
| 377 |
-
if (
|
| 378 |
-
int(handle.source_size) > _DIRECT_FILE_LIMIT_BYTES
|
| 379 |
-
or direct_file_bytes > _DIRECT_FILE_LIMIT_BYTES
|
| 380 |
-
):
|
| 381 |
-
raise ResourceExecutionError(
|
| 382 |
-
"OpenAI direct file input exceeds the bounded 50 MiB request limit"
|
| 383 |
-
)
|
| 384 |
-
content.append({"type": "input_file", "file_id": file_id})
|
| 385 |
-
|
| 386 |
-
payload: dict = {
|
| 387 |
-
"model": chat.model,
|
| 388 |
-
"instructions": instructions,
|
| 389 |
-
"input": [{"role": "user", "content": content}],
|
| 390 |
-
"max_output_tokens": chat.max_tokens,
|
| 391 |
-
"stream": bool(chat.stream),
|
| 392 |
-
"store": False,
|
| 393 |
-
}
|
| 394 |
-
if chat.effort:
|
| 395 |
-
payload["reasoning"] = {
|
| 396 |
-
"effort": {
|
| 397 |
-
"low": "low",
|
| 398 |
-
"medium": "medium",
|
| 399 |
-
"high": "high",
|
| 400 |
-
"extra": "high",
|
| 401 |
-
"max": "high",
|
| 402 |
-
}[chat.effort]
|
| 403 |
-
}
|
| 404 |
-
if tool_file_ids:
|
| 405 |
-
payload["tools"] = [
|
| 406 |
-
{
|
| 407 |
-
"type": "code_interpreter",
|
| 408 |
-
"container": {
|
| 409 |
-
"type": "auto",
|
| 410 |
-
"memory_limit": "1g",
|
| 411 |
-
"file_ids": tool_file_ids,
|
| 412 |
-
},
|
| 413 |
-
}
|
| 414 |
-
]
|
| 415 |
-
payload["tool_choice"] = "required"
|
| 416 |
-
content[0]["text"] += (
|
| 417 |
-
"\n\nProvider tool resources are attached to the code interpreter container. "
|
| 418 |
-
"Inspect them as needed to answer the user."
|
| 419 |
-
)
|
| 420 |
-
return payload
|
| 421 |
-
|
| 422 |
-
async def open_chat(
|
| 423 |
-
self,
|
| 424 |
-
*,
|
| 425 |
-
chat: ChatRequest,
|
| 426 |
-
handles: Sequence[ProviderPrivateResource],
|
| 427 |
-
) -> httpx.Response:
|
| 428 |
-
payload = self.responses_payload(chat, handles)
|
| 429 |
-
headers = self._headers()
|
| 430 |
-
headers["Content-Type"] = "application/json"
|
| 431 |
-
request = self._client.build_request(
|
| 432 |
-
"POST",
|
| 433 |
-
f"{self._base}/v1/responses",
|
| 434 |
-
headers=headers,
|
| 435 |
-
content=_bounded_json_bytes(payload),
|
| 436 |
-
timeout=self._response_timeout,
|
| 437 |
-
)
|
| 438 |
-
try:
|
| 439 |
-
return await self._client.send(request, stream=True)
|
| 440 |
-
except (httpx.HTTPError, OSError) as exc:
|
| 441 |
-
raise ResourceExecutionError("OpenAI Responses request failed") from exc
|
| 442 |
-
|
| 443 |
-
async def open_responses(
|
| 444 |
-
self,
|
| 445 |
-
chat: ChatRequest,
|
| 446 |
-
handles: Sequence[ProviderPrivateResource],
|
| 447 |
-
) -> httpx.Response:
|
| 448 |
-
"""Internally compatibility alias for focused provider tests/tools."""
|
| 449 |
-
return await self.open_chat(chat=chat, handles=handles)
|
| 450 |
-
|
| 451 |
-
async def buffered_chat_response(
|
| 452 |
-
self, upstream: httpx.Response, *, maximum: int
|
| 453 |
-
) -> dict:
|
| 454 |
-
total = 0
|
| 455 |
-
chunks: list[bytes] = []
|
| 456 |
-
async for chunk in upstream.aiter_bytes():
|
| 457 |
-
total += len(chunk)
|
| 458 |
-
if total > maximum:
|
| 459 |
-
raise ResourceExecutionError(
|
| 460 |
-
"OpenAI Responses body exceeded the configured response limit"
|
| 461 |
-
)
|
| 462 |
-
chunks.append(chunk)
|
| 463 |
-
try:
|
| 464 |
-
payload = json.loads(b"".join(chunks))
|
| 465 |
-
except (json.JSONDecodeError, ValueError) as exc:
|
| 466 |
-
raise ResourceExecutionError(
|
| 467 |
-
"OpenAI Responses returned invalid JSON"
|
| 468 |
-
) from exc
|
| 469 |
-
text = _response_text(payload)
|
| 470 |
-
return {
|
| 471 |
-
"choices": [{"message": {"role": "assistant", "content": text}}],
|
| 472 |
-
"model": str(payload.get("model", "") or ""),
|
| 473 |
-
}
|
| 474 |
-
|
| 475 |
-
async def chat_sse( # ruff: ignore[too-many-branches]
|
| 476 |
-
self,
|
| 477 |
-
upstream: httpx.Response,
|
| 478 |
-
*,
|
| 479 |
-
maximum: int,
|
| 480 |
-
) -> AsyncIterator[bytes]:
|
| 481 |
-
"""Bridge Responses SSE into the panel's Chat-Completions delta vocabulary."""
|
| 482 |
-
total = 0
|
| 483 |
-
buffer = bytearray()
|
| 484 |
-
done = False
|
| 485 |
-
async for chunk in upstream.aiter_bytes():
|
| 486 |
-
total += len(chunk)
|
| 487 |
-
if total > maximum:
|
| 488 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_RESPONSE_TOO_LARGE","message":"The upstream response exceeded the configured limit."}}\n\n'
|
| 489 |
-
return
|
| 490 |
-
buffer.extend(chunk)
|
| 491 |
-
if (
|
| 492 |
-
len(buffer) > _MAX_SSE_EVENT_BYTES
|
| 493 |
-
and b"\n\n" not in buffer
|
| 494 |
-
and b"\r\n\r\n" not in buffer
|
| 495 |
-
):
|
| 496 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROTOCOL_ERROR","message":"The upstream stream event exceeded the configured limit."}}\n\n'
|
| 497 |
-
return
|
| 498 |
-
while True:
|
| 499 |
-
marker = buffer.find(b"\n\n")
|
| 500 |
-
marker_len = 2
|
| 501 |
-
if marker < 0:
|
| 502 |
-
marker = buffer.find(b"\r\n\r\n")
|
| 503 |
-
marker_len = 4
|
| 504 |
-
if marker < 0:
|
| 505 |
-
break
|
| 506 |
-
raw = bytes(buffer[:marker])
|
| 507 |
-
del buffer[: marker + marker_len]
|
| 508 |
-
if len(raw) > _MAX_SSE_EVENT_BYTES:
|
| 509 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROTOCOL_ERROR","message":"The upstream stream event exceeded the configured limit."}}\n\n'
|
| 510 |
-
return
|
| 511 |
-
event_name = ""
|
| 512 |
-
data_lines: list[bytes] = []
|
| 513 |
-
for line in raw.replace(b"\r\n", b"\n").split(b"\n"):
|
| 514 |
-
if line.startswith(b"event:"):
|
| 515 |
-
event_name = line[6:].strip().decode("utf-8", "replace")[:128]
|
| 516 |
-
elif line.startswith(b"data:"):
|
| 517 |
-
data_lines.append(line[5:].lstrip())
|
| 518 |
-
if not data_lines:
|
| 519 |
-
continue
|
| 520 |
-
data = b"\n".join(data_lines)
|
| 521 |
-
if data == b"[DONE]":
|
| 522 |
-
done = True
|
| 523 |
-
break
|
| 524 |
-
try:
|
| 525 |
-
doc = json.loads(data)
|
| 526 |
-
except (json.JSONDecodeError, ValueError):
|
| 527 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROTOCOL_ERROR","message":"The upstream stream was malformed."}}\n\n'
|
| 528 |
-
return
|
| 529 |
-
kind = (
|
| 530 |
-
str(doc.get("type", "") or event_name)
|
| 531 |
-
if isinstance(doc, dict)
|
| 532 |
-
else event_name
|
| 533 |
-
)
|
| 534 |
-
if kind in {"response.output_text.delta", "response.refusal.delta"}:
|
| 535 |
-
delta = doc.get("delta", "") if isinstance(doc, dict) else ""
|
| 536 |
-
if isinstance(delta, str) and delta:
|
| 537 |
-
frame = {"choices": [{"delta": {"content": delta}}]}
|
| 538 |
-
yield b"data: " + _bounded_json_bytes(frame) + b"\n\n"
|
| 539 |
-
elif kind in {"response.failed", "response.incomplete", "error"}:
|
| 540 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_SERVICE_ERROR","message":"The upstream AI provider did not complete the streamed response."}}\n\n'
|
| 541 |
-
return
|
| 542 |
-
elif kind == "response.completed":
|
| 543 |
-
done = True
|
| 544 |
-
break
|
| 545 |
-
if done:
|
| 546 |
-
break
|
| 547 |
-
if not done:
|
| 548 |
-
# A Responses stream is successful only after an explicit completed
|
| 549 |
-
# terminal event. Partial output followed by EOF must not be promoted
|
| 550 |
-
# into a Chat-Completions-style success marker.
|
| 551 |
-
yield b'event: error\ndata: {"error":{"code":"UPSTREAM_PROTOCOL_ERROR","message":"The upstream stream ended before completion."}}\n\n'
|
| 552 |
-
return
|
| 553 |
-
yield b"data: [DONE]\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/policy.py
DELETED
|
@@ -1,256 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
Deterministic provider resource capability policies.
|
| 6 |
-
|
| 7 |
-
The selected *upstream route* owns provider identity. Model namespaces are not
|
| 8 |
-
provider authority: ``openai/gpt-oss-*`` may be served by Hugging Face, Groq,
|
| 9 |
-
or another router. This module therefore accepts an explicit adapter name from
|
| 10 |
-
the proxy's routing layer and returns only provider-facility candidates. A
|
| 11 |
-
later executor must still implement the selected route before bytes leave the
|
| 12 |
-
proxy.
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
from __future__ import annotations
|
| 16 |
-
|
| 17 |
-
import re
|
| 18 |
-
from dataclasses import dataclass
|
| 19 |
-
from typing import Mapping
|
| 20 |
-
|
| 21 |
-
from .base import ProviderResourceCapabilities
|
| 22 |
-
|
| 23 |
-
_MODALITIES = (
|
| 24 |
-
"text",
|
| 25 |
-
"image",
|
| 26 |
-
"animated_image",
|
| 27 |
-
"vector_image",
|
| 28 |
-
"audio",
|
| 29 |
-
"video",
|
| 30 |
-
"document",
|
| 31 |
-
"archive",
|
| 32 |
-
"data",
|
| 33 |
-
"binary",
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
# Conservative provider-facility candidates. These are not promises that every
|
| 37 |
-
# model supports every route; model-specific deployment overrides narrow or
|
| 38 |
-
# replace them. Unknown provider/model combinations fail closed.
|
| 39 |
-
_PROVIDER_DEFAULTS: dict[str, dict[str, tuple[str, ...]]] = {
|
| 40 |
-
"openai": {
|
| 41 |
-
"text": ("native", "context"),
|
| 42 |
-
"image": ("native",),
|
| 43 |
-
"document": ("native",),
|
| 44 |
-
"vector_image": ("tool", "extract"),
|
| 45 |
-
"data": ("tool",),
|
| 46 |
-
"archive": ("tool",),
|
| 47 |
-
"binary": ("tool",),
|
| 48 |
-
},
|
| 49 |
-
"anthropic": {
|
| 50 |
-
"text": ("native", "context"),
|
| 51 |
-
"image": ("native",),
|
| 52 |
-
"animated_image": ("native",),
|
| 53 |
-
"document": ("native",),
|
| 54 |
-
"vector_image": ("tool", "extract"),
|
| 55 |
-
"data": ("tool",),
|
| 56 |
-
"archive": ("tool",),
|
| 57 |
-
"binary": ("tool",),
|
| 58 |
-
},
|
| 59 |
-
"gemini": {
|
| 60 |
-
# File Search-capable Gemini models may transport a text file as a
|
| 61 |
-
# provider-managed retrieval resource; context remains the cheap
|
| 62 |
-
# bounded default when the user does not request raw/tool delivery.
|
| 63 |
-
"text": ("tool", "context"),
|
| 64 |
-
"image": ("native",),
|
| 65 |
-
"animated_image": ("native",),
|
| 66 |
-
"audio": ("native",),
|
| 67 |
-
"video": ("native",),
|
| 68 |
-
"document": ("native",),
|
| 69 |
-
"vector_image": ("extract",),
|
| 70 |
-
"data": ("tool", "extract"),
|
| 71 |
-
"archive": ("tool", "extract"),
|
| 72 |
-
},
|
| 73 |
-
# HF Inference Providers are heterogeneous. Chat can be LLM or VLM and
|
| 74 |
-
# other media tasks use different task APIs, so the default stays text-only
|
| 75 |
-
# until the deployment declares exact model routes.
|
| 76 |
-
"huggingface": {"text": ("context",)},
|
| 77 |
-
# The bundled Qwen model Space is currently a text-only causal LM. Future
|
| 78 |
-
# multimodal Spaces opt in through model overrides rather than changing this
|
| 79 |
-
# safe default.
|
| 80 |
-
"self_hosted": {"text": ("context",)},
|
| 81 |
-
"custom": {"text": ("context",)},
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
@dataclass(frozen=True)
|
| 86 |
-
class ModelResourceOverride:
|
| 87 |
-
adapter: str
|
| 88 |
-
routes: Mapping[str, tuple[str, ...]]
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
_HF_REVIEWED_VLM_MODELS = frozenset(
|
| 92 |
-
{
|
| 93 |
-
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 94 |
-
"zai-org/GLM-4.5V",
|
| 95 |
-
"zai-org/GLM-5.3-Flash",
|
| 96 |
-
}
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def huggingface_vlm_supported_model(model: str, *, extra_models=()) -> bool:
|
| 101 |
-
"""
|
| 102 |
-
Return whether *model* is an exact reviewed/opted-in HF chat VLM.
|
| 103 |
-
|
| 104 |
-
Provider suffixes (``:baseten``/``:together``/...) select an Inference
|
| 105 |
-
Provider for the same reviewed model and therefore retain the reviewed base
|
| 106 |
-
model authority. Deployment additions are exact full model strings.
|
| 107 |
-
"""
|
| 108 |
-
raw = str(model or "").strip()
|
| 109 |
-
base = raw.rsplit(":", 1)[0] if ":" in raw else raw
|
| 110 |
-
extras = {str(row).strip() for row in extra_models if str(row).strip()}
|
| 111 |
-
return base in _HF_REVIEWED_VLM_MODELS or raw in extras
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
_HF_REVIEWED_ASR_MODELS = frozenset({"openai/whisper-large-v3"})
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
def huggingface_asr_supported_model(model: str, *, extra_models=()) -> bool:
|
| 118 |
-
"""
|
| 119 |
-
Return whether *model* is an exact reviewed/opted-in HF ASR task model.
|
| 120 |
-
|
| 121 |
-
Unlike Chat/VLM provider suffixes, this executor pins the ``hf-inference``
|
| 122 |
-
ASR task endpoint directly, so ASR authority is an exact model id rather
|
| 123 |
-
than an OpenAI-compatible provider-qualified chat id.
|
| 124 |
-
"""
|
| 125 |
-
raw = str(model or "").strip()
|
| 126 |
-
extras = {str(row).strip() for row in extra_models if str(row).strip()}
|
| 127 |
-
return raw in _HF_REVIEWED_ASR_MODELS or raw in extras
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def gemini_native_media_supported_model(model: str) -> bool:
|
| 131 |
-
"""
|
| 132 |
-
Return whether current Gemini docs list native image/video/audio/PDF input.
|
| 133 |
-
|
| 134 |
-
Provider-wide Files storage is not model execution authority. Keep this
|
| 135 |
-
deliberately narrow so unknown/future model ids fail closed until reviewed.
|
| 136 |
-
"""
|
| 137 |
-
raw = str(model or "").strip().lower()
|
| 138 |
-
return raw in {
|
| 139 |
-
"gemini-3.8-flash",
|
| 140 |
-
"gemini-3.7-flash",
|
| 141 |
-
"gemini-3.6-flash",
|
| 142 |
-
"gemini-3.5-flash",
|
| 143 |
-
"gemini-3.5-flash-lite",
|
| 144 |
-
"gemini-3-flash-preview",
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def gemini_file_search_supported_model(model: str) -> bool:
|
| 149 |
-
"""
|
| 150 |
-
Return whether current Gemini docs list *model* for File Search.
|
| 151 |
-
|
| 152 |
-
Files storage and native multimodal input are separate facilities. File
|
| 153 |
-
Search creates persistent indexed stores and has its own model list, so
|
| 154 |
-
unknown/future models fail closed until that compatibility table is
|
| 155 |
-
deliberately reviewed.
|
| 156 |
-
"""
|
| 157 |
-
raw = str(model or "").strip().lower()
|
| 158 |
-
return raw in {
|
| 159 |
-
"gemini-3.8-flash",
|
| 160 |
-
"gemini-3.7-flash",
|
| 161 |
-
"gemini-3.6-flash",
|
| 162 |
-
"gemini-3.5-flash",
|
| 163 |
-
"gemini-3.5-flash-lite",
|
| 164 |
-
"gemini-3.1-pro-preview",
|
| 165 |
-
"gemini-3.1-flash-lite",
|
| 166 |
-
"gemini-3-flash-preview",
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def anthropic_code_execution_supported_model(model: str) -> bool:
|
| 171 |
-
"""
|
| 172 |
-
Return whether current Claude API docs list *model* for Code Execution.
|
| 173 |
-
|
| 174 |
-
The provider Files API may store arbitrary data, but storage is not model
|
| 175 |
-
execution authority. Unknown/future model families therefore fail closed
|
| 176 |
-
until this policy is deliberately updated.
|
| 177 |
-
"""
|
| 178 |
-
raw = str(model or "").strip().lower()
|
| 179 |
-
patterns = (
|
| 180 |
-
r"^claude-(?:opus|sonnet)-5(?:-\d{8})?$",
|
| 181 |
-
r"^claude-(?:fable|mythos)-5(?:-1)?(?:-\d{8})?$",
|
| 182 |
-
r"^claude-opus-4-(?:5|6|7|8)(?:-\d{8})?$",
|
| 183 |
-
r"^claude-sonnet-4-(?:5|6)(?:-\d{8})?$",
|
| 184 |
-
r"^claude-haiku-4-5(?:-\d{8})?$",
|
| 185 |
-
)
|
| 186 |
-
return any(re.match(pattern, raw) for pattern in patterns)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
def provider_names() -> tuple[str, ...]:
|
| 190 |
-
return tuple(sorted(_PROVIDER_DEFAULTS))
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
def default_routes(adapter: str) -> dict[str, tuple[str, ...]]:
|
| 194 |
-
raw = _PROVIDER_DEFAULTS.get(str(adapter or "").strip().lower(), {})
|
| 195 |
-
return {m: tuple(raw.get(m, ("unsupported",))) for m in _MODALITIES}
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
def capabilities_for(
|
| 199 |
-
*,
|
| 200 |
-
adapter: str,
|
| 201 |
-
model: str,
|
| 202 |
-
overrides: Mapping[str, ModelResourceOverride] | None = None,
|
| 203 |
-
max_files: int | None = None,
|
| 204 |
-
max_file_bytes: int | None = None,
|
| 205 |
-
max_total_bytes: int | None = None,
|
| 206 |
-
) -> ProviderResourceCapabilities:
|
| 207 |
-
name = str(adapter or "custom").strip().lower()
|
| 208 |
-
routes = default_routes(name)
|
| 209 |
-
if name == "anthropic" and not anthropic_code_execution_supported_model(model):
|
| 210 |
-
# Native images/text/PDF remain direct. Tool routes require a Claude
|
| 211 |
-
# family explicitly documented for server-side Code Execution.
|
| 212 |
-
for modality in ("vector_image", "data", "archive", "binary"):
|
| 213 |
-
routes[modality] = tuple(
|
| 214 |
-
route for route in routes.get(modality, ()) if route != "tool"
|
| 215 |
-
) or ("unsupported",)
|
| 216 |
-
if name == "huggingface":
|
| 217 |
-
if huggingface_asr_supported_model(model):
|
| 218 |
-
# ASR is a separate raw-audio task, not a conversational chat model.
|
| 219 |
-
routes["text"] = ("unsupported",)
|
| 220 |
-
routes["audio"] = ("native",)
|
| 221 |
-
elif huggingface_vlm_supported_model(model):
|
| 222 |
-
# Chat Completion is the reviewed execution surface for conversational
|
| 223 |
-
# VLMs. Other HF media tasks are separate APIs and remain fail-closed.
|
| 224 |
-
routes["image"] = ("native",)
|
| 225 |
-
if name == "gemini":
|
| 226 |
-
if not gemini_native_media_supported_model(model):
|
| 227 |
-
# Files storage and Interactions availability are not model modality
|
| 228 |
-
# authority. Unknown/future Gemini models retain text-context only.
|
| 229 |
-
for modality in ("image", "animated_image", "audio", "video", "document"):
|
| 230 |
-
routes[modality] = ("unsupported",)
|
| 231 |
-
if not gemini_file_search_supported_model(model):
|
| 232 |
-
# File Search is a separate persistent retrieval facility with its
|
| 233 |
-
# own compatibility table. Remove only its tool route; local
|
| 234 |
-
# context/extract candidates remain available to other execution
|
| 235 |
-
# planes when deliberately implemented.
|
| 236 |
-
for modality in ("text", "data", "archive"):
|
| 237 |
-
kept = tuple(
|
| 238 |
-
route for route in routes.get(modality, ()) if route != "tool"
|
| 239 |
-
)
|
| 240 |
-
routes[modality] = kept or ("unsupported",)
|
| 241 |
-
override = (overrides or {}).get(model)
|
| 242 |
-
if override is not None and str(override.adapter or "").strip().lower() == name:
|
| 243 |
-
# Overrides are provider-scoped. A model string may be served through
|
| 244 |
-
# multiple upstreams, so one adapter's declaration must never bleed into
|
| 245 |
-
# another adapter merely because the model id matches.
|
| 246 |
-
routes = {
|
| 247 |
-
m: tuple(override.routes.get(m, ("unsupported",))) for m in _MODALITIES
|
| 248 |
-
}
|
| 249 |
-
return ProviderResourceCapabilities(
|
| 250 |
-
provider=name,
|
| 251 |
-
model=model,
|
| 252 |
-
modality_routes=routes,
|
| 253 |
-
max_files=max_files,
|
| 254 |
-
max_file_bytes=max_file_bytes,
|
| 255 |
-
max_total_bytes=max_total_bytes,
|
| 256 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/registry.py
DELETED
|
@@ -1,124 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
"""Provider adapter registry and route planning for verified resources."""
|
| 4 |
-
|
| 5 |
-
from __future__ import annotations
|
| 6 |
-
|
| 7 |
-
from dataclasses import dataclass
|
| 8 |
-
from typing import Mapping, Sequence
|
| 9 |
-
|
| 10 |
-
from .base import ProviderResourceCapabilities, ResourceRoute
|
| 11 |
-
from .policy import ModelResourceOverride, capabilities_for, provider_names
|
| 12 |
-
|
| 13 |
-
try:
|
| 14 |
-
from .._utils._resource_transport import ResourceUpload
|
| 15 |
-
except ImportError: # standalone HF Space: _providers is top-level
|
| 16 |
-
from _utils._resource_transport import ResourceUpload
|
| 17 |
-
|
| 18 |
-
_AUTO_ORDER = ("native", "tool", "extract", "context")
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
@dataclass
|
| 22 |
-
class StaticProviderAdapter:
|
| 23 |
-
"""
|
| 24 |
-
Plan resource routes without performing provider I/O.
|
| 25 |
-
|
| 26 |
-
Run 127 deliberately stops at a verifiable routing plan. Provider-specific
|
| 27 |
-
upload/execution modules added later consume these plans. Keeping planning
|
| 28 |
-
separate makes health discovery truthful and testable without credentials.
|
| 29 |
-
"""
|
| 30 |
-
|
| 31 |
-
name: str
|
| 32 |
-
overrides: Mapping[str, ModelResourceOverride] | None = None
|
| 33 |
-
max_files: int | None = None
|
| 34 |
-
max_file_bytes: int | None = None
|
| 35 |
-
max_total_bytes: int | None = None
|
| 36 |
-
|
| 37 |
-
def capabilities(self, model: str) -> ProviderResourceCapabilities:
|
| 38 |
-
return capabilities_for(
|
| 39 |
-
adapter=self.name,
|
| 40 |
-
model=model,
|
| 41 |
-
overrides=self.overrides,
|
| 42 |
-
max_files=self.max_files,
|
| 43 |
-
max_file_bytes=self.max_file_bytes,
|
| 44 |
-
max_total_bytes=self.max_total_bytes,
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
async def route_resources(
|
| 48 |
-
self, model: str, resources: Sequence[ResourceUpload]
|
| 49 |
-
) -> Sequence[ResourceRoute]:
|
| 50 |
-
caps = self.capabilities(model)
|
| 51 |
-
planned: list[ResourceRoute] = []
|
| 52 |
-
for upload in resources:
|
| 53 |
-
verified = upload.verified
|
| 54 |
-
candidates = tuple(caps.routes_for(verified.detected_modality))
|
| 55 |
-
intent = verified.intent
|
| 56 |
-
route = "unsupported"
|
| 57 |
-
if intent == "raw":
|
| 58 |
-
for candidate in ("native", "tool"):
|
| 59 |
-
if candidate in candidates:
|
| 60 |
-
route = candidate
|
| 61 |
-
break
|
| 62 |
-
elif intent == "extract":
|
| 63 |
-
if "extract" in candidates:
|
| 64 |
-
route = "extract"
|
| 65 |
-
elif intent == "context":
|
| 66 |
-
if "context" in candidates:
|
| 67 |
-
route = "context"
|
| 68 |
-
else:
|
| 69 |
-
for candidate in _AUTO_ORDER:
|
| 70 |
-
if candidate in candidates:
|
| 71 |
-
route = candidate
|
| 72 |
-
break
|
| 73 |
-
planned.append(
|
| 74 |
-
ResourceRoute(
|
| 75 |
-
resource_id=verified.id,
|
| 76 |
-
route=route,
|
| 77 |
-
reason=(
|
| 78 |
-
"planned provider route"
|
| 79 |
-
if route != "unsupported"
|
| 80 |
-
else f"{caps.provider} adapter has no route for {verified.detected_modality}/{intent}"
|
| 81 |
-
),
|
| 82 |
-
metadata={
|
| 83 |
-
"provider": caps.provider,
|
| 84 |
-
"model": model,
|
| 85 |
-
"modality": verified.detected_modality,
|
| 86 |
-
"intent": intent,
|
| 87 |
-
"candidates": list(candidates),
|
| 88 |
-
"execution": "plan-only",
|
| 89 |
-
},
|
| 90 |
-
)
|
| 91 |
-
)
|
| 92 |
-
return tuple(planned)
|
| 93 |
-
|
| 94 |
-
async def close(self) -> None:
|
| 95 |
-
return None
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
class ProviderRegistry:
|
| 99 |
-
def __init__(
|
| 100 |
-
self,
|
| 101 |
-
*,
|
| 102 |
-
overrides: Mapping[str, ModelResourceOverride] | None = None,
|
| 103 |
-
max_files: int | None = None,
|
| 104 |
-
max_file_bytes: int | None = None,
|
| 105 |
-
max_total_bytes: int | None = None,
|
| 106 |
-
) -> None:
|
| 107 |
-
self._adapters = {
|
| 108 |
-
name: StaticProviderAdapter(
|
| 109 |
-
name,
|
| 110 |
-
overrides=overrides,
|
| 111 |
-
max_files=max_files,
|
| 112 |
-
max_file_bytes=max_file_bytes,
|
| 113 |
-
max_total_bytes=max_total_bytes,
|
| 114 |
-
)
|
| 115 |
-
for name in provider_names()
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
def get(self, name: str) -> StaticProviderAdapter:
|
| 119 |
-
return self._adapters.get(
|
| 120 |
-
str(name or "").strip().lower(), self._adapters["custom"]
|
| 121 |
-
)
|
| 122 |
-
|
| 123 |
-
def names(self) -> tuple[str, ...]:
|
| 124 |
-
return tuple(sorted(self._adapters))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_providers/self_hosted.py
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
# Authors: The scikit-plots developers
|
| 2 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 3 |
-
"""Bundled/self-hosted model resource adapter marker."""
|
| 4 |
-
|
| 5 |
-
from .registry import StaticProviderAdapter
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class SelfHostedAdapter(StaticProviderAdapter):
|
| 9 |
-
def __init__(self, **kwargs):
|
| 10 |
-
super().__init__("self_hosted", **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|