claude-smart 0.2.47 → 0.2.49

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 (139) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/claude-smart.js +289 -56
  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/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  8. package/plugin/dashboard/package-lock.json +61 -390
  9. package/plugin/dashboard/package.json +2 -2
  10. package/plugin/opencode/dist/server.mjs +60 -2
  11. package/plugin/opencode/server.mts +64 -2
  12. package/plugin/pyproject.toml +2 -2
  13. package/plugin/scripts/_lib.sh +58 -6
  14. package/plugin/scripts/backend-service.sh +15 -10
  15. package/plugin/scripts/codex-hook.js +16 -4
  16. package/plugin/scripts/dashboard-service.sh +1 -1
  17. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  18. package/plugin/scripts/opencode-claude-compat.js +8 -1
  19. package/plugin/scripts/smart-install.sh +101 -20
  20. package/plugin/src/README.md +1 -1
  21. package/plugin/src/claude_smart/cli.py +79 -29
  22. package/plugin/src/claude_smart/env_config.py +53 -11
  23. package/plugin/uv.lock +5 -5
  24. package/plugin/vendor/reflexio/.env.example +7 -0
  25. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  26. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  27. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  28. package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
  29. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  30. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  31. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  32. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
  33. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  34. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  35. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  36. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  37. package/plugin/vendor/reflexio/reflexio/server/api.py +148 -3277
  38. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  39. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  40. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  41. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  42. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  43. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  44. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  45. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  46. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  47. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  48. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +72 -1817
  49. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  50. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
  51. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  52. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  53. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  54. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  55. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  56. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  57. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  58. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  59. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  60. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +320 -0
  61. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  62. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  63. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  64. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  66. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  68. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  70. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  71. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  72. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  73. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  74. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  75. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  76. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  77. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +24 -41
  81. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +335 -113
  82. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  83. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  84. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  88. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
  89. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +31 -4
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +196 -353
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +17 -6
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +216 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +22 -10
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +25 -12
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +308 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +920 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -8
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +95 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +60 -80
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -1,5 +1,5 @@
1
1
  # server/api_endpoints
2
- Description: Bridge between FastAPI routes and business logic — builds `RequestContext`, validates requests, and delegates into `Reflexio`. Most endpoints are registered on the `core_router` in `../api.py`; the files here are the handlers/helpers it calls.
2
+ Description: Shared helpers between FastAPI domain routes and business logic — builds `RequestContext`, validates requests, and delegates into `Reflexio`. Public route declarations live in `../routes/` and are aggregated by `core_router` in `../api.py`; the files here are reusable handlers/helpers those routes call.
3
3
 
4
4
  > For the complete endpoint list (publish, retrieval, search, profile/playbook lifecycle, evaluation, Braintrust, operations), see the parent [server README](../README.md#api-endpoints).
5
5
 
@@ -18,8 +18,9 @@ Description: Bridge between FastAPI routes and business logic — builds `Reques
18
18
  ## Architecture Pattern
19
19
 
20
20
  ```
21
- api.py (core_router + sub-routers)
22
- -> Depends(get_request_context) -> RequestContext(org_id, storage, configurator, prompt_manager)
21
+ api.py (create_app + core_router)
22
+ -> routes/<domain>.py (FastAPI routers)
23
+ -> Depends(get_request_context) -> RequestContext(org_id, storage, configurator, prompt_manager)
23
24
  -> get_reflexio(org_id) -> Reflexio (reflexio_lib) -> services/
24
25
  ```
25
26
 
@@ -62,12 +62,22 @@ logger = logging.getLogger(__name__)
62
62
  def add_user_interaction(
63
63
  org_id: str,
64
64
  request: PublishUserInteractionRequest,
65
+ *,
66
+ use_publish_limiter: bool = True,
67
+ publish_limiter_wait_forever: bool = True,
68
+ defer_learning: bool = False,
65
69
  ) -> PublishUserInteractionResponse:
66
70
  """Add user interaction
67
71
 
68
72
  Args:
69
73
  org_id (str): Organization ID
70
74
  request (PublishUserInteractionRequest): The request containing interaction data
75
+ use_publish_limiter (bool): Whether GenerationService should throttle the
76
+ post-write learning pipeline.
77
+ publish_limiter_wait_forever (bool): Whether GenerationService should queue
78
+ indefinitely for that post-write learning limiter.
79
+ defer_learning (bool): Whether to enqueue post-persist learning instead
80
+ of running it inline.
71
81
 
72
82
  Returns:
73
83
  PublishUserInteractionResponse: Response containing success status and message
@@ -77,7 +87,12 @@ def add_user_interaction(
77
87
  return PublishUserInteractionResponse(success=False, message=message)
78
88
 
79
89
  reflexio = get_reflexio(org_id=org_id)
80
- return reflexio.publish_interaction(request=request)
90
+ return reflexio.publish_interaction(
91
+ request=request,
92
+ use_publish_limiter=use_publish_limiter,
93
+ publish_limiter_wait_forever=publish_limiter_wait_forever,
94
+ defer_learning=defer_learning,
95
+ )
81
96
 
82
97
 
83
98
  def add_user_playbook(
@@ -65,6 +65,17 @@ _reflexio_cache: TTLCache = TTLCache(
65
65
  maxsize=REFLEXIO_CACHE_MAX_SIZE, ttl=REFLEXIO_CACHE_TTL_SECONDS
66
66
  )
67
67
  _reflexio_cache_lock = threading.Lock()
68
+ _REFLEXIO_CONSTRUCTION_LOCK_STRIPES = 64
69
+ _reflexio_construction_locks = tuple(
70
+ threading.Lock() for _ in range(_REFLEXIO_CONSTRUCTION_LOCK_STRIPES)
71
+ )
72
+
73
+
74
+ def _get_construction_lock(cache_key: CacheKey) -> threading.Lock:
75
+ """Return a bounded striped lock for cold Reflexio construction."""
76
+ return _reflexio_construction_locks[
77
+ hash(cache_key) % _REFLEXIO_CONSTRUCTION_LOCK_STRIPES
78
+ ]
68
79
 
69
80
 
70
81
  def _probe_version_safe(reflexio: Reflexio) -> _ProbeResult:
@@ -157,45 +168,60 @@ def get_reflexio(org_id: str, storage_base_dir: str | None = None) -> Reflexio:
157
168
  del _reflexio_cache[cache_key]
158
169
  span.set_data("evicted", evicted)
159
170
 
160
- # Cache miss (or just-evicted stale entry) - create a new instance
161
- # outside the lock to avoid blocking concurrent requests for other orgs.
162
- with profile_step("reflexio.cache.construct"):
163
- reflexio = Reflexio(org_id=org_id, storage_base_dir=storage_base_dir)
164
- with profile_step("reflexio.cache.version_probe", cache_state="miss") as span:
165
- new_version = _probe_version_safe(reflexio)
166
- span.set_data("probe_failed", new_version is _PROBE_FAILED)
167
- span.set_data(
168
- "probe_supported",
169
- new_version is not None and new_version is not _PROBE_FAILED,
171
+ # Cache miss (or just-evicted stale entry). Only one request per cache
172
+ # key may construct at a time; construction can initialize storage and
173
+ # run migrations, so parallel cold starts for the same org are not safe.
174
+ construction_lock = _get_construction_lock(cache_key)
175
+ with construction_lock:
176
+ with _reflexio_cache_lock:
177
+ entry = _reflexio_cache.get(cache_key)
178
+ if entry is not None:
179
+ return entry.reflexio
180
+
181
+ logger.info(
182
+ "Constructing Reflexio instance for org %s storage_base_dir=%s",
183
+ org_id,
184
+ storage_base_dir,
170
185
  )
186
+ with profile_step("reflexio.cache.construct"):
187
+ reflexio = Reflexio(org_id=org_id, storage_base_dir=storage_base_dir)
188
+ with profile_step("reflexio.cache.version_probe", cache_state="miss") as span:
189
+ new_version = _probe_version_safe(reflexio)
190
+ span.set_data("probe_failed", new_version is _PROBE_FAILED)
191
+ span.set_data(
192
+ "probe_supported",
193
+ new_version is not None and new_version is not _PROBE_FAILED,
194
+ )
171
195
 
172
- # Construction-time probe failure: serve this request from the
173
- # newly-built instance but DON'T cache it. Caching with
174
- # ``cached_version=None`` would conflate the entry with a
175
- # legitimately-unprobeable backend and permanently disable
176
- # auto-eviction. Skipping the cache means the next request pays a
177
- # construction cost, but version-based eviction is preserved as
178
- # soon as the backend recovers.
179
- if new_version is _PROBE_FAILED:
180
- return reflexio
181
-
182
- new_entry = _CacheEntry(
183
- reflexio=reflexio,
184
- cached_version=new_version, # type: ignore[arg-type]
185
- )
186
-
187
- with profile_step("reflexio.cache.store") as span:
188
- with _reflexio_cache_lock:
189
- # Double-check in case another thread populated while we were constructing.
190
- existing = _reflexio_cache.get(cache_key)
191
- stored = existing is None
192
- if stored:
196
+ # Construction-time probe failure: serve this request from the
197
+ # newly-built instance but DON'T cache it. Caching with
198
+ # ``cached_version=None`` would conflate the entry with a
199
+ # legitimately-unprobeable backend and permanently disable
200
+ # auto-eviction. Skipping the cache means the next request pays a
201
+ # construction cost, but version-based eviction is preserved as
202
+ # soon as the backend recovers.
203
+ if new_version is _PROBE_FAILED:
204
+ logger.warning(
205
+ "Constructed uncached Reflexio instance for org %s because config version probe failed",
206
+ org_id,
207
+ )
208
+ return reflexio
209
+
210
+ new_entry = _CacheEntry(
211
+ reflexio=reflexio,
212
+ cached_version=new_version, # type: ignore[arg-type]
213
+ )
214
+
215
+ with profile_step("reflexio.cache.store") as span:
216
+ with _reflexio_cache_lock:
193
217
  _reflexio_cache[cache_key] = new_entry
194
- result = reflexio
195
- else:
196
- result = existing.reflexio
197
- span.set_data("stored", stored)
198
- return result
218
+ span.set_data("stored", True)
219
+ logger.info(
220
+ "Stored Reflexio instance for org %s storage_base_dir=%s",
221
+ org_id,
222
+ storage_base_dir,
223
+ )
224
+ return reflexio
199
225
 
200
226
 
201
227
  def invalidate_reflexio_cache(org_id: str, storage_base_dir: str | None = None) -> bool:
@@ -0,0 +1,69 @@
1
+ """Declarative deployment profile (Tier-3 Phase C).
2
+
3
+ A ``DeploymentProfile`` is a minimal, frozen description of *what a running app
4
+ serves*: its deployment ``role``, the set of ``router_groups`` it mounts, and
5
+ whether auth is required. It is a cleaner internal representation of the knobs
6
+ ``create_app`` already accepts (``mount_data_plane`` / ``require_auth``) — the
7
+ profile changes nothing observable; it just gives the composition root a single
8
+ value to thread instead of scattered booleans.
9
+
10
+ This module is an OSS public seam: it imports nothing from ``reflexio_ext`` and
11
+ carries no enterprise backend knowledge (string/stdlib only). The enterprise
12
+ composition defines its own profiles (self-host / platform) on top of this type.
13
+
14
+ ``mounts_data_plane`` is DERIVED from ``router_groups`` (membership of the
15
+ ``DATA_PLANE_GROUP``), not stored — so the two can never disagree.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ # The single OSS router group: the data-plane surface (core product endpoints,
23
+ # stall-state, pending-tool-call) that ``create_app`` mounts when data-plane is
24
+ # active. Enterprise adds its own group names on top of this type.
25
+ DATA_PLANE_GROUP = "data-plane"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DeploymentProfile:
30
+ """What a running app serves: role, mounted router groups, auth requirement.
31
+
32
+ Attributes:
33
+ role (str): Deployment role label (e.g. ``"all"``, ``"data-plane"``,
34
+ ``"control-plane"``). Informational for the OSS factory; enterprise
35
+ uses it to select its router set.
36
+ router_groups (frozenset[str]): Router-group names this app mounts.
37
+ auth_required (bool): Whether the app declares/enforces auth.
38
+ """
39
+
40
+ role: str
41
+ router_groups: frozenset[str]
42
+ auth_required: bool
43
+
44
+ @property
45
+ def mounts_data_plane(self) -> bool:
46
+ """Whether this profile mounts the data-plane router group."""
47
+ return DATA_PLANE_GROUP in self.router_groups
48
+
49
+
50
+ def resolve_profile(*, mount_data_plane: bool, require_auth: bool) -> DeploymentProfile:
51
+ """Derive the OSS profile from the legacy ``create_app`` knobs.
52
+
53
+ Behaviour-preserving: this is the identity mapping from the two booleans
54
+ ``create_app`` has always accepted onto the profile representation.
55
+
56
+ Args:
57
+ mount_data_plane (bool): Whether the data-plane routers/lifespan run.
58
+ require_auth (bool): Whether auth (Bearer security scheme) is declared.
59
+
60
+ Returns:
61
+ DeploymentProfile: ``role="all"`` + the data-plane group when
62
+ ``mount_data_plane`` is True, otherwise ``role="control-plane"`` with no
63
+ router groups. ``auth_required`` mirrors ``require_auth``.
64
+ """
65
+ router_groups = frozenset({DATA_PLANE_GROUP}) if mount_data_plane else frozenset()
66
+ role = "all" if mount_data_plane else "control-plane"
67
+ return DeploymentProfile(
68
+ role=role, router_groups=router_groups, auth_required=require_auth
69
+ )
@@ -0,0 +1,424 @@
1
+ """Embedding concern for ``LiteLLMClient`` — mixin + token-budget helpers (Tier-2.5).
2
+
3
+ ``EmbeddingMixin`` holds the three ``self``-bound embedding methods
4
+ (``_resolve_default_embedding_model``, ``get_embedding``, ``get_embeddings``); the
5
+ four stateless token-budget helpers (``_get_embedding_limit``,
6
+ ``_get_embedding_encoding``, ``_reject_cloud_mode``, ``_truncate_for_embedding``),
7
+ the module-level ``_TRUNCATION_WARNED_MODELS`` set and the budget constants live
8
+ alongside them at module scope.
9
+
10
+ SINK-1 (patch-where-used): the provider/router names this module references
11
+ (``resolve_model_name``, ``get_service_embeddings``, ``should_use_embedding_service``,
12
+ ``NomicEmbedder``, ``LocalEmbedder``, ``is_chromadb_importable``) are imported HERE,
13
+ so tests patch them at ``_litellm_embedding.<name>`` — patching the old facade
14
+ namespace would no-op and a no-op would hit the real embedder/137M model/network
15
+ (the P0). ``litellm.get_model_info``/``litellm.embedding`` are called via the shared
16
+ ``litellm`` module attr so the global mock and ``get_model_info`` patch still apply.
17
+
18
+ SINK-2 (identity): ``_TRUNCATION_WARNED_MODELS`` and the three test-imported budget
19
+ functions are re-exported by import binding from the facade — the facade set IS this
20
+ set (the test autouse ``.clear()`` fixture mutates it through the facade).
21
+
22
+ Bodies moved VERBATIM; only the ``self``-typing (per-mixin TYPE_CHECKING stubs of
23
+ foreign members, Tier-1b idiom) is added.
24
+ """
25
+
26
+ import logging
27
+ from functools import lru_cache
28
+ from typing import TYPE_CHECKING
29
+
30
+ import litellm
31
+ import tiktoken
32
+
33
+ from reflexio.server.llm._litellm_types import LiteLLMClientError
34
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
35
+ from reflexio.server.llm.providers.embedding_service_provider import (
36
+ EmbeddingUnavailableError,
37
+ embedding_provider_mode,
38
+ get_service_embeddings,
39
+ should_use_embedding_service,
40
+ )
41
+ from reflexio.server.llm.providers.local_embedding_provider import (
42
+ LocalEmbedder,
43
+ )
44
+ from reflexio.server.llm.providers.local_embedding_provider import (
45
+ is_chromadb_importable as _is_chromadb_importable,
46
+ )
47
+ from reflexio.server.llm.providers.nomic_embedding_provider import (
48
+ NomicEmbedder,
49
+ )
50
+ from reflexio.server.llm.providers.nomic_embedding_provider import (
51
+ is_nomic_model as _is_nomic_model,
52
+ )
53
+
54
+ if TYPE_CHECKING:
55
+ from reflexio.server.llm._litellm_types import LiteLLMConfig
56
+
57
+ _LOGGER = logging.getLogger(__name__)
58
+
59
+ # OpenAI's documented max input length for text-embedding-3-* and ada-002 is
60
+ # 8191 tokens. Used as the fallback limit only when a model's name looks
61
+ # OpenAI-family but litellm's registry has no entry for it.
62
+ _OPENAI_EMBEDDING_FALLBACK_MAX_TOKENS = 8191
63
+
64
+ # Models whose truncation warning has already been emitted this process. Keeps
65
+ # batch backfills of millions of long docs from flooding logs — the first hit
66
+ # per model goes to WARNING, everything after to DEBUG.
67
+ _TRUNCATION_WARNED_MODELS: set[str] = set()
68
+
69
+ # Model-name prefixes that route through OpenAI's embedding API (and therefore
70
+ # share the 8191-token cap). Anything that does not start with one of these is
71
+ # treated as "unknown provider" when litellm has no registry entry.
72
+ _OPENAI_EMBEDDING_FAMILY_PREFIXES = ("text-embedding-", "openai/", "azure/")
73
+
74
+
75
+ @lru_cache(maxsize=32)
76
+ def _get_embedding_limit(model: str) -> int | None:
77
+ """
78
+ Resolve the maximum input token count for an embedding model.
79
+
80
+ Consults ``litellm.get_model_info`` first so provider-specific caps are
81
+ respected (OpenAI ~8191, Cohere 512, Voyage 32000, etc.). When litellm has
82
+ no entry for the model, falls back to the OpenAI 8191 cap only when the
83
+ model name looks OpenAI-family; otherwise returns ``None`` to disable
84
+ truncation for unknown providers (safer than over-truncating their input).
85
+
86
+ Args:
87
+ model (str): Embedding model name (e.g. 'text-embedding-3-small',
88
+ 'cohere/embed-english-v3.0').
89
+
90
+ Returns:
91
+ int | None: Maximum input tokens, or ``None`` when the limit is unknown
92
+ and no safe fallback applies.
93
+ """
94
+ try:
95
+ info = litellm.get_model_info(model)
96
+ except Exception:
97
+ info = None
98
+ if info and info.get("mode") == "embedding":
99
+ max_tokens = info.get("max_input_tokens")
100
+ if isinstance(max_tokens, int) and max_tokens > 0:
101
+ return max_tokens
102
+ if model.startswith(_OPENAI_EMBEDDING_FAMILY_PREFIXES):
103
+ return _OPENAI_EMBEDDING_FALLBACK_MAX_TOKENS
104
+ return None
105
+
106
+
107
+ @lru_cache(maxsize=16)
108
+ def _get_embedding_encoding(model: str) -> tiktoken.Encoding:
109
+ """
110
+ Return the tiktoken encoding for an embedding model, falling back to cl100k_base.
111
+
112
+ For non-OpenAI providers tiktoken does not know the real tokenizer, so the
113
+ cl100k_base fallback is an approximate proxy for token counting. That is
114
+ acceptable here because we truncate toward the provider's cap with the
115
+ proxy, which tends to over-truncate by a small fraction rather than under-
116
+ truncate and cause upstream 400s.
117
+
118
+ Args:
119
+ model (str): Embedding model name (e.g. 'text-embedding-3-small').
120
+
121
+ Returns:
122
+ tiktoken.Encoding: Encoder to use for token counting and truncation.
123
+ """
124
+ try:
125
+ return tiktoken.encoding_for_model(model)
126
+ except KeyError:
127
+ return tiktoken.get_encoding("cl100k_base")
128
+
129
+
130
+ def _reject_cloud_mode(embedding_model: str, mode: str) -> None:
131
+ """
132
+ Raise when a local-only embedding model is configured for cloud mode.
133
+
134
+ Args:
135
+ embedding_model (str): The resolved embedding model name.
136
+ mode (str): The resolved embedding provider mode.
137
+
138
+ Raises:
139
+ EmbeddingUnavailableError: If ``mode`` is ``"cloud"``.
140
+ """
141
+ if mode == "cloud":
142
+ raise EmbeddingUnavailableError(
143
+ f"Local embedding model {embedding_model!r} cannot use cloud mode"
144
+ )
145
+
146
+
147
+ def _truncate_for_embedding(
148
+ text: str, model: str, max_tokens: int | None = None
149
+ ) -> str:
150
+ """
151
+ Truncate a string so its token count fits within an embedding model's input limit.
152
+
153
+ The token budget is auto-resolved from ``_get_embedding_limit`` by default.
154
+ When the model has no known limit (unknown provider not in litellm's
155
+ registry and not OpenAI-family), returns the text unchanged — over-
156
+ truncating an unknown provider's input is worse than passing it through
157
+ and letting the provider's own error surface.
158
+
159
+ Args:
160
+ text (str): Raw input text.
161
+ model (str): Embedding model name, used to pick the tokenizer and the
162
+ per-provider token cap.
163
+ max_tokens (int | None): Override for the resolved budget. Primarily
164
+ used by tests to exercise the truncation path on short strings;
165
+ leave as ``None`` in production callers.
166
+
167
+ Returns:
168
+ str: Original text if it already fits (or the model has no known
169
+ limit), otherwise a token-bounded prefix.
170
+ """
171
+ if not text:
172
+ return text
173
+ if max_tokens is None:
174
+ max_tokens = _get_embedding_limit(model)
175
+ if max_tokens is None:
176
+ return text
177
+ encoding = _get_embedding_encoding(model)
178
+ tokens = encoding.encode(text, disallowed_special=())
179
+ if len(tokens) <= max_tokens:
180
+ return text
181
+ if model in _TRUNCATION_WARNED_MODELS:
182
+ _LOGGER.debug(
183
+ "Truncating embedding input from %d to %d tokens for model %s",
184
+ len(tokens),
185
+ max_tokens,
186
+ model,
187
+ )
188
+ else:
189
+ _TRUNCATION_WARNED_MODELS.add(model)
190
+ _LOGGER.warning(
191
+ "Truncating embedding input from %d to %d tokens for model %s "
192
+ "(further occurrences will be logged at DEBUG)",
193
+ len(tokens),
194
+ max_tokens,
195
+ model,
196
+ )
197
+ return encoding.decode(tokens[:max_tokens])
198
+
199
+
200
+ class EmbeddingMixin:
201
+ """Embedding dispatch (service / Nomic / local ONNX / litellm) for ``LiteLLMClient``.
202
+
203
+ Mixed into ``LiteLLMClient``; the ``self`` members these methods read are
204
+ owned by the client-core ``__init__`` on the facade. The annotation-only
205
+ stubs below (Tier-1b idiom) give pyright the foreign-member types without
206
+ introducing shared class-level mutable state — NEVER assign here.
207
+ """
208
+
209
+ # Base-owned attributes these methods read (init'd in the facade ``__init__``).
210
+ config: "LiteLLMConfig"
211
+ # Lazy per-instance cache; ``_resolve_default_embedding_model`` populates it,
212
+ # ``update_config`` invalidates it. Annotation-only (no class default).
213
+ _default_embedding_model: str | None
214
+
215
+ if TYPE_CHECKING:
216
+ # Client-core credential resolver (stays on the facade per the split).
217
+ # Declared type-only so pyright can resolve ``self._resolve_api_key(...)``.
218
+ def _resolve_api_key(
219
+ self, model: str | None = ..., for_embedding: bool = ...
220
+ ) -> tuple[str | None, str | None, str | None]: ...
221
+
222
+ def _resolve_default_embedding_model(self) -> str:
223
+ """
224
+ Resolve the embedding model to use when callers do not specify one.
225
+
226
+ Routes through the same auto-detection chain as the rest of reflexio
227
+ (``resolve_model_name`` for ``ModelRole.EMBEDDING``) so a session that
228
+ has the local ONNX embedder enabled — or any non-OpenAI provider —
229
+ does not silently fall back to ``text-embedding-3-small`` and produce
230
+ OpenAI 401s. Higher-precedence org config and site-var overrides are
231
+ the caller's responsibility to resolve and pass via ``model=``; this
232
+ helper handles only the auto-detect tier.
233
+
234
+ Returns:
235
+ str: The auto-detected embedding model name (cached after first call).
236
+
237
+ Raises:
238
+ RuntimeError: Propagated from ``resolve_model_name`` when no
239
+ embedding-capable provider is available.
240
+ """
241
+ if self._default_embedding_model is None:
242
+ self._default_embedding_model = resolve_model_name(
243
+ ModelRole.EMBEDDING,
244
+ api_key_config=self.config.api_key_config,
245
+ )
246
+ return self._default_embedding_model
247
+
248
+ def get_embedding(
249
+ self, text: str, model: str | None = None, dimensions: int | None = None
250
+ ) -> list[float]:
251
+ """
252
+ Get embedding vector for the given text.
253
+
254
+ Args:
255
+ text: The text to get embedding for.
256
+ model: Optional embedding model. When omitted, the model is
257
+ auto-detected via ``resolve_model_name(ModelRole.EMBEDDING)``
258
+ so callers inherit the local-embedder gate and any non-OpenAI
259
+ provider configured for this client.
260
+ dimensions: Optional number of dimensions for the embedding vector.
261
+
262
+ Returns:
263
+ List of floats representing the embedding vector.
264
+
265
+ Raises:
266
+ LiteLLMClientError: If embedding generation fails.
267
+ """
268
+ embedding_model = model or self._resolve_default_embedding_model()
269
+ mode = embedding_provider_mode(embedding_model)
270
+ if mode == "off":
271
+ raise EmbeddingUnavailableError("Embedding provider is disabled")
272
+ if should_use_embedding_service(embedding_model):
273
+ return get_service_embeddings(
274
+ [text], model=embedding_model, dimensions=dimensions
275
+ )[0]
276
+
277
+ # local/nomic-embed-* must stay on the Nomic provider (137M params,
278
+ # 768d Matryoshka-truncated to 512). Falling through to MiniLM would
279
+ # mix embedding models inside existing vector stores.
280
+ if _is_nomic_model(embedding_model):
281
+ _reject_cloud_mode(embedding_model, mode)
282
+ try:
283
+ return NomicEmbedder.get().embed([text])[0]
284
+ except Exception as e:
285
+ raise LiteLLMClientError(
286
+ f"Nomic embedding generation failed: {str(e)}"
287
+ ) from e
288
+
289
+ # local/* models route through the in-process ONNX embedder — no
290
+ # network call, no litellm API, no tiktoken truncation (the embedder
291
+ # applies its own token cap). The dispatch is gated solely on
292
+ # ``chromadb`` being importable; the env-var opt-in (claude-smart's
293
+ # ``CLAUDE_SMART_USE_LOCAL_EMBEDDING``) is enforced earlier in the
294
+ # auto-detection layer (see ``model_defaults._auto_detect_model``).
295
+ if embedding_model.startswith("local/"):
296
+ _reject_cloud_mode(embedding_model, mode)
297
+ if not _is_chromadb_importable():
298
+ raise LiteLLMClientError(
299
+ f"Embedding model {embedding_model!r} requires chromadb. "
300
+ "Run `pip install chromadb`."
301
+ )
302
+ try:
303
+ return LocalEmbedder.get().embed([text])[0]
304
+ except Exception as e:
305
+ raise LiteLLMClientError(
306
+ f"Local embedding generation failed: {str(e)}"
307
+ ) from e
308
+
309
+ text = _truncate_for_embedding(text, embedding_model)
310
+
311
+ try:
312
+ params = {"model": embedding_model, "input": [text]}
313
+ if dimensions:
314
+ params["dimensions"] = dimensions
315
+
316
+ # Resolve and add API key configuration if provided (overrides env vars)
317
+ api_key, api_base, api_version = self._resolve_api_key(
318
+ embedding_model, for_embedding=True
319
+ )
320
+ if api_key:
321
+ params["api_key"] = api_key
322
+ if api_base:
323
+ params["api_base"] = api_base
324
+ if api_version:
325
+ params["api_version"] = api_version
326
+
327
+ response = litellm.embedding(
328
+ **params,
329
+ timeout=self.config.timeout,
330
+ num_retries=self.config.max_retries,
331
+ )
332
+ return response.data[0]["embedding"]
333
+ except Exception as e:
334
+ raise LiteLLMClientError(f"Embedding generation failed: {str(e)}") from e
335
+
336
+ def get_embeddings(
337
+ self,
338
+ texts: list[str],
339
+ model: str | None = None,
340
+ dimensions: int | None = None,
341
+ ) -> list[list[float]]:
342
+ """
343
+ Get embedding vectors for multiple texts in a single API call.
344
+
345
+ Args:
346
+ texts: List of texts to get embeddings for.
347
+ model: Optional embedding model. When omitted, the model is
348
+ auto-detected via ``resolve_model_name(ModelRole.EMBEDDING)``
349
+ so callers inherit the local-embedder gate and any non-OpenAI
350
+ provider configured for this client.
351
+ dimensions: Optional number of dimensions for the embedding vectors.
352
+
353
+ Returns:
354
+ List of embedding vectors, one per input text, in the same order as input.
355
+
356
+ Raises:
357
+ LiteLLMClientError: If embedding generation fails.
358
+ """
359
+ if not texts:
360
+ return []
361
+
362
+ embedding_model = model or self._resolve_default_embedding_model()
363
+ mode = embedding_provider_mode(embedding_model)
364
+ if mode == "off":
365
+ raise EmbeddingUnavailableError("Embedding provider is disabled")
366
+ if should_use_embedding_service(embedding_model):
367
+ return get_service_embeddings(
368
+ list(texts), model=embedding_model, dimensions=dimensions
369
+ )
370
+
371
+ # See matching short-circuits in get_embedding above.
372
+ if _is_nomic_model(embedding_model):
373
+ _reject_cloud_mode(embedding_model, mode)
374
+ try:
375
+ return NomicEmbedder.get().embed(list(texts))
376
+ except Exception as e:
377
+ raise LiteLLMClientError(
378
+ f"Nomic batch embedding generation failed: {str(e)}"
379
+ ) from e
380
+
381
+ if embedding_model.startswith("local/"):
382
+ _reject_cloud_mode(embedding_model, mode)
383
+ if not _is_chromadb_importable():
384
+ raise LiteLLMClientError(
385
+ f"Embedding model {embedding_model!r} requires chromadb. "
386
+ "Run `pip install chromadb`."
387
+ )
388
+ try:
389
+ return LocalEmbedder.get().embed(list(texts))
390
+ except Exception as e:
391
+ raise LiteLLMClientError(
392
+ f"Local batch embedding generation failed: {str(e)}"
393
+ ) from e
394
+
395
+ texts = [_truncate_for_embedding(t, embedding_model) for t in texts]
396
+
397
+ try:
398
+ params = {"model": embedding_model, "input": texts}
399
+ if dimensions:
400
+ params["dimensions"] = dimensions
401
+
402
+ # Resolve and add API key configuration if provided (overrides env vars)
403
+ api_key, api_base, api_version = self._resolve_api_key(
404
+ embedding_model, for_embedding=True
405
+ )
406
+ if api_key:
407
+ params["api_key"] = api_key
408
+ if api_base:
409
+ params["api_base"] = api_base
410
+ if api_version:
411
+ params["api_version"] = api_version
412
+
413
+ response = litellm.embedding(
414
+ **params,
415
+ timeout=self.config.timeout,
416
+ num_retries=self.config.max_retries,
417
+ )
418
+ # Response data may not be in order, sort by index to ensure correct ordering
419
+ sorted_data = sorted(response.data, key=lambda x: x["index"])
420
+ return [item["embedding"] for item in sorted_data]
421
+ except Exception as e:
422
+ raise LiteLLMClientError(
423
+ f"Batch embedding generation failed: {str(e)}"
424
+ ) from e