claude-smart 0.2.47 → 0.2.48

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 (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  7. package/plugin/dashboard/package-lock.json +61 -390
  8. package/plugin/dashboard/package.json +2 -2
  9. package/plugin/pyproject.toml +1 -1
  10. package/plugin/uv.lock +1 -1
  11. package/plugin/vendor/reflexio/.env.example +7 -0
  12. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  13. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  14. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  15. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  16. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  17. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  18. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  19. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  20. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  21. package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
  22. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  23. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  24. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  25. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  26. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  27. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  28. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  29. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  30. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  31. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  32. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  33. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  34. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  35. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  36. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  37. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  38. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  39. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  40. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  41. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  42. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  43. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  44. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  45. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  46. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  47. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  48. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  49. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  50. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  51. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  52. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  53. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  54. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  55. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  56. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  57. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  58. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  59. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  60. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  61. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  62. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  63. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  64. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  65. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  66. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  68. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
  70. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  71. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
  72. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  73. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
  74. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  75. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  76. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  77. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  88. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  89. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -62,14 +62,22 @@ _ENV_TIMEOUT = "CLAUDE_SMART_CLI_TIMEOUT"
62
62
  _ENV_MODEL = "CLAUDE_SMART_CLI_MODEL"
63
63
  _HOST_CODEX = "codex"
64
64
  _HOST_CLAUDE_CODE = "claude-code"
65
+
66
+
67
+ def _is_windows() -> bool:
68
+ return os.name == "nt"
69
+
70
+
65
71
  _CODEX_COMPAT_SCRIPT_NAMES = (
66
72
  ("codex-claude-compat.cmd", "codex-claude-compat")
67
- if os.name == "nt"
73
+ if _is_windows()
68
74
  else ("codex-claude-compat", "codex-claude-compat.cmd")
69
75
  )
70
76
  _CODEX_COMPAT_SCRIPT_NAME_SET = set(_CODEX_COMPAT_SCRIPT_NAMES)
71
77
  _DEFAULT_TIMEOUT_SECONDS = 120
72
78
  _DEFAULT_CLI_MODEL = "claude-sonnet-5"
79
+ _WINDOWS_ARGV_SYSTEM_PROMPT_LIMIT = 3_000
80
+ _WINDOWS_CLI_SUFFIXES = (".cmd", ".exe", ".bat")
73
81
 
74
82
  _TRUTHY_ENV_VALUES = {"1", "true", "yes"}
75
83
  _UNSUPPORTED_PARAMS_WARNED: set[str] = set()
@@ -116,6 +124,40 @@ def _candidate_codex_compat_path() -> Path | None:
116
124
  return None
117
125
 
118
126
 
127
+ def _windows_cli_suffixes() -> tuple[str, ...]:
128
+ suffixes: list[str] = []
129
+ for suffix in os.environ.get("PATHEXT", "").split(";"):
130
+ suffix = suffix.strip().lower()
131
+ if not suffix:
132
+ continue
133
+ suffix = suffix if suffix.startswith(".") else f".{suffix}"
134
+ # PowerShell scripts require a PowerShell host; do not return them as
135
+ # directly executable CLI shims for subprocess.run([...]).
136
+ if suffix == ".ps1":
137
+ continue
138
+ suffixes.append(suffix)
139
+ for suffix in _WINDOWS_CLI_SUFFIXES:
140
+ if suffix not in suffixes:
141
+ suffixes.append(suffix)
142
+ return tuple(suffixes)
143
+
144
+
145
+ def _resolve_cli_override_path(cli_path: str) -> str:
146
+ if not _is_windows():
147
+ return cli_path
148
+ path = Path(cli_path)
149
+ if path.suffix:
150
+ return cli_path
151
+
152
+ # Windows package managers expose extensioned shims while users often
153
+ # configure the extensionless binary name.
154
+ for suffix in _windows_cli_suffixes():
155
+ candidate = path.with_suffix(suffix)
156
+ if candidate.exists():
157
+ return str(candidate)
158
+ return cli_path
159
+
160
+
119
161
  def _resolve_cli_path() -> str | None:
120
162
  """Return the path to the active host CLI, or None if unavailable.
121
163
 
@@ -129,7 +171,7 @@ def _resolve_cli_path() -> str | None:
129
171
  """
130
172
  override = os.environ.get(_ENV_CLI_PATH)
131
173
  if override:
132
- candidate = Path(override)
174
+ candidate = Path(_resolve_cli_override_path(override))
133
175
  if candidate.is_file() and os.access(candidate, os.X_OK):
134
176
  return str(candidate)
135
177
  _LOGGER.warning(
@@ -432,6 +474,9 @@ def _run_claude_stream(
432
474
  ) -> ParseResult:
433
475
  """Invoke ``claude -p --output-format stream-json`` and return a ParseResult."""
434
476
  model = os.environ.get(_ENV_MODEL) or _DEFAULT_CLI_MODEL
477
+ inline_system_prompt = (
478
+ _is_windows() and len(system_prompt) > _WINDOWS_ARGV_SYSTEM_PROMPT_LIMIT
479
+ )
435
480
  cmd = [
436
481
  cli_path,
437
482
  "-p",
@@ -442,8 +487,11 @@ def _run_claude_stream(
442
487
  "--model",
443
488
  model,
444
489
  ]
445
- if system_prompt:
490
+ if system_prompt and not inline_system_prompt:
446
491
  cmd.extend(["--append-system-prompt", system_prompt])
492
+ stdin_text = dialogue
493
+ if inline_system_prompt:
494
+ stdin_text = f"{system_prompt}\n\n{dialogue}" if dialogue else system_prompt
447
495
 
448
496
  # Tag the child process so any hooks it fires (e.g. claude-smart's
449
497
  # Stop hook) can detect that this is a reflexio-internal invocation
@@ -460,9 +508,11 @@ def _run_claude_stream(
460
508
  try:
461
509
  proc = subprocess.run( # noqa: S603 — cmd is constructed from validated parts.
462
510
  cmd,
463
- input=dialogue,
511
+ input=stdin_text,
464
512
  capture_output=True,
465
513
  text=True,
514
+ encoding="utf-8",
515
+ errors="replace" if _is_windows() else "strict",
466
516
  timeout=timeout_seconds,
467
517
  check=False,
468
518
  env=env,
@@ -511,6 +561,8 @@ def _run_codex_stream(
511
561
  input=_codex_prompt(prompt=dialogue, system_prompt=system_prompt),
512
562
  capture_output=True,
513
563
  text=True,
564
+ encoding="utf-8",
565
+ errors="replace" if _is_windows() else "strict",
514
566
  timeout=timeout_seconds,
515
567
  check=False,
516
568
  env=env,
@@ -0,0 +1,244 @@
1
+ """HTTP middleware for the OSS FastAPI app (Tier3 A2 extraction from api.py).
2
+
3
+ Holds the five middleware classes ``create_app`` wires in (in that order) plus
4
+ the request-limit / CORS-origin helpers and their tunable constants. No
5
+ middleware reads mutable module-global app state — every read is of an
6
+ import-time constant or an env var — so this extraction is behavior-preserving.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+
13
+ from anyio.to_thread import current_default_thread_limiter
14
+ from fastapi import Request, status
15
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
16
+ from starlette.responses import Response
17
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
18
+
19
+ from reflexio.server.correlation import correlation_id_var, generate_correlation_id
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Bot protection configuration
24
+ REQUEST_TIMEOUT_SECONDS = 60
25
+ SYNC_REQUEST_TIMEOUT_SECONDS = (
26
+ 600 # Longer timeout for synchronous processing (wait_for_response=true)
27
+ )
28
+ SUSPICIOUS_USER_AGENTS = ["bot", "crawler", "spider", "scraper", "curl", "wget"]
29
+ ALLOWED_EMPTY_UA_PATHS = ["/health", "/"] # Paths that allow empty user agents
30
+ DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
31
+
32
+
33
+ def _resolve_cors_origins() -> list[str]:
34
+ """Resolve browser origins allowed to make credentialed CORS requests."""
35
+ configured_origins = os.getenv("REFLEXIO_ALLOWED_ORIGINS", "").strip()
36
+ if configured_origins:
37
+ origins = [
38
+ origin.strip().rstrip("/")
39
+ for origin in configured_origins.split(",")
40
+ if origin.strip()
41
+ ]
42
+ return origins or ["http://localhost:8080"]
43
+
44
+ frontend_url = os.getenv("FRONTEND_URL", "").strip()
45
+ if frontend_url:
46
+ return [frontend_url.rstrip("/")]
47
+
48
+ return ["http://localhost:8080"]
49
+
50
+
51
+ def _max_body_bytes_from_env() -> int:
52
+ raw_value = os.getenv("REFLEXIO_MAX_BODY_BYTES", str(DEFAULT_MAX_BODY_BYTES))
53
+ try:
54
+ max_bytes = int(raw_value)
55
+ except ValueError:
56
+ logger.warning(
57
+ "Ignoring invalid REFLEXIO_MAX_BODY_BYTES=%r; using %s",
58
+ raw_value,
59
+ DEFAULT_MAX_BODY_BYTES,
60
+ )
61
+ return DEFAULT_MAX_BODY_BYTES
62
+ if max_bytes <= 0:
63
+ logger.warning(
64
+ "Ignoring non-positive REFLEXIO_MAX_BODY_BYTES=%r; using %s",
65
+ raw_value,
66
+ DEFAULT_MAX_BODY_BYTES,
67
+ )
68
+ return DEFAULT_MAX_BODY_BYTES
69
+ return max_bytes
70
+
71
+
72
+ class BotProtectionMiddleware(BaseHTTPMiddleware):
73
+ """Middleware to detect and block suspicious bot-like requests."""
74
+
75
+ async def dispatch(
76
+ self, request: Request, call_next: RequestResponseEndpoint
77
+ ) -> Response:
78
+ """Process request and block suspicious patterns.
79
+
80
+ Args:
81
+ request (Request): The incoming request
82
+ call_next (RequestResponseEndpoint): Next middleware/handler in chain
83
+
84
+ Returns:
85
+ Response: The response from the next handler or a 403 JSON response
86
+ """
87
+ from starlette.responses import JSONResponse
88
+
89
+ user_agent = request.headers.get("user-agent", "").lower()
90
+ path = request.url.path
91
+
92
+ # Allow health check and root without user agent
93
+ if path not in ALLOWED_EMPTY_UA_PATHS:
94
+ # Block requests with no user agent
95
+ if not user_agent:
96
+ return JSONResponse(
97
+ status_code=status.HTTP_403_FORBIDDEN,
98
+ content={"detail": "Forbidden: Missing user agent"},
99
+ )
100
+
101
+ # Block requests with suspicious user agents
102
+ for suspicious in SUSPICIOUS_USER_AGENTS:
103
+ if suspicious in user_agent:
104
+ return JSONResponse(
105
+ status_code=status.HTTP_403_FORBIDDEN,
106
+ content={"detail": "Forbidden: Suspicious user agent"},
107
+ )
108
+
109
+ return await call_next(request)
110
+
111
+
112
+ class TimeoutMiddleware(BaseHTTPMiddleware):
113
+ """Middleware to enforce request timeout."""
114
+
115
+ async def dispatch(
116
+ self, request: Request, call_next: RequestResponseEndpoint
117
+ ) -> Response:
118
+ """Process request with timeout enforcement.
119
+
120
+ Args:
121
+ request (Request): The incoming request
122
+ call_next (RequestResponseEndpoint): Next middleware/handler in chain
123
+
124
+ Returns:
125
+ Response: The response from the next handler or a 504 JSON response
126
+ """
127
+ from starlette.responses import JSONResponse
128
+
129
+ # Use longer timeout for synchronous processing requests
130
+ timeout = REQUEST_TIMEOUT_SECONDS
131
+ if request.query_params.get("wait_for_response", "").lower() == "true":
132
+ timeout = SYNC_REQUEST_TIMEOUT_SECONDS
133
+
134
+ try:
135
+ return await asyncio.wait_for(call_next(request), timeout=timeout)
136
+ except TimeoutError:
137
+ return JSONResponse(
138
+ status_code=status.HTTP_504_GATEWAY_TIMEOUT,
139
+ content={"detail": "Request timeout"},
140
+ )
141
+
142
+
143
+ class _RequestBodyTooLargeError(Exception):
144
+ """Raised when the streamed request body exceeds the configured limit."""
145
+
146
+
147
+ class BodySizeLimitMiddleware:
148
+ """Reject requests whose declared or streamed body size exceeds the limit."""
149
+
150
+ def __init__(self, app: ASGIApp) -> None:
151
+ self.app = app
152
+
153
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
154
+ from starlette.responses import JSONResponse
155
+
156
+ if scope["type"] != "http":
157
+ await self.app(scope, receive, send)
158
+ return
159
+
160
+ max_body_bytes = _max_body_bytes_from_env()
161
+ content_length = None
162
+ for name, value in scope.get("headers", []):
163
+ if name.lower() == b"content-length":
164
+ content_length = value.decode("latin-1")
165
+ break
166
+
167
+ if content_length is not None:
168
+ try:
169
+ body_bytes = int(content_length)
170
+ except ValueError:
171
+ body_bytes = 0
172
+ if body_bytes > max_body_bytes:
173
+ await JSONResponse(
174
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
175
+ content={"detail": "Request body too large"},
176
+ )(scope, receive, send)
177
+ return
178
+
179
+ consumed_bytes = 0
180
+
181
+ async def limited_receive() -> Message:
182
+ nonlocal consumed_bytes
183
+ message = await receive()
184
+ if message["type"] == "http.request":
185
+ consumed_bytes += len(message.get("body", b""))
186
+ if consumed_bytes > max_body_bytes:
187
+ raise _RequestBodyTooLargeError
188
+ return message
189
+
190
+ try:
191
+ await self.app(scope, limited_receive, send)
192
+ except _RequestBodyTooLargeError:
193
+ await JSONResponse(
194
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
195
+ content={"detail": "Request body too large"},
196
+ )(scope, receive, send)
197
+
198
+
199
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
200
+ """Attach conservative browser security headers to every response."""
201
+
202
+ async def dispatch(
203
+ self, request: Request, call_next: RequestResponseEndpoint
204
+ ) -> Response:
205
+ response = await call_next(request)
206
+ response.headers["X-Content-Type-Options"] = "nosniff"
207
+ response.headers["X-Frame-Options"] = "DENY"
208
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
209
+ if (
210
+ request.url.scheme == "https"
211
+ or request.headers.get("x-forwarded-proto", "").lower() == "https"
212
+ ):
213
+ response.headers["Strict-Transport-Security"] = (
214
+ "max-age=31536000; includeSubDomains"
215
+ )
216
+ return response
217
+
218
+
219
+ class CorrelationIdMiddleware(BaseHTTPMiddleware):
220
+ """Middleware that assigns a unique correlation ID to each request.
221
+
222
+ The ID is stored in a ContextVar so it propagates to log records
223
+ (via CorrelationIdFilter) and to ThreadPoolExecutor workers when
224
+ ``contextvars.copy_context()`` is used.
225
+ """
226
+
227
+ async def dispatch(
228
+ self, request: Request, call_next: RequestResponseEndpoint
229
+ ) -> Response:
230
+ cid = generate_correlation_id()
231
+ correlation_id_var.set(cid)
232
+ try:
233
+ stats = current_default_thread_limiter().statistics()
234
+ request.state.tp_borrowed = stats.borrowed_tokens
235
+ request.state.tp_total = stats.total_tokens
236
+ request.state.tp_waiting = stats.tasks_waiting
237
+ except Exception as exc: # noqa: BLE001
238
+ logger.debug("Failed to snapshot threadpool limiter stats: %s", exc)
239
+ request.state.tp_borrowed = None
240
+ request.state.tp_total = None
241
+ request.state.tp_waiting = None
242
+ response = await call_next(request)
243
+ response.headers["X-Correlation-ID"] = cid
244
+ return response
@@ -62,6 +62,11 @@ def _limit_for(operation: OperationName) -> int:
62
62
  return _DEFAULT_LIMITS[operation]
63
63
 
64
64
 
65
+ def operation_limit_value(operation: OperationName) -> int:
66
+ """Return the configured concurrency limit for an operation."""
67
+ return _limit_for(operation)
68
+
69
+
65
70
  def _timeout_for(operation: OperationName) -> float:
66
71
  raw = os.getenv(_env_key(operation, "TIMEOUT_SECONDS"), "").strip()
67
72
  if raw:
@@ -403,6 +408,7 @@ def operation_limit(
403
408
  *,
404
409
  timeout_seconds: float | None = None,
405
410
  wait_forever: bool = False,
411
+ log_timeout: bool = True,
406
412
  ) -> Any:
407
413
  """Acquire the per-org limiter for an operation, recording wait metrics."""
408
414
  state = _state_for(org_id, operation)
@@ -448,7 +454,7 @@ def operation_limit(
448
454
  limit=state.limit,
449
455
  active=active,
450
456
  )
451
- if operation == "publish":
457
+ if operation == "publish" and log_timeout:
452
458
  logger.warning(
453
459
  "publish_limiter_timeout org_id=%s limit=%s active=%s "
454
460
  "waiting=%s wait_ms=%s timeout_seconds=%s hardware=%s",
@@ -504,12 +510,14 @@ def run_with_operation_limit[T](
504
510
  fn: Callable[[], T],
505
511
  timeout_seconds: float | None = None,
506
512
  wait_forever: bool = False,
513
+ log_timeout: bool = True,
507
514
  ) -> T:
508
515
  with operation_limit(
509
516
  org_id,
510
517
  operation,
511
518
  timeout_seconds=timeout_seconds,
512
519
  wait_forever=wait_forever,
520
+ log_timeout=log_timeout,
513
521
  ):
514
522
  return fn()
515
523
 
@@ -0,0 +1,79 @@
1
+ """Shared slowapi rate limiter for the OSS FastAPI app (Tier3 A2).
2
+
3
+ The ``limiter`` singleton and ``configure_rate_limiter`` live here so the domain
4
+ route modules can decorate handlers with ``@limiter.limit(...)`` without importing
5
+ from ``reflexio.server.api`` (which would be circular — ``api`` aggregates the
6
+ route modules). ``reflexio.server.api`` re-exports ``limiter`` and
7
+ ``configure_rate_limiter`` so the existing enterprise imports keep resolving.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from fastapi import Request
14
+ from slowapi import Limiter
15
+ from slowapi.util import get_remote_address
16
+
17
+ from reflexio.server.tracing import profile_step
18
+
19
+
20
+ def get_rate_limit_key(request: Request) -> str:
21
+ """Get rate limit key based on IP address.
22
+
23
+ Args:
24
+ request (Request): The incoming request
25
+
26
+ Returns:
27
+ str: Rate limit key (IP address)
28
+ """
29
+ return get_remote_address(request)
30
+
31
+
32
+ def _storage_backend_name(limiter_obj: Limiter) -> str:
33
+ storage = getattr(limiter_obj, "_storage", None)
34
+ if storage is None:
35
+ return "unknown"
36
+ return storage.__class__.__name__
37
+
38
+
39
+ def _trace_external_rate_limit_backend(limiter_obj: Limiter) -> None:
40
+ """Trace rate-limit storage hits when the backend is an external service."""
41
+ backend = _storage_backend_name(limiter_obj)
42
+ if backend == "MemoryStorage":
43
+ return
44
+
45
+ strategy = getattr(limiter_obj, "limiter", None)
46
+ if strategy is None or getattr(strategy, "_reflexio_traced", False):
47
+ return
48
+
49
+ original_hit = strategy.hit
50
+
51
+ def traced_hit(item: Any, *identifiers: str, cost: int = 1) -> bool:
52
+ with profile_step(
53
+ "rate_limit.backend_hit",
54
+ storage_backend=backend,
55
+ strategy=strategy.__class__.__name__,
56
+ cost=cost,
57
+ ):
58
+ return original_hit(item, *identifiers, cost=cost)
59
+
60
+ strategy.hit = traced_hit
61
+ strategy._reflexio_traced = True
62
+
63
+
64
+ # Initialize rate limiter
65
+ limiter = Limiter(key_func=get_rate_limit_key)
66
+ _trace_external_rate_limit_backend(limiter)
67
+
68
+
69
+ def configure_rate_limiter(key_func: Callable[..., str]) -> None:
70
+ """
71
+ Replace the rate limiter's key function.
72
+
73
+ This is the supported way to override the default IP-based key function
74
+ (e.g. with an org-scoped or token-scoped variant in the enterprise layer).
75
+
76
+ Args:
77
+ key_func: A callable that accepts a Request and returns a string key.
78
+ """
79
+ limiter._key_func = key_func # type: ignore[reportAttributeAccessIssue]
@@ -0,0 +1,6 @@
1
+ """Domain route modules for the OSS FastAPI app (Tier3 A2 split of api.py).
2
+
3
+ Each module owns its own ``APIRouter`` and is aggregated into
4
+ ``reflexio.server.api.core_router`` so the single ``core_router`` remains the
5
+ data-plane router surface the enterprise composition mounts and iterates.
6
+ """
@@ -0,0 +1,25 @@
1
+ """Shared route helpers used across domain route modules (Tier3 A2)."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ from reflexio.server.operation_limiter import (
6
+ OperationName,
7
+ limiter_http_exception,
8
+ run_with_operation_limit,
9
+ )
10
+
11
+
12
+ def _run_limited_api[T](
13
+ org_id: str,
14
+ operation: OperationName,
15
+ fn: Callable[[], T],
16
+ ) -> T:
17
+ try:
18
+ return run_with_operation_limit(
19
+ org_id=org_id,
20
+ operation=operation,
21
+ fn=fn,
22
+ )
23
+ except TimeoutError as exc:
24
+ http_exc = limiter_http_exception(operation)
25
+ raise http_exc from exc
@@ -0,0 +1,98 @@
1
+ """Billing money-path metering helpers shared by search + playbook routes (Tier3 A2).
2
+
3
+ These emit the ③ Application / search-request billing signals. They resolve
4
+ ``get_reflexio`` through the ``reflexio_cache`` module (not a bound import) so a
5
+ single ``patch("reflexio.server.cache.reflexio_cache.get_reflexio")`` at the
6
+ source intercepts the lookup regardless of which route module calls them.
7
+ """
8
+
9
+ import logging
10
+ import time
11
+
12
+ from fastapi import Request
13
+
14
+ from reflexio.server.cache import reflexio_cache
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _stamp_search_dependencies_done(request: Request) -> None:
20
+ """Stamp when dependency resolution reaches its final search dependency."""
21
+ request.state.search_deps_done_monotonic = time.monotonic()
22
+
23
+
24
+ def _meter_applied_learnings(
25
+ *,
26
+ org_id: str,
27
+ caller_type: str,
28
+ surfaced_count: int,
29
+ request_id: str | None = None,
30
+ session_id: str | None = None,
31
+ ) -> None:
32
+ """Emit the ③ Application event via the OSS emission helper.
33
+
34
+ No-op unless a production-agent caller surfaced >= 1 result. The cheap
35
+ caller-type and count guard runs first so the get_reflexio / config lookup
36
+ is skipped on the free paths (dashboard / empty result).
37
+
38
+ Args:
39
+ org_id: Organization ID for the requesting caller.
40
+ caller_type: Resolved caller classification (e.g. ``"production_agent"``).
41
+ surfaced_count: Total number of learnings returned to the caller.
42
+ request_id: Optional request correlation ID from the payload.
43
+ session_id: Optional session ID from the payload.
44
+ """
45
+ if caller_type != "production_agent" or surfaced_count <= 0:
46
+ return
47
+ try:
48
+ from reflexio.server.billing_meter import record_applied_learnings
49
+ from reflexio.server.billing_signals import platform_llm_from_config
50
+
51
+ config = reflexio_cache.get_reflexio(
52
+ org_id=org_id
53
+ ).request_context.configurator.get_config()
54
+ record_applied_learnings(
55
+ org_id=org_id,
56
+ surfaced_count=surfaced_count,
57
+ caller_type=caller_type,
58
+ platform_llm=platform_llm_from_config(config),
59
+ platform_storage=None, # resolved enterprise-side at rollup (Phase 1)
60
+ request_id=request_id,
61
+ session_id=session_id,
62
+ )
63
+ except Exception:
64
+ logger.warning(
65
+ "applied-learnings metering failed for org %s", org_id, exc_info=True
66
+ )
67
+
68
+
69
+ def _meter_search_request(
70
+ *,
71
+ org_id: str,
72
+ caller_type: str,
73
+ request_id: str | None = None,
74
+ session_id: str | None = None,
75
+ ) -> None:
76
+ """Emit one production-agent search request metric.
77
+
78
+ Args:
79
+ org_id: Organization ID for the requesting caller.
80
+ caller_type: Resolved caller classification.
81
+ request_id: Optional request correlation ID from the payload.
82
+ session_id: Optional session ID from the payload.
83
+ """
84
+ if caller_type != "production_agent":
85
+ return
86
+ try:
87
+ from reflexio.server.billing_meter import record_search_request
88
+
89
+ record_search_request(
90
+ org_id=org_id,
91
+ caller_type=caller_type,
92
+ request_id=request_id,
93
+ session_id=session_id,
94
+ )
95
+ except Exception:
96
+ logger.warning(
97
+ "search-request metering failed for org %s", org_id, exc_info=True
98
+ )