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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Process-wide bounded executor for deferred fire-and-forget callbacks.
|
|
2
|
+
|
|
3
|
+
Replaces thread-per-fire in the debounce schedulers (tagging, group
|
|
4
|
+
evaluation, playbook optimization) so thread count is O(1) instead of
|
|
5
|
+
O(active keys) (spec section 6.2). Sizes are module constants, not env vars.
|
|
6
|
+
|
|
7
|
+
Overflow policy: drop-oldest (a dropped fire is a stale debounced trigger —
|
|
8
|
+
the next event for that key re-creates it) with one warning log per drop and
|
|
9
|
+
a throttled anomaly when drops exceed ``_DROP_RATE_PER_MINUTE`` in a rolling
|
|
10
|
+
minute (the "this is routine, resize it" signal).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections import deque
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
from reflexio.server.tracing import capture_anomaly
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_WORKERS = 16
|
|
27
|
+
_QUEUE_SIZE = 256
|
|
28
|
+
_DROP_RATE_PER_MINUTE = 10
|
|
29
|
+
_DROP_ANOMALY_THROTTLE_SECONDS = 3600.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _DropFacts(NamedTuple):
|
|
33
|
+
"""Facts about one drop, collected while ``_cond`` is held.
|
|
34
|
+
|
|
35
|
+
Carries everything :meth:`BoundedCallbackExecutor._emit_drop` needs to log
|
|
36
|
+
+ escalate AFTER the lock is released, so the contended lock is never held
|
|
37
|
+
across a logging/anomaly call.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
dropped_name: str
|
|
41
|
+
drops_last_minute: int
|
|
42
|
+
fire_anomaly: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BoundedCallbackExecutor:
|
|
46
|
+
"""Fixed worker pool + bounded FIFO queue with drop-oldest overflow.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
workers: Number of daemon worker threads.
|
|
50
|
+
queue_size: Max queued callbacks before drop-oldest applies.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self, *, workers: int = _WORKERS, queue_size: int = _QUEUE_SIZE
|
|
55
|
+
) -> None:
|
|
56
|
+
self._queue: deque[tuple[str, Callable[[], None]]] = deque()
|
|
57
|
+
self._queue_size = queue_size
|
|
58
|
+
self._cond = threading.Condition()
|
|
59
|
+
self._drop_times: deque[float] = deque()
|
|
60
|
+
self._last_drop_anomaly = 0.0
|
|
61
|
+
for i in range(workers):
|
|
62
|
+
threading.Thread(
|
|
63
|
+
target=self._worker_loop,
|
|
64
|
+
daemon=True,
|
|
65
|
+
name=f"callback-exec-{i}",
|
|
66
|
+
).start()
|
|
67
|
+
|
|
68
|
+
def submit(self, name: str, fn: Callable[[], None]) -> None:
|
|
69
|
+
"""Enqueue a callback; on a full queue, evict the oldest queued fire.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
name: Short label for logs (e.g. the scheduler key).
|
|
73
|
+
fn: Zero-arg callback to run on a worker.
|
|
74
|
+
"""
|
|
75
|
+
drop_facts: _DropFacts | None = None
|
|
76
|
+
with self._cond:
|
|
77
|
+
if len(self._queue) >= self._queue_size:
|
|
78
|
+
dropped_name, _ = self._queue.popleft()
|
|
79
|
+
drop_facts = self._record_drop_locked(dropped_name)
|
|
80
|
+
self._queue.append((name, fn))
|
|
81
|
+
self._cond.notify()
|
|
82
|
+
if drop_facts is not None:
|
|
83
|
+
self._emit_drop(drop_facts)
|
|
84
|
+
|
|
85
|
+
def _record_drop_locked(self, dropped_name: str) -> _DropFacts:
|
|
86
|
+
"""Collect the facts of one drop while ``self._cond`` is held.
|
|
87
|
+
|
|
88
|
+
Only bookkeeping happens under the lock (all 16 workers + all
|
|
89
|
+
producers contend on it): pruning the rolling drop-time window and
|
|
90
|
+
deciding — and, if firing, committing — the throttle state
|
|
91
|
+
(``_last_drop_anomaly``) so the decision stays race-free. Logging and
|
|
92
|
+
anomaly emission are the caller's job, done AFTER the lock is
|
|
93
|
+
released (see :meth:`_emit_drop`).
|
|
94
|
+
"""
|
|
95
|
+
now = time.monotonic()
|
|
96
|
+
self._drop_times.append(now)
|
|
97
|
+
while self._drop_times and now - self._drop_times[0] > 60.0:
|
|
98
|
+
self._drop_times.popleft()
|
|
99
|
+
drops_last_minute = len(self._drop_times)
|
|
100
|
+
fire_anomaly = (
|
|
101
|
+
drops_last_minute > _DROP_RATE_PER_MINUTE
|
|
102
|
+
and now - self._last_drop_anomaly > _DROP_ANOMALY_THROTTLE_SECONDS
|
|
103
|
+
)
|
|
104
|
+
if fire_anomaly:
|
|
105
|
+
self._last_drop_anomaly = now
|
|
106
|
+
return _DropFacts(
|
|
107
|
+
dropped_name=dropped_name,
|
|
108
|
+
drops_last_minute=drops_last_minute,
|
|
109
|
+
fire_anomaly=fire_anomaly,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _emit_drop(self, facts: _DropFacts) -> None:
|
|
113
|
+
"""Log one drop and (if decided under the lock) escalate the anomaly.
|
|
114
|
+
|
|
115
|
+
Called with ``self._cond`` NOT held — logging and ``capture_anomaly``
|
|
116
|
+
never happen while the contended lock is owned. References the
|
|
117
|
+
module-level ``capture_anomaly`` name (not a bound attribute) so
|
|
118
|
+
tests that ``monkeypatch.setattr(module, "capture_anomaly", ...)``
|
|
119
|
+
still intercept the call.
|
|
120
|
+
"""
|
|
121
|
+
logger.warning(
|
|
122
|
+
"event=callback_executor_drop_oldest dropped=%s", facts.dropped_name
|
|
123
|
+
)
|
|
124
|
+
if facts.fire_anomaly:
|
|
125
|
+
capture_anomaly(
|
|
126
|
+
"callback_executor.drop_rate_exceeded",
|
|
127
|
+
drops_last_minute=facts.drops_last_minute,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _worker_loop(self) -> None:
|
|
131
|
+
while True:
|
|
132
|
+
with self._cond:
|
|
133
|
+
while not self._queue:
|
|
134
|
+
self._cond.wait()
|
|
135
|
+
name, fn = self._queue.popleft()
|
|
136
|
+
try:
|
|
137
|
+
fn()
|
|
138
|
+
except BaseException: # noqa: BLE001 — daemon worker threads must
|
|
139
|
+
# survive anything a callback raises (including a callback
|
|
140
|
+
# that itself raises SystemExit); KeyboardInterrupt is only
|
|
141
|
+
# ever delivered to the main thread, so swallowing it here is
|
|
142
|
+
# safe. The fixed 16-worker pool must never silently shrink.
|
|
143
|
+
logger.exception(
|
|
144
|
+
"event=callback_executor_callback_failed name=%s", name
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_executor: BoundedCallbackExecutor | None = None
|
|
149
|
+
_executor_lock = threading.Lock()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def submit_callback(name: str, fn: Callable[[], None]) -> None:
|
|
153
|
+
"""Submit to the lazily-created process-wide executor.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
name: Short label for logs.
|
|
157
|
+
fn: Zero-arg callback.
|
|
158
|
+
"""
|
|
159
|
+
global _executor # noqa: PLW0603
|
|
160
|
+
if _executor is None:
|
|
161
|
+
with _executor_lock:
|
|
162
|
+
if _executor is None:
|
|
163
|
+
_executor = BoundedCallbackExecutor()
|
|
164
|
+
_executor.submit(name, fn)
|
|
@@ -197,6 +197,24 @@ def _truncate_for_embedding(
|
|
|
197
197
|
return encoding.decode(tokens[:max_tokens])
|
|
198
198
|
|
|
199
199
|
|
|
200
|
+
def _embedding_error(message: str, mode: str) -> LiteLLMClientError:
|
|
201
|
+
"""Build a ``LiteLLMClientError`` and log it tagged with the resolved mode.
|
|
202
|
+
|
|
203
|
+
Attaching ``mode`` (daemon-path vs in-process-path vs cloud) to the log makes
|
|
204
|
+
embedding failures attributable to a routing decision. The exception message
|
|
205
|
+
itself is unchanged so existing callers/tests keep matching on it.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
message (str): The exception message (also logged).
|
|
209
|
+
mode (str): The resolved embedding provider mode.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
LiteLLMClientError: The exception to raise (``raise ... from e``).
|
|
213
|
+
"""
|
|
214
|
+
_LOGGER.warning("%s (mode=%s)", message, mode, extra={"mode": mode})
|
|
215
|
+
return LiteLLMClientError(message)
|
|
216
|
+
|
|
217
|
+
|
|
200
218
|
class EmbeddingMixin:
|
|
201
219
|
"""Embedding dispatch (service / Nomic / local ONNX / litellm) for ``LiteLLMClient``.
|
|
202
220
|
|
|
@@ -251,6 +269,10 @@ class EmbeddingMixin:
|
|
|
251
269
|
"""
|
|
252
270
|
Get embedding vector for the given text.
|
|
253
271
|
|
|
272
|
+
Thin wrapper over ``get_embeddings`` — the single- and batch-text paths
|
|
273
|
+
share one dispatch body so their routing, truncation, and error handling
|
|
274
|
+
cannot drift.
|
|
275
|
+
|
|
254
276
|
Args:
|
|
255
277
|
text: The text to get embedding for.
|
|
256
278
|
model: Optional embedding model. When omitted, the model is
|
|
@@ -265,73 +287,7 @@ class EmbeddingMixin:
|
|
|
265
287
|
Raises:
|
|
266
288
|
LiteLLMClientError: If embedding generation fails.
|
|
267
289
|
"""
|
|
268
|
-
|
|
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
|
|
290
|
+
return self._embed_texts([text], model, dimensions, batch=False)[0]
|
|
335
291
|
|
|
336
292
|
def get_embeddings(
|
|
337
293
|
self,
|
|
@@ -358,44 +314,86 @@ class EmbeddingMixin:
|
|
|
358
314
|
"""
|
|
359
315
|
if not texts:
|
|
360
316
|
return []
|
|
317
|
+
return self._embed_texts(list(texts), model, dimensions, batch=True)
|
|
361
318
|
|
|
319
|
+
def _embed_texts(
|
|
320
|
+
self,
|
|
321
|
+
texts: list[str],
|
|
322
|
+
model: str | None,
|
|
323
|
+
dimensions: int | None,
|
|
324
|
+
*,
|
|
325
|
+
batch: bool,
|
|
326
|
+
) -> list[list[float]]:
|
|
327
|
+
"""Shared embedding dispatch for ``get_embedding`` and ``get_embeddings``.
|
|
328
|
+
|
|
329
|
+
``get_embedding`` passes a single-element list and unwraps ``[0]``;
|
|
330
|
+
``get_embeddings`` passes the whole list. Behavior is identical between
|
|
331
|
+
the two callers except the error-message wording, which ``batch``
|
|
332
|
+
selects ("batch" wording matches the historical batch-path messages).
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
texts: Non-empty list of texts to embed (caller guarantees non-empty).
|
|
336
|
+
model: Optional embedding model; auto-detected when ``None``.
|
|
337
|
+
dimensions: Optional embedding dimensions.
|
|
338
|
+
batch: Selects the batch vs single error-message wording.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
One embedding vector per input text, in input order.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
EmbeddingUnavailableError: If the embedding provider is disabled.
|
|
345
|
+
LiteLLMClientError: If embedding generation fails.
|
|
346
|
+
"""
|
|
362
347
|
embedding_model = model or self._resolve_default_embedding_model()
|
|
363
348
|
mode = embedding_provider_mode(embedding_model)
|
|
364
349
|
if mode == "off":
|
|
365
350
|
raise EmbeddingUnavailableError("Embedding provider is disabled")
|
|
366
351
|
if should_use_embedding_service(embedding_model):
|
|
367
352
|
return get_service_embeddings(
|
|
368
|
-
|
|
353
|
+
texts, model=embedding_model, dimensions=dimensions
|
|
369
354
|
)
|
|
370
355
|
|
|
371
|
-
#
|
|
356
|
+
# local/nomic-embed-* must stay on the Nomic provider (137M params,
|
|
357
|
+
# 768d Matryoshka-truncated to 512). Falling through to MiniLM would
|
|
358
|
+
# mix embedding models inside existing vector stores.
|
|
372
359
|
if _is_nomic_model(embedding_model):
|
|
373
360
|
_reject_cloud_mode(embedding_model, mode)
|
|
374
361
|
try:
|
|
375
|
-
return NomicEmbedder.get().embed(
|
|
362
|
+
return NomicEmbedder.get().embed(texts)
|
|
376
363
|
except Exception as e:
|
|
377
|
-
raise
|
|
378
|
-
f"Nomic batch embedding generation
|
|
364
|
+
raise _embedding_error(
|
|
365
|
+
f"Nomic{' batch' if batch else ''} embedding generation "
|
|
366
|
+
f"failed: {str(e)}",
|
|
367
|
+
mode,
|
|
379
368
|
) from e
|
|
380
369
|
|
|
370
|
+
# local/* models route through the in-process ONNX embedder — no
|
|
371
|
+
# network call, no litellm API, no tiktoken truncation (the embedder
|
|
372
|
+
# applies its own token cap). The dispatch is gated solely on
|
|
373
|
+
# ``chromadb`` being importable; the env-var opt-in (claude-smart's
|
|
374
|
+
# ``CLAUDE_SMART_USE_LOCAL_EMBEDDING``) is enforced earlier in the
|
|
375
|
+
# auto-detection layer (see ``model_defaults._auto_detect_model``).
|
|
381
376
|
if embedding_model.startswith("local/"):
|
|
382
377
|
_reject_cloud_mode(embedding_model, mode)
|
|
383
378
|
if not _is_chromadb_importable():
|
|
384
|
-
raise
|
|
379
|
+
raise _embedding_error(
|
|
385
380
|
f"Embedding model {embedding_model!r} requires chromadb. "
|
|
386
|
-
"Run `pip install chromadb`."
|
|
381
|
+
"Run `pip install chromadb`.",
|
|
382
|
+
mode,
|
|
387
383
|
)
|
|
388
384
|
try:
|
|
389
|
-
return LocalEmbedder.get().embed(
|
|
385
|
+
return LocalEmbedder.get().embed(texts)
|
|
390
386
|
except Exception as e:
|
|
391
|
-
raise
|
|
392
|
-
f"Local batch embedding generation
|
|
387
|
+
raise _embedding_error(
|
|
388
|
+
f"Local{' batch' if batch else ''} embedding generation "
|
|
389
|
+
f"failed: {str(e)}",
|
|
390
|
+
mode,
|
|
393
391
|
) from e
|
|
394
392
|
|
|
395
|
-
|
|
393
|
+
truncated = [_truncate_for_embedding(t, embedding_model) for t in texts]
|
|
396
394
|
|
|
397
395
|
try:
|
|
398
|
-
params = {"model": embedding_model, "input":
|
|
396
|
+
params = {"model": embedding_model, "input": truncated}
|
|
399
397
|
if dimensions:
|
|
400
398
|
params["dimensions"] = dimensions
|
|
401
399
|
|
|
@@ -419,6 +417,8 @@ class EmbeddingMixin:
|
|
|
419
417
|
sorted_data = sorted(response.data, key=lambda x: x["index"])
|
|
420
418
|
return [item["embedding"] for item in sorted_data]
|
|
421
419
|
except Exception as e:
|
|
422
|
-
raise
|
|
423
|
-
f"Batch embedding
|
|
420
|
+
raise _embedding_error(
|
|
421
|
+
f"{'Batch embedding' if batch else 'Embedding'} generation "
|
|
422
|
+
f"failed: {str(e)}",
|
|
423
|
+
mode,
|
|
424
424
|
) from e
|
|
@@ -141,9 +141,37 @@ def _picklable_completion_result(response: Any) -> Any:
|
|
|
141
141
|
return response
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def _reset_llm_client_state_after_fork() -> None:
|
|
145
|
+
"""Drop litellm's module-level HTTP client cache in the forked CHILD.
|
|
146
|
+
|
|
147
|
+
``_litellm_completion_worker`` runs in a ``multiprocessing`` fork child, which
|
|
148
|
+
inherits a *copy* of the parent's ``litellm.in_memory_llm_clients_cache`` —
|
|
149
|
+
including any warm keep-alive HTTPS connection the parent held. Reusing such
|
|
150
|
+
an inherited socket in the child shares one TLS/HTTP connection across two
|
|
151
|
+
processes and corrupts the child's first completion (surfaces as
|
|
152
|
+
``[SSL] record layer failure`` / ``Server disconnected`` / a garbled request
|
|
153
|
+
the provider rejects as missing auth). Clearing the cache here forces the
|
|
154
|
+
child to build a fresh client (and socket) for its one completion. Best-effort
|
|
155
|
+
and fully guarded: a litellm build without these attributes is a no-op, never
|
|
156
|
+
a crash. Only the child's copy is mutated, so the parent cache is untouched.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
cache = getattr(litellm, "in_memory_llm_clients_cache", None)
|
|
160
|
+
if cache is not None:
|
|
161
|
+
flush = getattr(cache, "flush_cache", None)
|
|
162
|
+
if callable(flush):
|
|
163
|
+
flush()
|
|
164
|
+
cache_dict = getattr(cache, "cache_dict", None)
|
|
165
|
+
if isinstance(cache_dict, dict):
|
|
166
|
+
cache_dict.clear()
|
|
167
|
+
except Exception: # noqa: BLE001, S110 — a reset failure must never break the call
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
144
171
|
def _litellm_completion_worker(
|
|
145
172
|
params: dict[str, Any], result_queue: multiprocessing.Queue
|
|
146
173
|
) -> None:
|
|
174
|
+
_reset_llm_client_state_after_fork()
|
|
147
175
|
try:
|
|
148
176
|
result_queue.put(
|
|
149
177
|
("ok", _picklable_completion_result(litellm.completion(**params)))
|
|
@@ -53,7 +53,11 @@ from reflexio.server.llm.image_utils import (
|
|
|
53
53
|
encode_image_to_base64 as _encode_image_to_base64,
|
|
54
54
|
)
|
|
55
55
|
from reflexio.server.llm.llm_utils import is_pydantic_model
|
|
56
|
-
from reflexio.server.llm.model_defaults import
|
|
56
|
+
from reflexio.server.llm.model_defaults import (
|
|
57
|
+
ModelRole,
|
|
58
|
+
default_max_tokens_for_model,
|
|
59
|
+
resolve_model_name,
|
|
60
|
+
)
|
|
57
61
|
|
|
58
62
|
if TYPE_CHECKING:
|
|
59
63
|
from reflexio.server.llm._litellm_types import LiteLLMConfig
|
|
@@ -74,6 +78,36 @@ _MODEL_TIMEOUT_FLOOR_SECONDS: dict[str, int] = {
|
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
|
|
81
|
+
# Upstream-provider errors that are EXPECTED and transient. By the time one of
|
|
82
|
+
# these reaches the request handler the fallback ladder is already exhausted,
|
|
83
|
+
# but the caller — not the client — owns fatality, and many callers degrade
|
|
84
|
+
# gracefully (FTS fallback for document expansion, skip-dedup for profile
|
|
85
|
+
# consolidation). Log these at WARNING so a flaky provider (e.g. minimax
|
|
86
|
+
# timeouts / 529 overload) can't flood ERROR-level alerts for handled failures;
|
|
87
|
+
# genuinely-unexpected errors (bugs, auth, malformed structured output) stay
|
|
88
|
+
# ERROR. Classified by exception TYPE NAME to avoid importing the heavy
|
|
89
|
+
# ``litellm``/``openai`` exception hierarchies at module import.
|
|
90
|
+
_TRANSIENT_LLM_ERROR_NAMES: frozenset[str] = frozenset(
|
|
91
|
+
{
|
|
92
|
+
"Timeout",
|
|
93
|
+
"APITimeoutError",
|
|
94
|
+
"APIConnectionError",
|
|
95
|
+
"RateLimitError",
|
|
96
|
+
"InternalServerError",
|
|
97
|
+
"ServiceUnavailableError",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_expected_transient_llm_error(exc: BaseException) -> bool:
|
|
103
|
+
"""True for expected transient upstream failures (timeout / connection /
|
|
104
|
+
rate-limit / overload), including our own ``LLMHardTimeoutError`` (a
|
|
105
|
+
``TimeoutError`` subclass raised when a provider hang is killed)."""
|
|
106
|
+
if isinstance(exc, TimeoutError): # incl. LLMHardTimeoutError
|
|
107
|
+
return True
|
|
108
|
+
return type(exc).__name__ in _TRANSIENT_LLM_ERROR_NAMES
|
|
109
|
+
|
|
110
|
+
|
|
77
111
|
class TextGenerationMixin:
|
|
78
112
|
"""Chat/response generation, completion-param build, hard-timeout, cost, multimodal.
|
|
79
113
|
|
|
@@ -293,7 +327,10 @@ class TextGenerationMixin:
|
|
|
293
327
|
tool_choice = kwargs.pop("tool_choice", None)
|
|
294
328
|
model_role: ModelRole | None = kwargs.pop("model_role", None)
|
|
295
329
|
|
|
296
|
-
|
|
330
|
+
# An explicit ``model=None`` means "use the config default" — callers
|
|
331
|
+
# like the eval judges forward an optional model straight through, and
|
|
332
|
+
# a literal None would crash on ``.lower()`` during key resolution.
|
|
333
|
+
actual_model = kwargs.pop("model", None) or self.config.model
|
|
297
334
|
|
|
298
335
|
# model_role takes priority over the default model but falls through
|
|
299
336
|
# to the custom_endpoint override below (highest priority).
|
|
@@ -322,8 +359,16 @@ class TextGenerationMixin:
|
|
|
322
359
|
}
|
|
323
360
|
|
|
324
361
|
# Drop any fallback entry that points back at the primary — sending the
|
|
325
|
-
# same broken endpoint twice never helps.
|
|
326
|
-
|
|
362
|
+
# same broken endpoint twice never helps. Also drop in-process ``local/*``
|
|
363
|
+
# embedding models: they have no litellm completion route (they are served
|
|
364
|
+
# in-process by ``_litellm_embedding.py``), so litellm would raise
|
|
365
|
+
# ``BadRequestError: LLM Provider NOT provided`` deep inside its fallback
|
|
366
|
+
# ladder if one ever landed in this list (Sentry PYTHON-FASTAPI-CV).
|
|
367
|
+
fallback_models = [
|
|
368
|
+
m
|
|
369
|
+
for m in fallback_models_raw
|
|
370
|
+
if m != actual_model and not m.startswith("local/")
|
|
371
|
+
]
|
|
327
372
|
|
|
328
373
|
temperature = kwargs.pop("temperature", self.config.temperature)
|
|
329
374
|
if self._is_temperature_restricted_model(actual_model):
|
|
@@ -357,6 +402,10 @@ class TextGenerationMixin:
|
|
|
357
402
|
params["temperature"] = 0.0
|
|
358
403
|
|
|
359
404
|
max_tokens = kwargs.pop("max_tokens", self.config.max_tokens)
|
|
405
|
+
if max_tokens is None:
|
|
406
|
+
# Provider-level guard: some providers (MiniMax-M3) stall into the
|
|
407
|
+
# request timeout when max_tokens is omitted. See model_defaults.
|
|
408
|
+
max_tokens = default_max_tokens_for_model(actual_model)
|
|
360
409
|
if max_tokens:
|
|
361
410
|
params["max_tokens"] = max_tokens
|
|
362
411
|
if self.config.top_p != 1.0:
|
|
@@ -815,7 +864,15 @@ class TextGenerationMixin:
|
|
|
815
864
|
)
|
|
816
865
|
return _call_and_parse()
|
|
817
866
|
except Exception as e:
|
|
818
|
-
|
|
867
|
+
# Expected transient upstream failures (provider timeout / connection
|
|
868
|
+
# / rate-limit / overload) log at WARNING — callers own fatality and
|
|
869
|
+
# most degrade gracefully; only genuinely-unexpected errors are ERROR.
|
|
870
|
+
log = (
|
|
871
|
+
self.logger.warning
|
|
872
|
+
if _is_expected_transient_llm_error(e)
|
|
873
|
+
else self.logger.error
|
|
874
|
+
)
|
|
875
|
+
log(
|
|
819
876
|
"event=llm_request_end model=%s elapsed_seconds=%.3f success=False error_type=%s error=%s",
|
|
820
877
|
params.get("model"),
|
|
821
878
|
time.perf_counter() - request_start,
|
|
@@ -34,29 +34,19 @@ _SUPPORTED_MODELS = {
|
|
|
34
34
|
_ACTIVE_MODEL: str | None = None
|
|
35
35
|
_ACTIVE_MODEL_LOCK = threading.Lock()
|
|
36
36
|
|
|
37
|
-
# The underlying sentence-transformers / ONNX embedders share internal buffers
|
|
38
|
-
# and are NOT thread-safe: concurrent ``.embed()`` calls interleave and corrupt
|
|
39
|
-
# each other's padding/attention tensors (observed under concurrent publishes as
|
|
40
|
-
# ``RuntimeError: The size of tensor a (62) must match the size of tensor b (61)
|
|
41
|
-
# at non-singleton dimension 1``). The ``_ENCODE_SEMAPHORE`` below only caps how
|
|
42
|
-
# many requests run at once (for memory), it does NOT serialize them, so this
|
|
43
|
-
# lock guards the actual model inference. Throughput comes from micro-batch
|
|
44
|
-
# coalescing (many texts per encode), not from parallel encodes on one model.
|
|
45
|
-
_MODEL_ENCODE_LOCK = threading.Lock()
|
|
46
|
-
|
|
47
37
|
DEFAULT_OSS_EMBEDDING_MODEL = MINILM_MODEL
|
|
48
38
|
|
|
49
|
-
# Bound how many
|
|
50
|
-
# served from Starlette's threadpool, so without a
|
|
51
|
-
# would
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# frees — they
|
|
55
|
-
#
|
|
39
|
+
# Bound how many micro-batch processor threads run at once. The endpoint is a
|
|
40
|
+
# sync ``def`` served from Starlette's threadpool, so without a cap a burst of
|
|
41
|
+
# requests would spawn a processor per request and stack their activation
|
|
42
|
+
# memory, OOM-killing the daemon. This caps concurrent processors; excess
|
|
43
|
+
# requests queue on the micro-batch condition and are picked up when a slot
|
|
44
|
+
# frees — they are never rejected. The embedder singletons (NomicEmbedder /
|
|
45
|
+
# LocalEmbedder) serialize the actual model.encode() internally — the shared
|
|
46
|
+
# models are not thread-safe — so no encode lock or semaphore is needed here.
|
|
47
|
+
# Pair with a small encode batch_size so each in-flight encode stays cheap.
|
|
56
48
|
_DEFAULT_MAX_CONCURRENCY = 4
|
|
57
49
|
_ENV_MAX_CONCURRENCY = "REFLEXIO_EMBED_MAX_CONCURRENCY"
|
|
58
|
-
_ENCODE_SEMAPHORE: threading.BoundedSemaphore | None = None
|
|
59
|
-
_ENCODE_SEMAPHORE_LOCK = threading.Lock()
|
|
60
50
|
|
|
61
51
|
# Opportunistically coalesce concurrent small requests before calling
|
|
62
52
|
# model.encode(). This keeps request concurrency thread-based while giving the
|
|
@@ -111,16 +101,6 @@ def _micro_batch_max_texts() -> int:
|
|
|
111
101
|
)
|
|
112
102
|
|
|
113
103
|
|
|
114
|
-
def _encode_semaphore() -> threading.BoundedSemaphore:
|
|
115
|
-
"""Return the process-wide encode semaphore, building it on first use."""
|
|
116
|
-
global _ENCODE_SEMAPHORE
|
|
117
|
-
if _ENCODE_SEMAPHORE is None:
|
|
118
|
-
with _ENCODE_SEMAPHORE_LOCK:
|
|
119
|
-
if _ENCODE_SEMAPHORE is None:
|
|
120
|
-
_ENCODE_SEMAPHORE = threading.BoundedSemaphore(_max_concurrency())
|
|
121
|
-
return _ENCODE_SEMAPHORE
|
|
122
|
-
|
|
123
|
-
|
|
124
104
|
class EmbeddingRequest(BaseModel):
|
|
125
105
|
model: str
|
|
126
106
|
input: str | list[str]
|
|
@@ -313,29 +293,16 @@ def _process_micro_batch(jobs: list[_EmbeddingJob]) -> None:
|
|
|
313
293
|
|
|
314
294
|
|
|
315
295
|
def _encode_texts_now(model: str, texts: list[str]) -> list[list[float]]:
|
|
316
|
-
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
#
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
)
|
|
325
|
-
|
|
326
|
-
try:
|
|
327
|
-
# Serialize the actual inference: the shared embedder models are not
|
|
328
|
-
# thread-safe, so concurrent encodes race and produce tensor-shape
|
|
329
|
-
# errors. The semaphore (above) bounds memory; this lock bounds the
|
|
330
|
-
# model to one in-flight encode at a time.
|
|
331
|
-
with _MODEL_ENCODE_LOCK:
|
|
332
|
-
if is_nomic_model(model):
|
|
333
|
-
return NomicEmbedder.get().embed(texts)
|
|
334
|
-
if model == MINILM_MODEL:
|
|
335
|
-
return LocalEmbedder.get().embed(texts)
|
|
336
|
-
raise HTTPException(status_code=400, detail=f"Unsupported model: {model}")
|
|
337
|
-
finally:
|
|
338
|
-
semaphore.release()
|
|
296
|
+
# The embedder singletons serialize their own model.encode() internally
|
|
297
|
+
# (the shared sentence-transformers / ONNX models are not thread-safe — see
|
|
298
|
+
# NomicEmbedder / LocalEmbedder). Concurrency is already bounded by the
|
|
299
|
+
# micro-batch processor cap (``_max_concurrency``), so no extra encode
|
|
300
|
+
# lock/semaphore is needed here.
|
|
301
|
+
if is_nomic_model(model):
|
|
302
|
+
return NomicEmbedder.get().embed(texts)
|
|
303
|
+
if model == MINILM_MODEL:
|
|
304
|
+
return LocalEmbedder.get().embed(texts)
|
|
305
|
+
raise HTTPException(status_code=400, detail=f"Unsupported model: {model}")
|
|
339
306
|
|
|
340
307
|
|
|
341
308
|
def _activate_model(model: str) -> None:
|
|
@@ -267,6 +267,34 @@ _PROVIDER_DEFAULTS: dict[str, ProviderDefaults] = {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
# Output-token cap applied when neither the call site nor the client config
|
|
271
|
+
# sets max_tokens. MiniMax-M3 misbehaves with unbounded output: omitting
|
|
272
|
+
# max_tokens (especially combined with a strict json_schema response_format)
|
|
273
|
+
# deterministically stalls generation into litellm's 120s timeout (reproduced
|
|
274
|
+
# 2026-07; observed in prod as consolidator/document-expansion timeouts).
|
|
275
|
+
# Sizing (measured in prod, 2026-07-14): M3's reasoning tokens count against
|
|
276
|
+
# this budget, so too small a cap starves the visible output — at 4096 the
|
|
277
|
+
# model regularly spent the whole budget thinking and returned empty/truncated
|
|
278
|
+
# content (structured-output parse failures ran ~10-20x the 8192-era rate,
|
|
279
|
+
# breaking extraction). 8192 was the healthiest measured setting. The 120s
|
|
280
|
+
# provider stalls occur at every cap value (provider-side; mitigate with
|
|
281
|
+
# fallback models, not here). Providers absent from this map stay unbounded.
|
|
282
|
+
_PROVIDER_DEFAULT_MAX_TOKENS: dict[str, int] = {"minimax": 8192}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def default_max_tokens_for_model(model: str) -> int | None:
|
|
286
|
+
"""Return the provider-level default output-token cap for ``model``.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
model (str): Full model name, e.g. ``"minimax/MiniMax-M3"``.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
int | None: Cap to apply when the caller set none, or None (no cap).
|
|
293
|
+
"""
|
|
294
|
+
provider = model.split("/", 1)[0] if "/" in model else ""
|
|
295
|
+
return _PROVIDER_DEFAULT_MAX_TOKENS.get(provider)
|
|
296
|
+
|
|
297
|
+
|
|
270
298
|
EMBEDDING_CAPABLE_PROVIDERS: frozenset[str] = frozenset(
|
|
271
299
|
p for p, d in _PROVIDER_DEFAULTS.items() if d.embedding is not None
|
|
272
300
|
)
|