unstructured-platform-plugins 0.0.45__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/PKG-INFO +4 -4
  2. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/pyproject.toml +2 -2
  3. unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/__version__.py +1 -0
  4. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/api_generator.py +178 -49
  5. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/main.py +10 -0
  6. unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/generated/__init__.py +1 -0
  7. unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/generated/error_audience_v1.py +28 -0
  8. unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/generated/invocation_context_v1.py +89 -0
  9. unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/invocation_context.py +125 -0
  10. unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/invocation_settings.py +536 -0
  11. unstructured_platform_plugins-0.0.45/unstructured_platform_plugins/__version__.py +0 -1
  12. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/.gitignore +0 -0
  13. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/LICENSE.md +0 -0
  14. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/README.md +0 -0
  15. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/__init__.py +0 -0
  16. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/__init__.py +0 -0
  17. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/otel.py +0 -0
  18. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/utils.py +0 -0
  19. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/__init__.py +0 -0
  20. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/filedata_meta.py +0 -0
  21. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/json_schema.py +0 -0
  22. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/model.py +0 -0
  23. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/usage.py +0 -0
  24. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/utils.py +0 -0
  25. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/type_hints.py +0 -0
  26. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/validate_api.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: unstructured_platform_plugins
3
- Version: 0.0.45
3
+ Version: 0.1.0
4
4
  Summary: Wrapper to convert arbitrary code into a uvicorn/fastapi implementation for Unstructured Platform
5
5
  License-File: LICENSE.md
6
6
  Classifier: Development Status :: 4 - Beta
@@ -10,12 +10,11 @@ Classifier: Intended Audience :: Science/Research
10
10
  Classifier: License :: OSI Approved :: Apache Software License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.10
14
13
  Classifier: Programming Language :: Python :: 3.11
15
14
  Classifier: Programming Language :: Python :: 3.12
16
15
  Classifier: Programming Language :: Python :: 3.13
17
16
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
- Requires-Python: >=3.10
17
+ Requires-Python: >=3.11
19
18
  Requires-Dist: click
20
19
  Requires-Dist: dataclasses-json
21
20
  Requires-Dist: fastapi
@@ -23,6 +22,7 @@ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc
23
22
  Requires-Dist: opentelemetry-instrumentation-fastapi
24
23
  Requires-Dist: requests
25
24
  Requires-Dist: unstructured-ingest
25
+ Requires-Dist: utic-invocation-settings<1.0.0,>=0.5.0
26
26
  Requires-Dist: uvicorn
27
27
  Description-Content-Type: text/markdown
28
28
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "unstructured_platform_plugins"
3
3
  description = "Wrapper to convert arbitrary code into a uvicorn/fastapi implementation for Unstructured Platform"
4
- requires-python = ">=3.10"
4
+ requires-python = ">=3.11"
5
5
  classifiers = [
6
6
  "Development Status :: 4 - Beta",
7
7
  "Intended Audience :: Developers",
@@ -10,7 +10,6 @@ classifiers = [
10
10
  "License :: OSI Approved :: Apache Software License",
11
11
  "Operating System :: OS Independent",
12
12
  "Programming Language :: Python :: 3",
13
- "Programming Language :: Python :: 3.10",
14
13
  "Programming Language :: Python :: 3.11",
15
14
  "Programming Language :: Python :: 3.12",
16
15
  "Programming Language :: Python :: 3.13",
@@ -24,6 +23,7 @@ dependencies = [
24
23
  "fastapi",
25
24
  "click",
26
25
  "unstructured-ingest",
26
+ "utic-invocation-settings>=0.5.0,<1.0.0",
27
27
  "opentelemetry-instrumentation-fastapi",
28
28
  "opentelemetry-exporter-otlp-proto-grpc",
29
29
  "dataclasses-json"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0" # pragma: no cover
@@ -3,8 +3,7 @@ import hashlib
3
3
  import inspect
4
4
  import json
5
5
  import logging
6
- from functools import partial
7
- from typing import Any, Callable, Optional, Union
6
+ from typing import Any, Callable, Optional, Union, get_origin
8
7
 
9
8
  from fastapi import FastAPI, HTTPException, status
10
9
  from fastapi.responses import StreamingResponse
@@ -13,7 +12,7 @@ from pydantic import BaseModel, Field, create_model
13
12
  from starlette.responses import RedirectResponse
14
13
  from typing_extensions import deprecated
15
14
  from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict
16
- from unstructured_ingest.error import UnstructuredIngestError
15
+ from unstructured_ingest.error import UnstructuredIngestError, UserError
17
16
  from uvicorn.config import LOG_LEVELS
18
17
  from uvicorn.importer import import_from_string
19
18
 
@@ -26,6 +25,14 @@ from unstructured_platform_plugins.etl_uvicorn.utils import (
26
25
  get_schema_dict,
27
26
  map_inputs,
28
27
  )
28
+ from unstructured_platform_plugins.generated.error_audience_v1 import ErrorAudience
29
+ from unstructured_platform_plugins.invocation_settings import (
30
+ add_metadata_route,
31
+ current_invocation_context,
32
+ current_invocation_settings,
33
+ install_invocation_envelope,
34
+ invocation_envelope,
35
+ )
29
36
  from unstructured_platform_plugins.schema import FileDataMeta, NewRecord, UsageData
30
37
  from unstructured_platform_plugins.schema.json_schema import (
31
38
  schema_to_base_model,
@@ -46,6 +53,16 @@ class MessageChannels(BaseModel):
46
53
  warnings: list[str] = Field(default_factory=list)
47
54
 
48
55
 
56
+ class PluginErrorMetadata(BaseModel):
57
+ """Canonical metadata nested at ``plugin_error`` on a failed legacy response."""
58
+
59
+ error_type: str
60
+ error_reason: str
61
+ dependency: Optional[str] = None
62
+ audience: Optional[ErrorAudience] = None
63
+ retryable: bool = False
64
+
65
+
49
66
  def log_func_and_body(func: Callable, body: Optional[str] = None) -> None:
50
67
  msg = None
51
68
  if logger.level == LOG_LEVELS.get("debug", logging.NOTSET):
@@ -62,21 +79,90 @@ def log_func_and_body(func: Callable, body: Optional[str] = None) -> None:
62
79
  logger.log(level=logger.level, msg=msg)
63
80
 
64
81
 
82
+ def _error_attr(error: BaseException, name: str) -> Any:
83
+ """Read an attribute off a raised error, treating a raising property as absent.
84
+
85
+ Runs while an exception handler is building the sanitized response; an
86
+ attribute access that itself raises must not replace that response with a
87
+ raw 500.
88
+ """
89
+ try:
90
+ return getattr(error, name, None)
91
+ except Exception:
92
+ return None
93
+
94
+
95
+ def _safe_str(value: object) -> str:
96
+ """str() on a plugin-supplied error can itself raise; never let that escape the handler.
97
+
98
+ An escape replaces the sanitized envelope with a raw HTTP 500, which the
99
+ controller's preflight reads as fail-open — a plugin-reported failure would
100
+ silently become a proceed.
101
+ """
102
+ try:
103
+ return str(value)
104
+ except Exception:
105
+ return "<unrenderable error>"
106
+
107
+
108
+ def failure_category_of(error: BaseException) -> Optional[str]:
109
+ """Return the error's failure_category only when it is a plain string."""
110
+ category = _error_attr(error, "failure_category")
111
+ return category if isinstance(category, str) else None
112
+
113
+
114
+ def plugin_error_of(error: BaseException) -> Optional[PluginErrorMetadata]:
115
+ """Map the legacy UserError family onto the canonical plugin-error envelope.
116
+
117
+ The ratified ErrorAudience vocabulary owns the wire spelling. A non-user failure remains
118
+ unclassified: orchestrators must not infer actionability from its HTTP status.
119
+ """
120
+ if not isinstance(error, UserError):
121
+ return None
122
+ return PluginErrorMetadata(
123
+ error_type="configuration",
124
+ error_reason=failure_category_of(error) or "invalid_input",
125
+ audience=ErrorAudience.USER,
126
+ retryable=False,
127
+ )
128
+
129
+
130
+ def status_code_of(error: BaseException) -> int:
131
+ """Return the error's status_code only when it is an int in the HTTP range, else 500."""
132
+ status_code = _error_attr(error, "status_code")
133
+ if (
134
+ isinstance(status_code, int)
135
+ and not isinstance(status_code, bool)
136
+ and 100 <= status_code <= 599
137
+ ):
138
+ return status_code
139
+ return status.HTTP_500_INTERNAL_SERVER_ERROR
140
+
141
+
65
142
  async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Any:
66
143
  kwargs = kwargs or {}
67
144
  if inspect.iscoroutinefunction(func):
68
145
  return await func(**kwargs)
69
- else:
70
- return await asyncio.get_event_loop().run_in_executor(None, partial(func, **kwargs))
146
+ # to_thread copies contextvars into the worker thread, so OpenTelemetry
147
+ # context (and any wide-event adoption inside func) survives the hop;
148
+ # run_in_executor does not.
149
+ return await asyncio.to_thread(func, **kwargs)
71
150
 
72
151
 
73
152
  def check_precheck_func(precheck_func: Callable):
74
- sig = inspect.signature(precheck_func)
75
- inputs = sig.parameters.values()
153
+ try:
154
+ # eval_str resolves postponed/string annotations ('list', 'None')
155
+ sig = inspect.signature(precheck_func, eval_str=True)
156
+ except (NameError, TypeError):
157
+ sig = inspect.signature(precheck_func)
158
+ inputs = list(sig.parameters.values())
76
159
  outputs = sig.return_annotation
77
160
  if len(inputs) == 1:
78
161
  i = inputs[0]
79
- if i.name != "usage" or i.annotation is list:
162
+ annotation_is_list = (
163
+ i.annotation is sig.empty or i.annotation is list or get_origin(i.annotation) is list
164
+ )
165
+ if i.name != "usage" or not annotation_is_list:
80
166
  raise ValueError("the only input available for precheck is usage which must be a list")
81
167
  if outputs not in [None, sig.empty]:
82
168
  raise ValueError(f"no output should exist for precheck function, found: {outputs}")
@@ -117,9 +203,15 @@ def wrap_in_fastapi(
117
203
  func: Callable,
118
204
  plugin_id: str,
119
205
  precheck_func: Optional[Callable] = None,
206
+ invoke_with_sealed_dag_node_settings_v2: bool = False,
120
207
  ) -> FastAPI:
121
208
  try:
122
- return _wrap_in_fastapi(func=func, plugin_id=plugin_id, precheck_func=precheck_func)
209
+ return _wrap_in_fastapi(
210
+ func=func,
211
+ plugin_id=plugin_id,
212
+ precheck_func=precheck_func,
213
+ invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
214
+ )
123
215
  except Exception as e:
124
216
  logger.error(f"failed to wrap function in FastAPI: {e}", exc_info=True)
125
217
  raise EtlApiException(e) from e
@@ -129,13 +221,19 @@ def _wrap_in_fastapi(
129
221
  func: Callable,
130
222
  plugin_id: str,
131
223
  precheck_func: Optional[Callable] = None,
224
+ invoke_with_sealed_dag_node_settings_v2: bool = False,
132
225
  ) -> FastAPI:
133
226
  if precheck_func is not None:
134
227
  check_precheck_func(precheck_func=precheck_func)
135
228
 
136
229
  logger.debug(f"set static id response to: {plugin_id}")
137
230
 
231
+ if "usage" not in inspect.signature(func).parameters:
232
+ logger.warning("usage data not an expected parameter, omitting")
233
+
138
234
  fastapi_app = FastAPI()
235
+ # Installation contributes a public router dependency, so it must happen before /invoke.
236
+ install_invocation_envelope(fastapi_app)
139
237
 
140
238
  response_type = get_output_sig(func)
141
239
  filedata_meta_model = update_filedata_model(response_type)
@@ -146,6 +244,8 @@ def _wrap_in_fastapi(
146
244
  file_data: Optional[FileDataType] = None
147
245
  filedata_meta: Optional[filedata_meta_model] = None
148
246
  status_code_text: Optional[str] = None
247
+ failure_category: Optional[str] = None
248
+ plugin_error: Optional[PluginErrorMetadata] = None
149
249
  output: Optional[response_type] = None
150
250
  message_channels: MessageChannels = Field(default_factory=MessageChannels)
151
251
 
@@ -161,20 +261,43 @@ def _wrap_in_fastapi(
161
261
  filedata_meta = FileDataMeta()
162
262
  message_channels = MessageChannels()
163
263
  request_dict = kwargs if kwargs else {}
164
- if "usage" in inspect.signature(func).parameters:
264
+ params = inspect.signature(func).parameters
265
+ if "usage" in params:
165
266
  request_dict["usage"] = usage
166
- else:
167
- logger.warning("usage data not an expected parameter, omitting")
168
- if "message_channels" in inspect.signature(func).parameters:
267
+ if "message_channels" in params:
169
268
  request_dict["message_channels"] = message_channels
170
- if "filedata_meta" in inspect.signature(func).parameters:
269
+ if "filedata_meta" in params:
171
270
  request_dict["filedata_meta"] = filedata_meta
172
271
  try:
173
272
  if inspect.isasyncgenfunction(func):
273
+ bound_settings = current_invocation_settings()
274
+ bound_context = current_invocation_context()
275
+
174
276
  # Stream response if function is an async generator
175
277
  async def _stream_response():
176
- try:
177
- async for output in func(**(request_dict or {})):
278
+ # FastAPI 0.117 closes yield dependencies before iterating a
279
+ # StreamingResponse. Re-enter the captured binding inside the generator so the
280
+ # plugin sees the right request regardless of dependency-cleanup timing.
281
+ with invocation_envelope(bound_settings, bound_context):
282
+ try:
283
+ async for output in func(**(request_dict or {})):
284
+ yield (
285
+ InvokeResponse(
286
+ usage=usage,
287
+ message_channels=message_channels,
288
+ filedata_meta=filedata_meta_model.model_validate(
289
+ filedata_meta.model_dump()
290
+ ),
291
+ status_code=status.HTTP_200_OK,
292
+ output=output,
293
+ file_data=request_dict.get("file_data", None),
294
+ ).model_dump_json()
295
+ + "\n"
296
+ )
297
+ except Exception as e:
298
+ logger.error(
299
+ f"Failure streaming response: {_safe_str(e)}", exc_info=True
300
+ )
178
301
  yield (
179
302
  InvokeResponse(
180
303
  usage=usage,
@@ -182,27 +305,13 @@ def _wrap_in_fastapi(
182
305
  filedata_meta=filedata_meta_model.model_validate(
183
306
  filedata_meta.model_dump()
184
307
  ),
185
- status_code=status.HTTP_200_OK,
186
- output=output,
187
- file_data=request_dict.get("file_data", None),
308
+ status_code=status_code_of(e),
309
+ status_code_text=f"[{type(e).__name__}] {_safe_str(e)}",
310
+ failure_category=failure_category_of(e),
311
+ plugin_error=plugin_error_of(e),
188
312
  ).model_dump_json()
189
313
  + "\n"
190
314
  )
191
- except Exception as e:
192
- logger.error(f"Failure streaming response: {e}", exc_info=True)
193
- yield (
194
- InvokeResponse(
195
- usage=usage,
196
- message_channels=message_channels,
197
- filedata_meta=filedata_meta_model.model_validate(
198
- filedata_meta.model_dump()
199
- ),
200
- status_code=getattr(e, "status_code", None)
201
- or status.HTTP_500_INTERNAL_SERVER_ERROR,
202
- status_code_text=f"[{e.__class__.__name__}] {e}",
203
- ).model_dump_json()
204
- + "\n"
205
- )
206
315
 
207
316
  return StreamingResponse(_stream_response(), media_type="application/x-ndjson")
208
317
  else:
@@ -217,40 +326,45 @@ def _wrap_in_fastapi(
217
326
  )
218
327
  except HTTPException as exc:
219
328
  logger.error(
220
- f"HTTPException: {exc.detail} (status_code={exc.status_code})", exc_info=True
329
+ f"HTTPException: {_safe_str(exc.detail)} (status_code={exc.status_code})",
330
+ exc_info=True,
221
331
  )
222
332
  return InvokeResponse(
223
333
  usage=usage,
224
334
  message_channels=message_channels,
225
335
  filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
226
336
  status_code=exc.status_code,
227
- status_code_text=json.dumps(exc.detail)
228
- if isinstance(exc.detail, dict)
229
- else exc.detail,
337
+ status_code_text=exc.detail
338
+ if isinstance(exc.detail, str)
339
+ else json.dumps(exc.detail, default=_safe_str),
340
+ failure_category=failure_category_of(exc),
230
341
  file_data=request_dict.get("file_data", None),
231
342
  )
232
343
  except UnstructuredIngestError as exc:
233
344
  logger.error(
234
- f"UnstructuredIngestError: {str(exc)} (status_code={exc.status_code})",
345
+ f"UnstructuredIngestError: {_safe_str(exc)} "
346
+ f"(status_code={_error_attr(exc, 'status_code')})",
235
347
  exc_info=True,
236
348
  )
237
349
  return InvokeResponse(
238
350
  usage=usage,
239
351
  message_channels=message_channels,
240
352
  filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
241
- status_code=exc.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR,
242
- status_code_text=str(exc),
353
+ status_code=status_code_of(exc),
354
+ status_code_text=_safe_str(exc),
355
+ failure_category=failure_category_of(exc),
356
+ plugin_error=plugin_error_of(exc),
243
357
  file_data=request_dict.get("file_data", None),
244
358
  )
245
359
  except Exception as invoke_error:
246
- logger.error(f"failed to invoke plugin: {invoke_error}", exc_info=True)
360
+ logger.error(f"failed to invoke plugin: {_safe_str(invoke_error)}", exc_info=True)
247
361
  return InvokeResponse(
248
362
  usage=usage,
249
363
  message_channels=message_channels,
250
364
  filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
251
- status_code=getattr(invoke_error, "status_code", None)
252
- or status.HTTP_500_INTERNAL_SERVER_ERROR,
253
- status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}",
365
+ status_code=status_code_of(invoke_error),
366
+ status_code_text=f"[{type(invoke_error).__name__}] {_safe_str(invoke_error)}",
367
+ failure_category=failure_category_of(invoke_error),
254
368
  file_data=request_dict.get("file_data", None),
255
369
  )
256
370
 
@@ -287,9 +401,7 @@ def _wrap_in_fastapi(
287
401
 
288
402
  @fastapi_app.post("/invoke", response_model=InvokeResponse)
289
403
  async def run_job(request: Optional[input_schema_model] = None) -> ResponseType:
290
- return await run_job_with_body(
291
- request if request is not None else input_schema_model()
292
- )
404
+ return await run_job_with_body(request if request is not None else input_schema_model())
293
405
 
294
406
  elif input_schema_model.model_fields:
295
407
 
@@ -318,6 +430,8 @@ def _wrap_in_fastapi(
318
430
  usage: list[UsageData]
319
431
  status_code: int
320
432
  status_code_text: Optional[str] = None
433
+ failure_category: Optional[str] = None
434
+ plugin_error: Optional[PluginErrorMetadata] = None
321
435
 
322
436
  @fastapi_app.get("/schema")
323
437
  async def get_schema() -> SchemaOutputResponse:
@@ -332,6 +446,8 @@ def _wrap_in_fastapi(
332
446
  return InvokePrecheckResponse(
333
447
  status_code=fn_response.status_code,
334
448
  status_code_text=fn_response.status_code_text,
449
+ failure_category=fn_response.failure_category,
450
+ plugin_error=fn_response.plugin_error,
335
451
  usage=fn_response.usage,
336
452
  )
337
453
  else:
@@ -347,6 +463,13 @@ def _wrap_in_fastapi(
347
463
  except TypeError as e:
348
464
  raise TypeError(f"failed to validate function schema: {e}") from e
349
465
 
466
+ # Registered last so add_metadata_route replaces any /metadata the plugin registered itself.
467
+ add_metadata_route(
468
+ fastapi_app,
469
+ identifier=plugin_id,
470
+ invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
471
+ )
472
+
350
473
  FastAPIInstrumentor.instrument_app(
351
474
  fastapi_app, tracer_provider=get_trace_provider(), meter_provider=get_metric_provider()
352
475
  )
@@ -361,6 +484,7 @@ def generate_fast_api(
361
484
  id_method: Optional[str] = None,
362
485
  precheck_str: Optional[str] = None,
363
486
  precheck_method: Optional[str] = None,
487
+ invoke_with_sealed_dag_node_settings_v2: bool = False,
364
488
  ) -> FastAPI:
365
489
  instance = import_from_string(app)
366
490
  func = get_func(instance, method_name)
@@ -379,4 +503,9 @@ def generate_fast_api(
379
503
  elif precheck_method:
380
504
  precheck_func = get_func(instance, precheck_method)
381
505
 
382
- return wrap_in_fastapi(func=func, plugin_id=plugin_id, precheck_func=precheck_func)
506
+ return wrap_in_fastapi(
507
+ func=func,
508
+ plugin_id=plugin_id,
509
+ precheck_func=precheck_func,
510
+ invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
511
+ )
@@ -56,6 +56,7 @@ def get_command() -> click.Command:
56
56
  plugin_id_method: Optional[str] = None,
57
57
  precheck_app: Optional[str] = None,
58
58
  precheck_app_method: Optional[str] = None,
59
+ sealed_dag_node_settings_v2: bool = False,
59
60
  **kwargs,
60
61
  ):
61
62
  # Make sure logging is configured before the call to run() so any setup has the same format
@@ -73,6 +74,7 @@ def get_command() -> click.Command:
73
74
  id_method=plugin_id_method,
74
75
  precheck_str=precheck_app,
75
76
  precheck_method=precheck_app_method,
77
+ invoke_with_sealed_dag_node_settings_v2=sealed_dag_node_settings_v2,
76
78
  )
77
79
  # Explicitly map values that are manipulated in the original
78
80
  # call to run(), preventing **kwargs reference
@@ -130,6 +132,14 @@ def get_command() -> click.Command:
130
132
  "If precheck-app not provided, assumes method "
131
133
  "lives on main class passes in.",
132
134
  ),
135
+ click.Option(
136
+ ["--sealed-dag-node-settings-v2"],
137
+ is_flag=True,
138
+ default=False,
139
+ help="Advertise the invoke_with_sealed_dag_node_settings_v2 capability on "
140
+ "/metadata. Set only for a plugin that consumes per-invoke settings "
141
+ "through current_invocation_settings().",
142
+ ),
133
143
  ]
134
144
  )
135
145
  return cmd
@@ -0,0 +1 @@
1
+ """Schema-generated local wire-contract bindings."""
@@ -0,0 +1,28 @@
1
+ # Generated by scripts/generate_invocation_contracts.py; do not edit by hand.
2
+ # ruff: noqa: E501
3
+ # Source: https://schemas.u10d.dev/errors/audience/v1.json
4
+ """Error Audience — generated from the ratified JSON Schema.
5
+ https://schemas.u10d.dev/errors/audience/v1
6
+
7
+ Non-null actionability audience carried by plugin_error.audience; distinct from technical fault-
8
+ source classifications such as utic_invocation_settings.Blame
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from enum import Enum
14
+
15
+
16
+ class ErrorAudienceValue(str, Enum):
17
+ """Who can act to resolve an error."""
18
+ USER = "user"
19
+ PLATFORM = "platform"
20
+
21
+
22
+ # Non-null actionability audience carried by plugin_error.audience; distinct from technical
23
+ # fault-source classifications such as utic_invocation_settings.Blame
24
+ ErrorAudience = ErrorAudienceValue
25
+
26
+ # Generation provenance used by drift tests and reviewers.
27
+ SCHEMA_ID = 'https://schemas.u10d.dev/errors/audience/v1.json'
28
+ SCHEMA_SHA256 = '36d017a1b132d0c06ded5a60376c425e8d5a5e681f134cfdc4dcb1fa4c9498d8'
@@ -0,0 +1,89 @@
1
+ # Generated by scripts/generate_invocation_contracts.py; do not edit by hand.
2
+ # ruff: noqa: E501
3
+ # Source: https://schemas.u10d.dev/invocation-context/v1.json
4
+ """Invocation Context — generated from the ratified JSON Schema.
5
+ https://schemas.u10d.dev/invocation-context/v1
6
+
7
+ Versioned request-scoped identity and correlation context carried in the reserved
8
+ invocation_context field of a plugin /invoke body
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class InvocationContext(BaseModel):
19
+ """
20
+ Versioned request-scoped identity and correlation context carried in the reserved
21
+ invocation_context field of a plugin /invoke body
22
+ """
23
+
24
+ model_config = ConfigDict(extra="allow")
25
+
26
+ schema_version: Literal["1"] = Field(
27
+ default="1",
28
+ description="Payload contract version. A missing value defaults to v1; consumers reject an unknown value rather than silently dropping attribution. Additive fields do not change this value.",
29
+ )
30
+ invocation_id: str | None = Field(
31
+ default=None,
32
+ description="Correlation identifier for this individual invocation.",
33
+ )
34
+ job_id: str | None = Field(default=None, description="Job that owns the invocation.")
35
+ workflow_id: str | None = Field(
36
+ default=None,
37
+ description="Workflow that owns the job, when known.",
38
+ )
39
+ attribution_id: str | None = Field(
40
+ default=None,
41
+ description="Platform attribution identifier associated with the invocation.",
42
+ )
43
+ tenant_id: str | None = Field(
44
+ default=None,
45
+ description="Tenant identity to which the invocation is attributed.",
46
+ )
47
+ org_id: str | None = Field(
48
+ default=None,
49
+ description="Organization identity to which the invocation is attributed.",
50
+ )
51
+ dag_node_id: str | None = Field(
52
+ default=None,
53
+ description="Identifier of the DAG node being invoked.",
54
+ )
55
+ dag_node_type: str | None = Field(
56
+ default=None,
57
+ description="Plugin type of the DAG node being invoked.",
58
+ )
59
+ dag_node_subtype: str | None = Field(
60
+ default=None,
61
+ description="Plugin subtype of the DAG node being invoked.",
62
+ )
63
+ record_id: str | None = Field(
64
+ default=None,
65
+ description="Record being processed by a single-record invocation.",
66
+ )
67
+ attempt: int | None = Field(
68
+ default=None,
69
+ description="Attempt number associated with the claimed work.",
70
+ )
71
+ job_created_timestamp: str | None = Field(
72
+ default=None,
73
+ description="Job creation timestamp forwarded for lifecycle timing. It is context metadata, not a telemetry dimension.",
74
+ )
75
+ record_ids: list[str] | None = Field(
76
+ default=None,
77
+ description="Record identifiers in a batch invocation. When invocation_ids is also present, the two arrays are index-aligned.",
78
+ )
79
+ invocation_ids: list[str | None] | None = Field(
80
+ default=None,
81
+ description="Per-record invocation identifiers for a batch invocation. Entry i identifies record_ids[i]; null means that record carried no invocation identifier.",
82
+ )
83
+
84
+ # Generation provenance used by drift tests and reviewers.
85
+ SCHEMA_ID = 'https://schemas.u10d.dev/invocation-context/v1.json'
86
+ SCHEMA_SHA256 = 'ca64be22a104acc697b7aff0fb47633ad75fa30cd88a0c92259af732fa1023c0'
87
+ RESERVED_CONTEXT_KEY = 'invocation_context'
88
+ SUPPORTED_CONTEXT_VERSIONS = frozenset(['1'])
89
+ DIMENSION_FIELDS = ('invocation_id', 'tenant_id', 'org_id', 'job_id', 'workflow_id', 'attribution_id', 'dag_node_id', 'dag_node_type', 'dag_node_subtype', 'record_id', 'attempt')
@@ -0,0 +1,125 @@
1
+ """The ``invocation_context`` companion to the settings envelope.
2
+
3
+ Where ``invocation_settings`` carries *what* a plugin should be configured with, the context
4
+ carries *who* the invocation is for: the identity facets a shared-tenancy pod can no longer read
5
+ from its process environment. It travels in a second reserved, out-of-schema field of the
6
+ ``/invoke`` body, extracted by the same route dependency that resolves the settings field.
7
+
8
+ The context is `/invoke` protocol identity, not settings security: it touches no crypto and no
9
+ secrets, and it evolves with the plugin protocol this package defines. The errors it raises come
10
+ from the shared ``InvocationSettingsError`` taxonomy so hosts classify context failures with the
11
+ same ``reason``/``blame`` machinery as settings failures.
12
+
13
+ The model below is the **consumer** view of that contract, deliberately lenient: unknown keys are
14
+ preserved for additive forward compatibility, and every identity field is optional so a
15
+ partially-populated context degrades to "less telemetry" rather than a failed invoke. The one thing
16
+ it is strict about is ``schema_version`` — that field makes incompatible producer and consumer
17
+ contracts detectable. The payload carries the version, so evolving the contract does not require
18
+ adding endpoints.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Mapping
24
+
25
+ import pydantic
26
+ from utic_invocation_settings import Blame, InvocationSettingsError, MalformedEnvelopeError
27
+
28
+ from unstructured_platform_plugins.generated.invocation_context_v1 import (
29
+ DIMENSION_FIELDS,
30
+ RESERVED_CONTEXT_KEY,
31
+ SUPPORTED_CONTEXT_VERSIONS,
32
+ )
33
+ from unstructured_platform_plugins.generated.invocation_context_v1 import (
34
+ InvocationContext as _GeneratedInvocationContext,
35
+ )
36
+
37
+ # Sentinel distinguishing a truly-absent reserved key from one present with a ``None`` value.
38
+ _ABSENT = object()
39
+
40
+
41
+ class UnsupportedContextVersionError(InvocationSettingsError):
42
+ """The ``invocation_context`` declares a ``schema_version`` this package does not understand.
43
+
44
+ This indicates deployment skew between platform components, not a fault in the request.
45
+ ``CONTENT`` (a 5xx) rather than ``CALLER``: contexts are produced by the platform's own claim
46
+ pipeline, and a 422 would make an upstream blame classifier pin a version-skew failure on the
47
+ customer. Failing the request prevents an unreadable context from silently removing telemetry
48
+ dimensions.
49
+ """
50
+
51
+ reason = "unsupported_context_version"
52
+ blame = Blame.CONTENT
53
+
54
+
55
+ class InvocationContext(_GeneratedInvocationContext):
56
+ """Request-scoped identity delivered alongside one claimed unit of work.
57
+
58
+ The field shape, version literal, extra-field policy, reserved key, and dimensions are generated
59
+ from the ratified schema. This adapter retains the cross-field invariant JSON Schema cannot
60
+ express.
61
+ """
62
+
63
+ @pydantic.model_validator(mode="after")
64
+ def _batch_lists_stay_index_aligned(self) -> "InvocationContext":
65
+ if (
66
+ self.record_ids is not None
67
+ and self.invocation_ids is not None
68
+ and len(self.record_ids) != len(self.invocation_ids)
69
+ ):
70
+ raise ValueError(
71
+ "record_ids and invocation_ids must be the same length: "
72
+ "entry i of invocation_ids describes record i"
73
+ )
74
+ return self
75
+
76
+ def extract_context(payload: Mapping[str, Any]) -> InvocationContext | None:
77
+ """Return the :class:`InvocationContext` from ``payload[RESERVED_CONTEXT_KEY]``.
78
+
79
+ Returns ``None`` only when the producer omitted the reserved key. A present-but-invalid value
80
+ fails closed rather than degrading to "no context", because a context that silently vanishes
81
+ takes a pod's tenant attribution with it.
82
+
83
+ A recognizable context carrying an unknown ``schema_version`` raises
84
+ :class:`UnsupportedContextVersionError` so an incompatible contract cannot be mistaken for
85
+ absent context.
86
+ """
87
+ raw = payload.get(RESERVED_CONTEXT_KEY, _ABSENT)
88
+ if raw is _ABSENT:
89
+ return None
90
+ if isinstance(raw, InvocationContext):
91
+ return raw
92
+ try:
93
+ return InvocationContext.model_validate(raw)
94
+ except pydantic.ValidationError as exc:
95
+ errors = exc.errors()
96
+ # Only a well-typed version string this package does not know reads as deployment skew; a
97
+ # schema_version of the wrong type is the caller's malformed context like any other field.
98
+ reported = _reported_version(raw)
99
+ if isinstance(reported, str) and any(
100
+ error["loc"] == ("schema_version",) for error in errors
101
+ ):
102
+ raise UnsupportedContextVersionError(
103
+ f"unsupported invocation_context schema_version: "
104
+ f"{_reported_version(raw)!r}; expected one of {sorted(SUPPORTED_CONTEXT_VERSIONS)}"
105
+ ) from None
106
+ # `from None` so the pydantic error tree does not cross the domain-error boundary; a count
107
+ # plus the first message is enough signal. Mirrors the envelope extraction.
108
+ raise MalformedEnvelopeError(
109
+ f"invalid invocation_context: {exc.error_count()} validation error(s), "
110
+ f"first: {errors[0]['msg']}"
111
+ ) from None
112
+
113
+
114
+ def _reported_version(raw: Any) -> Any:
115
+ """The offending ``schema_version``, for the error message only. Never trusted."""
116
+ return raw.get("schema_version") if isinstance(raw, Mapping) else None
117
+
118
+
119
+ def dimensions(context: InvocationContext | None) -> dict[str, Any]:
120
+ """The context's populated identity facets, ready to bind as telemetry dimensions."""
121
+ if context is None:
122
+ return {}
123
+ return {
124
+ field: value for field in DIMENSION_FIELDS if (value := getattr(context, field)) is not None
125
+ }
@@ -0,0 +1,536 @@
1
+ """Transport for the reserved `/invoke` fields: request dependency, `/metadata`, optional body cap.
2
+
3
+ The *settings contract* — including the only accepted sealed `/invoke` shape (the v2 settings
4
+ document), field-level resolution, and what an absent field is allowed to mean — lives in
5
+ `utic_invocation_settings`, next to the crypto it governs; every decision about a settings payload
6
+ is delegated there. The *identity contract* —
7
+ the `invocation_context` model — is
8
+ `/invoke` protocol rather than settings security and lives in this package's
9
+ `invocation_context` module. This module is the delivery mechanism for both: getting the payloads
10
+ off the wire and the results to the handler, and spelling the shared `blame` taxonomy as HTTP
11
+ statuses.
12
+
13
+ The reserved fields are a first-class HTTP contract independent of the generated input schema.
14
+ They never appear in a plugin's declared signature, so `wrap_in_fastapi` keeps producing a handler
15
+ model built purely from the wrapped function, and a plugin reads the fields through
16
+ `current_invocation_settings()` / `current_invocation_context()` instead.
17
+
18
+ Well-formed extraction runs in a route dependency (`bind_invocation_envelope`), so the `/invoke`
19
+ body is buffered and parsed exactly once: Starlette caches both the bytes and the parsed JSON on
20
+ the `Request`, and the dependency reads that cached parse. Generated routes can reject malformed
21
+ JSON while validating their typed body before dependencies run, so the installer normalizes that
22
+ specific validation failure to the same transport error. Hosts that have established a safe
23
+ request ceiling may opt into `InvokeBodyLimitMiddleware`, which counts bytes as they stream through
24
+ without buffering. Wrapped plugins do not acquire a fleet-wide limit merely by upgrading this
25
+ package.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import hashlib
32
+ import inspect
33
+ import json
34
+ import logging
35
+ import threading
36
+ import time
37
+ from collections import OrderedDict
38
+ from collections.abc import AsyncIterator, Callable, Iterator, Mapping
39
+ from contextlib import contextmanager
40
+ from contextvars import ContextVar
41
+ from typing import Any, Optional, TypeVar
42
+
43
+ from fastapi import Depends, FastAPI, Request
44
+ from fastapi.exception_handlers import request_validation_exception_handler
45
+ from fastapi.exceptions import RequestValidationError
46
+ from opentelemetry import trace
47
+ from starlette.requests import ClientDisconnect
48
+ from starlette.responses import JSONResponse
49
+ from starlette.routing import get_route_path
50
+ from starlette.types import ASGIApp, Receive, Scope, Send
51
+ from utic_invocation_settings import (
52
+ INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_V2_CAPABILITY,
53
+ RESERVED_ENVELOPE_KEY,
54
+ Blame,
55
+ InvocationSettingsError,
56
+ MalformedEnvelopeError,
57
+ resolve_invocation_settings,
58
+ )
59
+
60
+ from unstructured_platform_plugins.invocation_context import (
61
+ RESERVED_CONTEXT_KEY,
62
+ InvocationContext,
63
+ dimensions,
64
+ extract_context,
65
+ )
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ def http_status_for(error: BaseException) -> int:
71
+ """The HTTP status this transport answers for a failed resolution, from ``blame``.
72
+
73
+ One rule, the one the library README states normatively: ``Blame.CALLER`` -> 422, everything
74
+ else -> 500. The line it draws is whether a different request would work. Sealing drift, an
75
+ envelope for another recipient and a broken local mount are all 5xx, which keeps a controller's
76
+ blame classification off the customer, whose request was fine. Anything that is not a
77
+ classified error is a 500: an unclassified failure is not the caller's.
78
+ """
79
+ return 422 if getattr(error, "blame", None) is Blame.CALLER else 500
80
+
81
+
82
+ T = TypeVar("T")
83
+
84
+ _METADATA_PATH = "/metadata"
85
+ _INVOKE_PATH = "/invoke"
86
+
87
+ # A convenient opt-in ceiling for hosts that have verified it against their request distribution.
88
+ # It is deliberately not the install default: batch invokes carry arrays of file_data payloads,
89
+ # and a wrapper upgrade must not reject previously valid fleet traffic without an explicit choice.
90
+ MAX_INVOKE_BODY_BYTES = 64 * 1024 * 1024
91
+
92
+ _INVOCATION: ContextVar[tuple[Optional[dict], Optional[InvocationContext]]] = ContextVar(
93
+ "invocation", default=(None, None)
94
+ )
95
+
96
+
97
+ def current_invocation_settings() -> Optional[dict]:
98
+ """Reserved `invocation_settings` field bound for the current request, if any.
99
+
100
+ `None` means the field was genuinely absent — the only case in which a plugin may fall back to
101
+ its boot-time settings. A field that arrived and could not be opened never reaches a handler:
102
+ the binding dependency fails the request first.
103
+ """
104
+ return _INVOCATION.get()[0]
105
+
106
+
107
+ def current_invocation_context() -> Optional[InvocationContext]:
108
+ """Reserved `invocation_context` field bound for the current request, if any."""
109
+ return _INVOCATION.get()[1]
110
+
111
+
112
+ @contextmanager
113
+ def invocation_envelope(
114
+ invocation_settings: Optional[dict], invocation_context: Optional[InvocationContext]
115
+ ) -> Iterator[None]:
116
+ """Bind the reserved /invoke fields for the current context."""
117
+ token = _INVOCATION.set((invocation_settings, invocation_context))
118
+ try:
119
+ yield
120
+ finally:
121
+ _INVOCATION.reset(token)
122
+
123
+
124
+ def add_metadata_route(
125
+ app: FastAPI,
126
+ identifier: Optional[str] = None,
127
+ invoke_with_sealed_dag_node_settings_v2: bool = False,
128
+ ) -> None:
129
+ """Register GET /metadata advertising the reserved /invoke fields this plugin accepts.
130
+
131
+ `/metadata` is the plugin API spec's own discovery surface (`PluginMetadataOutput`): capability
132
+ flags are strings in its `capabilities` list, which is where the controller looks before
133
+ forwarding the reserved fields — no controller-private probe route.
134
+
135
+ `invocation_settings` and `invocation_context` are transport capabilities: installing the
136
+ dependency makes the host receive, resolve, and bind those fields. The sealed-settings
137
+ capability is stronger: it tells the controller that the plugin handler consumes the resolved
138
+ field-level v2 settings in place of boot-time state. The controller may therefore send the
139
+ versioned v2 document, so this remains an explicit opt-in.
140
+
141
+ Last call wins: the payload lives on `app.state` and every call overwrites it, while the route
142
+ is registered once. A host wrapper may register at app construction and a plugin can still
143
+ re-register with its own identifier afterwards, with no route-order dependence.
144
+ """
145
+ capabilities = [RESERVED_ENVELOPE_KEY, RESERVED_CONTEXT_KEY]
146
+ if invoke_with_sealed_dag_node_settings_v2:
147
+ capabilities.append(INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_V2_CAPABILITY)
148
+ app.state.plugin_metadata_payload = {
149
+ "api_version": "3",
150
+ "identifier": identifier,
151
+ "capabilities": capabilities,
152
+ }
153
+ if getattr(app.state, "plugin_metadata_route_installed", False):
154
+ return
155
+ app.state.plugin_metadata_route_installed = True
156
+
157
+ # A /metadata route registered by the application itself would win by route order and pin its
158
+ # own stale payload; drop it so the last add_metadata_route call is the one that answers.
159
+ app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != _METADATA_PATH]
160
+
161
+ @app.get(_METADATA_PATH)
162
+ async def plugin_metadata() -> dict:
163
+ return app.state.plugin_metadata_payload
164
+
165
+
166
+ class UnusableInvocationEnvelope(Exception):
167
+ """A reserved /invoke field arrived but cannot be used.
168
+
169
+ Raised by `bind_invocation_envelope` and answered by the handler
170
+ `install_invocation_envelope` registers, so the response shape — `detail` plus the library's
171
+ stable `reason` code as top-level siblings — stays what orchestrators parse, independent of
172
+ FastAPI's own error envelope.
173
+ """
174
+
175
+ def __init__(self, status_code: int, payload: dict):
176
+ super().__init__(payload.get("detail"))
177
+ self.status_code = status_code
178
+ self.payload = payload
179
+
180
+
181
+ async def _unusable_envelope_response(
182
+ _request: Request, exc: UnusableInvocationEnvelope
183
+ ) -> JSONResponse:
184
+ return JSONResponse(status_code=exc.status_code, content=exc.payload)
185
+
186
+
187
+ def _is_malformed_json_validation(exc: RequestValidationError) -> bool:
188
+ """Whether FastAPI rejected the request body before route dependencies could run."""
189
+ # A top-level JSONDecodeError stores the raw document and reports its integer parser offset as
190
+ # exactly ("body", offset). Pydantic's Json fields also emit `json_invalid`, but by then the
191
+ # outer request has been decoded into a mapping/list and the location continues through the
192
+ # model field. Those are ordinary schema-validation errors and must retain FastAPI's detail.
193
+ if not isinstance(exc.body, (str, bytes, bytearray)):
194
+ return False
195
+ return any(
196
+ error.get("type") == "json_invalid"
197
+ and len(location := tuple(error.get("loc", ()))) == 2
198
+ and location[0] == "body"
199
+ and isinstance(location[1], int)
200
+ for error in exc.errors()
201
+ )
202
+
203
+
204
+ async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]:
205
+ """Resolve the reserved /invoke fields and bind them for the duration of the request.
206
+
207
+ Runs as a route dependency, after the framework has read the body: `request.json()` is
208
+ Starlette-cached, so the parse is shared with the framework's own body handling.
209
+ A reserved field that is present but unusable fails the request rather than being
210
+ treated as absent, because absence is the signal to fall back to the boot-time settings file:
211
+ degrading a malformed field to absence would quietly answer a request configured for one
212
+ tenant with whatever the pod happened to boot with. Under
213
+ `FF_INVOCATION_SETTINGS` there is no settings file to fall back to,
214
+ so absent or plaintext settings fail too — as does any /invoke whose body is not a JSON
215
+ object, since such a body cannot carry the envelope a native pod requires.
216
+ """
217
+ # ASGI `path` includes any deployment root_path; get_route_path strips it, which is how the
218
+ # router itself matches, so binding fires exactly when the /invoke route does.
219
+ if request.method != "POST" or get_route_path(request.scope) != _INVOKE_PATH:
220
+ yield
221
+ return
222
+
223
+ try:
224
+ parsed = await request.json()
225
+ except ValueError:
226
+ # request.json() reads through Starlette's cached body, so this does not consume or buffer
227
+ # the stream a second time. A truly empty body is absence and remains subject to the
228
+ # native-pod policy below. Any bytes that fail JSON parsing are a malformed request, not
229
+ # absence: collapsing them would turn a caller-fixable syntax error into a native pod's
230
+ # SealedDagNodeSettingsRequiredError (RECIPIENT -> 500).
231
+ if await request.body():
232
+ raise UnusableInvocationEnvelope(
233
+ 422,
234
+ {
235
+ "detail": "Invalid JSON body",
236
+ "reason": MalformedEnvelopeError.reason,
237
+ },
238
+ ) from None
239
+ parsed = None
240
+
241
+ raw_settings: Optional[Any] = None
242
+ if isinstance(parsed, dict):
243
+ raw_settings = parsed.get(RESERVED_ENVELOPE_KEY)
244
+ if RESERVED_ENVELOPE_KEY in parsed and not isinstance(raw_settings, dict):
245
+ raise UnusableInvocationEnvelope(
246
+ 422,
247
+ {
248
+ "detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}",
249
+ "reason": MalformedEnvelopeError.reason,
250
+ },
251
+ )
252
+ try:
253
+ # Off the event loop: resolution may perform blocking cryptography for independently
254
+ # sealed fields, and this dependency fronts every invoke on the pod.
255
+ invocation_settings = await asyncio.to_thread(resolve_invocation_settings, raw_settings)
256
+ except Exception as exc:
257
+ # Class name only — never envelope contents, and never the exception's own message,
258
+ # which can embed request-controlled values.
259
+ logger.warning("unusable %s payload: %s", RESERVED_ENVELOPE_KEY, type(exc).__name__)
260
+ payload = {"detail": f"Unusable invocation settings: {type(exc).__name__}"}
261
+ reason = getattr(exc, "reason", None)
262
+ if isinstance(reason, str):
263
+ payload["reason"] = reason
264
+ raise UnusableInvocationEnvelope(http_status_for(exc), payload) from exc
265
+
266
+ invocation_context: Optional[InvocationContext] = None
267
+ if isinstance(parsed, dict):
268
+ try:
269
+ invocation_context = extract_context(parsed)
270
+ except InvocationSettingsError as exc:
271
+ # A context this plugin cannot read fails loudly here rather than running with
272
+ # silently absent identity. Status comes from the blame taxonomy: a malformed
273
+ # field is the caller's 422, but an unreadable schema_version is deployment skew
274
+ # between platform components and must not read as a caller fault. The log line is
275
+ # truncated because the message can embed request-controlled values.
276
+ logger.warning("rejecting invalid %s: %.200s", RESERVED_CONTEXT_KEY, exc)
277
+ status = http_status_for(exc)
278
+ detail = (
279
+ f"Invalid field: {RESERVED_CONTEXT_KEY}"
280
+ if status == 422
281
+ else f"Unusable {RESERVED_CONTEXT_KEY}: {type(exc).__name__}"
282
+ )
283
+ raise UnusableInvocationEnvelope(
284
+ status, {"detail": detail, "reason": exc.reason}
285
+ ) from exc
286
+
287
+ # Keep plugin-side request spans aligned with the controller's wide-event dimensions. The
288
+ # ratified DIMENSION_FIELDS list deliberately excludes batch correlation and unknown additive
289
+ # fields, so only the shared invocation identity is promoted to indexed telemetry.
290
+ invocation_dimensions = dimensions(invocation_context)
291
+ if invocation_dimensions:
292
+ span = trace.get_current_span()
293
+ for key, value in invocation_dimensions.items():
294
+ span.set_attribute(key, value)
295
+
296
+ with invocation_envelope(invocation_settings, invocation_context):
297
+ yield
298
+
299
+
300
+ async def _send_json(send: Send, status_code: int, payload: dict) -> None:
301
+ body = json.dumps(payload).encode()
302
+ await send(
303
+ {
304
+ "type": "http.response.start",
305
+ "status": status_code,
306
+ "headers": [
307
+ (b"content-type", b"application/json"),
308
+ (b"content-length", str(len(body)).encode()),
309
+ ],
310
+ }
311
+ )
312
+ await send({"type": "http.response.body", "body": body})
313
+
314
+
315
+ class InvokeBodyLimitMiddleware:
316
+ """Reject a POST /invoke body over ``max_body_bytes`` with 413.
317
+
318
+ Counts bytes as the framework consumes them; nothing is buffered here. When the count crosses
319
+ the cap the downstream read is answered with ``http.disconnect``, which aborts the framework's
320
+ body read before another byte is held, and the 413 is sent once the application has unwound.
321
+ This has to sit below the framework because neither Starlette nor uvicorn bounds request-body
322
+ size.
323
+ """
324
+
325
+ def __init__(self, app: ASGIApp, max_body_bytes: int = MAX_INVOKE_BODY_BYTES):
326
+ self.app = app
327
+ self.max_body_bytes = max_body_bytes
328
+
329
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
330
+ if (
331
+ scope["type"] != "http"
332
+ or scope.get("method") != "POST"
333
+ or get_route_path(scope) != _INVOKE_PATH
334
+ ):
335
+ await self.app(scope, receive, send)
336
+ return
337
+
338
+ seen = 0
339
+ exceeded = False
340
+ response_started = False
341
+
342
+ async def counting_receive() -> dict:
343
+ nonlocal seen, exceeded
344
+ message = await receive()
345
+ if message["type"] == "http.request":
346
+ seen += len(message.get("body", b""))
347
+ if seen > self.max_body_bytes:
348
+ exceeded = True
349
+ return {"type": "http.disconnect"}
350
+ return message
351
+
352
+ async def guarded_send(message: dict) -> None:
353
+ nonlocal response_started
354
+ if exceeded and not response_started:
355
+ # A response computed after the body was cut is answering a truncated request;
356
+ # drop it so the 413 below is what the caller sees. A response that started
357
+ # before the cap tripped keeps streaming — its start is already on the wire.
358
+ return
359
+ if message["type"] == "http.response.start":
360
+ response_started = True
361
+ await send(message)
362
+
363
+ try:
364
+ await self.app(scope, counting_receive, guarded_send)
365
+ except ClientDisconnect:
366
+ if not exceeded:
367
+ raise
368
+ except Exception as exc:
369
+ # The cut body stream can surface downstream as something other than
370
+ # ClientDisconnect; once the cap is the cause, the 413 below is the answer. Retain a
371
+ # type-only diagnostic for a coincident downstream bug without rendering its message,
372
+ # which may contain request data or credentials.
373
+ if not exceeded:
374
+ raise
375
+ logger.debug(
376
+ "downstream raised after /invoke body limit was exceeded: %s",
377
+ type(exc).__name__,
378
+ )
379
+ if exceeded and not response_started:
380
+ await _send_json(send, 413, {"detail": "Request body too large"})
381
+
382
+
383
+ def install_invocation_envelope(app: FastAPI, max_body_bytes: int | None = None) -> None:
384
+ """Install reserved-field binding before registering a FastAPI app's routes.
385
+
386
+ Adds `bind_invocation_envelope` through the router's public dependency list and registers the
387
+ failure response shape. FastAPI parses a generated route's typed body before dependencies run,
388
+ so malformed JSON validation is normalized to that same response shape at the app boundary;
389
+ every other validation error retains the handler the app already had. When ``max_body_bytes``
390
+ is supplied, also installs the body-size cap beneath the framework. The cap is opt-in because
391
+ a wrapper upgrade must not impose an unvalidated fleet-wide request limit. The dependency is a
392
+ path/method-aware no-op outside POST /invoke, so routes registered after this call can all
393
+ inherit it without private dependency-graph mutation. Calling after routes have already been
394
+ registered raises rather than silently leaving those routes uncovered.
395
+
396
+ Idempotent per app because both host-wrapper and plugin setup may call this function, while a
397
+ double installation would resolve settings twice per request. A repeated call asking for a
398
+ different ``max_body_bytes`` raises because the installed configuration cannot be changed and
399
+ silently keeping the first value would misrepresent the limit actually enforced.
400
+ """
401
+ if getattr(app.state, "invocation_envelope_installed", False):
402
+ installed_max = app.state.invocation_envelope_max_body_bytes
403
+ if max_body_bytes != installed_max:
404
+ raise ValueError(
405
+ "install_invocation_envelope already installed with "
406
+ f"max_body_bytes={installed_max}; cannot reinstall with {max_body_bytes}"
407
+ )
408
+ return
409
+ if any(
410
+ getattr(route, "path", None) == _INVOKE_PATH
411
+ and "POST" in (getattr(route, "methods", None) or set())
412
+ for route in app.router.routes
413
+ ):
414
+ raise RuntimeError(
415
+ "install_invocation_envelope must be called before the POST /invoke route is registered"
416
+ )
417
+ app.state.invocation_envelope_installed = True
418
+ app.state.invocation_envelope_max_body_bytes = max_body_bytes
419
+ app.router.dependencies.append(Depends(bind_invocation_envelope))
420
+ if max_body_bytes is not None:
421
+ app.add_middleware(InvokeBodyLimitMiddleware, max_body_bytes=max_body_bytes)
422
+ app.add_exception_handler(UnusableInvocationEnvelope, _unusable_envelope_response)
423
+
424
+ previous_validation_handler = app.exception_handlers.get(
425
+ RequestValidationError, request_validation_exception_handler
426
+ )
427
+
428
+ async def invocation_request_validation_response(request: Request, exc: RequestValidationError):
429
+ if (
430
+ request.method == "POST"
431
+ and get_route_path(request.scope) == _INVOKE_PATH
432
+ and _is_malformed_json_validation(exc)
433
+ ):
434
+ return JSONResponse(
435
+ status_code=422,
436
+ content={
437
+ "detail": "Invalid JSON body",
438
+ "reason": MalformedEnvelopeError.reason,
439
+ },
440
+ )
441
+ response = previous_validation_handler(request, exc)
442
+ return await response if inspect.isawaitable(response) else response
443
+
444
+ app.add_exception_handler(RequestValidationError, invocation_request_validation_response)
445
+
446
+
447
+ def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str:
448
+ """Digest of an ordinary resolved settings mapping, safe for secret-bearing values."""
449
+ return hashlib.sha256(json.dumps(invocation_settings, sort_keys=True).encode()).hexdigest()
450
+
451
+
452
+ class SettingsScopedCache:
453
+ """Bind expensive derived state (clients, models, handlers) to the settings that built it.
454
+
455
+ A plugin consuming ``current_invocation_settings()`` derives a handler from each distinct
456
+ resolved mapping. Construction typically performs network work (model resolution, prechecks),
457
+ so results are memoized by ``settings_cache_key``. Raw field envelopes never reach this cache:
458
+ the public settings library resolves and caches them at the field boundary before this
459
+ transport binds the result. Both bounds matter under shared tenancy: size caps how many
460
+ distinct mappings stay live, and age evicts state after credential rotation. Eviction driven
461
+ only by the count of distinct mappings can take arbitrarily long on a quiet pod.
462
+
463
+ Thread-safe for lookups and inserts. Concurrent misses for the same settings may build twice;
464
+ the extra build is wasted work, never wrong state.
465
+ """
466
+
467
+ def __init__(
468
+ self,
469
+ *,
470
+ ttl_seconds: float = 15 * 60,
471
+ maxsize: int = 32,
472
+ clock: Callable[[], float] = time.monotonic,
473
+ ) -> None:
474
+ if ttl_seconds <= 0:
475
+ raise ValueError("ttl_seconds must be positive")
476
+ if maxsize < 1:
477
+ raise ValueError("maxsize must be at least 1")
478
+ self._ttl_seconds = float(ttl_seconds)
479
+ self._maxsize = maxsize
480
+ self._clock = clock
481
+ self._lock = threading.Lock()
482
+ self._entries: OrderedDict[str, tuple[float, Any]] = OrderedDict()
483
+
484
+ def get_or_build(self, invocation_settings: Mapping[str, Any], build: Callable[[], T]) -> T:
485
+ """Return the cached value for these settings, building it on a miss."""
486
+ key = settings_cache_key(invocation_settings)
487
+ now = self._clock()
488
+ with self._lock:
489
+ entry = self._entries.get(key)
490
+ if entry is not None:
491
+ expires_at, value = entry
492
+ if now < expires_at:
493
+ self._entries.move_to_end(key)
494
+ return value
495
+ del self._entries[key]
496
+ value = build()
497
+ with self._lock:
498
+ # Every insert also sweeps entries whose TTL has lapsed, so a tenant that stops
499
+ # sending requests does not keep its credential-bearing handler live while the pod
500
+ # stays busy for others; per-key expiry alone only fires on that tenant's next hit.
501
+ # Re-read the clock: build() may take long enough for more entries to lapse.
502
+ now = self._clock()
503
+ for stale_key, (expires_at, _) in list(self._entries.items()):
504
+ if now >= expires_at:
505
+ del self._entries[stale_key]
506
+ self._entries[key] = (now + self._ttl_seconds, value)
507
+ self._entries.move_to_end(key)
508
+ while len(self._entries) > self._maxsize:
509
+ self._entries.popitem(last=False)
510
+ return value
511
+
512
+ def handler_for(
513
+ self,
514
+ resolved_settings: Optional[Mapping[str, Any]],
515
+ *,
516
+ boot: Callable[[], Optional[T]],
517
+ build: Callable[[], T],
518
+ ) -> T:
519
+ """The handler for a request: cached per distinct resolved mapping, or the boot fallback.
520
+
521
+ Absent settings select the single handler configured by the boot-time settings file.
522
+ ``boot`` returning ``None`` means the pod has no boot configuration and sealed per-invoke
523
+ settings are required, so the request fails rather than running an unconfigured handler.
524
+ """
525
+ if resolved_settings is None:
526
+ handler = boot()
527
+ if handler is None:
528
+ raise ValueError(
529
+ "no boot-time handler on this pod: sealed per-invoke settings are required"
530
+ )
531
+ return handler
532
+ return self.get_or_build(resolved_settings, build)
533
+
534
+ def clear(self) -> None:
535
+ with self._lock:
536
+ self._entries.clear()
@@ -1 +0,0 @@
1
- __version__ = "0.0.45" # pragma: no cover