crabcode 0.1.6__py3-none-win_amd64.whl
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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Embedding backends — API and local model support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Embedder(ABC):
|
|
14
|
+
"""Abstract embedding backend."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
|
|
18
|
+
"""Embed a batch of texts. Returns (N, dimension) float32 array, L2-normalized.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
texts: List of texts to embed.
|
|
22
|
+
task_type: Optional hint for the embedding model (e.g. "RETRIEVAL_DOCUMENT",
|
|
23
|
+
"CODE_RETRIEVAL_QUERY"). Non-Gemini backends ignore this.
|
|
24
|
+
"""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def dimension(self) -> int: ...
|
|
30
|
+
|
|
31
|
+
async def preload(self) -> None:
|
|
32
|
+
"""Pre-load the model. Override for backends with deferred initialization."""
|
|
33
|
+
|
|
34
|
+
def _normalize(self, vecs: np.ndarray) -> np.ndarray:
|
|
35
|
+
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
|
|
36
|
+
norms = np.where(norms == 0, 1, norms)
|
|
37
|
+
return (vecs / norms).astype(np.float32)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OllamaEmbedder(Embedder):
|
|
41
|
+
"""Ollama local HTTP API — no extra Python deps."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
model: str = "nomic-embed-text",
|
|
46
|
+
base_url: str = "http://localhost:11434",
|
|
47
|
+
dim: int | None = None,
|
|
48
|
+
):
|
|
49
|
+
self._model = model
|
|
50
|
+
self._base_url = base_url.rstrip("/")
|
|
51
|
+
self._dim = dim
|
|
52
|
+
self._resolved_dim: int | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def dimension(self) -> int:
|
|
56
|
+
if self._dim:
|
|
57
|
+
return self._dim
|
|
58
|
+
if self._resolved_dim:
|
|
59
|
+
return self._resolved_dim
|
|
60
|
+
return 768
|
|
61
|
+
|
|
62
|
+
async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
|
|
63
|
+
import httpx
|
|
64
|
+
|
|
65
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
66
|
+
resp = await client.post(
|
|
67
|
+
f"{self._base_url}/api/embed",
|
|
68
|
+
json={"model": self._model, "input": texts},
|
|
69
|
+
)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
data = resp.json()
|
|
72
|
+
|
|
73
|
+
embeddings = data["embeddings"]
|
|
74
|
+
vecs = np.array(embeddings, dtype=np.float32)
|
|
75
|
+
if self._resolved_dim is None:
|
|
76
|
+
self._resolved_dim = vecs.shape[1]
|
|
77
|
+
return self._normalize(vecs)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GeminiEmbedder(Embedder):
|
|
81
|
+
"""Google Gemini Embedding API — zero extra deps (pure httpx).
|
|
82
|
+
|
|
83
|
+
Uses batchEmbedContents for efficiency (one HTTP round-trip per batch).
|
|
84
|
+
Supports task_type for domain-optimised embeddings:
|
|
85
|
+
- "RETRIEVAL_DOCUMENT" — for code chunks being indexed
|
|
86
|
+
- "CODE_RETRIEVAL_QUERY" — for natural-language search queries
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
BATCH_SIZE = 100 # max items per batchEmbedContents call
|
|
90
|
+
_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
model: str = "gemini-embedding-2-preview",
|
|
95
|
+
dim: int = 768,
|
|
96
|
+
api_key: str | None = None,
|
|
97
|
+
default_task_type: str = "RETRIEVAL_DOCUMENT",
|
|
98
|
+
):
|
|
99
|
+
# Strip leading "models/" if user mistakenly includes it
|
|
100
|
+
self._model = model.removeprefix("models/")
|
|
101
|
+
self._dim = dim
|
|
102
|
+
self._api_key = api_key or os.environ.get("GEMINI_API_KEY", "")
|
|
103
|
+
self._default_task_type = default_task_type
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def dimension(self) -> int:
|
|
107
|
+
return self._dim
|
|
108
|
+
|
|
109
|
+
async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
|
|
110
|
+
import httpx
|
|
111
|
+
|
|
112
|
+
effective_task_type = task_type or self._default_task_type
|
|
113
|
+
url = f"{self._BASE_URL}/models/{self._model}:batchEmbedContents"
|
|
114
|
+
headers = {
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
"x-goog-api-key": self._api_key,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
all_vecs: list[list[float]] = []
|
|
120
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
121
|
+
for i in range(0, len(texts), self.BATCH_SIZE):
|
|
122
|
+
batch = texts[i : i + self.BATCH_SIZE]
|
|
123
|
+
requests: list[dict[str, Any]] = []
|
|
124
|
+
for text in batch:
|
|
125
|
+
req: dict[str, Any] = {
|
|
126
|
+
"model": f"models/{self._model}",
|
|
127
|
+
"content": {"parts": [{"text": text}]},
|
|
128
|
+
"taskType": effective_task_type,
|
|
129
|
+
}
|
|
130
|
+
if self._dim:
|
|
131
|
+
req["outputDimensionality"] = self._dim
|
|
132
|
+
requests.append(req)
|
|
133
|
+
|
|
134
|
+
resp = await client.post(
|
|
135
|
+
url, headers=headers, json={"requests": requests}
|
|
136
|
+
)
|
|
137
|
+
resp.raise_for_status()
|
|
138
|
+
data = resp.json()
|
|
139
|
+
for emb in data["embeddings"]:
|
|
140
|
+
all_vecs.append(emb["values"])
|
|
141
|
+
|
|
142
|
+
vecs = np.array(all_vecs, dtype=np.float32)
|
|
143
|
+
return self._normalize(vecs)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class OpenAIEmbedder(Embedder):
|
|
147
|
+
"""OpenAI-compatible embedding API."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
model: str = "text-embedding-3-small",
|
|
152
|
+
base_url: str = "https://api.openai.com/v1",
|
|
153
|
+
api_key: str | None = None,
|
|
154
|
+
dim: int = 1536,
|
|
155
|
+
):
|
|
156
|
+
self._model = model
|
|
157
|
+
self._base_url = base_url.rstrip("/")
|
|
158
|
+
self._api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
|
|
159
|
+
self._dim = dim
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def dimension(self) -> int:
|
|
163
|
+
return self._dim
|
|
164
|
+
|
|
165
|
+
async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
|
|
166
|
+
import httpx
|
|
167
|
+
|
|
168
|
+
headers = {
|
|
169
|
+
"Content-Type": "application/json",
|
|
170
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
171
|
+
}
|
|
172
|
+
body: dict[str, Any] = {
|
|
173
|
+
"model": self._model,
|
|
174
|
+
"input": texts,
|
|
175
|
+
}
|
|
176
|
+
if self._dim:
|
|
177
|
+
body["dimensions"] = self._dim
|
|
178
|
+
|
|
179
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
180
|
+
resp = await client.post(
|
|
181
|
+
f"{self._base_url}/embeddings",
|
|
182
|
+
headers=headers,
|
|
183
|
+
json=body,
|
|
184
|
+
)
|
|
185
|
+
resp.raise_for_status()
|
|
186
|
+
data = resp.json()
|
|
187
|
+
|
|
188
|
+
embeddings = [item["embedding"] for item in data["data"]]
|
|
189
|
+
vecs = np.array(embeddings, dtype=np.float32)
|
|
190
|
+
return self._normalize(vecs)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _detect_best_device() -> str:
|
|
194
|
+
"""Auto-detect the best available compute device: cuda > mps > cpu."""
|
|
195
|
+
try:
|
|
196
|
+
import torch
|
|
197
|
+
|
|
198
|
+
if torch.cuda.is_available():
|
|
199
|
+
return "cuda"
|
|
200
|
+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
201
|
+
return "mps"
|
|
202
|
+
except ImportError:
|
|
203
|
+
pass
|
|
204
|
+
return "cpu"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_GPU_PER_SAMPLE_MB = 80.0
|
|
208
|
+
_MPS_PER_SAMPLE_MB = 150.0 # MPS has higher overhead than CUDA due to shared memory
|
|
209
|
+
_CPU_PER_SAMPLE_MB = 50.0
|
|
210
|
+
_MAX_BATCH = {"cuda": 64, "mps": 8, "cpu": 8} # MPS capped low: Metal buffer
|
|
211
|
+
# allocation failures are fatal (abort, not catchable)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _auto_batch_size(device: str = "cpu") -> int:
|
|
215
|
+
"""Compute a safe encode batch_size from available device memory.
|
|
216
|
+
|
|
217
|
+
Transformer attention memory scales quadratically with sequence length,
|
|
218
|
+
so per-sample cost on GPU is much higher than the raw embedding size.
|
|
219
|
+
|
|
220
|
+
For CUDA, uses free VRAM (40% budget). For MPS and CPU, uses available
|
|
221
|
+
system RAM — on Apple Silicon the two share unified memory.
|
|
222
|
+
|
|
223
|
+
NOTE: MPS batch sizes are deliberately conservative because Metal buffer
|
|
224
|
+
allocation failures trigger a hard abort (C++ assertion) that kills the
|
|
225
|
+
process — they cannot be caught as Python exceptions.
|
|
226
|
+
"""
|
|
227
|
+
if device == "mps":
|
|
228
|
+
per_sample = _MPS_PER_SAMPLE_MB
|
|
229
|
+
elif device == "cuda":
|
|
230
|
+
per_sample = _GPU_PER_SAMPLE_MB
|
|
231
|
+
else:
|
|
232
|
+
per_sample = _CPU_PER_SAMPLE_MB
|
|
233
|
+
cap = _MAX_BATCH.get(device, 8)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
if device == "cuda":
|
|
237
|
+
import torch
|
|
238
|
+
|
|
239
|
+
free, _ = torch.cuda.mem_get_info()
|
|
240
|
+
available_mb = free / (1024**2)
|
|
241
|
+
budget_ratio = 0.4
|
|
242
|
+
else:
|
|
243
|
+
import psutil
|
|
244
|
+
|
|
245
|
+
available_mb = psutil.virtual_memory().available / (1024**2)
|
|
246
|
+
# MPS is very conservative — Metal buffer alloc failures are fatal
|
|
247
|
+
budget_ratio = 0.1 if device == "mps" else (0.2 if device != "cpu" else 0.3)
|
|
248
|
+
|
|
249
|
+
size = max(1, int(available_mb * budget_ratio / per_sample))
|
|
250
|
+
return min(size, cap)
|
|
251
|
+
except Exception:
|
|
252
|
+
return 4 if device in ("cuda", "mps") else 8
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _is_oom_error(exc: Exception) -> bool:
|
|
256
|
+
"""Detect GPU/MPS out-of-memory errors (including MPS buffer limits)."""
|
|
257
|
+
try:
|
|
258
|
+
import torch
|
|
259
|
+
|
|
260
|
+
if isinstance(exc, torch.cuda.OutOfMemoryError):
|
|
261
|
+
return True
|
|
262
|
+
except (ImportError, AttributeError):
|
|
263
|
+
pass
|
|
264
|
+
msg = str(exc).lower()
|
|
265
|
+
return (
|
|
266
|
+
"out of memory" in msg
|
|
267
|
+
or "oom" in msg
|
|
268
|
+
or "invalid buffer size" in msg
|
|
269
|
+
or "failed to allocate" in msg
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Minimum available memory (MB) to safely use MPS for embedding inference.
|
|
274
|
+
# Metal buffer allocation failures are fatal (hard abort, uncatchable), so we
|
|
275
|
+
# need a generous safety margin. 0.6B embedding models + batch overhead
|
|
276
|
+
# typically need ~2-3 GB; we require at least 4 GB free to proceed on MPS.
|
|
277
|
+
_MPS_MIN_AVAILABLE_MB = 4096
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _safe_mps_device() -> str:
|
|
281
|
+
"""Return "mps" if safe to use, otherwise "cpu".
|
|
282
|
+
|
|
283
|
+
Metal buffer allocation failures on Apple Silicon trigger a C++ assertion
|
|
284
|
+
(abort) that kills the entire process — they cannot be caught as Python
|
|
285
|
+
exceptions. This function checks available memory and downgrades to CPU
|
|
286
|
+
proactively when memory is too tight.
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
import psutil
|
|
290
|
+
|
|
291
|
+
available_mb = psutil.virtual_memory().available / (1024**2)
|
|
292
|
+
if available_mb < _MPS_MIN_AVAILABLE_MB:
|
|
293
|
+
import sys
|
|
294
|
+
print(
|
|
295
|
+
f"[embedder] available memory {available_mb:.0f} MB < "
|
|
296
|
+
f"{_MPS_MIN_AVAILABLE_MB} MB safety threshold, "
|
|
297
|
+
f"falling back to CPU (MPS buffer alloc failures are fatal)",
|
|
298
|
+
file=sys.stderr,
|
|
299
|
+
)
|
|
300
|
+
return "cpu"
|
|
301
|
+
except Exception:
|
|
302
|
+
return "cpu"
|
|
303
|
+
return "mps"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class HuggingFaceEmbedder(Embedder):
|
|
307
|
+
"""Local embedding via sentence-transformers (HuggingFace models).
|
|
308
|
+
|
|
309
|
+
Model loading is deferred to a background thread on first use to avoid
|
|
310
|
+
blocking the event loop (loading a 0.6B model can take 10+ seconds).
|
|
311
|
+
|
|
312
|
+
device: ``None`` (default) auto-detects the best device (CUDA > MPS > CPU).
|
|
313
|
+
Pass ``"cpu"`` / ``"cuda"`` / ``"mps"`` to force a specific device.
|
|
314
|
+
|
|
315
|
+
batch_size: ``None`` (default) computes dynamically from available memory
|
|
316
|
+
(VRAM for CUDA, unified memory for MPS/CPU).
|
|
317
|
+
|
|
318
|
+
If an OOM error occurs on GPU/MPS, the model is moved to CPU
|
|
319
|
+
transparently and the batch is retried.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def __init__(
|
|
323
|
+
self,
|
|
324
|
+
model: str = "BAAI/bge-small-en-v1.5",
|
|
325
|
+
batch_size: int | None = None,
|
|
326
|
+
device: str | None = None,
|
|
327
|
+
sync: bool = False,
|
|
328
|
+
):
|
|
329
|
+
try:
|
|
330
|
+
import sentence_transformers # noqa: F401
|
|
331
|
+
except ImportError as exc:
|
|
332
|
+
raise ImportError(
|
|
333
|
+
"HuggingFace backend requires sentence-transformers: "
|
|
334
|
+
"pip install crabcode-search[huggingface]"
|
|
335
|
+
) from exc
|
|
336
|
+
self._model_name = model
|
|
337
|
+
self._batch_size = batch_size
|
|
338
|
+
self._requested_device = device
|
|
339
|
+
self._device: str | None = None
|
|
340
|
+
self._sync_mode = sync
|
|
341
|
+
self._model: Any = None
|
|
342
|
+
self._dim: int | None = None
|
|
343
|
+
|
|
344
|
+
async def _ensure_loaded(self) -> None:
|
|
345
|
+
if self._model is not None:
|
|
346
|
+
return
|
|
347
|
+
from sentence_transformers import SentenceTransformer
|
|
348
|
+
|
|
349
|
+
if self._requested_device is not None:
|
|
350
|
+
self._device = self._requested_device
|
|
351
|
+
else:
|
|
352
|
+
self._device = _detect_best_device()
|
|
353
|
+
|
|
354
|
+
# MPS pre-flight: Metal buffer allocation failures trigger a hard C++
|
|
355
|
+
# assertion that aborts the process — they cannot be caught in Python.
|
|
356
|
+
# Downgrade to CPU proactively when available memory is tight.
|
|
357
|
+
if self._device == "mps":
|
|
358
|
+
self._device = _safe_mps_device()
|
|
359
|
+
|
|
360
|
+
import sys
|
|
361
|
+
print(f"[embedder] using device: {self._device}", file=sys.stderr)
|
|
362
|
+
|
|
363
|
+
if self._sync_mode:
|
|
364
|
+
self._model = SentenceTransformer(self._model_name, device=self._device)
|
|
365
|
+
else:
|
|
366
|
+
self._model = await asyncio.to_thread(
|
|
367
|
+
SentenceTransformer, self._model_name, device=self._device,
|
|
368
|
+
)
|
|
369
|
+
self._dim = self._model.get_sentence_embedding_dimension()
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def dimension(self) -> int:
|
|
373
|
+
if self._dim is not None:
|
|
374
|
+
return self._dim
|
|
375
|
+
return 1024
|
|
376
|
+
|
|
377
|
+
async def preload(self) -> None:
|
|
378
|
+
await self._ensure_loaded()
|
|
379
|
+
|
|
380
|
+
async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
|
|
381
|
+
await self._ensure_loaded()
|
|
382
|
+
assert self._device is not None
|
|
383
|
+
bs = self._batch_size if self._batch_size is not None else _auto_batch_size(self._device)
|
|
384
|
+
|
|
385
|
+
def _encode() -> np.ndarray:
|
|
386
|
+
return self._model.encode(texts, normalize_embeddings=True, batch_size=bs)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
if self._sync_mode:
|
|
390
|
+
vecs = _encode()
|
|
391
|
+
else:
|
|
392
|
+
vecs = await asyncio.to_thread(_encode)
|
|
393
|
+
except RuntimeError as exc:
|
|
394
|
+
if not _is_oom_error(exc):
|
|
395
|
+
raise
|
|
396
|
+
vecs = await self._oom_retry(texts, bs, exc)
|
|
397
|
+
return np.asarray(vecs, dtype=np.float32)
|
|
398
|
+
|
|
399
|
+
async def _oom_retry(
|
|
400
|
+
self, texts: list[str], failed_bs: int, original_exc: Exception,
|
|
401
|
+
) -> np.ndarray:
|
|
402
|
+
"""Progressive OOM recovery: halve batch on same device, then CPU."""
|
|
403
|
+
import sys
|
|
404
|
+
|
|
405
|
+
if self._device != "cpu" and failed_bs > 1:
|
|
406
|
+
new_bs = max(1, failed_bs // 2)
|
|
407
|
+
print(
|
|
408
|
+
f"[embedder] OOM on {self._device} (batch={failed_bs}), "
|
|
409
|
+
f"retrying with batch={new_bs}",
|
|
410
|
+
file=sys.stderr,
|
|
411
|
+
)
|
|
412
|
+
try:
|
|
413
|
+
vecs = await asyncio.to_thread(
|
|
414
|
+
self._model.encode, texts, normalize_embeddings=True,
|
|
415
|
+
batch_size=new_bs,
|
|
416
|
+
)
|
|
417
|
+
self._batch_size = new_bs
|
|
418
|
+
return vecs
|
|
419
|
+
except RuntimeError as exc2:
|
|
420
|
+
if not _is_oom_error(exc2):
|
|
421
|
+
raise
|
|
422
|
+
|
|
423
|
+
if self._device == "cpu":
|
|
424
|
+
raise original_exc
|
|
425
|
+
|
|
426
|
+
print(
|
|
427
|
+
f"[embedder] still OOM on {self._device}, falling back to CPU",
|
|
428
|
+
file=sys.stderr,
|
|
429
|
+
)
|
|
430
|
+
self._model = self._model.to("cpu")
|
|
431
|
+
self._device = "cpu"
|
|
432
|
+
bs = _auto_batch_size("cpu")
|
|
433
|
+
self._batch_size = bs
|
|
434
|
+
return await asyncio.to_thread(
|
|
435
|
+
self._model.encode, texts, normalize_embeddings=True, batch_size=bs,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class ModelScopeEmbedder(Embedder):
|
|
440
|
+
"""Local embedding via ModelScope SDK."""
|
|
441
|
+
|
|
442
|
+
def __init__(self, model: str = "iic/nlp_gte_sentence-embedding_chinese-base"):
|
|
443
|
+
try:
|
|
444
|
+
from modelscope.models import Model
|
|
445
|
+
from modelscope.pipelines import pipeline
|
|
446
|
+
except ImportError as exc:
|
|
447
|
+
raise ImportError(
|
|
448
|
+
"ModelScope backend requires modelscope: "
|
|
449
|
+
"pip install crabcode-search[modelscope]"
|
|
450
|
+
) from exc
|
|
451
|
+
self._pipeline = pipeline("sentence-embedding", model=model)
|
|
452
|
+
self._dim: int = 0
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def dimension(self) -> int:
|
|
456
|
+
return self._dim
|
|
457
|
+
|
|
458
|
+
async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
|
|
459
|
+
result = await asyncio.to_thread(
|
|
460
|
+
self._pipeline, {"source_sentence": texts}
|
|
461
|
+
)
|
|
462
|
+
vecs = np.array(result["text_embedding"], dtype=np.float32)
|
|
463
|
+
if self._dim == 0:
|
|
464
|
+
self._dim = vecs.shape[1]
|
|
465
|
+
return self._normalize(vecs)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
_EMBEDDER_REGISTRY: dict[str, type[Embedder]] = {
|
|
469
|
+
"ollama": OllamaEmbedder,
|
|
470
|
+
"gemini": GeminiEmbedder,
|
|
471
|
+
"openai": OpenAIEmbedder,
|
|
472
|
+
"huggingface": HuggingFaceEmbedder,
|
|
473
|
+
"modelscope": ModelScopeEmbedder,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def create_embedder(
|
|
478
|
+
backend: str = "ollama",
|
|
479
|
+
model: str | None = None,
|
|
480
|
+
dimension: int | None = None,
|
|
481
|
+
**kwargs: Any,
|
|
482
|
+
) -> Embedder:
|
|
483
|
+
"""Factory: create an embedder from config."""
|
|
484
|
+
cls = _EMBEDDER_REGISTRY.get(backend)
|
|
485
|
+
if cls is None:
|
|
486
|
+
raise ValueError(
|
|
487
|
+
f"Unknown embedder backend: {backend!r}. "
|
|
488
|
+
f"Available: {', '.join(_EMBEDDER_REGISTRY)}"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
init_kwargs: dict[str, Any] = {k: v for k, v in kwargs.items() if v is not None}
|
|
492
|
+
if model:
|
|
493
|
+
init_kwargs["model"] = model
|
|
494
|
+
if dimension is not None:
|
|
495
|
+
init_kwargs["dim"] = dimension
|
|
496
|
+
|
|
497
|
+
return cls(**init_kwargs)
|