claude-smart 0.2.43 → 0.2.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/bin/claude-smart.js +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/README.md +21 -1
- package/plugin/pyproject.toml +2 -2
- package/plugin/scripts/backend-service.sh +50 -4
- package/plugin/scripts/cli.sh +2 -1
- package/plugin/scripts/ensure-plugin-root.sh +1 -0
- package/plugin/scripts/hook_entry.sh +5 -3
- package/plugin/scripts/smart-install.sh +2 -2
- package/plugin/src/README.md +57 -0
- package/plugin/uv.lock +126 -5
- package/plugin/vendor/reflexio/.env.example +9 -0
- package/plugin/vendor/reflexio/pyproject.toml +5 -2
- package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +0 -1
- package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +7 -4
- package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +4 -4
- package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +2 -2
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +13 -4
- package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +3 -1
- package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +59 -6
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/README.md +7 -1
- package/plugin/vendor/reflexio/reflexio/server/api.py +188 -34
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +33 -11
- package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +278 -29
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +457 -181
- package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +22 -12
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +137 -9
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +36 -3
- package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +13 -3
- package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +17 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.1.prompt.md +69 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +27 -23
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +19 -9
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +1 -5
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +44 -2
- package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +91 -24
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +3 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +18 -5
- package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +2 -2
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +88 -3
- package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +36 -5
- package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +32 -22
- package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +89 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +46 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +12 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +8 -4
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# server/api_endpoints
|
|
2
|
+
Description: Bridge between FastAPI routes and business logic — builds `RequestContext`, validates requests, and delegates into `Reflexio`. Most endpoints are registered on the `core_router` in `../api.py`; the files here are the handlers/helpers it calls.
|
|
3
|
+
|
|
4
|
+
> For the complete endpoint list (publish, retrieval, search, profile/playbook lifecycle, evaluation, Braintrust, operations), see the parent [server README](../README.md#api-endpoints).
|
|
5
|
+
|
|
6
|
+
## Files
|
|
7
|
+
|
|
8
|
+
| File | Purpose |
|
|
9
|
+
|------|---------|
|
|
10
|
+
| `request_context.py` | `RequestContext` — bundles `org_id`, `storage`, `configurator`, `prompt_manager`. Built per request via `get_request_context()` (FastAPI `Depends`). The one object every handler reads storage/config/prompts through. |
|
|
11
|
+
| `publisher_api.py` | Publishing + direct CRUD helpers: `add_user_interaction/profile/playbook`, `update_*`, and the full family of single / by-ids / bulk delete helpers for interactions, profiles, playbooks, requests, and sessions; plus `run_playbook_aggregation()` and `clear_user_data()`. |
|
|
12
|
+
| `account_api.py` | Identity/config helpers behind `/api/whoami`, `/api/my_config`. |
|
|
13
|
+
| `health_api.py` | `GET /`, `/health`, `/healthz`, `/healthz/eval`; `install()` adds response-time + liveness tracking. |
|
|
14
|
+
| `pending_tool_call_api.py` | Router for resumable-extraction human clarification: list/get, `resolve`, `answer`, `not_applicable`, `cancel`; HMAC signature verify + migration-retry helpers. |
|
|
15
|
+
| `stall_state_api.py` | `GET /api/stall_state`, `POST /api/stall_state/notified` — extraction-agent waiting state. |
|
|
16
|
+
| `precondition_checks.py` | `precondition_checks()` — shared request validation. |
|
|
17
|
+
|
|
18
|
+
## Architecture Pattern
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
api.py (core_router + sub-routers)
|
|
22
|
+
-> Depends(get_request_context) -> RequestContext(org_id, storage, configurator, prompt_manager)
|
|
23
|
+
-> get_reflexio(org_id) -> Reflexio (reflexio_lib) -> services/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **`RequestContext` is the context-passing contract** — handlers receive it via `Depends` and never reach for storage/config/prompts globally.
|
|
27
|
+
- **Route handlers call `Reflexio` through `get_reflexio(org_id)`** — these helper files do **not** instantiate `Reflexio()` directly.
|
|
28
|
+
- **Auth is injected, not implemented here** — the OS app uses `default_get_org_id` / `DEFAULT_ORG_ID`; the enterprise extension swaps in authenticated org resolution (see `reflexio_ext/server/api_endpoints/`).
|
|
29
|
+
|
|
30
|
+
## Requirements / Problems to Avoid
|
|
31
|
+
|
|
32
|
+
- **NEVER instantiate `Reflexio()` in a handler** — use `get_reflexio(org_id)` from `server/cache/`.
|
|
33
|
+
- **Keep business logic in `services/`** — endpoints validate, build context, and delegate; they don't embed extraction/evaluation logic.
|
|
34
|
+
- **Pending-tool-call writes can race** — use the migration-retry + HMAC-verify helpers already in `pending_tool_call_api.py` rather than writing raw.
|
|
@@ -155,7 +155,9 @@ def delete_user_profile(
|
|
|
155
155
|
try:
|
|
156
156
|
return reflexio.delete_profile(request)
|
|
157
157
|
except Exception as e:
|
|
158
|
-
with sentry_tags(
|
|
158
|
+
with sentry_tags(
|
|
159
|
+
subsystem="publisher_api", action="delete_user_profile", org_id=org_id
|
|
160
|
+
):
|
|
159
161
|
logger.exception("Failed to delete user profile")
|
|
160
162
|
return DeleteUserProfileResponse(success=False, message=str(e))
|
|
161
163
|
|
|
@@ -176,7 +178,9 @@ def delete_user_interaction(
|
|
|
176
178
|
try:
|
|
177
179
|
return reflexio.delete_interaction(request)
|
|
178
180
|
except Exception as e:
|
|
179
|
-
with sentry_tags(
|
|
181
|
+
with sentry_tags(
|
|
182
|
+
subsystem="publisher_api", action="delete_user_interaction", org_id=org_id
|
|
183
|
+
):
|
|
180
184
|
logger.exception("Failed to delete user interaction")
|
|
181
185
|
return DeleteUserInteractionResponse(success=False, message=str(e))
|
|
182
186
|
|
|
@@ -195,7 +199,9 @@ def delete_request(org_id: str, request: DeleteRequestRequest) -> DeleteRequestR
|
|
|
195
199
|
try:
|
|
196
200
|
return reflexio.delete_request(request)
|
|
197
201
|
except Exception as e:
|
|
198
|
-
with sentry_tags(
|
|
202
|
+
with sentry_tags(
|
|
203
|
+
subsystem="publisher_api", action="delete_request", org_id=org_id
|
|
204
|
+
):
|
|
199
205
|
logger.exception("Failed to delete request")
|
|
200
206
|
return DeleteRequestResponse(success=False, message=str(e))
|
|
201
207
|
|
|
@@ -214,7 +220,9 @@ def delete_session(org_id: str, request: DeleteSessionRequest) -> DeleteSessionR
|
|
|
214
220
|
try:
|
|
215
221
|
return reflexio.delete_session(request)
|
|
216
222
|
except Exception as e:
|
|
217
|
-
with sentry_tags(
|
|
223
|
+
with sentry_tags(
|
|
224
|
+
subsystem="publisher_api", action="delete_session", org_id=org_id
|
|
225
|
+
):
|
|
218
226
|
logger.exception("Failed to delete session")
|
|
219
227
|
return DeleteSessionResponse(success=False, message=str(e))
|
|
220
228
|
|
|
@@ -235,7 +243,9 @@ def delete_agent_playbook(
|
|
|
235
243
|
try:
|
|
236
244
|
return reflexio.delete_agent_playbook(request)
|
|
237
245
|
except Exception as e:
|
|
238
|
-
with sentry_tags(
|
|
246
|
+
with sentry_tags(
|
|
247
|
+
subsystem="publisher_api", action="delete_agent_playbook", org_id=org_id
|
|
248
|
+
):
|
|
239
249
|
logger.exception("Failed to delete agent playbook")
|
|
240
250
|
return DeleteAgentPlaybookResponse(success=False, message=str(e))
|
|
241
251
|
|
|
@@ -256,7 +266,9 @@ def delete_user_playbook(
|
|
|
256
266
|
try:
|
|
257
267
|
return reflexio.delete_user_playbook(request)
|
|
258
268
|
except Exception as e:
|
|
259
|
-
with sentry_tags(
|
|
269
|
+
with sentry_tags(
|
|
270
|
+
subsystem="publisher_api", action="delete_user_playbook", org_id=org_id
|
|
271
|
+
):
|
|
260
272
|
logger.exception("Failed to delete user playbook")
|
|
261
273
|
return DeleteUserPlaybookResponse(success=False, message=str(e))
|
|
262
274
|
|
|
@@ -434,7 +446,9 @@ def run_playbook_aggregation(
|
|
|
434
446
|
request.agent_version, request.playbook_name
|
|
435
447
|
)
|
|
436
448
|
except Exception as e:
|
|
437
|
-
with sentry_tags(
|
|
449
|
+
with sentry_tags(
|
|
450
|
+
subsystem="publisher_api", action="run_playbook_aggregation", org_id=org_id
|
|
451
|
+
):
|
|
438
452
|
logger.exception("Failed to run playbook aggregation")
|
|
439
453
|
return RunPlaybookAggregationResponse(success=False, message=str(e))
|
|
440
454
|
|
|
@@ -472,7 +486,9 @@ def update_agent_playbook_status(
|
|
|
472
486
|
try:
|
|
473
487
|
return reflexio.update_agent_playbook_status(request)
|
|
474
488
|
except Exception as e:
|
|
475
|
-
with sentry_tags(
|
|
489
|
+
with sentry_tags(
|
|
490
|
+
subsystem="publisher_api", action="update_playbook_status", org_id=org_id
|
|
491
|
+
):
|
|
476
492
|
logger.exception("Failed to update playbook status")
|
|
477
493
|
return UpdatePlaybookStatusResponse(success=False, msg=str(e))
|
|
478
494
|
|
|
@@ -493,7 +509,9 @@ def update_agent_playbook(
|
|
|
493
509
|
try:
|
|
494
510
|
return reflexio.update_agent_playbook(request)
|
|
495
511
|
except Exception as e:
|
|
496
|
-
with sentry_tags(
|
|
512
|
+
with sentry_tags(
|
|
513
|
+
subsystem="publisher_api", action="update_agent_playbook", org_id=org_id
|
|
514
|
+
):
|
|
497
515
|
logger.exception("Failed to update agent playbook")
|
|
498
516
|
return UpdateAgentPlaybookResponse(success=False, msg=str(e))
|
|
499
517
|
|
|
@@ -514,7 +532,9 @@ def update_user_playbook(
|
|
|
514
532
|
try:
|
|
515
533
|
return reflexio.update_user_playbook(request)
|
|
516
534
|
except Exception as e:
|
|
517
|
-
with sentry_tags(
|
|
535
|
+
with sentry_tags(
|
|
536
|
+
subsystem="publisher_api", action="update_user_playbook", org_id=org_id
|
|
537
|
+
):
|
|
518
538
|
logger.exception("Failed to update user playbook")
|
|
519
539
|
return UpdateUserPlaybookResponse(success=False, msg=str(e))
|
|
520
540
|
|
|
@@ -535,6 +555,8 @@ def update_user_profile(
|
|
|
535
555
|
try:
|
|
536
556
|
return reflexio.update_user_profile(request)
|
|
537
557
|
except Exception as e:
|
|
538
|
-
with sentry_tags(
|
|
558
|
+
with sentry_tags(
|
|
559
|
+
subsystem="publisher_api", action="update_user_profile", org_id=org_id
|
|
560
|
+
):
|
|
539
561
|
logger.exception("Failed to update user profile")
|
|
540
562
|
return UpdateUserProfileResponse(success=False, msg=str(e))
|
|
@@ -2,29 +2,113 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import math
|
|
7
|
+
import os
|
|
6
8
|
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass, field
|
|
7
13
|
from typing import Any
|
|
8
14
|
|
|
9
15
|
from fastapi import FastAPI, HTTPException
|
|
10
16
|
from pydantic import BaseModel, Field
|
|
11
17
|
|
|
18
|
+
from reflexio.server.llm.llm_utils import positive_int_env
|
|
12
19
|
from reflexio.server.llm.providers.local_embedding_provider import LocalEmbedder
|
|
13
20
|
from reflexio.server.llm.providers.nomic_embedding_provider import (
|
|
14
21
|
NomicEmbedder,
|
|
15
22
|
is_nomic_model,
|
|
16
23
|
)
|
|
17
24
|
|
|
18
|
-
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
MINILM_MODEL = "local/minilm-l6-v2"
|
|
28
|
+
NOMIC_TEXT_MODEL = "local/nomic-embed-text-v1.5"
|
|
19
29
|
_SUPPORTED_MODELS = {
|
|
20
|
-
|
|
30
|
+
MINILM_MODEL,
|
|
21
31
|
"local/nomic-embed-v1.5",
|
|
22
|
-
|
|
32
|
+
NOMIC_TEXT_MODEL,
|
|
23
33
|
}
|
|
24
34
|
_ACTIVE_MODEL: str | None = None
|
|
25
35
|
_ACTIVE_MODEL_LOCK = threading.Lock()
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
DEFAULT_OSS_EMBEDDING_MODEL = MINILM_MODEL
|
|
38
|
+
|
|
39
|
+
# Bound how many embed/encode calls run at once. The endpoint is a sync ``def``
|
|
40
|
+
# served from Starlette's threadpool, so without a guard a burst of requests
|
|
41
|
+
# would run that many model.encode() calls in parallel, stacking their
|
|
42
|
+
# activation memory and OOM-killing the daemon. The semaphore caps simultaneous
|
|
43
|
+
# encodes; excess requests block on acquire() and are picked up when a slot
|
|
44
|
+
# frees — they queue, they are never rejected. Pair with a small encode
|
|
45
|
+
# batch_size so each in-flight encode stays cheap.
|
|
46
|
+
_DEFAULT_MAX_CONCURRENCY = 4
|
|
47
|
+
_ENV_MAX_CONCURRENCY = "REFLEXIO_EMBED_MAX_CONCURRENCY"
|
|
48
|
+
_ENCODE_SEMAPHORE: threading.BoundedSemaphore | None = None
|
|
49
|
+
_ENCODE_SEMAPHORE_LOCK = threading.Lock()
|
|
50
|
+
|
|
51
|
+
# Opportunistically coalesce concurrent small requests before calling
|
|
52
|
+
# model.encode(). This keeps request concurrency thread-based while giving the
|
|
53
|
+
# GPU larger batches during bursts.
|
|
54
|
+
_DEFAULT_MICRO_BATCH_DELAY_MS = 5
|
|
55
|
+
_DEFAULT_MICRO_BATCH_MAX_TEXTS = 64
|
|
56
|
+
_ENV_MICRO_BATCH_DELAY_MS = "REFLEXIO_EMBED_MICRO_BATCH_DELAY_MS"
|
|
57
|
+
_ENV_MICRO_BATCH_MAX_TEXTS = "REFLEXIO_EMBED_MICRO_BATCH_MAX_TEXTS"
|
|
58
|
+
_MICRO_BATCH_CONDITION = threading.Condition()
|
|
59
|
+
_MICRO_BATCH_QUEUE: list[_EmbeddingJob] = []
|
|
60
|
+
_ACTIVE_BATCH_PROCESSORS = 0
|
|
61
|
+
# Failsafe bound for a submitter waiting on its job (see _embed_texts).
|
|
62
|
+
_JOB_WAIT_TIMEOUT_SECONDS = 600.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class _EmbeddingJob:
|
|
67
|
+
model: str
|
|
68
|
+
texts: list[str]
|
|
69
|
+
done: threading.Event = field(default_factory=threading.Event)
|
|
70
|
+
result: list[list[float]] | None = None
|
|
71
|
+
error: BaseException | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _nonnegative_int_env(name: str, default: int) -> int:
|
|
75
|
+
raw = os.environ.get(name)
|
|
76
|
+
if not raw:
|
|
77
|
+
return default
|
|
78
|
+
try:
|
|
79
|
+
value = int(raw)
|
|
80
|
+
except ValueError:
|
|
81
|
+
logger.warning("Invalid %s=%r; falling back to default %d", name, raw, default)
|
|
82
|
+
return default
|
|
83
|
+
return value if value >= 0 else default
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _max_concurrency() -> int:
|
|
87
|
+
"""Resolve the max simultaneous encodes from env, defaulting to 4."""
|
|
88
|
+
return positive_int_env(_ENV_MAX_CONCURRENCY, _DEFAULT_MAX_CONCURRENCY, logger)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _micro_batch_delay_seconds() -> float:
|
|
92
|
+
return (
|
|
93
|
+
_nonnegative_int_env(_ENV_MICRO_BATCH_DELAY_MS, _DEFAULT_MICRO_BATCH_DELAY_MS)
|
|
94
|
+
/ 1000
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _micro_batch_max_texts() -> int:
|
|
99
|
+
return positive_int_env(
|
|
100
|
+
_ENV_MICRO_BATCH_MAX_TEXTS, _DEFAULT_MICRO_BATCH_MAX_TEXTS, logger
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _encode_semaphore() -> threading.BoundedSemaphore:
|
|
105
|
+
"""Return the process-wide encode semaphore, building it on first use."""
|
|
106
|
+
global _ENCODE_SEMAPHORE
|
|
107
|
+
if _ENCODE_SEMAPHORE is None:
|
|
108
|
+
with _ENCODE_SEMAPHORE_LOCK:
|
|
109
|
+
if _ENCODE_SEMAPHORE is None:
|
|
110
|
+
_ENCODE_SEMAPHORE = threading.BoundedSemaphore(_max_concurrency())
|
|
111
|
+
return _ENCODE_SEMAPHORE
|
|
28
112
|
|
|
29
113
|
|
|
30
114
|
class EmbeddingRequest(BaseModel):
|
|
@@ -45,36 +129,198 @@ class EmbeddingResponse(BaseModel):
|
|
|
45
129
|
model: str
|
|
46
130
|
|
|
47
131
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"""Health check endpoint."""
|
|
51
|
-
return {"status": "ok", "active_model": _ACTIVE_MODEL}
|
|
132
|
+
def create_embedding_app(default_model: str | None = None) -> FastAPI:
|
|
133
|
+
"""Create the embedding daemon app and optionally warm a default model."""
|
|
52
134
|
|
|
135
|
+
@asynccontextmanager
|
|
136
|
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
137
|
+
if not default_model:
|
|
138
|
+
yield
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
_embed_texts(default_model, ["reflexio embedding daemon warmup"])
|
|
142
|
+
except Exception:
|
|
143
|
+
logger.exception("Failed to warm embedding model %s", default_model)
|
|
144
|
+
raise
|
|
145
|
+
yield
|
|
53
146
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
147
|
+
embedding_app = FastAPI(title="Reflexio Embedding Service", lifespan=lifespan)
|
|
148
|
+
|
|
149
|
+
@embedding_app.get("/health")
|
|
150
|
+
def health() -> dict[str, Any]:
|
|
151
|
+
"""Health check endpoint."""
|
|
152
|
+
return {"status": "ok", "active_model": _ACTIVE_MODEL}
|
|
153
|
+
|
|
154
|
+
@embedding_app.post("/v1/embeddings")
|
|
155
|
+
def create_embeddings(request: EmbeddingRequest) -> EmbeddingResponse:
|
|
156
|
+
"""Create embeddings using the daemon's single active local model."""
|
|
157
|
+
texts = (
|
|
158
|
+
[request.input] if isinstance(request.input, str) else list(request.input)
|
|
66
159
|
)
|
|
160
|
+
embeddings = _embed_texts(request.model, texts)
|
|
67
161
|
|
|
68
|
-
|
|
69
|
-
|
|
162
|
+
if request.dimensions:
|
|
163
|
+
embeddings = [
|
|
164
|
+
_resize_embedding(vec, request.dimensions) for vec in embeddings
|
|
165
|
+
]
|
|
70
166
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
167
|
+
return EmbeddingResponse(
|
|
168
|
+
data=[
|
|
169
|
+
EmbeddingData(embedding=embedding, index=index)
|
|
170
|
+
for index, embedding in enumerate(embeddings)
|
|
171
|
+
],
|
|
172
|
+
model=request.model,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return embedding_app
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _embed_texts(model: str, texts: list[str]) -> list[list[float]]:
|
|
179
|
+
_activate_model(model)
|
|
180
|
+
if not texts:
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
job = _EmbeddingJob(model=model, texts=list(texts))
|
|
184
|
+
should_process = False
|
|
185
|
+
|
|
186
|
+
global _ACTIVE_BATCH_PROCESSORS
|
|
187
|
+
with _MICRO_BATCH_CONDITION:
|
|
188
|
+
_MICRO_BATCH_QUEUE.append(job)
|
|
189
|
+
if _max_concurrency() > _ACTIVE_BATCH_PROCESSORS:
|
|
190
|
+
_ACTIVE_BATCH_PROCESSORS += 1
|
|
191
|
+
should_process = True
|
|
192
|
+
_MICRO_BATCH_CONDITION.notify()
|
|
193
|
+
|
|
194
|
+
if should_process:
|
|
195
|
+
threading.Thread(
|
|
196
|
+
target=_run_micro_batch_processor,
|
|
197
|
+
daemon=True,
|
|
198
|
+
name="embedding-micro-batch",
|
|
199
|
+
).start()
|
|
200
|
+
|
|
201
|
+
# Last-resort failsafe: a processor thread that dies between taking and
|
|
202
|
+
# completing jobs would otherwise leave this request hanging forever. The
|
|
203
|
+
# bound is far above any legitimate CPU bulk encode.
|
|
204
|
+
if not job.done.wait(timeout=_JOB_WAIT_TIMEOUT_SECONDS):
|
|
205
|
+
raise RuntimeError(
|
|
206
|
+
f"Embedding micro-batch did not complete within "
|
|
207
|
+
f"{_JOB_WAIT_TIMEOUT_SECONDS:.0f}s"
|
|
208
|
+
)
|
|
209
|
+
if job.error is not None:
|
|
210
|
+
raise job.error
|
|
211
|
+
if job.result is None:
|
|
212
|
+
raise RuntimeError("Embedding micro-batch completed without a result")
|
|
213
|
+
return job.result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run_micro_batch_processor() -> None:
|
|
217
|
+
global _ACTIVE_BATCH_PROCESSORS
|
|
218
|
+
try:
|
|
219
|
+
while True:
|
|
220
|
+
jobs = _take_micro_batch()
|
|
221
|
+
if not jobs:
|
|
222
|
+
with _MICRO_BATCH_CONDITION:
|
|
223
|
+
if _MICRO_BATCH_QUEUE:
|
|
224
|
+
continue
|
|
225
|
+
_ACTIVE_BATCH_PROCESSORS -= 1
|
|
226
|
+
_MICRO_BATCH_CONDITION.notify_all()
|
|
227
|
+
return
|
|
228
|
+
_process_micro_batch(jobs)
|
|
229
|
+
except BaseException:
|
|
230
|
+
with _MICRO_BATCH_CONDITION:
|
|
231
|
+
_ACTIVE_BATCH_PROCESSORS -= 1
|
|
232
|
+
_MICRO_BATCH_CONDITION.notify_all()
|
|
233
|
+
raise
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _take_micro_batch() -> list[_EmbeddingJob]:
|
|
237
|
+
max_texts = _micro_batch_max_texts()
|
|
238
|
+
deadline = time.monotonic() + _micro_batch_delay_seconds()
|
|
239
|
+
|
|
240
|
+
with _MICRO_BATCH_CONDITION:
|
|
241
|
+
if not _MICRO_BATCH_QUEUE:
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
first = _MICRO_BATCH_QUEUE.pop(0)
|
|
245
|
+
jobs = [first]
|
|
246
|
+
total_texts = len(first.texts)
|
|
247
|
+
|
|
248
|
+
while total_texts < max_texts:
|
|
249
|
+
compatible_index = next(
|
|
250
|
+
(
|
|
251
|
+
index
|
|
252
|
+
for index, candidate in enumerate(_MICRO_BATCH_QUEUE)
|
|
253
|
+
if candidate.model == first.model
|
|
254
|
+
and total_texts + len(candidate.texts) <= max_texts
|
|
255
|
+
),
|
|
256
|
+
None,
|
|
257
|
+
)
|
|
258
|
+
if compatible_index is not None:
|
|
259
|
+
candidate = _MICRO_BATCH_QUEUE.pop(compatible_index)
|
|
260
|
+
jobs.append(candidate)
|
|
261
|
+
total_texts += len(candidate.texts)
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
remaining = deadline - time.monotonic()
|
|
265
|
+
if remaining <= 0:
|
|
266
|
+
break
|
|
267
|
+
_MICRO_BATCH_CONDITION.wait(timeout=remaining)
|
|
268
|
+
|
|
269
|
+
return jobs
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _process_micro_batch(jobs: list[_EmbeddingJob]) -> None:
|
|
273
|
+
texts: list[str] = []
|
|
274
|
+
slices: list[tuple[_EmbeddingJob, int, int]] = []
|
|
275
|
+
for job in jobs:
|
|
276
|
+
start = len(texts)
|
|
277
|
+
texts.extend(job.texts)
|
|
278
|
+
slices.append((job, start, len(texts)))
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
embeddings = _encode_texts_now(jobs[0].model, texts)
|
|
282
|
+
except BaseException as exc:
|
|
283
|
+
for job in jobs:
|
|
284
|
+
job.error = exc
|
|
285
|
+
job.done.set()
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if len(embeddings) != len(texts):
|
|
289
|
+
# Slicing a short result would silently hand jobs truncated/empty
|
|
290
|
+
# vectors; fail every job loudly at the source instead.
|
|
291
|
+
mismatch = RuntimeError(
|
|
292
|
+
f"Embedding count mismatch: encoded {len(texts)} texts but got "
|
|
293
|
+
f"{len(embeddings)} embeddings"
|
|
294
|
+
)
|
|
295
|
+
for job in jobs:
|
|
296
|
+
job.error = mismatch
|
|
297
|
+
job.done.set()
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
for job, start, end in slices:
|
|
301
|
+
job.result = embeddings[start:end]
|
|
302
|
+
job.done.set()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _encode_texts_now(model: str, texts: list[str]) -> list[list[float]]:
|
|
306
|
+
semaphore = _encode_semaphore()
|
|
307
|
+
# Non-blocking probe purely for observability: if no slot is free, this
|
|
308
|
+
# request will have to wait. The real acquire below blocks until a slot
|
|
309
|
+
# opens, so the request queues and is picked up later — never rejected.
|
|
310
|
+
if not semaphore.acquire(blocking=False):
|
|
311
|
+
logger.info(
|
|
312
|
+
"Embedding request queued; all %d encode slots busy",
|
|
313
|
+
_max_concurrency(),
|
|
314
|
+
)
|
|
315
|
+
semaphore.acquire()
|
|
316
|
+
try:
|
|
317
|
+
if is_nomic_model(model):
|
|
318
|
+
return NomicEmbedder.get().embed(texts)
|
|
319
|
+
if model == MINILM_MODEL:
|
|
320
|
+
return LocalEmbedder.get().embed(texts)
|
|
321
|
+
raise HTTPException(status_code=400, detail=f"Unsupported model: {model}")
|
|
322
|
+
finally:
|
|
323
|
+
semaphore.release()
|
|
78
324
|
|
|
79
325
|
|
|
80
326
|
def _activate_model(model: str) -> None:
|
|
@@ -108,3 +354,6 @@ def _resize_embedding(vec: list[float], dimensions: int) -> list[float]:
|
|
|
108
354
|
if norm <= 0:
|
|
109
355
|
return sliced
|
|
110
356
|
return [value / norm for value in sliced]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
app = create_embedding_app(default_model=DEFAULT_OSS_EMBEDDING_MODEL)
|