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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
- package/plugin/dashboard/package-lock.json +61 -390
- package/plugin/dashboard/package.json +2 -2
- package/plugin/pyproject.toml +1 -1
- package/plugin/uv.lock +1 -1
- package/plugin/vendor/reflexio/.env.example +7 -0
- package/plugin/vendor/reflexio/pyproject.toml +2 -1
- package/plugin/vendor/reflexio/reflexio/README.md +8 -5
- package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
- package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
- package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
- package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
- package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
- package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
- package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
- package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
- package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
- 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
|
|
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=
|
|
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
|
+
)
|