claude-smart 0.2.43 → 0.2.44

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +2 -2
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +21 -1
  8. package/plugin/pyproject.toml +2 -2
  9. package/plugin/scripts/backend-service.sh +50 -4
  10. package/plugin/scripts/cli.sh +2 -1
  11. package/plugin/scripts/ensure-plugin-root.sh +1 -0
  12. package/plugin/scripts/hook_entry.sh +5 -3
  13. package/plugin/scripts/smart-install.sh +2 -2
  14. package/plugin/src/README.md +57 -0
  15. package/plugin/uv.lock +126 -5
  16. package/plugin/vendor/reflexio/.env.example +9 -0
  17. package/plugin/vendor/reflexio/pyproject.toml +5 -2
  18. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +0 -1
  19. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +7 -4
  20. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +4 -4
  21. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +2 -2
  22. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +13 -4
  23. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +3 -1
  24. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +59 -6
  25. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  26. package/plugin/vendor/reflexio/reflexio/server/README.md +7 -1
  27. package/plugin/vendor/reflexio/reflexio/server/api.py +188 -34
  28. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -0
  29. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +33 -11
  30. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +278 -29
  31. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +457 -181
  32. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +28 -0
  33. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +22 -12
  34. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +137 -9
  35. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +1 -1
  36. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +36 -3
  37. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +13 -3
  38. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +17 -0
  39. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +1 -1
  40. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.1.prompt.md +69 -0
  41. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
  42. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +27 -23
  43. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
  44. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
  45. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +19 -9
  46. package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +1 -5
  48. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +44 -2
  49. package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
  50. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +8 -1
  51. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +91 -24
  52. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +3 -1
  53. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +6 -3
  54. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +1 -1
  55. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +18 -5
  56. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +2 -2
  57. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +88 -3
  58. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +36 -5
  59. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +6 -3
  60. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +6 -3
  61. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +32 -22
  62. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +89 -4
  63. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +46 -1
  64. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +12 -0
  65. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +1 -1
  66. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +8 -4
@@ -26,7 +26,11 @@ app = typer.Typer(
26
26
  )
27
27
 
28
28
  _PROVIDERS: dict[str, dict[str, str]] = {
29
- "openai": {"env_var": "OPENAI_API_KEY", "model": "gpt-5-mini", "display": "OpenAI"},
29
+ "openai": {
30
+ "env_var": "OPENAI_API_KEY",
31
+ "model": "gpt-5.4-mini",
32
+ "display": "OpenAI",
33
+ },
30
34
  "anthropic": {
31
35
  "env_var": "ANTHROPIC_API_KEY",
32
36
  "model": "claude-sonnet-4-6",
@@ -885,9 +889,8 @@ def openai_codex_setup(
885
889
  reads from this file directly — no dependency on OpenClaw or any other
886
890
  CLI. The proxy auto-refreshes the access token when it nears expiry.
887
891
 
888
- Run this once, then start the codex proxy with::
889
-
890
- ./reflexio_ext/scripts/start_with_codex_proxy.sh
892
+ Run this once; the codex proxy (``codex_proxy.py``) then picks up the
893
+ stored tokens automatically on start.
891
894
 
892
895
  Re-run this command if your subscription tier changes or the
893
896
  refresh_token gets revoked (rare).
@@ -107,8 +107,8 @@ def load_reflexio_env(
107
107
 
108
108
  Args:
109
109
  package_data_module: Module containing bundled .env.example
110
- (for importlib.resources). OS package uses "reflexio.data",
111
- enterprise uses "reflexio_ext.data".
110
+ (for importlib.resources). The OS package passes "reflexio.data";
111
+ a downstream build may pass its own data module.
112
112
  auto_generate_keys: Env var names to auto-generate as hex tokens
113
113
  (e.g., ["JWT_SECRET_KEY"]).
114
114
 
@@ -150,8 +150,8 @@ def ensure_user_env_for_setup(
150
150
 
151
151
  Args:
152
152
  package_data_module: Module containing the bundled ``.env.example``
153
- template (for ``importlib.resources``). OS uses ``reflexio.data``,
154
- enterprise uses ``reflexio_ext.data``.
153
+ template (for ``importlib.resources``). The OS package passes
154
+ ``reflexio.data``; a downstream build may pass its own data module.
155
155
  auto_generate_keys: Env var names to auto-fill with random hex
156
156
  tokens when creating from the template.
157
157
 
@@ -24,8 +24,8 @@ def describe_storage(
24
24
  to print anywhere.
25
25
 
26
26
  Enterprise-only storage types (Supabase, Postgres, local directory)
27
- are matched by class name so we don't take a hard import dependency
28
- on ``reflexio_ext`` from the open-source package.
27
+ are matched by class name so the open-source package takes no hard
28
+ import dependency on its enterprise extension.
29
29
 
30
30
  Args:
31
31
  storage_config: The storage configuration to describe.
@@ -233,7 +233,9 @@ class UserProfile(BaseModel):
233
233
  custom_features: dict | None = None
234
234
  source: str | None = None
235
235
  status: Status | None = None # indicates the status of the profile
236
- extractor_names: list[str] | None = None # Retained provenance data column (merged on dedup); new profiles write None.
236
+ extractor_names: list[str] | None = (
237
+ None # Retained provenance data column (merged on dedup); new profiles write None.
238
+ )
237
239
  expanded_terms: str | None = None
238
240
  embedding: EmbeddingVector = []
239
241
  source_span: str | None = None
@@ -554,6 +556,7 @@ class InteractionData(BaseModel):
554
556
 
555
557
  # publish user interaction request
556
558
  class PublishUserInteractionRequest(BaseModel):
559
+ request_id: NonEmptyStr | None = None
557
560
  user_id: NonEmptyStr
558
561
  interaction_data_list: list[InteractionData] = Field(min_length=1)
559
562
  source: str = ""
@@ -891,7 +894,9 @@ class RerunProfileGenerationRequest(BaseModel):
891
894
  start_time: datetime | None = None
892
895
  end_time: datetime | None = None
893
896
  source: str | None = None
894
- extractor_names: list[str] | None = None # Deprecated compatibility field; ignored for selection.
897
+ extractor_names: list[str] | None = (
898
+ None # Deprecated compatibility field; ignored for selection.
899
+ )
895
900
 
896
901
  @model_validator(mode="after")
897
902
  def check_time_range(self) -> Self:
@@ -936,7 +941,9 @@ class ManualPlaybookGenerationRequest(BaseModel):
936
941
 
937
942
  agent_version: str = DEFAULT_AGENT_VERSION
938
943
  source: str | None = None
939
- playbook_name: str | None = None # Deprecated compatibility field; ignored for selection.
944
+ playbook_name: str | None = (
945
+ None # Deprecated compatibility field; ignored for selection.
946
+ )
940
947
 
941
948
  @field_validator("agent_version")
942
949
  @classmethod
@@ -956,7 +963,9 @@ class RerunPlaybookGenerationRequest(BaseModel):
956
963
  agent_version: str = DEFAULT_AGENT_VERSION
957
964
  start_time: datetime | None = None
958
965
  end_time: datetime | None = None
959
- playbook_name: str | None = None # Deprecated compatibility field; ignored for selection.
966
+ playbook_name: str | None = (
967
+ None # Deprecated compatibility field; ignored for selection.
968
+ )
960
969
  source: str | None = None
961
970
 
962
971
  @field_validator("agent_version")
@@ -56,7 +56,9 @@ class SearchUserProfileRequest(BaseModel):
56
56
  top_k: int | None = Field(default=10, gt=0)
57
57
  source: str | None = None
58
58
  custom_feature: str | None = None
59
- extractor_name: str | None = None # Deprecated compatibility field; accepted but ignored.
59
+ extractor_name: str | None = (
60
+ None # Deprecated compatibility field; accepted but ignored.
61
+ )
60
62
  threshold: float | None = Field(default=0.4, ge=0.0, le=1.0)
61
63
  enable_reformulation: bool | None = False
62
64
  search_mode: SearchMode = SearchMode.HYBRID
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  import ipaddress
18
18
  import os
19
19
  import re
20
+ import socket
20
21
  from typing import Annotated, Any
21
22
  from urllib.parse import urlparse
22
23
 
@@ -125,6 +126,33 @@ def _is_strict_mode() -> bool:
125
126
  )
126
127
 
127
128
 
129
+ def _is_metadata_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
130
+ """Return whether an IP address is a known cloud metadata endpoint."""
131
+ return str(ip) in METADATA_IPS
132
+
133
+
134
+ def _is_forbidden_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
135
+ """Return whether an IP address is unsafe for server-side HTTP callbacks."""
136
+ return (
137
+ _is_metadata_ip(ip)
138
+ or ip.is_private
139
+ or ip.is_reserved
140
+ or ip.is_loopback
141
+ or ip.is_link_local
142
+ or ip.is_multicast
143
+ or ip.is_unspecified
144
+ )
145
+
146
+
147
+ def _resolved_host_ips(host: str, port: int | None) -> set[str]:
148
+ """Resolve a hostname into IP address strings for SSRF validation."""
149
+ try:
150
+ addr_info = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
151
+ except socket.gaierror as exc:
152
+ raise ValueError(f"URL hostname could not be resolved: {host}") from exc
153
+ return {str(entry[4][0]) for entry in addr_info}
154
+
155
+
128
156
  def _check_safe_url(v: Any) -> Any:
129
157
  """Validate URL is not targeting dangerous resources.
130
158
 
@@ -150,13 +178,11 @@ def _check_safe_url(v: Any) -> Any:
150
178
 
151
179
  try:
152
180
  ip = ipaddress.ip_address(host)
153
- if str(ip) in METADATA_IPS:
181
+ if _is_metadata_ip(ip):
154
182
  raise ValueError(f"URL must not target cloud metadata endpoint: {host}")
155
183
 
156
184
  # In strict mode, also block private/localhost
157
- if _is_strict_mode() and (
158
- ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local
159
- ):
185
+ if _is_strict_mode() and _is_forbidden_ip(ip):
160
186
  raise ValueError(
161
187
  f"URL targets private/reserved IP '{host}'. "
162
188
  f"If running locally, unset REFLEXIO_BLOCK_PRIVATE_URLS."
@@ -164,12 +190,39 @@ def _check_safe_url(v: Any) -> Any:
164
190
  except ValueError as e:
165
191
  if "must not target" in str(e) or "targets private" in str(e):
166
192
  raise
167
- # Not an IP (hostname) check localhost in strict mode
168
- if _is_strict_mode() and host in ("localhost", "0.0.0.0"): # noqa: S104
193
+ # Not an IP literal (hostname). Resolve and inspect each IP: cloud
194
+ # metadata endpoints are ALWAYS blocked (no legitimate use case),
195
+ # while private/reserved ranges are blocked only in strict mode.
196
+ strict = _is_strict_mode()
197
+ if strict and host in ("localhost", "0.0.0.0"): # noqa: S104
169
198
  raise ValueError(
170
199
  f"URL targets '{host}'. "
171
200
  f"If running locally, unset REFLEXIO_BLOCK_PRIVATE_URLS."
172
201
  ) from None
202
+ try:
203
+ resolved_ips = _resolved_host_ips(host, parsed.port)
204
+ except ValueError:
205
+ # A resolution failure is only fatal in strict mode; otherwise we
206
+ # cannot confirm a metadata target and must not break local dev.
207
+ if strict:
208
+ raise
209
+ resolved_ips = set()
210
+ for resolved_ip in resolved_ips:
211
+ try:
212
+ ip = ipaddress.ip_address(resolved_ip)
213
+ except ValueError:
214
+ continue
215
+ if _is_metadata_ip(ip):
216
+ raise ValueError(
217
+ f"URL hostname '{host}' resolves to cloud metadata "
218
+ f"endpoint '{resolved_ip}'."
219
+ ) from None
220
+ if strict and _is_forbidden_ip(ip):
221
+ raise ValueError(
222
+ f"URL hostname '{host}' resolves to private/reserved IP "
223
+ f"'{resolved_ip}'. If running locally, unset "
224
+ f"REFLEXIO_BLOCK_PRIVATE_URLS."
225
+ ) from None
173
226
 
174
227
  return v
175
228
 
@@ -18,7 +18,7 @@ EMBEDDING_DIMENSIONS = 512
18
18
 
19
19
  # Default sliding window parameters for extraction
20
20
  DEFAULT_WINDOW_SIZE = 10
21
- DEFAULT_STRIDE_SIZE = 5
21
+ DEFAULT_STRIDE_SIZE = 8
22
22
 
23
23
  # Deprecated aliases kept for older imports.
24
24
  DEFAULT_BATCH_SIZE = DEFAULT_WINDOW_SIZE
@@ -54,6 +54,8 @@ Description: FastAPI backend server that processes user interactions to generate
54
54
 
55
55
  **Directory**: `api_endpoints/`
56
56
 
57
+ **Detailed Documentation**: See [`api_endpoints/README.md`](api_endpoints/README.md) for the `RequestContext` contract and per-file handler map.
58
+
57
59
  | File | Purpose |
58
60
  |------|---------|
59
61
  | `request_context.py` | RequestContext (bundles org_id, storage, configurator, prompt_manager) |
@@ -77,7 +79,7 @@ Description: FastAPI backend server that processes user interactions to generate
77
79
  - **Operations/admin**: `GET /api/get_operation_status`, `POST /api/cancel_operation`, `POST /api/admin/cache/invalidate`, `DELETE /api/delete_interaction`, `DELETE /api/delete_request`, `DELETE /api/delete_session`, `DELETE /api/delete_requests_by_ids`, `DELETE /api/delete_all_interactions`, `POST /api/clear_user_data`
78
80
  - **Human clarification/stall state**: `GET /api/pending_tool_calls`, `GET /api/pending_tool_calls/{pending_tool_call_id}`, `POST /api/pending_tool_calls/{pending_tool_call_id}/resolve`, `PATCH /api/pending_tool_calls/{pending_tool_call_id}/answer`, `POST /api/pending_tool_calls/{pending_tool_call_id}/not_applicable`, `POST /api/pending_tool_calls/{pending_tool_call_id}/cancel`, `GET /api/stall_state`, `POST /api/stall_state/notified`
79
81
 
80
- **Authentication Pattern**: The open-source app uses `default_get_org_id` and `DEFAULT_ORG_ID` for local/no-auth starts. Enterprise wraps `create_app()` in `reflexio_ext/server/api.py` with authenticated org resolution, login/OAuth/account/share/waitlist routers, admin checks, Sentry tracing, and usage metrics.
82
+ **Authentication Pattern**: The open-source app uses `default_get_org_id` and `DEFAULT_ORG_ID` for local/no-auth starts. The enterprise extension wraps `create_app()` with authenticated org resolution, login/OAuth/account/share/waitlist routers, admin checks, Sentry tracing, and usage metrics.
81
83
 
82
84
  **Pattern**: Core route handlers call `Reflexio` through `get_reflexio(org_id)`; endpoint helper files should not instantiate `Reflexio` directly.
83
85
 
@@ -172,6 +174,8 @@ python -m reflexio.server.scripts.manage_invitation_codes list --show-used
172
174
 
173
175
  **Directory**: `services/`
174
176
 
177
+ **Detailed Documentation**: See [`services/README.md`](services/README.md) for the per-directory file index across generation, evaluation, async extraction, search, and persistence.
178
+
175
179
  **Service Boundary**: The service layer owns LLM orchestration, extraction, evaluation, optimization, search preparation, storage access, and long-running operation state. API endpoints should validate/authenticate requests, build `RequestContext`, and delegate into `Reflexio` or focused service helpers rather than embedding business logic.
176
180
 
177
181
  **Encapsulated Components**:
@@ -611,6 +615,8 @@ All services follow BaseGenerationService:
611
615
  ## See Also
612
616
 
613
617
  - [Code Map (root README)](../README.md) -- high-level overview of all Reflexio components
618
+ - [API Endpoints README](api_endpoints/README.md) -- RequestContext contract and handler/helper map
619
+ - [Services README](services/README.md) -- per-directory index of the business-logic layer
614
620
  - [Prompt Bank README](prompt/prompt_bank/README.md) -- versioned prompt template system
615
621
  - [Playbook Service README](services/playbook/README.md) -- playbook extraction, aggregation, and deduplication pipeline
616
622
  - [Site Variables README](site_var/README.md) -- global configuration and feature flags
@@ -1,8 +1,9 @@
1
1
  import asyncio
2
2
  import inspect
3
3
  import logging
4
- import threading
4
+ import os
5
5
  from collections.abc import Callable
6
+ from concurrent.futures import ThreadPoolExecutor
6
7
  from datetime import UTC, datetime
7
8
  from typing import Any
8
9
 
@@ -21,6 +22,7 @@ from slowapi.errors import RateLimitExceeded
21
22
  from slowapi.util import get_remote_address
22
23
  from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
23
24
  from starlette.responses import Response
25
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
24
26
 
25
27
  from reflexio.models.api_schema.braintrust_schema import (
26
28
  BraintrustStatusResponse,
@@ -194,6 +196,51 @@ SYNC_REQUEST_TIMEOUT_SECONDS = (
194
196
  )
195
197
  SUSPICIOUS_USER_AGENTS = ["bot", "crawler", "spider", "scraper", "curl", "wget"]
196
198
  ALLOWED_EMPTY_UA_PATHS = ["/health", "/"] # Paths that allow empty user agents
199
+ DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
200
+ REGENERATE_MAX_WORKERS = 2
201
+ _regen_executor = ThreadPoolExecutor(
202
+ max_workers=REGENERATE_MAX_WORKERS,
203
+ thread_name_prefix="reflexio-regen",
204
+ )
205
+
206
+
207
+ def _resolve_cors_origins() -> list[str]:
208
+ """Resolve browser origins allowed to make credentialed CORS requests."""
209
+ configured_origins = os.getenv("REFLEXIO_ALLOWED_ORIGINS", "").strip()
210
+ if configured_origins:
211
+ origins = [
212
+ origin.strip().rstrip("/")
213
+ for origin in configured_origins.split(",")
214
+ if origin.strip()
215
+ ]
216
+ return origins or ["http://localhost:8080"]
217
+
218
+ frontend_url = os.getenv("FRONTEND_URL", "").strip()
219
+ if frontend_url:
220
+ return [frontend_url.rstrip("/")]
221
+
222
+ return ["http://localhost:8080"]
223
+
224
+
225
+ def _max_body_bytes_from_env() -> int:
226
+ raw_value = os.getenv("REFLEXIO_MAX_BODY_BYTES", str(DEFAULT_MAX_BODY_BYTES))
227
+ try:
228
+ max_bytes = int(raw_value)
229
+ except ValueError:
230
+ logger.warning(
231
+ "Ignoring invalid REFLEXIO_MAX_BODY_BYTES=%r; using %s",
232
+ raw_value,
233
+ DEFAULT_MAX_BODY_BYTES,
234
+ )
235
+ return DEFAULT_MAX_BODY_BYTES
236
+ if max_bytes <= 0:
237
+ logger.warning(
238
+ "Ignoring non-positive REFLEXIO_MAX_BODY_BYTES=%r; using %s",
239
+ raw_value,
240
+ DEFAULT_MAX_BODY_BYTES,
241
+ )
242
+ return DEFAULT_MAX_BODY_BYTES
243
+ return max_bytes
197
244
 
198
245
 
199
246
  def get_rate_limit_key(request: Request) -> str:
@@ -312,6 +359,82 @@ class TimeoutMiddleware(BaseHTTPMiddleware):
312
359
  )
313
360
 
314
361
 
362
+ class _RequestBodyTooLargeError(Exception):
363
+ """Raised when the streamed request body exceeds the configured limit."""
364
+
365
+
366
+ class BodySizeLimitMiddleware:
367
+ """Reject requests whose declared or streamed body size exceeds the limit."""
368
+
369
+ def __init__(self, app: ASGIApp) -> None:
370
+ self.app = app
371
+
372
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
373
+ from starlette.responses import JSONResponse
374
+
375
+ if scope["type"] != "http":
376
+ await self.app(scope, receive, send)
377
+ return
378
+
379
+ max_body_bytes = _max_body_bytes_from_env()
380
+ content_length = None
381
+ for name, value in scope.get("headers", []):
382
+ if name.lower() == b"content-length":
383
+ content_length = value.decode("latin-1")
384
+ break
385
+
386
+ if content_length is not None:
387
+ try:
388
+ body_bytes = int(content_length)
389
+ except ValueError:
390
+ body_bytes = 0
391
+ if body_bytes > max_body_bytes:
392
+ await JSONResponse(
393
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
394
+ content={"detail": "Request body too large"},
395
+ )(scope, receive, send)
396
+ return
397
+
398
+ consumed_bytes = 0
399
+
400
+ async def limited_receive() -> Message:
401
+ nonlocal consumed_bytes
402
+ message = await receive()
403
+ if message["type"] == "http.request":
404
+ consumed_bytes += len(message.get("body", b""))
405
+ if consumed_bytes > max_body_bytes:
406
+ raise _RequestBodyTooLargeError
407
+ return message
408
+
409
+ try:
410
+ await self.app(scope, limited_receive, send)
411
+ except _RequestBodyTooLargeError:
412
+ await JSONResponse(
413
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
414
+ content={"detail": "Request body too large"},
415
+ )(scope, receive, send)
416
+
417
+
418
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
419
+ """Attach conservative browser security headers to every response."""
420
+
421
+ async def dispatch(
422
+ self, request: Request, call_next: RequestResponseEndpoint
423
+ ) -> Response:
424
+ response = await call_next(request)
425
+ response.headers["X-Content-Type-Options"] = "nosniff"
426
+ response.headers["X-Frame-Options"] = "DENY"
427
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
428
+ if (
429
+ request.url.scheme == "https"
430
+ or request.headers.get("x-forwarded-proto", "").lower() == "https"
431
+ ):
432
+ response.headers["Strict-Transport-Security"] = (
433
+ "max-age=31536000; includeSubDomains"
434
+ )
435
+ return response
436
+
437
+
315
438
  class CorrelationIdMiddleware(BaseHTTPMiddleware):
316
439
  """Middleware that assigns a unique correlation ID to each request.
317
440
 
@@ -931,7 +1054,9 @@ def delete_user_playbooks_by_ids(
931
1054
  response_model=BulkDeleteResponse,
932
1055
  response_model_exclude_none=True,
933
1056
  )
1057
+ @limiter.limit("10/minute")
934
1058
  def delete_all_interactions(
1059
+ request: Request,
935
1060
  org_id: str = Depends(default_get_org_id),
936
1061
  ) -> BulkDeleteResponse:
937
1062
  """Delete all requests and their associated interactions.
@@ -950,7 +1075,9 @@ def delete_all_interactions(
950
1075
  response_model=BulkDeleteResponse,
951
1076
  response_model_exclude_none=True,
952
1077
  )
1078
+ @limiter.limit("10/minute")
953
1079
  def delete_all_profiles(
1080
+ request: Request,
954
1081
  org_id: str = Depends(default_get_org_id),
955
1082
  ) -> BulkDeleteResponse:
956
1083
  """Delete all profiles.
@@ -969,7 +1096,9 @@ def delete_all_profiles(
969
1096
  response_model=BulkDeleteResponse,
970
1097
  response_model_exclude_none=True,
971
1098
  )
1099
+ @limiter.limit("10/minute")
972
1100
  def delete_all_playbooks(
1101
+ request: Request,
973
1102
  org_id: str = Depends(default_get_org_id),
974
1103
  ) -> BulkDeleteResponse:
975
1104
  """Delete all playbooks (both user and agent).
@@ -988,7 +1117,9 @@ def delete_all_playbooks(
988
1117
  response_model=BulkDeleteResponse,
989
1118
  response_model_exclude_none=True,
990
1119
  )
1120
+ @limiter.limit("10/minute")
991
1121
  def delete_all_user_playbooks(
1122
+ request: Request,
992
1123
  org_id: str = Depends(default_get_org_id),
993
1124
  ) -> BulkDeleteResponse:
994
1125
  """Delete all user playbooks (user only, not agent).
@@ -1007,7 +1138,9 @@ def delete_all_user_playbooks(
1007
1138
  response_model=BulkDeleteResponse,
1008
1139
  response_model_exclude_none=True,
1009
1140
  )
1141
+ @limiter.limit("10/minute")
1010
1142
  def delete_all_agent_playbooks(
1143
+ request: Request,
1011
1144
  org_id: str = Depends(default_get_org_id),
1012
1145
  ) -> BulkDeleteResponse:
1013
1146
  """Delete all agent playbooks (agent only, not user).
@@ -1026,8 +1159,10 @@ def delete_all_agent_playbooks(
1026
1159
  response_model=ClearUserDataResponse,
1027
1160
  response_model_exclude_none=True,
1028
1161
  )
1162
+ @limiter.limit("10/minute")
1029
1163
  def clear_user_data(
1030
- request: ClearUserDataRequest,
1164
+ request: Request,
1165
+ payload: ClearUserDataRequest,
1031
1166
  org_id: str = Depends(default_get_org_id),
1032
1167
  ) -> ClearUserDataResponse:
1033
1168
  """Delete all rows scoped to a single ``user_id``.
@@ -1046,7 +1181,7 @@ def clear_user_data(
1046
1181
  Returns:
1047
1182
  ClearUserDataResponse: Response with per-entity deletion counts
1048
1183
  """
1049
- return publisher_api.clear_user_data(org_id=org_id, request=request)
1184
+ return publisher_api.clear_user_data(org_id=org_id, request=payload)
1050
1185
 
1051
1186
 
1052
1187
  @core_router.post(
@@ -1071,7 +1206,9 @@ def get_interactions(
1071
1206
  response_model=GetInteractionsViewResponse,
1072
1207
  response_model_exclude_none=True,
1073
1208
  )
1209
+ @limiter.limit("30/minute")
1074
1210
  def get_all_interactions(
1211
+ request: Request,
1075
1212
  limit: int = 100,
1076
1213
  org_id: str = Depends(default_get_org_id),
1077
1214
  ) -> GetInteractionsViewResponse:
@@ -1154,7 +1291,9 @@ def get_profiles(
1154
1291
  response_model=GetProfilesViewResponse,
1155
1292
  response_model_exclude_none=True,
1156
1293
  )
1294
+ @limiter.limit("30/minute")
1157
1295
  def get_all_profiles(
1296
+ request: Request,
1158
1297
  limit: int = 100,
1159
1298
  status_filter: str | None = None,
1160
1299
  org_id: str = Depends(default_get_org_id),
@@ -1230,7 +1369,9 @@ def run_playbook_aggregation(
1230
1369
 
1231
1370
 
1232
1371
  @core_router.post("/api/set_config")
1372
+ @limiter.limit("10/minute")
1233
1373
  def set_config(
1374
+ request: Request,
1234
1375
  config: dict[str, Any],
1235
1376
  org_id: str = Depends(default_get_org_id),
1236
1377
  ) -> SetConfigResponse:
@@ -1257,7 +1398,9 @@ def set_config(
1257
1398
 
1258
1399
 
1259
1400
  @core_router.post("/api/update_config")
1401
+ @limiter.limit("10/minute")
1260
1402
  def update_config(
1403
+ request: Request,
1261
1404
  partial: dict[str, Any],
1262
1405
  org_id: str = Depends(default_get_org_id),
1263
1406
  ) -> SetConfigResponse:
@@ -1566,8 +1709,10 @@ def update_user_profile_endpoint(
1566
1709
  response_model=GetDashboardStatsResponse,
1567
1710
  response_model_exclude_none=True,
1568
1711
  )
1712
+ @limiter.limit("30/minute")
1569
1713
  def get_dashboard_stats(
1570
- request: GetDashboardStatsRequest,
1714
+ request: Request,
1715
+ payload: GetDashboardStatsRequest,
1571
1716
  org_id: str = Depends(default_get_org_id),
1572
1717
  ) -> GetDashboardStatsResponse:
1573
1718
  """Get comprehensive dashboard statistics including counts and time-series data.
@@ -1583,7 +1728,7 @@ def get_dashboard_stats(
1583
1728
  reflexio = get_reflexio(org_id=org_id)
1584
1729
 
1585
1730
  # Get dashboard stats using Reflexio's method
1586
- return reflexio.get_dashboard_stats(request)
1731
+ return reflexio.get_dashboard_stats(payload)
1587
1732
 
1588
1733
 
1589
1734
  @core_router.post(
@@ -1755,7 +1900,9 @@ def get_evaluation_overview(
1755
1900
  response_model=RegenerateStartResponse,
1756
1901
  response_model_exclude_none=True,
1757
1902
  )
1903
+ @limiter.limit("5/minute")
1758
1904
  def start_regenerate(
1905
+ request: Request,
1759
1906
  payload: RegenerateRequest,
1760
1907
  org_id: str = Depends(default_get_org_id),
1761
1908
  ) -> RegenerateStartResponse:
@@ -1789,15 +1936,12 @@ def start_regenerate(
1789
1936
  )
1790
1937
  except RuntimeError as e:
1791
1938
  raise HTTPException(status_code=409, detail=str(e)) from e
1792
- threading.Thread(
1793
- target=run_regen,
1794
- kwargs={
1795
- "job": job,
1796
- "request_context": reflexio.request_context,
1797
- "llm_client": reflexio.llm_client,
1798
- },
1799
- daemon=True,
1800
- ).start()
1939
+ _regen_executor.submit(
1940
+ run_regen,
1941
+ job=job,
1942
+ request_context=reflexio.request_context,
1943
+ llm_client=reflexio.llm_client,
1944
+ )
1801
1945
  return RegenerateStartResponse(job_id=job.job_id, total=job.total)
1802
1946
 
1803
1947
 
@@ -2612,24 +2756,16 @@ def create_app(
2612
2756
  @asynccontextmanager
2613
2757
  async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001
2614
2758
  validate_llm_availability()
2615
- bootstrap_org_id = _lifespan_org_id()
2616
- bootstrap_context = RequestContext(org_id=bootstrap_org_id)
2617
- retrieval_floor = bootstrap_context.configurator.get_config().retrieval_floor
2618
- if retrieval_floor.enabled:
2619
- from reflexio.server.llm.rerank import prewarm as _prewarm_cross_encoder
2620
-
2621
- _prewarm_cross_encoder()
2622
- else:
2623
- logger.info(
2624
- "Cross-encoder pre-warm skipped because retrieval_floor.enabled=false"
2625
- )
2759
+ from reflexio.server.llm.rerank import prewarm as _prewarm_cross_encoder
2760
+
2761
+ _prewarm_cross_encoder()
2626
2762
  # The scheduler discovers every org with resumable work each tick and
2627
2763
  # drives a per-org worker with org-scoped claims, so it is not limited
2628
2764
  # to the bootstrap org. The bootstrap org is only used to read config
2629
2765
  # and to seed cross-org discovery.
2630
2766
  scheduler = maybe_start_resume_scheduler(
2631
2767
  lambda org_id: RequestContext(org_id=org_id),
2632
- bootstrap_org_id=bootstrap_org_id,
2768
+ bootstrap_org_id=_lifespan_org_id(),
2633
2769
  )
2634
2770
  try:
2635
2771
  yield
@@ -2661,14 +2797,32 @@ def create_app(
2661
2797
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[reportArgumentType]
2662
2798
 
2663
2799
  # CORS
2664
- origins = ["*"]
2665
- app.add_middleware(
2666
- CORSMiddleware,
2667
- allow_origins=origins,
2668
- allow_credentials=True,
2669
- allow_methods=["*"],
2670
- allow_headers=["*"],
2671
- )
2800
+ # The locked-down, credentialed allowlist is an enterprise concern: only
2801
+ # hosts that wire in auth (``require_auth=True``) restrict browser origins.
2802
+ # OSS/local runs have no auth and bundle their own docs playground on a
2803
+ # separate port, so they allow any origin (no credentials needed).
2804
+ if require_auth:
2805
+ app.add_middleware(
2806
+ CORSMiddleware,
2807
+ allow_origins=_resolve_cors_origins(),
2808
+ allow_credentials=True,
2809
+ allow_methods=["*"],
2810
+ allow_headers=["*"],
2811
+ )
2812
+ else:
2813
+ app.add_middleware(
2814
+ CORSMiddleware,
2815
+ allow_origins=["*"],
2816
+ allow_credentials=False,
2817
+ allow_methods=["*"],
2818
+ allow_headers=["*"],
2819
+ )
2820
+
2821
+ # Reject oversized requests before they reach endpoint handlers.
2822
+ app.add_middleware(BodySizeLimitMiddleware)
2823
+
2824
+ # Security headers
2825
+ app.add_middleware(SecurityHeadersMiddleware)
2672
2826
 
2673
2827
  # Timeout middleware
2674
2828
  app.add_middleware(TimeoutMiddleware)