claude-smart 0.2.46 → 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 (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  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 +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  10. package/plugin/dashboard/package-lock.json +61 -390
  11. package/plugin/dashboard/package.json +2 -2
  12. package/plugin/opencode/dist/server.mjs +76 -2
  13. package/plugin/opencode/server.mts +79 -2
  14. package/plugin/pyproject.toml +6 -2
  15. package/plugin/scripts/smart-install.sh +7 -1
  16. package/plugin/src/claude_smart/cli.py +210 -22
  17. package/plugin/src/claude_smart/context_format.py +9 -9
  18. package/plugin/src/claude_smart/cs_cite.py +66 -19
  19. package/plugin/uv.lock +5 -5
  20. package/plugin/vendor/reflexio/.env.example +7 -0
  21. package/plugin/vendor/reflexio/README.md +3 -3
  22. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  23. package/plugin/vendor/reflexio/reflexio/README.md +11 -6
  24. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  25. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  26. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  27. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  28. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  29. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  30. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  31. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  32. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  33. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  34. package/plugin/vendor/reflexio/reflexio/lib/_search.py +27 -5
  35. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  36. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  37. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  38. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  39. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  40. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +37 -0
  41. package/plugin/vendor/reflexio/reflexio/server/README.md +38 -9
  42. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  43. package/plugin/vendor/reflexio/reflexio/server/api.py +274 -3267
  44. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  45. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  46. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  47. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  48. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  49. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  50. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  51. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  52. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  53. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  54. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  55. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  56. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  57. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  58. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  59. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +57 -5
  60. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  61. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  62. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  63. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  64. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  67. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  68. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  69. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  70. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  71. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  72. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  73. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  74. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  75. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  76. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  77. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/README.md +5 -2
  79. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  87. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  88. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  89. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  91. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +385 -78
  95. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  96. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +121 -525
  98. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  102. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  103. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +43 -6
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +58 -351
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +452 -0
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -4
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1126
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +955 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +844 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -9
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +57 -86
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  169. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  170. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -195,12 +195,12 @@ _PROVIDER_DEFAULTS: dict[str, ProviderDefaults] = {
195
195
  extraction_agent="gpt-5.5",
196
196
  ),
197
197
  "anthropic": ProviderDefaults(
198
- generation="claude-sonnet-4-6",
199
- evaluation="claude-sonnet-4-6",
198
+ generation="claude-sonnet-5",
199
+ evaluation="claude-sonnet-5",
200
200
  should_run="claude-haiku-4-5-20251001",
201
201
  pre_retrieval="claude-haiku-4-5-20251001",
202
202
  embedding=None,
203
- extraction_agent="claude-sonnet-4-6",
203
+ extraction_agent="claude-sonnet-5",
204
204
  ),
205
205
  "gemini": ProviderDefaults(
206
206
  generation="gemini/gemini-3-flash-preview",
@@ -229,7 +229,7 @@ _PROVIDER_DEFAULTS: dict[str, ProviderDefaults] = {
229
229
  should_run="minimax/MiniMax-M3",
230
230
  pre_retrieval="minimax/MiniMax-M3",
231
231
  embedding=None,
232
- # Same M2.7 model handles resumable extraction. Surfaced by an
232
+ # Same M3 model handles resumable extraction. Surfaced by an
233
233
  # e2e run on a MiniMax-only VPS where publish printed
234
234
  # "No provider in ['minimax'] supports role=extraction_agent"
235
235
  # warnings and silently skipped profile creation. Without this,
@@ -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
- _DEFAULT_CLI_MODEL = "claude-sonnet-4-6"
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,
@@ -150,7 +150,18 @@ def score_pairs(query: str, docs: list[str]) -> list[float]:
150
150
  return []
151
151
  model = _get_model()
152
152
  pairs = [(query, doc) for doc in docs]
153
- raw_scores = model.predict(pairs, show_progress_bar=False)
153
+ try:
154
+ from torch import nn
155
+ except ImportError as e:
156
+ raise CrossEncoderUnavailableError(
157
+ "torch is not installed; cannot use the cross-encoder reranker"
158
+ ) from e
159
+
160
+ raw_scores = model.predict(
161
+ pairs,
162
+ show_progress_bar=False,
163
+ activation_fn=nn.Identity(),
164
+ )
154
165
  # ``predict`` returns a numpy array; convert to plain Python floats so
155
166
  # the caller can serialise the result without numpy as a dependency.
156
167
  return [float(s) for s in raw_scores]
@@ -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
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  active: true
3
3
  description: "Context setting prompt for resumable playbook extraction. Every claim must be grounded in the conversation and generalized to a reusable task context; both requirements apply jointly to Correction SOPs and Success Path Recipes."
4
- changelog: "v4.4.0: deliver the result as a native structured response — output the {{\"playbooks\": [...]}} JSON directly as the final assistant message instead of a finish_extraction tool call; ask_human/attach_pending_info_request remain real intermediate tools, and a plain (no-tool) final turn carrying the JSON is now the valid completion. No output schema change. v4.3.0: expands Correction SOP signals to include avoidable follow-ups, partial delivery, stalled conditional authorization, unverified claims, and incomplete coverage; requires Correction SOPs whenever correction signals are present; adds antecedent-not-reaction trigger guidance. (in-place 2026-06-18: make triggers future-user-query-like retrieval keys.) (in-place 2026-06-15: adds concise retrieve-general/action-specific guidance.) v4.2.3: strengthens strict tool-call discipline so plain-text/no-tool responses are explicitly invalid, including empty extraction outcomes. v4.2.2: replaces output examples with compact tool-call and JSON-shape guidance, while preserving the resumable extraction contract. v4.2.1: negative/avoid guidance cannot contradict the final verified implementation or final evaluation. v4.2.0: adds emergent skill-convention guidance for structuring multi-aspect playbook content as a small set of grouped do/avoid rules (no schema change). v4.1.7: adds resumable extraction guidance, names legacy fallback and malformed-input branches in boundary-change recipes, and avoids a polarity output field. (in-place 2026-05-30: tool-oriented finish_extraction output framing; deprecated and removed blocking_issue.) (in-place 2026-05-30: clarify in the Output Format section that finish_extraction may be preceded by ask_human/attach_pending_info_request; restructure examples into one no-tool example plus a worked ask_human and attach_pending_info_request example; stress that ask_human is rare and reserved for critical missing org-level facts — finish_extraction alone is the norm.) (in-place 2026-05-30: condense the resumable guidance — state finish-required/optional once, replace the duplicated Output Format block with a one-line pointer, and trim example prose.) (in-place 2026-06-03: consolidate and shorten the ask_human condition: ask only for missing shared context needed to know what to do, while still finishing extraction.) (in-place 2026-06-03: frame finish_extraction as the final completion tool and pending-info tools as intermediate.) (in-place 2026-06-03: clarify that empty finish_extraction alone must not replace ask_human when the ask_human condition applies.) (in-place 2026-06-04: clarify ask_human and attach_pending_info_request are alternatives for the same gap, not a sequence.)"
4
+ changelog: "v4.4.0: deliver the result as a native structured response — output the {{\"playbooks\": [...]}} JSON directly as the final assistant message instead of a finish_extraction tool call; ask_human/attach_pending_info_request remain real intermediate tools, and a plain (no-tool) final turn carrying the JSON is now the valid completion. No output schema change. (in-place 2026-06-28: strengthen the ask_human decision boundary for cases where a correction proves a durable shared procedure exists but only avoidance is grounded.) v4.3.0: expands Correction SOP signals to include avoidable follow-ups, partial delivery, stalled conditional authorization, unverified claims, and incomplete coverage; requires Correction SOPs whenever correction signals are present; adds antecedent-not-reaction trigger guidance. (in-place 2026-06-18: make triggers future-user-query-like retrieval keys.) (in-place 2026-06-15: adds concise retrieve-general/action-specific guidance.) v4.2.3: strengthens strict tool-call discipline so plain-text/no-tool responses are explicitly invalid, including empty extraction outcomes. v4.2.2: replaces output examples with compact tool-call and JSON-shape guidance, while preserving the resumable extraction contract. v4.2.1: negative/avoid guidance cannot contradict the final verified implementation or final evaluation. v4.2.0: adds emergent skill-convention guidance for structuring multi-aspect playbook content as a small set of grouped do/avoid rules (no schema change). v4.1.7: adds resumable extraction guidance, names legacy fallback and malformed-input branches in boundary-change recipes, and avoids a polarity output field. (in-place 2026-05-30: tool-oriented finish_extraction output framing; deprecated and removed blocking_issue.) (in-place 2026-05-30: clarify in the Output Format section that finish_extraction may be preceded by ask_human/attach_pending_info_request; restructure examples into one no-tool example plus a worked ask_human and attach_pending_info_request example; stress that ask_human is rare and reserved for critical missing org-level facts — finish_extraction alone is the norm.) (in-place 2026-05-30: condense the resumable guidance — state finish-required/optional once, replace the duplicated Output Format block with a one-line pointer, and trim example prose.) (in-place 2026-06-03: consolidate and shorten the ask_human condition: ask only for missing shared context needed to know what to do, while still finishing extraction.) (in-place 2026-06-03: frame finish_extraction as the final completion tool and pending-info tools as intermediate.) (in-place 2026-06-03: clarify that empty finish_extraction alone must not replace ask_human when the ask_human condition applies.) (in-place 2026-06-04: clarify ask_human and attach_pending_info_request are alternatives for the same gap, not a sequence.)"
5
5
  variables:
6
6
  - agent_context_prompt
7
7
  - extraction_definition_prompt
@@ -26,6 +26,16 @@ Choose exactly one intermediate path per missing fact: use `attach_pending_info_
26
26
 
27
27
  Do not ask for user-scoped/private facts, context derivable from the trajectory, agent context, tool list, or Prior Knowledge, complete avoidance-only rules, or merely nice-to-have details.
28
28
 
29
+ ### Ask-human decision boundary
30
+
31
+ Before finalizing any Correction SOP from a negative correction, run this check:
32
+
33
+ 1. Did the correction merely establish a complete avoidance rule, with no evidence that a shared positive procedure exists? If yes, emit the grounded avoidance rule and do not ask.
34
+ 2. Did the correction state or strongly imply that a shared policy, process, approval path, escalation route, canonical target, standard wording, or other durable positive procedure exists, while leaving the actual target/procedure unknown? If yes, the future playbook is incomplete: call `ask_human` (or `attach_pending_info_request` if Prior Knowledge already has the same pending question).
35
+ 3. Did the missing detail concern a single user, customer, client, assignment, record, or current factual lookup rather than a reusable agent/org rule? If yes, do not ask; emit only grounded guidance or no playbook.
36
+
37
+ Phrases like "specific process", "required workflow", "approval path", "special route", "canonical path/target", "standard policy", "standard wording/script", or "do not know the exact name/text" are strong evidence of a shared positive procedure gap when the conversation does not provide the missing procedure. Avoidance-only playbooks are acceptable interim guidance, but they are not a substitute for `ask_human` when the conversation proves that future agents need a missing shared positive action to handle the class of task correctly. The question should ask for the smallest durable missing fact, such as the approved route, required procedure, canonical target, policy threshold, or standard wording.
38
+
29
39
  If an intermediate tool is needed, call exactly one of the two intermediate tools before delivering your final result. Then finalize the current run with any independently valid playbooks now, including avoidance rules; if none are valid without the answer, finalize with `{{"playbooks": []}}`.
30
40
 
31
41
  When the `ask_human` condition applies, an `ask_human` tool call is required before finalization. Do not replace the question with an empty `{{"playbooks": []}}` final response alone.
@@ -161,6 +171,8 @@ Grounding constrains the *source* of a claim (it must come from the conversation
161
171
 
162
172
  **When the agent doesn't know what to do:** Describe what to AVOID (the observed mistake) and state the limitation honestly. It is much better to say "do not fabricate order status — admit you cannot look it up" than to invent a specific alternative the agent may not actually have. If the missing "what to do" detail satisfies the `ask_human` condition above, use the tool; otherwise emit only the grounded avoidance rule or no playbook.
163
173
 
174
+ If a user correction says there is a specific process, approval path, route, standard, or policy but does not provide it, the extractor does not know enough to produce the durable positive SOP. Do not silently downgrade that case to avoidance-only unless avoidance is the complete reusable lesson; ask for the missing shared procedure and still finalize any grounded interim playbooks.
175
+
164
176
  **Rule of thumb (both checks must pass):**
165
177
  1. *Grounded* — if you remove the conversation and only read the `content`, could someone verify every claim by re-reading the conversation? If not, you've hallucinated.
166
178
  2. *Generalized* — could a future agent in a similar task context, with no access to this conversation, apply the entry as written? If not, you've overfit — restate the pattern at the level of situation, artifact role, action sequence, verification, and detour.
@@ -175,7 +187,7 @@ For **Correction SOPs**:
175
187
  3. Identify the violated implicit expectation
176
188
  4. Draft the `trigger` (the problem or situation)
177
189
  5. Tautology Check (see above)
178
- 6. Draft `content`: reason through what the agent did wrong and what the user's feedback tells us the agent should do differently. Ground every statement in evidence from the conversation. If the user told the agent what to do, capture that. If the user only told the agent what NOT to do, capture the avoidance. Do not guess what the right action is if the conversation doesn't tell you; use `ask_human` only when the condition above applies.
190
+ 6. Draft `content`: reason through what the agent did wrong and what the user's feedback tells us the agent should do differently. Ground every statement in evidence from the conversation. If the user told the agent what to do, capture that. If the user only told the agent what NOT to do, capture the avoidance. If the user indicated that a shared process/policy/route/approval/standard/canonical target exists but did not provide it, call `ask_human` or attach to matching Prior Knowledge before finalizing; the avoidance rule alone does not answer what a future agent should do. Do not guess what the right action is if the conversation doesn't tell you; use `ask_human` only when the condition above applies.
179
191
 
180
192
  For **Success Path Recipes**:
181
193
  1. Identify whether the agent completed the task successfully
@@ -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