celik-muhammed commited on
Commit
869286b
·
verified ·
1 Parent(s): df4ab6e

Delete _providers

Browse files
_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)