claude-smart 0.2.49 → 0.2.50
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 +2 -2
- package/README.md +10 -43
- package/bin/claude-smart.js +105 -0
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.coverage +0 -0
- package/plugin/README.md +4 -3
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
- package/plugin/dashboard/app/dashboard/page.tsx +6 -1
- package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
- package/plugin/dashboard/app/preferences/page.tsx +32 -35
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
- package/plugin/dashboard/app/sessions/page.tsx +2 -0
- package/plugin/dashboard/app/skills/page.tsx +65 -50
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
- package/plugin/dashboard/components/common/host-badge.tsx +118 -0
- package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
- package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
- package/plugin/dashboard/components/common/page-header.tsx +3 -3
- package/plugin/dashboard/lib/config-file.ts +5 -1
- package/plugin/dashboard/lib/host-attribution.ts +62 -0
- package/plugin/dashboard/lib/session-reader.ts +40 -2
- package/plugin/dashboard/lib/types.ts +7 -1
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +197 -1
- package/plugin/scripts/backend-python-runner.py +46 -0
- package/plugin/scripts/backend-service.sh +757 -119
- package/plugin/scripts/codex-hook.js +63 -225
- package/plugin/scripts/dashboard-open.sh +6 -4
- package/plugin/scripts/dashboard-service.sh +117 -136
- package/plugin/scripts/hook_entry.sh +3 -0
- package/plugin/scripts/smart-install.sh +15 -1
- package/plugin/src/claude_smart/cli.py +14 -0
- package/plugin/src/claude_smart/context_inject.py +3 -0
- package/plugin/src/claude_smart/env_config.py +4 -1
- package/plugin/src/claude_smart/events/post_tool.py +2 -1
- package/plugin/src/claude_smart/events/session_end.py +2 -1
- package/plugin/src/claude_smart/events/stop.py +3 -0
- package/plugin/src/claude_smart/events/user_prompt.py +2 -1
- package/plugin/src/claude_smart/internal_call.py +5 -2
- package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
- package/plugin/src/claude_smart/publish.py +59 -7
- package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
- package/plugin/src/claude_smart/runtime.py +15 -6
- package/plugin/src/claude_smart/state.py +211 -52
- package/plugin/uv.lock +1 -1
- package/plugin/vendor/reflexio/.env.example +13 -0
- package/plugin/vendor/reflexio/reflexio/README.md +7 -3
- package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
- package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
- package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
- package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
- package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
- package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
- package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
- package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
- package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
- package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
- package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
- package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
- package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
- package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
- package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
- package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
- package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
- package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
- package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
- package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
- package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
- package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
|
@@ -89,6 +89,12 @@ class ClaudeCodeCLIError(RuntimeError):
|
|
|
89
89
|
"""Raised when the claude CLI subprocess fails in a way we cannot recover from."""
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def _diagnostic_excerpt(text: str, limit: int = 500) -> str:
|
|
93
|
+
"""Return a bounded single-line excerpt for local CLI diagnostics."""
|
|
94
|
+
compact = " ".join((text or "").split())
|
|
95
|
+
return compact[:limit]
|
|
96
|
+
|
|
97
|
+
|
|
92
98
|
def _env_enabled() -> bool:
|
|
93
99
|
"""Return True when ``CLAUDE_SMART_USE_LOCAL_CLI`` is set to a truthy value.
|
|
94
100
|
|
|
@@ -1037,7 +1043,9 @@ class ClaudeCodeLLM(CustomLLM):
|
|
|
1037
1043
|
self._record_stall_safely(result)
|
|
1038
1044
|
raise ClaudeCodeCLIError(
|
|
1039
1045
|
f"claude -p stream failed; retry_errors={result.retry_errors}; "
|
|
1040
|
-
f"stderr={result.stderr_text
|
|
1046
|
+
f"stderr={_diagnostic_excerpt(result.stderr_text)!r}; "
|
|
1047
|
+
f"stdout={_diagnostic_excerpt(result.terminal_text)!r}; "
|
|
1048
|
+
f"parsed={result.raw_lines_parsed}; failed={result.raw_lines_failed}"
|
|
1041
1049
|
)
|
|
1042
1050
|
|
|
1043
1051
|
def _record_stall_safely(self, result: ParseResult) -> None:
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Warm-before-ready readiness gate for the in-process local embedder.
|
|
2
|
+
|
|
3
|
+
Phase 2 preparation for the embedding-stability redesign. Everything here is
|
|
4
|
+
**dormant** until a future config flip sets ``REFLEXIO_EMBEDDING_PROVIDER=inprocess``
|
|
5
|
+
with a ``local/*`` default embedding model. Pre-flip (the current daemon-mode
|
|
6
|
+
prod state, or any cloud/off/OSS-dev deployment) none of this changes ``/health``
|
|
7
|
+
or startup behaviour.
|
|
8
|
+
|
|
9
|
+
Three concerns live here:
|
|
10
|
+
|
|
11
|
+
- **D5 warm-before-ready** — a process-level ``threading.Event`` set once the
|
|
12
|
+
in-process embedder has loaded, plus a non-blocking startup thread that loads
|
|
13
|
+
it. ``/health`` reports 503 while the gate is active and the embedder is not
|
|
14
|
+
yet warm, so the load balancer does not route embedding traffic at a worker
|
|
15
|
+
that would pay the ~2-8s cold-load on its first request.
|
|
16
|
+
- **D8 config guards** — loud startup warnings for foot-guns of the in-process
|
|
17
|
+
topology: multiple uvicorn workers (each loads its own model copy), a
|
|
18
|
+
half-configured daemon-disable / provider pair, and an in-process provider set
|
|
19
|
+
alongside a configured service endpoint (which the provider silently overrides).
|
|
20
|
+
|
|
21
|
+
The gate is intentionally cheap to evaluate (no network probe, no provider
|
|
22
|
+
auto-detection) because ``/health`` is the ALB + container health target and is
|
|
23
|
+
polled frequently.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
|
|
34
|
+
_LOGGER = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Warm-before-ready retry: cold model load is ~2-8s and reliable in prod, but a
|
|
37
|
+
# bounded retry absorbs a transient blip before giving up (a give-up leaves
|
|
38
|
+
# /health at 503, forcing a full ECS task replacement).
|
|
39
|
+
_WARM_MAX_ATTEMPTS = 3
|
|
40
|
+
_WARM_RETRY_BACKOFF_S = 2.0
|
|
41
|
+
|
|
42
|
+
_ENV_PROVIDER = "REFLEXIO_EMBEDDING_PROVIDER"
|
|
43
|
+
_ENV_DISABLE_DAEMON = "REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON"
|
|
44
|
+
# Service-endpoint envs, named to match embedding_service_provider.py exactly. An
|
|
45
|
+
# in-process provider takes precedence over both, so either being set alongside
|
|
46
|
+
# PROVIDER=inprocess silently strands the configured service host.
|
|
47
|
+
_ENV_SERVICE_URL = "REFLEXIO_EMBEDDING_SERVICE_URL"
|
|
48
|
+
_ENV_DAEMON_HOST = "REFLEXIO_EMBEDDING_DAEMON_HOST"
|
|
49
|
+
# Recorded by ``reflexio.server.__main__`` before ``uvicorn.run`` so the guard
|
|
50
|
+
# can read the configured worker count from inside a worker process, where
|
|
51
|
+
# uvicorn exposes no worker-count env of its own.
|
|
52
|
+
_ENV_WORKERS = "REFLEXIO_SERVER_WORKERS"
|
|
53
|
+
_ENV_WEB_CONCURRENCY = "WEB_CONCURRENCY"
|
|
54
|
+
|
|
55
|
+
_INPROCESS = "inprocess"
|
|
56
|
+
|
|
57
|
+
# Process-level readiness signal: set once the in-process embedder is loaded.
|
|
58
|
+
_ready = threading.Event()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mark_embedder_ready() -> None:
|
|
62
|
+
"""Mark the in-process embedder as loaded/warm for this process."""
|
|
63
|
+
_ready.set()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_embedder_ready() -> bool:
|
|
67
|
+
"""Return True once the in-process embedder has been warmed in this process."""
|
|
68
|
+
return _ready.is_set()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def reset_warmup_state_for_test() -> None:
|
|
72
|
+
"""Clear the readiness signal. Test-only hygiene helper."""
|
|
73
|
+
_ready.clear()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _provider() -> str:
|
|
77
|
+
return os.environ.get(_ENV_PROVIDER, "").strip().lower()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def inprocess_local_gate_active() -> bool:
|
|
81
|
+
"""Return True iff this deployment serves embeddings via the in-process local path.
|
|
82
|
+
|
|
83
|
+
The gate is active only when BOTH hold:
|
|
84
|
+
|
|
85
|
+
- ``REFLEXIO_EMBEDDING_PROVIDER == "inprocess"`` (explicit config flip), and
|
|
86
|
+
- the resolved default embedding model is a ``local/*`` model.
|
|
87
|
+
|
|
88
|
+
Any other configuration (cloud, off, daemon-mode ``local_service`` /
|
|
89
|
+
``internal_service``, or no explicit provider at all) returns False, keeping
|
|
90
|
+
the warm-before-ready behaviour dormant. Cheap by construction: neither
|
|
91
|
+
branch performs a network probe or provider auto-detection.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
bool: True when warm-before-ready ``/health`` gating should apply.
|
|
95
|
+
"""
|
|
96
|
+
from reflexio.server.llm.providers.embedding_service_provider import (
|
|
97
|
+
embedding_provider_mode,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
mode = embedding_provider_mode()
|
|
102
|
+
except Exception: # noqa: BLE001
|
|
103
|
+
# An invalid REFLEXIO_EMBEDDING_PROVIDER must not turn /health (polled on
|
|
104
|
+
# every ALB probe) into a 500 — treat unresolvable config as gate-off.
|
|
105
|
+
_LOGGER.debug(
|
|
106
|
+
"Embedding provider mode unresolvable; gate inactive", exc_info=True
|
|
107
|
+
)
|
|
108
|
+
return False
|
|
109
|
+
if mode != _INPROCESS:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
model = resolve_model_name(ModelRole.EMBEDDING)
|
|
116
|
+
except Exception: # noqa: BLE001
|
|
117
|
+
# No embedding-capable provider resolvable — cannot be the in-process
|
|
118
|
+
# local path, so the gate stays inactive rather than wedging /health.
|
|
119
|
+
_LOGGER.debug("Embedding model resolution failed; gate inactive", exc_info=True)
|
|
120
|
+
return False
|
|
121
|
+
return model.startswith("local/")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_local_embedder_loader() -> Callable[[], object] | None:
|
|
125
|
+
"""Return a zero-arg loader for the embedder matching the resolved local model.
|
|
126
|
+
|
|
127
|
+
Dispatches on the SAME model the gate resolved so readiness reflects the
|
|
128
|
+
embedder that will actually serve: ``local/nomic-*`` -> ``NomicEmbedder``
|
|
129
|
+
(sentence-transformers), any other ``local/*`` -> ``LocalEmbedder`` (chromadb
|
|
130
|
+
ONNX, e.g. the OSS ``local/minilm-l6-v2`` default). Returns None when no
|
|
131
|
+
in-process local model resolves.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Callable[[], object] | None: A loader that warms the correct singleton,
|
|
135
|
+
or None if the resolved model is not an in-process local one.
|
|
136
|
+
"""
|
|
137
|
+
from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
model = resolve_model_name(ModelRole.EMBEDDING)
|
|
141
|
+
except Exception: # noqa: BLE001
|
|
142
|
+
return None
|
|
143
|
+
if not model.startswith("local/"):
|
|
144
|
+
return None
|
|
145
|
+
if "nomic" in model:
|
|
146
|
+
from reflexio.server.llm.providers.nomic_embedding_provider import NomicEmbedder
|
|
147
|
+
|
|
148
|
+
return lambda: NomicEmbedder.get()._load()
|
|
149
|
+
from reflexio.server.llm.providers.local_embedding_provider import LocalEmbedder
|
|
150
|
+
|
|
151
|
+
return lambda: LocalEmbedder.get()._load()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _warm_embedder() -> None:
|
|
155
|
+
"""Load the resolved in-process local embedder, then flip the readiness signal.
|
|
156
|
+
|
|
157
|
+
Warms whichever embedder the gate's resolved ``local/*`` model maps to (see
|
|
158
|
+
:func:`_resolve_local_embedder_loader`), so readiness reflects the model that
|
|
159
|
+
will actually serve — not a hardcoded one. Fire-and-forget in a daemon thread
|
|
160
|
+
so a slow load never blocks startup; a bounded retry absorbs a transient blip.
|
|
161
|
+
On final failure the readiness signal stays clear and ``/health`` keeps
|
|
162
|
+
reporting not-ready — the intended fail-safe (deploy fails loud, old rev serves).
|
|
163
|
+
"""
|
|
164
|
+
loader = _resolve_local_embedder_loader()
|
|
165
|
+
if loader is None:
|
|
166
|
+
_LOGGER.warning(
|
|
167
|
+
"Warmup gate active but no in-process local embedder resolved; "
|
|
168
|
+
"/health stays not-ready."
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
for attempt in range(1, _WARM_MAX_ATTEMPTS + 1):
|
|
172
|
+
try:
|
|
173
|
+
loader()
|
|
174
|
+
mark_embedder_ready()
|
|
175
|
+
_LOGGER.info("In-process embedder warm; /health now reports ready.")
|
|
176
|
+
return
|
|
177
|
+
except Exception: # noqa: BLE001
|
|
178
|
+
_LOGGER.warning(
|
|
179
|
+
"In-process embedder warmup attempt %d/%d failed.",
|
|
180
|
+
attempt,
|
|
181
|
+
_WARM_MAX_ATTEMPTS,
|
|
182
|
+
exc_info=True,
|
|
183
|
+
)
|
|
184
|
+
if attempt < _WARM_MAX_ATTEMPTS:
|
|
185
|
+
time.sleep(_WARM_RETRY_BACKOFF_S * attempt)
|
|
186
|
+
_LOGGER.error(
|
|
187
|
+
"In-process embedder warmup exhausted %d attempts; /health stays not-ready.",
|
|
188
|
+
_WARM_MAX_ATTEMPTS,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def maybe_start_embedder_warmup() -> bool:
|
|
193
|
+
"""Spawn the non-blocking warm-before-ready thread when the gate is active.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
bool: True when a warmup thread was started (gate active), else False.
|
|
197
|
+
"""
|
|
198
|
+
if not inprocess_local_gate_active():
|
|
199
|
+
return False
|
|
200
|
+
threading.Thread(target=_warm_embedder, daemon=True, name="embedder-warmup").start()
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _detected_worker_count() -> int | None:
|
|
205
|
+
"""Best-effort read of the configured uvicorn worker count.
|
|
206
|
+
|
|
207
|
+
uvicorn exposes no worker-count env inside a worker process, so
|
|
208
|
+
``reflexio.server.__main__`` records it in ``REFLEXIO_SERVER_WORKERS``. A
|
|
209
|
+
``WEB_CONCURRENCY`` fallback covers gunicorn-style entrypoints. Returns
|
|
210
|
+
``None`` when neither is set (custom entrypoint) — the guard then warns that
|
|
211
|
+
it could not verify the count rather than refusing.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
int | None: The detected worker count, or None if undetectable.
|
|
215
|
+
"""
|
|
216
|
+
for name in (_ENV_WORKERS, _ENV_WEB_CONCURRENCY):
|
|
217
|
+
raw = os.environ.get(name)
|
|
218
|
+
if raw:
|
|
219
|
+
try:
|
|
220
|
+
return int(raw)
|
|
221
|
+
except ValueError:
|
|
222
|
+
continue
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _guard_workers_multiply_model() -> None:
|
|
227
|
+
"""D8(a): warn when in-process mode runs under multiple workers.
|
|
228
|
+
|
|
229
|
+
In-process embedding loads one model copy per worker process, so a
|
|
230
|
+
memory-bounded host that is fine with 1 worker can OOM at N. Warn-only: the
|
|
231
|
+
worker count is not always reliably detectable from inside a worker.
|
|
232
|
+
"""
|
|
233
|
+
if _provider() != _INPROCESS:
|
|
234
|
+
return
|
|
235
|
+
count = _detected_worker_count()
|
|
236
|
+
if count is not None and count > 1:
|
|
237
|
+
_LOGGER.warning(
|
|
238
|
+
"%s=%s with %d uvicorn workers: the in-process embedder loads one "
|
|
239
|
+
"model copy PER worker process, multiplying memory on a "
|
|
240
|
+
"memory-bounded task. Prefer 1 worker for in-process embedding, or "
|
|
241
|
+
"run the shared embedding daemon.",
|
|
242
|
+
_ENV_PROVIDER,
|
|
243
|
+
_INPROCESS,
|
|
244
|
+
count,
|
|
245
|
+
)
|
|
246
|
+
elif count is None:
|
|
247
|
+
_LOGGER.warning(
|
|
248
|
+
"%s=%s but the uvicorn worker count could not be verified (neither "
|
|
249
|
+
"%s nor %s is set). If this deployment runs multiple workers, each "
|
|
250
|
+
"loads its own in-process model copy.",
|
|
251
|
+
_ENV_PROVIDER,
|
|
252
|
+
_INPROCESS,
|
|
253
|
+
_ENV_WORKERS,
|
|
254
|
+
_ENV_WEB_CONCURRENCY,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _guard_daemon_disable_half_pair() -> None:
|
|
259
|
+
"""D8(b): warn when the daemon-disable flag and provider disagree.
|
|
260
|
+
|
|
261
|
+
``REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON`` (truthy) and
|
|
262
|
+
``REFLEXIO_EMBEDDING_PROVIDER=inprocess`` describe the same intent from two
|
|
263
|
+
angles and are meant to be flipped together. If exactly one is set the
|
|
264
|
+
topology is half-configured (e.g. the daemon is disabled but requests still
|
|
265
|
+
route to daemon mode, or vice-versa).
|
|
266
|
+
"""
|
|
267
|
+
from reflexio.server.env_utils import env_truthy
|
|
268
|
+
|
|
269
|
+
daemon_disabled = env_truthy(os.environ.get(_ENV_DISABLE_DAEMON, ""))
|
|
270
|
+
inprocess = _provider() == _INPROCESS
|
|
271
|
+
if daemon_disabled != inprocess:
|
|
272
|
+
_LOGGER.warning(
|
|
273
|
+
"Half-configured in-process embedding: %s=%s and %s=%s disagree. "
|
|
274
|
+
"Set them together (disable the daemon AND select the in-process "
|
|
275
|
+
"provider) or neither.",
|
|
276
|
+
_ENV_DISABLE_DAEMON,
|
|
277
|
+
daemon_disabled,
|
|
278
|
+
_ENV_PROVIDER,
|
|
279
|
+
_provider() or "<unset>",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _guard_inprocess_overrides_service_host() -> None:
|
|
284
|
+
"""D8(c): warn when in-process mode is set alongside a configured service host.
|
|
285
|
+
|
|
286
|
+
``REFLEXIO_EMBEDDING_PROVIDER=inprocess`` wins the provider-precedence race in
|
|
287
|
+
``embedding_provider_mode`` outright, so a co-configured
|
|
288
|
+
``REFLEXIO_EMBEDDING_SERVICE_URL`` / ``REFLEXIO_EMBEDDING_DAEMON_HOST`` is
|
|
289
|
+
silently ignored and this process loads its own in-process local model instead
|
|
290
|
+
of routing to the service. On a GPU-service / self-host fleet that means an
|
|
291
|
+
unintended per-instance CPU model load. Warn-only: the intended in-process
|
|
292
|
+
flip leaves both endpoint envs unset, so this stays silent there.
|
|
293
|
+
"""
|
|
294
|
+
if _provider() != _INPROCESS:
|
|
295
|
+
return
|
|
296
|
+
configured = [
|
|
297
|
+
name
|
|
298
|
+
for name in (_ENV_SERVICE_URL, _ENV_DAEMON_HOST)
|
|
299
|
+
if os.environ.get(name, "").strip()
|
|
300
|
+
]
|
|
301
|
+
if not configured:
|
|
302
|
+
return
|
|
303
|
+
_LOGGER.warning(
|
|
304
|
+
"%s=%s takes precedence and loads an in-process local model, silently "
|
|
305
|
+
"ignoring the configured service endpoint(s): %s. Unset the endpoint(s) "
|
|
306
|
+
"to keep the in-process embedder, or unset %s to route embeddings to the "
|
|
307
|
+
"service.",
|
|
308
|
+
_ENV_PROVIDER,
|
|
309
|
+
_INPROCESS,
|
|
310
|
+
", ".join(configured),
|
|
311
|
+
_ENV_PROVIDER,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def run_startup_config_guards() -> None:
|
|
316
|
+
"""Run the D8 config guards once at server startup (idempotent, warn-only)."""
|
|
317
|
+
_guard_workers_multiply_model()
|
|
318
|
+
_guard_daemon_disable_half_pair()
|
|
319
|
+
_guard_inprocess_overrides_service_host()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
__all__ = [
|
|
323
|
+
"inprocess_local_gate_active",
|
|
324
|
+
"is_embedder_ready",
|
|
325
|
+
"mark_embedder_ready",
|
|
326
|
+
"maybe_start_embedder_warmup",
|
|
327
|
+
"reset_warmup_state_for_test",
|
|
328
|
+
"run_startup_config_guards",
|
|
329
|
+
]
|
|
@@ -54,9 +54,19 @@ _LOCAL_SERVICE_PROBE_FAILURE_CACHE_SECONDS = 5.0
|
|
|
54
54
|
# never hands out a connection the load balancer already closed.
|
|
55
55
|
_HTTP_KEEPALIVE_EXPIRY_SECONDS = 50.0
|
|
56
56
|
_EMBEDDING_RETRY_BACKOFF_SECONDS = 0.1
|
|
57
|
+
# ``embedding_provider_mode`` is called on every embedding request (often several
|
|
58
|
+
# times per request), and a down daemon resolves to ``inprocess`` every time, so
|
|
59
|
+
# an unconditional warning would flood the logs. Emit the fallback WARNING at most
|
|
60
|
+
# once per this interval per process.
|
|
61
|
+
_INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS = 60.0
|
|
57
62
|
_SERVICE_MODES = {"local_service", "internal_service"}
|
|
58
63
|
_VALID_MODES = {"cloud", *_SERVICE_MODES, "inprocess", "off"}
|
|
59
64
|
_local_service_probe_cache: tuple[float, bool, str | None] | None = None
|
|
65
|
+
# Reason the most recent *fresh* /health probe failed (populated by
|
|
66
|
+
# ``_local_service_status``), surfaced in the inprocess-fallback WARNING.
|
|
67
|
+
_last_probe_failure_reason: str | None = None
|
|
68
|
+
# Monotonic timestamp of the last inprocess-fallback WARNING, for rate limiting.
|
|
69
|
+
_last_inprocess_fallback_warn_at: float | None = None
|
|
60
70
|
_http_client_lock = threading.Lock()
|
|
61
71
|
_http_client_instance: httpx.Client | None = None
|
|
62
72
|
_http_client_pid: int | None = None
|
|
@@ -154,7 +164,7 @@ def _http_client() -> httpx.Client:
|
|
|
154
164
|
|
|
155
165
|
|
|
156
166
|
def _local_service_status() -> tuple[bool, str | None]:
|
|
157
|
-
global _local_service_probe_cache
|
|
167
|
+
global _local_service_probe_cache, _last_probe_failure_reason
|
|
158
168
|
now = time.monotonic()
|
|
159
169
|
if _local_service_probe_cache is not None:
|
|
160
170
|
cached_at, cached_reachable, cached_model = _local_service_probe_cache
|
|
@@ -166,6 +176,7 @@ def _local_service_status() -> tuple[bool, str | None]:
|
|
|
166
176
|
if now - cached_at < ttl:
|
|
167
177
|
return cached_reachable, cached_model
|
|
168
178
|
|
|
179
|
+
reason: str | None = None
|
|
169
180
|
try:
|
|
170
181
|
response = _http_client().get(
|
|
171
182
|
f"{_local_service_url()}/health",
|
|
@@ -175,19 +186,53 @@ def _local_service_status() -> tuple[bool, str | None]:
|
|
|
175
186
|
active_model = response.json().get("active_model") if reachable else None
|
|
176
187
|
if not isinstance(active_model, str):
|
|
177
188
|
active_model = None
|
|
178
|
-
|
|
189
|
+
if not reachable:
|
|
190
|
+
reason = f"/health returned HTTP {response.status_code}"
|
|
191
|
+
except httpx.HTTPError as exc:
|
|
179
192
|
reachable = False
|
|
180
193
|
active_model = None
|
|
181
|
-
|
|
194
|
+
reason = f"{type(exc).__name__}: {exc}"
|
|
195
|
+
except ValueError as exc:
|
|
182
196
|
reachable = False
|
|
183
197
|
active_model = None
|
|
198
|
+
reason = f"invalid /health response: {exc}"
|
|
199
|
+
_last_probe_failure_reason = reason
|
|
184
200
|
_local_service_probe_cache = (now, reachable, active_model)
|
|
185
201
|
return reachable, active_model
|
|
186
202
|
|
|
187
203
|
|
|
188
|
-
def
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
def _warn_inprocess_fallback(model: str | None, reason: str | None) -> None:
|
|
205
|
+
"""Warn (rate-limited) that a ``local/*`` model fell back to the in-process embedder.
|
|
206
|
+
|
|
207
|
+
Emitted only when daemon-mode resolution for a ``local/*`` model fails the
|
|
208
|
+
``/health`` probe (or the daemon serves a different model) and routing falls
|
|
209
|
+
back to ``inprocess`` — NOT when ``inprocess`` was configured explicitly. The
|
|
210
|
+
fallback loads a second copy of the embedding model into this worker process,
|
|
211
|
+
so operators need to distinguish it from an intentional in-process config.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
model (str | None): The ``local/*`` embedding model being resolved.
|
|
215
|
+
reason (str | None): Why the daemon path was rejected, if known.
|
|
216
|
+
"""
|
|
217
|
+
global _last_inprocess_fallback_warn_at
|
|
218
|
+
now = time.monotonic()
|
|
219
|
+
if (
|
|
220
|
+
_last_inprocess_fallback_warn_at is not None
|
|
221
|
+
and now - _last_inprocess_fallback_warn_at
|
|
222
|
+
< _INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS
|
|
223
|
+
):
|
|
224
|
+
return
|
|
225
|
+
_last_inprocess_fallback_warn_at = now
|
|
226
|
+
_LOGGER.warning(
|
|
227
|
+
"Embedding daemon probe at %s failed for model %r; falling back to an "
|
|
228
|
+
"in-process embedding model copy (a second model is loaded into this "
|
|
229
|
+
"worker process). Probe failure: %s. Further fallback warnings are "
|
|
230
|
+
"suppressed for %.0fs.",
|
|
231
|
+
_local_service_url(),
|
|
232
|
+
model,
|
|
233
|
+
reason or "unknown",
|
|
234
|
+
_INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS,
|
|
235
|
+
)
|
|
191
236
|
|
|
192
237
|
|
|
193
238
|
def _ordered_embeddings_from_response(
|
|
@@ -260,7 +305,18 @@ def embedding_provider_mode(model: str | None = None) -> EmbeddingProviderMode:
|
|
|
260
305
|
return "local_service"
|
|
261
306
|
|
|
262
307
|
if _is_local_model(model):
|
|
263
|
-
|
|
308
|
+
reachable, active_model = _local_service_status()
|
|
309
|
+
if reachable and (active_model is None or active_model == model):
|
|
310
|
+
return "local_service"
|
|
311
|
+
if reachable:
|
|
312
|
+
reason = (
|
|
313
|
+
f"daemon is reachable but serves active_model={active_model!r}, "
|
|
314
|
+
f"not {model!r}"
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
reason = _last_probe_failure_reason
|
|
318
|
+
_warn_inprocess_fallback(model, reason)
|
|
319
|
+
return "inprocess"
|
|
264
320
|
return "cloud"
|
|
265
321
|
|
|
266
322
|
|
|
@@ -311,7 +367,12 @@ def get_service_embeddings(
|
|
|
311
367
|
chunk = texts[start : start + chunk_size]
|
|
312
368
|
embeddings.extend(
|
|
313
369
|
_post_embedding_batch(
|
|
314
|
-
url,
|
|
370
|
+
url,
|
|
371
|
+
model=model,
|
|
372
|
+
texts=chunk,
|
|
373
|
+
dimensions=dimensions,
|
|
374
|
+
timeout=timeout,
|
|
375
|
+
mode=mode,
|
|
315
376
|
)
|
|
316
377
|
)
|
|
317
378
|
return embeddings
|
|
@@ -324,6 +385,7 @@ def _post_embedding_batch(
|
|
|
324
385
|
texts: list[str],
|
|
325
386
|
dimensions: int | None,
|
|
326
387
|
timeout: float,
|
|
388
|
+
mode: EmbeddingProviderMode,
|
|
327
389
|
) -> list[list[float]]:
|
|
328
390
|
"""POST one bounded batch of texts to the embedding service."""
|
|
329
391
|
payload: dict[str, Any] = {"model": model, "input": texts}
|
|
@@ -368,9 +430,22 @@ def _post_embedding_batch(
|
|
|
368
430
|
last_error = exc
|
|
369
431
|
break
|
|
370
432
|
|
|
371
|
-
|
|
433
|
+
# For HTTP-status failures the response body carries the actionable
|
|
434
|
+
# detail (e.g. the daemon's 409 "already owns model X" message) — without
|
|
435
|
+
# it the log reads as a connectivity problem when it is a config conflict.
|
|
436
|
+
error_detail = str(last_error)
|
|
437
|
+
if isinstance(last_error, httpx.HTTPStatusError):
|
|
438
|
+
body = last_error.response.text.strip()
|
|
439
|
+
if body:
|
|
440
|
+
error_detail = f"{last_error} — response body: {body[:300]}"
|
|
441
|
+
_LOGGER.warning(
|
|
442
|
+
"Embedding service unavailable at %s: %s",
|
|
443
|
+
url,
|
|
444
|
+
error_detail,
|
|
445
|
+
extra={"mode": mode},
|
|
446
|
+
)
|
|
372
447
|
raise EmbeddingUnavailableError(
|
|
373
|
-
f"Embedding service unavailable at {url}: {
|
|
448
|
+
f"Embedding service unavailable at {url}: {error_detail}"
|
|
374
449
|
) from last_error
|
|
375
450
|
|
|
376
451
|
|
|
@@ -74,7 +74,17 @@ class LocalEmbedder:
|
|
|
74
74
|
|
|
75
75
|
def __init__(self) -> None:
|
|
76
76
|
self._ef: Any | None = None
|
|
77
|
+
# Guards lazy construction / cache-recovery swaps of ``self._ef``.
|
|
77
78
|
self._ef_lock = threading.Lock()
|
|
79
|
+
# Serializes the actual encode call. The shared ONNX embedding function
|
|
80
|
+
# is NOT thread-safe — concurrent ``ef(...)`` calls interleave and
|
|
81
|
+
# corrupt each other's padding/attention buffers (observed in prod as
|
|
82
|
+
# tensor-shape mismatches). ``threading.Lock`` is non-reentrant, so this
|
|
83
|
+
# is a SEPARATE lock from ``_ef_lock`` (the recovery path re-acquires
|
|
84
|
+
# ``_ef_lock`` while we hold this one): fixed order is ``_encode_lock``
|
|
85
|
+
# outer, ``_ef_lock`` inner, which cannot deadlock. NOTE: this lock is
|
|
86
|
+
# not FIFO-fair — do not turn it into a fairness queue without measuring.
|
|
87
|
+
self._encode_lock = threading.Lock()
|
|
78
88
|
|
|
79
89
|
@classmethod
|
|
80
90
|
def get(cls) -> LocalEmbedder:
|
|
@@ -121,12 +131,17 @@ class LocalEmbedder:
|
|
|
121
131
|
``_TARGET_DIM`` (512) floats with the last 128 positions
|
|
122
132
|
zero-padded.
|
|
123
133
|
"""
|
|
124
|
-
ef = self._load()
|
|
125
134
|
safe_inputs = [(text or "")[:_MAX_CHARS] for text in texts]
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
# Serialize the encode: the shared ONNX embedder is not thread-safe.
|
|
136
|
+
# ``_load()`` and ``_retry_embed_after_cache_clear()`` both acquire
|
|
137
|
+
# ``_ef_lock`` internally; running them under ``_encode_lock`` fixes the
|
|
138
|
+
# lock order (``_encode_lock`` outer) so recovery can never deadlock.
|
|
139
|
+
with self._encode_lock:
|
|
140
|
+
ef = self._load()
|
|
141
|
+
try:
|
|
142
|
+
raw = ef(safe_inputs)
|
|
143
|
+
except Exception as exc: # noqa: BLE001 - Chroma raises varied cache errors.
|
|
144
|
+
raw = self._retry_embed_after_cache_clear(ef, exc, safe_inputs)
|
|
130
145
|
return [_pad(vec) for vec in raw]
|
|
131
146
|
|
|
132
147
|
def _retry_embed_after_cache_clear(
|
|
@@ -63,12 +63,64 @@ _MAX_CHARS = 32_000
|
|
|
63
63
|
_DEFAULT_ENCODE_BATCH_SIZE = 4
|
|
64
64
|
_ENV_BATCH_SIZE = "REFLEXIO_EMBED_BATCH_SIZE"
|
|
65
65
|
|
|
66
|
+
# D7 (Phase 2 prep): optionally pin torch intra-op thread count so the embedder
|
|
67
|
+
# does not oversubscribe CPU under bounded concurrency. Unset (the default) =
|
|
68
|
+
# leave torch untouched, i.e. zero behaviour change. Applies at Nomic model load
|
|
69
|
+
# (both the in-process fallback and the shared daemon run NomicEmbedder). Note the
|
|
70
|
+
# chromadb/minilm LocalEmbedder path uses onnxruntime, not torch, so this knob does
|
|
71
|
+
# not affect it.
|
|
72
|
+
_ENV_TORCH_THREADS = "REFLEXIO_EMBED_TORCH_THREADS"
|
|
73
|
+
_torch_threads_pinned = False
|
|
74
|
+
_torch_threads_lock = threading.Lock()
|
|
75
|
+
|
|
66
76
|
|
|
67
77
|
def _encode_batch_size() -> int:
|
|
68
78
|
"""Resolve the encode mini-batch size from env, defaulting to 4."""
|
|
69
79
|
return positive_int_env(_ENV_BATCH_SIZE, _DEFAULT_ENCODE_BATCH_SIZE, _LOGGER)
|
|
70
80
|
|
|
71
81
|
|
|
82
|
+
def _maybe_pin_torch_threads() -> None:
|
|
83
|
+
"""Pin torch intra-op threads to ``REFLEXIO_EMBED_TORCH_THREADS`` if set.
|
|
84
|
+
|
|
85
|
+
Dormant by default: when the env var is unset (or blank) this is a no-op and
|
|
86
|
+
``torch.set_num_threads`` is never called, so torch keeps its default
|
|
87
|
+
autodetected thread count. When set to a positive int it is applied exactly
|
|
88
|
+
once per process (guarded against re-calling). A non-positive or non-integer
|
|
89
|
+
value is ignored with a warning.
|
|
90
|
+
"""
|
|
91
|
+
global _torch_threads_pinned
|
|
92
|
+
if _torch_threads_pinned:
|
|
93
|
+
return
|
|
94
|
+
raw = os.environ.get(_ENV_TORCH_THREADS)
|
|
95
|
+
if not raw:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
n = int(raw)
|
|
99
|
+
except ValueError:
|
|
100
|
+
_LOGGER.warning(
|
|
101
|
+
"%s must be a positive integer; got %r — leaving torch threads at "
|
|
102
|
+
"their default.",
|
|
103
|
+
_ENV_TORCH_THREADS,
|
|
104
|
+
raw,
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
if n < 1:
|
|
108
|
+
_LOGGER.warning(
|
|
109
|
+
"%s must be >= 1; got %d — leaving torch threads at their default.",
|
|
110
|
+
_ENV_TORCH_THREADS,
|
|
111
|
+
n,
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
with _torch_threads_lock:
|
|
115
|
+
if _torch_threads_pinned:
|
|
116
|
+
return
|
|
117
|
+
import torch
|
|
118
|
+
|
|
119
|
+
torch.set_num_threads(n)
|
|
120
|
+
_torch_threads_pinned = True
|
|
121
|
+
_LOGGER.info("Pinned torch intra-op threads to %d (%s)", n, _ENV_TORCH_THREADS)
|
|
122
|
+
|
|
123
|
+
|
|
72
124
|
class NomicEmbedderError(RuntimeError):
|
|
73
125
|
"""Raised when the Nomic embedder is requested but its deps are missing."""
|
|
74
126
|
|
|
@@ -118,6 +170,7 @@ class NomicEmbedder:
|
|
|
118
170
|
"sentence-transformers is required for the Nomic local "
|
|
119
171
|
"embedder. Install with `uv add sentence-transformers`."
|
|
120
172
|
) from exc
|
|
173
|
+
_maybe_pin_torch_threads()
|
|
121
174
|
_LOGGER.info(
|
|
122
175
|
"Loading Nomic embedding model %s — first call may download "
|
|
123
176
|
"~550 MB to %s",
|
|
@@ -157,15 +210,30 @@ class NomicEmbedder:
|
|
|
157
210
|
"""
|
|
158
211
|
model = self._load()
|
|
159
212
|
safe = [(t or "")[:_MAX_CHARS] for t in texts]
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
213
|
+
# The sentence-transformers model is NOT thread-safe: nomic-bert's
|
|
214
|
+
# rotary-embedding code mutates instance attrs (``_cos_cached`` /
|
|
215
|
+
# ``_sin_cached``) on every forward pass, so two concurrent
|
|
216
|
+
# ``encode()`` calls on this shared singleton corrupt each other's
|
|
217
|
+
# buffers (observed in prod as "size of tensor a (N) must match tensor
|
|
218
|
+
# b (M)" and "'NoneType' object is not subscriptable"). Serialize the
|
|
219
|
+
# encode so every caller — daemon, in-process fallback, prewarm,
|
|
220
|
+
# regeneration — is safe by construction. ``_load()`` acquires and
|
|
221
|
+
# releases ``_model_lock`` and returns before we re-acquire it here, so
|
|
222
|
+
# there is no nesting/deadlock. NOTE: ``threading.Lock`` is not FIFO-fair
|
|
223
|
+
# — do not "improve" this into a fairness queue without measuring; the
|
|
224
|
+
# micro-batch coalescing in ``embedding_service`` is where throughput
|
|
225
|
+
# comes from, not parallel encodes on one model.
|
|
226
|
+
with self._model_lock:
|
|
227
|
+
# show_progress_bar=False so server logs stay clean during ingest
|
|
228
|
+
# batches. convert_to_numpy=True returns a numpy ndarray; we slice
|
|
229
|
+
# and renormalise per-row (below, outside the lock) before
|
|
230
|
+
# converting to plain Python lists.
|
|
231
|
+
raw = model.encode(
|
|
232
|
+
safe,
|
|
233
|
+
batch_size=_encode_batch_size(),
|
|
234
|
+
show_progress_bar=False,
|
|
235
|
+
convert_to_numpy=True,
|
|
236
|
+
)
|
|
169
237
|
return [_truncate_and_renormalise(vec.tolist()) for vec in raw]
|
|
170
238
|
|
|
171
239
|
|