File size: 14,026 Bytes
cef9869
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# Authors: The scikit-plots developers
# SPDX-License-Identifier: BSD-3-Clause

"""
Credential-neutral provider resource execution lifecycle.

Run 128 defines the state machine every later provider executor must obey.  It
contains no provider credentials and performs no provider I/O by itself.  The
important invariant is ownership: once a provider-side resource is created it
is registered for cleanup immediately, and cleanup is attempted in reverse
creation order on success, error, timeout, or cancellation.
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Protocol, Sequence

from typing_extensions import Self

from .base import ResourceRoute

try:
    from .._utils._resource_transport import ResourceUpload
except ImportError:  # standalone HF Space: _providers is top-level
    from _utils._resource_transport import ResourceUpload


class ResourceExecutionError(RuntimeError):
    """A provider resource could not enter a safe executable state."""


@dataclass
class ProviderPrivateResource:
    """Provider-owned resource handle that must never be serialized publicly."""

    resource_id: str
    route: str
    provider: str
    source_sha256: str
    source_size: int
    opaque: Any = None
    cleanup_required: bool = True
    released: bool = False


@dataclass
class ResourceExecutionReceipt:
    """Sanitized chain-of-custody record for one resource lifecycle."""

    resource_id: str
    provider: str
    model: str
    route: str
    source_sha256: str
    source_size: int
    state: str = "prepared"
    cleanup_required: bool = True
    cleanup_error: bool = False
    metadata: dict[str, Any] = field(default_factory=dict)

    def as_public_dict(self) -> dict[str, Any]:
        """Return a bounded receipt that contains no provider opaque identifier."""
        return {
            "resource_id": self.resource_id,
            "provider": self.provider,
            "model": self.model,
            "route": self.route,
            "source_sha256": self.source_sha256,
            "source_size": self.source_size,
            "state": self.state,
            "cleanup_required": self.cleanup_required,
            "cleanup_error": self.cleanup_error,
            "metadata": dict(self.metadata),
        }


class ProviderResourceExecutor(Protocol):
    """Provider-specific prepare/release boundary with route-level authority."""

    name: str
    enabled: bool

    def executable_routes(self, model: str) -> dict[str, tuple[str, ...]]:
        """Return exact modality routes this executor can perform for *model*."""
        ...

    async def prepare(
        self,
        *,
        model: str,
        route: ResourceRoute,
        upload: ResourceUpload,
    ) -> ProviderPrivateResource: ...

    async def release(self, handle: ProviderPrivateResource) -> None: ...


class ProviderChatExecutor(ProviderResourceExecutor, Protocol):
    """
    Executable provider boundary for one prepared resource chat request.

    Provider planning and temporary-resource ownership remain generic.  Only an
    executor registered in the separate execution registry may implement this
    credential-bearing boundary.  ``chat`` and ``upstream`` deliberately stay
    provider-neutral here so the common proxy lifecycle does not depend on one
    SDK's public types.
    """

    async def open_chat(
        self,
        *,
        chat: Any,
        handles: Sequence[ProviderPrivateResource],
    ) -> Any: ...

    async def buffered_chat_response(self, upstream: Any, *, maximum: int) -> dict: ...

    def chat_sse(self, upstream: Any, *, maximum: int) -> AsyncIterator[bytes]: ...


@dataclass
class PendingProviderExecutor:
    """Explicit non-executor used until a credential-bearing adapter is enabled."""

    name: str
    enabled: bool = False

    def executable_routes(self, model: str) -> dict[str, tuple[str, ...]]:
        del model
        return {}

    async def prepare(
        self,
        *,
        model: str,
        route: ResourceRoute,
        upload: ResourceUpload,
    ) -> ProviderPrivateResource:
        del model, route, upload
        raise ResourceExecutionError(f"{self.name} resource executor is not enabled")

    async def release(self, handle: ProviderPrivateResource) -> None:
        del handle


class ProviderExecutorRegistry:
    """Execution registry separate from provider capability/route planning."""

    def __init__(self, names: Sequence[str] = ()) -> None:
        names = [str(name).strip().lower() for name in names]
        self._executors: dict[str, ProviderResourceExecutor] = {
            name: PendingProviderExecutor(name) for name in names if str(name).strip()
        }

    def register(self, name: str, executor: ProviderResourceExecutor) -> None:
        key = str(name or "").strip().lower()
        if not key or key != str(executor.name or "").strip().lower():
            raise ValueError("executor registry name mismatch")
        self._executors[key] = executor

    def unregister(self, name: str) -> None:
        """Return one provider to explicit plan-only state."""
        key = str(name or "").strip().lower()
        if key:
            self._executors[key] = PendingProviderExecutor(key)

    def get(self, name: str) -> ProviderResourceExecutor:
        key = str(name or "custom").strip().lower() or "custom"
        return self._executors.get(key, PendingProviderExecutor(key))

    def execution_state(self, name: str) -> str:
        return "enabled" if bool(self.get(name).enabled) else "plan-only"


class ResourceExecutionSession:
    """Sequential prepare + shielded LIFO cleanup for one provider request."""

    def __init__(
        self,
        *,
        provider: str,
        model: str,
        executor: ProviderResourceExecutor,
        routes: Sequence[ResourceRoute],
    ) -> None:
        self.provider = str(provider or "").strip().lower()
        self.model = str(model or "")
        self.executor = executor
        self.routes = tuple(routes)
        self._handles: list[ProviderPrivateResource] = []
        self._receipts: list[ResourceExecutionReceipt] = []
        self._closed = False
        self._close_lock = asyncio.Lock()

    @property
    def receipts(self) -> tuple[ResourceExecutionReceipt, ...]:
        return tuple(self._receipts)

    @property
    def private_handles(self) -> tuple[ProviderPrivateResource, ...]:
        """
        Return request-private provider handles for the in-process executor only.

        These handles are intentionally never exposed by receipts, health, logs,
        persistence, or browser responses.  Provider execution consumes them only
        after :meth:`prepare` has validated the full upload/route identity.
        """
        return tuple(self._handles)

    def public_receipts(self) -> tuple[dict[str, Any], ...]:
        return tuple(row.as_public_dict() for row in self._receipts)

    def _validate_plan(
        self, uploads: Sequence[ResourceUpload]
    ) -> dict[str, ResourceUpload]:
        if not bool(getattr(self.executor, "enabled", False)):
            raise ResourceExecutionError(
                f"{self.provider} resource executor is not enabled"
            )
        by_id: dict[str, ResourceUpload] = {}
        for upload in uploads:
            rid = upload.verified.id
            if rid in by_id:
                raise ResourceExecutionError("duplicate resource upload id")
            by_id[rid] = upload
        route_ids: set[str] = set()
        for route in self.routes:
            if route.resource_id in route_ids:
                raise ResourceExecutionError("duplicate resource route id")
            route_ids.add(route.resource_id)
            if route.route == "unsupported":
                raise ResourceExecutionError("resource plan contains unsupported route")
        if route_ids != set(by_id):
            raise ResourceExecutionError("resource plan/upload ids do not match")
        return by_id

    def _register_handle(
        self,
        *,
        route: ResourceRoute,
        upload: ResourceUpload,
        handle: ProviderPrivateResource,
    ) -> None:
        """
        Validate and register one private handle before it can be used.

        Batch-capable executors may create shared provider resources (for
        example one retrieval store for many files).  The common lifecycle
        still validates every returned per-resource handle against the exact
        verified upload and route so batch preparation cannot weaken identity
        or receipt authority.
        """
        if (
            handle.resource_id != route.resource_id
            or handle.route != route.route
            or handle.provider != self.provider
            or handle.source_sha256 != upload.verified.sha256
            or int(handle.source_size) != int(upload.verified.actual_size)
        ):
            # Register first so even a malformed provider response is released
            # before the validation error escapes.
            self._handles.append(handle)
            raise ResourceExecutionError(
                "provider resource handle failed identity validation"
            )
        self._handles.append(handle)
        self._receipts.append(
            ResourceExecutionReceipt(
                resource_id=route.resource_id,
                provider=self.provider,
                model=self.model,
                route=route.route,
                source_sha256=upload.verified.sha256,
                source_size=upload.verified.actual_size,
                cleanup_required=bool(handle.cleanup_required),
                metadata={
                    "modality": route.metadata.get("modality", ""),
                    "intent": route.metadata.get("intent", ""),
                },
            )
        )

    async def prepare(
        self, uploads: Sequence[ResourceUpload]
    ) -> tuple[ResourceExecutionReceipt, ...]:
        by_id = self._validate_plan(uploads)
        ordered_uploads = tuple(by_id[route.resource_id] for route in self.routes)
        try:
            prepare_many = getattr(self.executor, "prepare_many", None)
            if callable(prepare_many):
                # A batch executor owns rollback for provider objects created
                # before this method returns.  Once returned, the common
                # session immediately registers every handle and owns cleanup.
                handles = tuple(
                    await prepare_many(
                        model=self.model,
                        routes=self.routes,
                        uploads=ordered_uploads,
                    )
                )
                if len(handles) != len(self.routes):
                    # Returned handles are the only provider cleanup authority
                    # available to the session. Register all of them so a bad
                    # batch count cannot silently leak those that are usable.
                    self._handles.extend(handles)
                    raise ResourceExecutionError(
                        "provider batch prepare returned the wrong handle count"
                    )
                for route, upload, handle in zip(self.routes, ordered_uploads, handles):
                    self._register_handle(route=route, upload=upload, handle=handle)
            else:
                for route, upload in zip(self.routes, ordered_uploads):
                    handle = await self.executor.prepare(
                        model=self.model,
                        route=route,
                        upload=upload,
                    )
                    self._register_handle(route=route, upload=upload, handle=handle)
            return self.receipts
        except BaseException:
            await self.close_shielded()
            raise

    async def _release_all(self) -> None:
        # Receipts and handles are aligned only for handles that passed identity
        # validation. A malformed last handle may therefore have no receipt.
        receipt_by_id = {row.resource_id: row for row in self._receipts}
        for handle in reversed(self._handles):
            if handle.released or not handle.cleanup_required:
                handle.released = True
                receipt = receipt_by_id.get(handle.resource_id)
                if receipt is not None:
                    receipt.state = "released"
                continue
            try:
                await self.executor.release(handle)
            except Exception:  # noqa: BLE001
                receipt = receipt_by_id.get(handle.resource_id)
                if receipt is not None:
                    receipt.state = "cleanup_failed"
                    receipt.cleanup_error = True
            else:
                handle.released = True
                receipt = receipt_by_id.get(handle.resource_id)
                if receipt is not None:
                    receipt.state = "released"

    async def close(self) -> tuple[ResourceExecutionReceipt, ...]:
        async with self._close_lock:
            if self._closed:
                return self.receipts
            await self._release_all()
            self._closed = True
            return self.receipts

    async def close_shielded(self) -> tuple[ResourceExecutionReceipt, ...]:
        """Finish cleanup even when the caller task is being cancelled."""
        task = asyncio.create_task(self.close())
        try:
            return await asyncio.shield(task)
        except asyncio.CancelledError:
            # Do not abandon provider cleanup. Preserve cancellation only after
            # the cleanup task has reached its terminal state.
            try:
                await task
            finally:
                raise

    async def __aenter__(self) -> Self:
        return self

    async def __aexit__(self, exc_type, exc, tb) -> bool:
        del exc_type, exc, tb
        await self.close_shielded()
        return False