unstructured-platform-plugins 0.0.46__tar.gz → 0.1.1.dev1__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 (28) hide show
  1. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/PKG-INFO +3 -3
  2. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/pyproject.toml +2 -2
  3. unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/__init__.py +13 -0
  4. unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/__version__.py +1 -0
  5. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/etl_uvicorn/api_generator.py +129 -23
  6. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/etl_uvicorn/main.py +10 -0
  7. unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/generated/__init__.py +1 -0
  8. unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/generated/error_audience_v1.py +28 -0
  9. unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/generated/invocation_context_v1.py +89 -0
  10. unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/invocation_context.py +125 -0
  11. unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/invocation_scope.py +63 -0
  12. unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/invocation_settings.py +536 -0
  13. unstructured_platform_plugins-0.0.46/unstructured_platform_plugins/__version__.py +0 -1
  14. unstructured_platform_plugins-0.0.46/unstructured_platform_plugins/etl_uvicorn/__init__.py +0 -0
  15. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/.gitignore +0 -0
  16. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/LICENSE.md +0 -0
  17. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/README.md +0 -0
  18. {unstructured_platform_plugins-0.0.46/unstructured_platform_plugins → unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/etl_uvicorn}/__init__.py +0 -0
  19. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/etl_uvicorn/otel.py +0 -0
  20. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/etl_uvicorn/utils.py +0 -0
  21. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/__init__.py +0 -0
  22. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/filedata_meta.py +0 -0
  23. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/json_schema.py +0 -0
  24. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/model.py +0 -0
  25. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/usage.py +0 -0
  26. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/utils.py +0 -0
  27. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/type_hints.py +0 -0
  28. {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/validate_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unstructured_platform_plugins
3
- Version: 0.0.46
3
+ Version: 0.1.1.dev1
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,13 @@
1
+ from unstructured_platform_plugins.invocation_scope import (
2
+ InvocationIdentity,
3
+ current_invocation_identity,
4
+ invocation_scratch_dir,
5
+ )
6
+ from unstructured_platform_plugins.invocation_settings import current_invocation_context
7
+
8
+ __all__ = [
9
+ "InvocationIdentity",
10
+ "current_invocation_context",
11
+ "current_invocation_identity",
12
+ "invocation_scratch_dir",
13
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1.dev1" # pragma: no cover
@@ -3,6 +3,7 @@ import hashlib
3
3
  import inspect
4
4
  import json
5
5
  import logging
6
+ import re
6
7
  from typing import Any, Callable, Optional, Union, get_origin
7
8
 
8
9
  from fastapi import FastAPI, HTTPException, status
@@ -12,7 +13,7 @@ from pydantic import BaseModel, Field, create_model
12
13
  from starlette.responses import RedirectResponse
13
14
  from typing_extensions import deprecated
14
15
  from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict
15
- from unstructured_ingest.error import UnstructuredIngestError
16
+ from unstructured_ingest.error import RateLimitError, UnstructuredIngestError, UserError
16
17
  from uvicorn.config import LOG_LEVELS
17
18
  from uvicorn.importer import import_from_string
18
19
 
@@ -25,6 +26,14 @@ from unstructured_platform_plugins.etl_uvicorn.utils import (
25
26
  get_schema_dict,
26
27
  map_inputs,
27
28
  )
29
+ from unstructured_platform_plugins.generated.error_audience_v1 import ErrorAudience
30
+ from unstructured_platform_plugins.invocation_settings import (
31
+ add_metadata_route,
32
+ current_invocation_context,
33
+ current_invocation_settings,
34
+ install_invocation_envelope,
35
+ invocation_envelope,
36
+ )
28
37
  from unstructured_platform_plugins.schema import FileDataMeta, NewRecord, UsageData
29
38
  from unstructured_platform_plugins.schema.json_schema import (
30
39
  schema_to_base_model,
@@ -45,6 +54,16 @@ class MessageChannels(BaseModel):
45
54
  warnings: list[str] = Field(default_factory=list)
46
55
 
47
56
 
57
+ class PluginErrorMetadata(BaseModel):
58
+ """Canonical metadata nested at ``plugin_error`` on a failed legacy response."""
59
+
60
+ error_type: str
61
+ error_reason: str
62
+ dependency: Optional[str] = None
63
+ audience: Optional[ErrorAudience] = None
64
+ retryable: bool = False
65
+
66
+
48
67
  def log_func_and_body(func: Callable, body: Optional[str] = None) -> None:
49
68
  msg = None
50
69
  if logger.level == LOG_LEVELS.get("debug", logging.NOTSET):
@@ -93,6 +112,57 @@ def failure_category_of(error: BaseException) -> Optional[str]:
93
112
  return category if isinstance(category, str) else None
94
113
 
95
114
 
115
+ def _as_error_reason(category: Optional[str]) -> Optional[str]:
116
+ """Render a failure_category as an ``error.reason`` token, or None when it cannot be.
117
+
118
+ ``failure_category`` and ``error.reason`` are two vocabularies, not one: the preflight
119
+ categories are SCREAMING_SNAKE (``AUTH_PERMISSION_DENIED``) while ``error.reason`` is
120
+ specified lower_snake_case. ``failure_category`` still rides its own top-level response
121
+ field verbatim, so nothing is lost by normalizing the copy that lands here.
122
+
123
+ The category is plugin-supplied free text rather than a closed enum, so lowercasing
124
+ alone would not produce a snake_case token; punctuation and whitespace collapse to
125
+ single underscores and a category with no usable characters yields None.
126
+ """
127
+ if category is None:
128
+ return None
129
+ # -- str.lower, not category.lower: the isinstance check upstream admits str SUBCLASSES,
130
+ # -- and this renders an error response, where raising is the one thing it must not do --
131
+ reason = re.sub(r"[^a-z0-9]+", "_", str.lower(category)).strip("_")
132
+ return reason or None
133
+
134
+
135
+ def plugin_error_of(error: BaseException) -> Optional[PluginErrorMetadata]:
136
+ """Map the legacy UserError family onto the canonical plugin-error envelope.
137
+
138
+ The ratified ErrorAudience vocabulary owns the wire spelling. A non-user failure remains
139
+ unclassified: orchestrators must not infer actionability from its HTTP status.
140
+
141
+ Terminal is the safe default, so a transient condition has to declare itself: a provider
142
+ rate limit is the one member of this family that clears on its own, and it mirrors the
143
+ utic_plugin_base ``RateLimitError`` declaration (platform-libs #889) field for field.
144
+ Retryability is orthogonal to audience -- a throttled request is still the caller's
145
+ quota, so the audience stays ``user``.
146
+
147
+ The class picks the DEFAULT reason; an explicit ``failure_category`` still wins, on this
148
+ branch as on every other. A plugin that declares a category has said something more
149
+ specific than the class did (a rate limit raised as ``PROVIDER_RATE_LIMITED``), and
150
+ dropping it here would discard the more precise of the two.
151
+ """
152
+ if not isinstance(error, UserError):
153
+ return None
154
+ if isinstance(error, RateLimitError):
155
+ error_type, default_reason, retryable = "dependency", "rate_limited", True
156
+ else:
157
+ error_type, default_reason, retryable = "configuration", "invalid_input", False
158
+ return PluginErrorMetadata(
159
+ error_type=error_type,
160
+ error_reason=_as_error_reason(failure_category_of(error)) or default_reason,
161
+ audience=ErrorAudience.USER,
162
+ retryable=retryable,
163
+ )
164
+
165
+
96
166
  def status_code_of(error: BaseException) -> int:
97
167
  """Return the error's status_code only when it is an int in the HTTP range, else 500."""
98
168
  status_code = _error_attr(error, "status_code")
@@ -169,9 +239,15 @@ def wrap_in_fastapi(
169
239
  func: Callable,
170
240
  plugin_id: str,
171
241
  precheck_func: Optional[Callable] = None,
242
+ invoke_with_sealed_dag_node_settings_v2: bool = False,
172
243
  ) -> FastAPI:
173
244
  try:
174
- return _wrap_in_fastapi(func=func, plugin_id=plugin_id, precheck_func=precheck_func)
245
+ return _wrap_in_fastapi(
246
+ func=func,
247
+ plugin_id=plugin_id,
248
+ precheck_func=precheck_func,
249
+ invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
250
+ )
175
251
  except Exception as e:
176
252
  logger.error(f"failed to wrap function in FastAPI: {e}", exc_info=True)
177
253
  raise EtlApiException(e) from e
@@ -181,6 +257,7 @@ def _wrap_in_fastapi(
181
257
  func: Callable,
182
258
  plugin_id: str,
183
259
  precheck_func: Optional[Callable] = None,
260
+ invoke_with_sealed_dag_node_settings_v2: bool = False,
184
261
  ) -> FastAPI:
185
262
  if precheck_func is not None:
186
263
  check_precheck_func(precheck_func=precheck_func)
@@ -191,6 +268,8 @@ def _wrap_in_fastapi(
191
268
  logger.warning("usage data not an expected parameter, omitting")
192
269
 
193
270
  fastapi_app = FastAPI()
271
+ # Installation contributes a public router dependency, so it must happen before /invoke.
272
+ install_invocation_envelope(fastapi_app)
194
273
 
195
274
  response_type = get_output_sig(func)
196
275
  filedata_meta_model = update_filedata_model(response_type)
@@ -202,6 +281,7 @@ def _wrap_in_fastapi(
202
281
  filedata_meta: Optional[filedata_meta_model] = None
203
282
  status_code_text: Optional[str] = None
204
283
  failure_category: Optional[str] = None
284
+ plugin_error: Optional[PluginErrorMetadata] = None
205
285
  output: Optional[response_type] = None
206
286
  message_channels: MessageChannels = Field(default_factory=MessageChannels)
207
287
 
@@ -226,10 +306,34 @@ def _wrap_in_fastapi(
226
306
  request_dict["filedata_meta"] = filedata_meta
227
307
  try:
228
308
  if inspect.isasyncgenfunction(func):
309
+ bound_settings = current_invocation_settings()
310
+ bound_context = current_invocation_context()
311
+
229
312
  # Stream response if function is an async generator
230
313
  async def _stream_response():
231
- try:
232
- async for output in func(**(request_dict or {})):
314
+ # FastAPI 0.117 closes yield dependencies before iterating a
315
+ # StreamingResponse. Re-enter the captured binding inside the generator so the
316
+ # plugin sees the right request regardless of dependency-cleanup timing.
317
+ with invocation_envelope(bound_settings, bound_context):
318
+ try:
319
+ async for output in func(**(request_dict or {})):
320
+ yield (
321
+ InvokeResponse(
322
+ usage=usage,
323
+ message_channels=message_channels,
324
+ filedata_meta=filedata_meta_model.model_validate(
325
+ filedata_meta.model_dump()
326
+ ),
327
+ status_code=status.HTTP_200_OK,
328
+ output=output,
329
+ file_data=request_dict.get("file_data", None),
330
+ ).model_dump_json()
331
+ + "\n"
332
+ )
333
+ except Exception as e:
334
+ logger.error(
335
+ f"Failure streaming response: {_safe_str(e)}", exc_info=True
336
+ )
233
337
  yield (
234
338
  InvokeResponse(
235
339
  usage=usage,
@@ -237,27 +341,13 @@ def _wrap_in_fastapi(
237
341
  filedata_meta=filedata_meta_model.model_validate(
238
342
  filedata_meta.model_dump()
239
343
  ),
240
- status_code=status.HTTP_200_OK,
241
- output=output,
242
- file_data=request_dict.get("file_data", None),
344
+ status_code=status_code_of(e),
345
+ status_code_text=f"[{type(e).__name__}] {_safe_str(e)}",
346
+ failure_category=failure_category_of(e),
347
+ plugin_error=plugin_error_of(e),
243
348
  ).model_dump_json()
244
349
  + "\n"
245
350
  )
246
- except Exception as e:
247
- logger.error(f"Failure streaming response: {_safe_str(e)}", exc_info=True)
248
- yield (
249
- InvokeResponse(
250
- usage=usage,
251
- message_channels=message_channels,
252
- filedata_meta=filedata_meta_model.model_validate(
253
- filedata_meta.model_dump()
254
- ),
255
- status_code=status_code_of(e),
256
- status_code_text=f"[{type(e).__name__}] {_safe_str(e)}",
257
- failure_category=failure_category_of(e),
258
- ).model_dump_json()
259
- + "\n"
260
- )
261
351
 
262
352
  return StreamingResponse(_stream_response(), media_type="application/x-ndjson")
263
353
  else:
@@ -299,6 +389,7 @@ def _wrap_in_fastapi(
299
389
  status_code=status_code_of(exc),
300
390
  status_code_text=_safe_str(exc),
301
391
  failure_category=failure_category_of(exc),
392
+ plugin_error=plugin_error_of(exc),
302
393
  file_data=request_dict.get("file_data", None),
303
394
  )
304
395
  except Exception as invoke_error:
@@ -376,6 +467,7 @@ def _wrap_in_fastapi(
376
467
  status_code: int
377
468
  status_code_text: Optional[str] = None
378
469
  failure_category: Optional[str] = None
470
+ plugin_error: Optional[PluginErrorMetadata] = None
379
471
 
380
472
  @fastapi_app.get("/schema")
381
473
  async def get_schema() -> SchemaOutputResponse:
@@ -391,6 +483,7 @@ def _wrap_in_fastapi(
391
483
  status_code=fn_response.status_code,
392
484
  status_code_text=fn_response.status_code_text,
393
485
  failure_category=fn_response.failure_category,
486
+ plugin_error=fn_response.plugin_error,
394
487
  usage=fn_response.usage,
395
488
  )
396
489
  else:
@@ -406,6 +499,13 @@ def _wrap_in_fastapi(
406
499
  except TypeError as e:
407
500
  raise TypeError(f"failed to validate function schema: {e}") from e
408
501
 
502
+ # Registered last so add_metadata_route replaces any /metadata the plugin registered itself.
503
+ add_metadata_route(
504
+ fastapi_app,
505
+ identifier=plugin_id,
506
+ invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
507
+ )
508
+
409
509
  FastAPIInstrumentor.instrument_app(
410
510
  fastapi_app, tracer_provider=get_trace_provider(), meter_provider=get_metric_provider()
411
511
  )
@@ -420,6 +520,7 @@ def generate_fast_api(
420
520
  id_method: Optional[str] = None,
421
521
  precheck_str: Optional[str] = None,
422
522
  precheck_method: Optional[str] = None,
523
+ invoke_with_sealed_dag_node_settings_v2: bool = False,
423
524
  ) -> FastAPI:
424
525
  instance = import_from_string(app)
425
526
  func = get_func(instance, method_name)
@@ -438,4 +539,9 @@ def generate_fast_api(
438
539
  elif precheck_method:
439
540
  precheck_func = get_func(instance, precheck_method)
440
541
 
441
- return wrap_in_fastapi(func=func, plugin_id=plugin_id, precheck_func=precheck_func)
542
+ return wrap_in_fastapi(
543
+ func=func,
544
+ plugin_id=plugin_id,
545
+ precheck_func=precheck_func,
546
+ invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
547
+ )
@@ -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,63 @@
1
+ """Identity and filesystem scope for the current invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass, fields
9
+ from pathlib import Path
10
+ from uuid import uuid4
11
+
12
+ from unstructured_platform_plugins.invocation_settings import current_invocation_context
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class InvocationIdentity:
17
+ job_id: str | None
18
+ tenant_id: str | None
19
+ org_id: str | None
20
+ workflow_id: str | None
21
+ attribution_id: str | None
22
+ dag_node_id: str | None
23
+ dag_node_type: str | None
24
+ dag_node_subtype: str | None
25
+
26
+
27
+ def current_invocation_identity() -> InvocationIdentity:
28
+ """Read identity from the bound context, or the environment when no context is bound."""
29
+ context = current_invocation_context()
30
+ values = {}
31
+ for field in fields(InvocationIdentity):
32
+ values[field.name] = (
33
+ getattr(context, field.name, None)
34
+ if context is not None
35
+ else os.getenv(field.name.upper())
36
+ )
37
+ return InvocationIdentity(**values)
38
+
39
+
40
+ def _segment(value: str | None) -> str:
41
+ if not value:
42
+ return "unknown"
43
+ safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", value)
44
+ if safe in {".", ".."} or safe != value or len(safe) > 96:
45
+ digest = hashlib.sha256(value.encode()).hexdigest()[:16]
46
+ safe = f"{safe[:79].strip('.') or 'unknown'}-{digest}"
47
+ return safe
48
+
49
+
50
+ def invocation_scratch_dir(root: str | os.PathLike[str], *, create: bool = True) -> Path:
51
+ """Return an invocation directory, or the unchanged root when no context is bound."""
52
+ path = Path(root)
53
+ context = current_invocation_context()
54
+ if context is not None:
55
+ identity = current_invocation_identity()
56
+ invocation_id = context.invocation_id
57
+ if not invocation_id and context.record_ids is None and context.invocation_ids is None:
58
+ invocation_id = context.record_id
59
+ path = path / _segment(identity.job_id) / _segment(identity.dag_node_id)
60
+ path = path / _segment(invocation_id or uuid4().hex)
61
+ if create:
62
+ path.mkdir(parents=True, exist_ok=True)
63
+ return path
@@ -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.46" # pragma: no cover