deeptutor-knowledge 1.7.0__py3-none-any.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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Ollama Embedding Adapter for local embeddings."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
from urllib.parse import urljoin, urlparse
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from deeptutor.services.llm.openai_http_client import disable_ssl_verify_enabled
|
|
10
|
+
|
|
11
|
+
from .base import BaseEmbeddingAdapter, EmbeddingRequest, EmbeddingResponse
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OllamaEmbeddingAdapter(BaseEmbeddingAdapter):
|
|
17
|
+
MODELS_INFO = {
|
|
18
|
+
"all-minilm": 384,
|
|
19
|
+
"all-mpnet-base-v2": 768,
|
|
20
|
+
"nomic-embed-text": 768,
|
|
21
|
+
"mxbai-embed-large": 1024,
|
|
22
|
+
"snowflake-arctic-embed": 1024,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def _should_send_dimensions(self) -> bool:
|
|
26
|
+
# Ollama models historically ignore the param, so default to NOT
|
|
27
|
+
# sending unless the user explicitly opts in.
|
|
28
|
+
return self.send_dimensions is True
|
|
29
|
+
|
|
30
|
+
def _tags_url(self) -> str:
|
|
31
|
+
# Probe `/api/tags` on the same host as the configured embed URL,
|
|
32
|
+
# regardless of which embed path the user chose.
|
|
33
|
+
parsed = urlparse(self.base_url)
|
|
34
|
+
if parsed.scheme and parsed.netloc:
|
|
35
|
+
return urljoin(f"{parsed.scheme}://{parsed.netloc}", "/api/tags")
|
|
36
|
+
return urljoin(self.base_url, "/api/tags")
|
|
37
|
+
|
|
38
|
+
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
|
|
39
|
+
if request.contents:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"Ollama embedding adapter does not support multimodal `contents` input."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
payload = {
|
|
45
|
+
"model": request.model or self.model,
|
|
46
|
+
"input": request.texts,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
dim_value = request.dimensions or self.dimensions
|
|
50
|
+
if dim_value and self._should_send_dimensions():
|
|
51
|
+
payload["dimensions"] = dim_value
|
|
52
|
+
|
|
53
|
+
if request.truncate is not None:
|
|
54
|
+
payload["truncate"] = request.truncate
|
|
55
|
+
|
|
56
|
+
payload["keep_alive"] = "5m"
|
|
57
|
+
|
|
58
|
+
url = self.base_url
|
|
59
|
+
|
|
60
|
+
logger.debug(f"Sending embedding request to {url} with {len(request.texts)} texts")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
async with httpx.AsyncClient(
|
|
64
|
+
timeout=self.request_timeout, verify=not disable_ssl_verify_enabled()
|
|
65
|
+
) as client:
|
|
66
|
+
response = await client.post(
|
|
67
|
+
url,
|
|
68
|
+
json=payload,
|
|
69
|
+
headers={str(k): str(v) for k, v in self.extra_headers.items()},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if response.status_code == 404:
|
|
73
|
+
try:
|
|
74
|
+
health_check = await client.get(self._tags_url())
|
|
75
|
+
if health_check.status_code == 200:
|
|
76
|
+
available_models = [
|
|
77
|
+
m.get("name", "") for m in health_check.json().get("models", [])
|
|
78
|
+
]
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Model '{payload['model']}' not found in Ollama. "
|
|
81
|
+
f"Available models: {', '.join(available_models[:10])}. "
|
|
82
|
+
f"Download it with: ollama pull {payload['model']}"
|
|
83
|
+
)
|
|
84
|
+
except httpx.HTTPError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Model '{payload['model']}' not found. "
|
|
89
|
+
f"Download it with: ollama pull {payload['model']}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
response.raise_for_status()
|
|
93
|
+
data = response.json()
|
|
94
|
+
|
|
95
|
+
except httpx.ConnectError as e:
|
|
96
|
+
raise ConnectionError(
|
|
97
|
+
f"Cannot connect to Ollama at {self.base_url}. "
|
|
98
|
+
f"Make sure Ollama is running. Start it with: ollama serve"
|
|
99
|
+
) from e
|
|
100
|
+
|
|
101
|
+
except httpx.TimeoutException as e:
|
|
102
|
+
raise TimeoutError(
|
|
103
|
+
f"Request to Ollama timed out after {self.request_timeout}s. "
|
|
104
|
+
f"The model might be too large or the server is overloaded."
|
|
105
|
+
) from e
|
|
106
|
+
|
|
107
|
+
except httpx.HTTPError as e:
|
|
108
|
+
logger.error(f"Ollama API error: {e}")
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
embeddings = data["embeddings"]
|
|
112
|
+
|
|
113
|
+
actual_dims = len(embeddings[0]) if embeddings else 0
|
|
114
|
+
expected_dims = request.dimensions or self.dimensions
|
|
115
|
+
|
|
116
|
+
if expected_dims and actual_dims != expected_dims:
|
|
117
|
+
logger.warning(
|
|
118
|
+
f"Dimension mismatch: expected {expected_dims}, got {actual_dims}. "
|
|
119
|
+
f"Model '{payload['model']}' may not support custom dimensions."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
logger.info(
|
|
123
|
+
f"Successfully generated {len(embeddings)} embeddings "
|
|
124
|
+
f"(model: {data.get('model', self.model)}, dimensions: {actual_dims})"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return EmbeddingResponse(
|
|
128
|
+
embeddings=embeddings,
|
|
129
|
+
model=data.get("model", self.model),
|
|
130
|
+
dimensions=actual_dims,
|
|
131
|
+
usage={
|
|
132
|
+
"prompt_eval_count": data.get("prompt_eval_count", 0),
|
|
133
|
+
"total_duration": data.get("total_duration", 0),
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def get_model_info(self) -> Dict[str, Any]:
|
|
138
|
+
return {
|
|
139
|
+
"model": self.model,
|
|
140
|
+
"dimensions": self.MODELS_INFO.get(self.model, self.dimensions),
|
|
141
|
+
"local": True,
|
|
142
|
+
"supports_variable_dimensions": False,
|
|
143
|
+
"multimodal": False,
|
|
144
|
+
"provider": "ollama",
|
|
145
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""OpenAI-compatible embedding adapter for OpenAI, Azure, HuggingFace, LM Studio, etc."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from deeptutor.services.embedding.request_options import should_send_embedding_dimensions
|
|
10
|
+
from deeptutor.services.llm.error_mapping import parse_retry_after_seconds
|
|
11
|
+
from deeptutor.services.llm.openai_http_client import disable_ssl_verify_enabled
|
|
12
|
+
|
|
13
|
+
from .base import (
|
|
14
|
+
BaseEmbeddingAdapter,
|
|
15
|
+
EmbeddingProviderError,
|
|
16
|
+
EmbeddingRequest,
|
|
17
|
+
EmbeddingResponse,
|
|
18
|
+
looks_like_multimodal_embedding_model,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def rejects_absent_encoding_format(status_code: int, body: str) -> bool:
|
|
25
|
+
"""Whether *body* is a gateway refusing the request for lacking the param.
|
|
26
|
+
|
|
27
|
+
Two gateways impose opposite requirements and neither can be satisfied by
|
|
28
|
+
picking a default: SiliconFlow rejects `encoding_format` when it is
|
|
29
|
+
present (#651, which is why it is omitted), and ModelScope rejects it when
|
|
30
|
+
absent — it reads the missing field as `''` and answers `encoding_format
|
|
31
|
+
must be 'float' or 'base64', got ''` (#934). Both constraints belong to
|
|
32
|
+
the gateway, not to the model, so a model-family rule would key off the
|
|
33
|
+
wrong thing: the same Qwen3-Embedding weights are served by gateways on
|
|
34
|
+
both sides of the disagreement.
|
|
35
|
+
|
|
36
|
+
Recover from what the provider actually said instead. The body must name
|
|
37
|
+
the parameter *and* name the value we are about to send, so a gateway
|
|
38
|
+
complaining that `encoding_format` is unsupported does not trigger a retry
|
|
39
|
+
that would fail the same way.
|
|
40
|
+
"""
|
|
41
|
+
if status_code != 400:
|
|
42
|
+
return False
|
|
43
|
+
lowered = body.lower()
|
|
44
|
+
return "encoding_format" in lowered and "float" in lowered
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OpenAICompatibleEmbeddingAdapter(BaseEmbeddingAdapter):
|
|
48
|
+
NO_KEY_SENTINEL = "sk-no-key-required"
|
|
49
|
+
|
|
50
|
+
MODELS_INFO = {
|
|
51
|
+
"text-embedding-3-large": {"default": 3072, "dimensions": [256, 512, 1024, 3072]},
|
|
52
|
+
"text-embedding-3-small": {"default": 1536, "dimensions": [512, 1536]},
|
|
53
|
+
"text-embedding-ada-002": 1536,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def _auth_api_key(self) -> str:
|
|
57
|
+
"""Return a real API key, suppressing local-provider placeholder keys."""
|
|
58
|
+
key = self._key_pool.next() if self._key_pool else str(self.api_key or "").strip()
|
|
59
|
+
if key == self.NO_KEY_SENTINEL:
|
|
60
|
+
return ""
|
|
61
|
+
return key
|
|
62
|
+
|
|
63
|
+
def _set_auth_header(self, headers: dict[str, str], api_key: str) -> None:
|
|
64
|
+
header = "api-key" if self.api_version else "Authorization"
|
|
65
|
+
headers.pop(header, None)
|
|
66
|
+
if api_key:
|
|
67
|
+
headers[header] = api_key if self.api_version else f"Bearer {api_key}"
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _extract_embeddings_from_response(data: Any) -> list[list[float]]:
|
|
71
|
+
"""
|
|
72
|
+
Extract embeddings from different OpenAI-compatible response schemas.
|
|
73
|
+
|
|
74
|
+
Supported shapes include:
|
|
75
|
+
- {"data": [{"embedding": [...]}, ...]}
|
|
76
|
+
- {"embeddings": [[...], ...]}
|
|
77
|
+
- {"embedding": [...]} (Ollama /api/embeddings)
|
|
78
|
+
- {"result": {"data": [{"embedding": [...]}, ...]}}
|
|
79
|
+
- {"output": {"embeddings": [[...], ...]}}
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
raise ValueError(f"Embedding response is not a JSON object: type={type(data).__name__}")
|
|
83
|
+
|
|
84
|
+
# Some providers return HTTP 200 with {"error": ...} payload.
|
|
85
|
+
if "error" in data:
|
|
86
|
+
err = data.get("error")
|
|
87
|
+
if isinstance(err, dict):
|
|
88
|
+
msg = (
|
|
89
|
+
err.get("message")
|
|
90
|
+
or err.get("msg")
|
|
91
|
+
or err.get("detail")
|
|
92
|
+
or json.dumps(err, ensure_ascii=False)
|
|
93
|
+
)
|
|
94
|
+
code = err.get("code")
|
|
95
|
+
etype = err.get("type")
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Embedding provider returned error payload: "
|
|
98
|
+
f"message={msg}, code={code}, type={etype}"
|
|
99
|
+
)
|
|
100
|
+
raise ValueError(f"Embedding provider returned error payload: {err}")
|
|
101
|
+
|
|
102
|
+
candidates = []
|
|
103
|
+
# Standard OpenAI schema
|
|
104
|
+
if isinstance(data.get("data"), list):
|
|
105
|
+
candidates.append(data["data"])
|
|
106
|
+
# Common proxy schema
|
|
107
|
+
if isinstance(data.get("embeddings"), list):
|
|
108
|
+
candidates.append(data["embeddings"])
|
|
109
|
+
# Ollama /api/embeddings returns singular "embedding" as a flat vector
|
|
110
|
+
if isinstance(data.get("embedding"), list):
|
|
111
|
+
emb = data["embedding"]
|
|
112
|
+
if emb and isinstance(emb[0], (int, float)):
|
|
113
|
+
candidates.append([emb])
|
|
114
|
+
else:
|
|
115
|
+
candidates.append(emb)
|
|
116
|
+
# Nested result/output variants
|
|
117
|
+
result = data.get("result")
|
|
118
|
+
if isinstance(result, dict):
|
|
119
|
+
if isinstance(result.get("data"), list):
|
|
120
|
+
candidates.append(result["data"])
|
|
121
|
+
if isinstance(result.get("embeddings"), list):
|
|
122
|
+
candidates.append(result["embeddings"])
|
|
123
|
+
output = data.get("output")
|
|
124
|
+
if isinstance(output, dict):
|
|
125
|
+
if isinstance(output.get("data"), list):
|
|
126
|
+
candidates.append(output["data"])
|
|
127
|
+
if isinstance(output.get("embeddings"), list):
|
|
128
|
+
candidates.append(output["embeddings"])
|
|
129
|
+
|
|
130
|
+
for c in candidates:
|
|
131
|
+
if not c:
|
|
132
|
+
continue
|
|
133
|
+
first = c[0]
|
|
134
|
+
# list of {"embedding":[...]}
|
|
135
|
+
if isinstance(first, dict) and "embedding" in first:
|
|
136
|
+
return [item.get("embedding") or [] for item in c if isinstance(item, dict)]
|
|
137
|
+
# list of vectors [[...], ...]
|
|
138
|
+
if isinstance(first, list):
|
|
139
|
+
return [item for item in c if isinstance(item, list)]
|
|
140
|
+
|
|
141
|
+
keys = sorted(list(data.keys()))
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"Cannot parse embeddings from response JSON. "
|
|
144
|
+
f"Top-level keys={keys}, expected one of: data/embedding/embeddings/result/output."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
_MAX_RETRIES = 5
|
|
148
|
+
_RETRY_BACKOFF = 1.0
|
|
149
|
+
_RATE_LIMIT_BACKOFF = 5.0
|
|
150
|
+
|
|
151
|
+
def _should_send_dimensions(self, model_name: str | None) -> bool:
|
|
152
|
+
"""Decide whether to attach `dimensions` to the request payload.
|
|
153
|
+
|
|
154
|
+
Tri-state semantics driven by `self.send_dimensions`:
|
|
155
|
+
* ``True`` -> always send (user explicitly opted in)
|
|
156
|
+
* ``False`` -> never send (user explicitly opted out)
|
|
157
|
+
* ``None`` -> auto: send for known model families that accept the
|
|
158
|
+
OpenAI-style ``dimensions`` parameter — OpenAI ``text-embedding-3*``,
|
|
159
|
+
Qwen3-Embedding, Qwen3-VL-Embedding.
|
|
160
|
+
"""
|
|
161
|
+
return should_send_embedding_dimensions(
|
|
162
|
+
binding=None,
|
|
163
|
+
model=model_name,
|
|
164
|
+
# ``embed`` only calls this hook when a request dimension exists.
|
|
165
|
+
# Keep the historical one-argument override contract used by
|
|
166
|
+
# provider adapters such as Gemini.
|
|
167
|
+
dimension=self.dimensions or 1,
|
|
168
|
+
send_dimensions=self.send_dimensions,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
|
|
172
|
+
import asyncio
|
|
173
|
+
|
|
174
|
+
headers = {"Content-Type": "application/json"}
|
|
175
|
+
api_key = self._auth_api_key()
|
|
176
|
+
self._set_auth_header(headers, api_key)
|
|
177
|
+
headers.update({str(k): str(v) for k, v in self.extra_headers.items()})
|
|
178
|
+
|
|
179
|
+
# Multimodal: pass `contents` through as `input` only for model names
|
|
180
|
+
# that clearly advertise image/vision embedding support. This prevents
|
|
181
|
+
# image indexing from accidentally hitting ordinary text-embedding
|
|
182
|
+
# models just because the provider family has some multimodal models.
|
|
183
|
+
model = request.model or self.model
|
|
184
|
+
if request.contents and not looks_like_multimodal_embedding_model(model):
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"OpenAI-compatible embedding model '{model}' does not support "
|
|
187
|
+
"multimodal `contents`."
|
|
188
|
+
)
|
|
189
|
+
input_payload: Any = request.contents if request.contents else request.texts
|
|
190
|
+
|
|
191
|
+
payload = {
|
|
192
|
+
"input": input_payload,
|
|
193
|
+
"model": model,
|
|
194
|
+
}
|
|
195
|
+
# `encoding_format` is opt-in: omit it by default (request default is
|
|
196
|
+
# None) because several OpenAI-compatible gateways (e.g. SiliconFlow)
|
|
197
|
+
# reject the param with HTTP 400. Only forward an explicit choice.
|
|
198
|
+
# Do not add a default here for the gateways that require the param —
|
|
199
|
+
# that trades #934 for #651. The retry below recovers those from the
|
|
200
|
+
# provider's own refusal, leaving every working config untouched.
|
|
201
|
+
if request.encoding_format:
|
|
202
|
+
payload["encoding_format"] = request.encoding_format
|
|
203
|
+
|
|
204
|
+
# `dimensions` is opt-in. The user's `send_dimensions` flag wins when set
|
|
205
|
+
# explicitly (True/False); otherwise we fall back to a model-family
|
|
206
|
+
# heuristic since only OpenAI's text-embedding-3* family officially
|
|
207
|
+
# supports the param — other providers (e.g. Qwen text-embedding-v4 via
|
|
208
|
+
# litellm gateway) return HTTP 400 if we send it.
|
|
209
|
+
dim_value = request.dimensions or self.dimensions
|
|
210
|
+
if dim_value and self._should_send_dimensions(model):
|
|
211
|
+
payload["dimensions"] = dim_value
|
|
212
|
+
|
|
213
|
+
# URL transparency: hit `base_url` verbatim. Azure's `?api-version=...`
|
|
214
|
+
# is a query param (not a path component) so we still append it.
|
|
215
|
+
url = self.base_url
|
|
216
|
+
if self.api_version:
|
|
217
|
+
if "?" not in url:
|
|
218
|
+
url += f"?api-version={self.api_version}"
|
|
219
|
+
else:
|
|
220
|
+
url += f"&api-version={self.api_version}"
|
|
221
|
+
|
|
222
|
+
logger.debug(f"Sending embedding request to {url} with {len(request.texts)} texts")
|
|
223
|
+
|
|
224
|
+
timeout = httpx.Timeout(
|
|
225
|
+
connect=10.0,
|
|
226
|
+
read=max(self.request_timeout, 60),
|
|
227
|
+
write=10.0,
|
|
228
|
+
pool=10.0,
|
|
229
|
+
)
|
|
230
|
+
last_exc: Exception | None = None
|
|
231
|
+
rate_limit_retries = 0
|
|
232
|
+
# 槽位必须 > 限流重试上限:429 的 continue 也消耗 attempt,若循环
|
|
233
|
+
# 先耗尽而 last_exc 仍为 None,会静默落到循环外的 data 引用上
|
|
234
|
+
# (UnboundLocalError)。多给 4 个槽位兜住 8 轮限流 + 网络重试。
|
|
235
|
+
for attempt in range(1 + max(self._MAX_RETRIES, 10)):
|
|
236
|
+
try:
|
|
237
|
+
async with httpx.AsyncClient(
|
|
238
|
+
timeout=timeout, verify=not disable_ssl_verify_enabled()
|
|
239
|
+
) as client:
|
|
240
|
+
response = await client.post(url, json=payload, headers=headers)
|
|
241
|
+
|
|
242
|
+
# Handle rate limiting (429) with retry
|
|
243
|
+
if response.status_code == 429:
|
|
244
|
+
if self._key_pool:
|
|
245
|
+
self._key_pool.mark_429(api_key)
|
|
246
|
+
# 滑动窗口 429 是瞬态的:长跑(全库 reindex 数小时)里
|
|
247
|
+
# 单次 429 不该报废整跑。最多 8 轮,每轮等窗口滑过
|
|
248
|
+
# (Retry-After 优先,无头保守 60s)。月度额度耗尽的
|
|
249
|
+
# 429 会连挂 8 轮后仍然 raise,不会无限空转。
|
|
250
|
+
if rate_limit_retries < 8:
|
|
251
|
+
rate_limit_retries += 1
|
|
252
|
+
retry_after = parse_retry_after_seconds(
|
|
253
|
+
response.headers.get("Retry-After")
|
|
254
|
+
)
|
|
255
|
+
await asyncio.sleep(max(retry_after or 0.0, 60))
|
|
256
|
+
try:
|
|
257
|
+
api_key = self._auth_api_key()
|
|
258
|
+
except RuntimeError:
|
|
259
|
+
# 池内 key 全在冷却(KeyPool 冷却 60s)。
|
|
260
|
+
# 等冷却期过后再取一次;仍取不到才认输。
|
|
261
|
+
await asyncio.sleep(65)
|
|
262
|
+
api_key = self._auth_api_key()
|
|
263
|
+
self._set_auth_header(headers, api_key)
|
|
264
|
+
continue
|
|
265
|
+
retry_after = parse_retry_after_seconds(response.headers.get("Retry-After"))
|
|
266
|
+
raise EmbeddingProviderError(
|
|
267
|
+
"Embedding provider remained rate limited after key rotation"
|
|
268
|
+
+ (f" (Retry-After: {retry_after:g}s)" if retry_after else ""),
|
|
269
|
+
status=429,
|
|
270
|
+
model=model,
|
|
271
|
+
url=url,
|
|
272
|
+
provider="openai_compat",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if response.status_code >= 400:
|
|
276
|
+
body_text = response.text
|
|
277
|
+
if "encoding_format" not in payload and rejects_absent_encoding_format(
|
|
278
|
+
response.status_code, body_text
|
|
279
|
+
):
|
|
280
|
+
payload["encoding_format"] = "float"
|
|
281
|
+
logger.info(
|
|
282
|
+
"Gateway requires an explicit `encoding_format`; "
|
|
283
|
+
"retrying once with 'float' (%s)",
|
|
284
|
+
url,
|
|
285
|
+
)
|
|
286
|
+
continue
|
|
287
|
+
logger.error(f"HTTP {response.status_code} from {url}: {body_text[:2000]}")
|
|
288
|
+
raise EmbeddingProviderError(
|
|
289
|
+
f"Embedding provider returned HTTP {response.status_code}",
|
|
290
|
+
status=response.status_code,
|
|
291
|
+
body=body_text,
|
|
292
|
+
model=model,
|
|
293
|
+
url=url,
|
|
294
|
+
provider="openai_compat",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# A 2xx response with non-JSON body usually means the
|
|
298
|
+
# endpoint/model pairing is wrong or a gateway routed us to
|
|
299
|
+
# an HTML page. Surface that as structured diagnostics.
|
|
300
|
+
try:
|
|
301
|
+
data = response.json()
|
|
302
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
303
|
+
body_text = response.text
|
|
304
|
+
content_type = response.headers.get("content-type", "")
|
|
305
|
+
body_preview = body_text.strip()[:200] or "<empty body>"
|
|
306
|
+
hint = ""
|
|
307
|
+
if not body_text.strip():
|
|
308
|
+
hint = (
|
|
309
|
+
" The response body was empty — the endpoint may "
|
|
310
|
+
"not support embeddings or the selected model "
|
|
311
|
+
"may not be an embedding model."
|
|
312
|
+
)
|
|
313
|
+
elif (
|
|
314
|
+
"text/html" in content_type.lower()
|
|
315
|
+
or body_preview.lstrip().startswith("<")
|
|
316
|
+
):
|
|
317
|
+
hint = (
|
|
318
|
+
" The response was HTML, not JSON — the URL is "
|
|
319
|
+
"likely wrong or the gateway does not expose "
|
|
320
|
+
"`/v1/embeddings`."
|
|
321
|
+
)
|
|
322
|
+
raise EmbeddingProviderError(
|
|
323
|
+
(
|
|
324
|
+
f"Embedding provider returned non-JSON response "
|
|
325
|
+
f"(content-type={content_type!r}): {exc}.{hint}"
|
|
326
|
+
),
|
|
327
|
+
status=response.status_code,
|
|
328
|
+
body=body_text,
|
|
329
|
+
model=model,
|
|
330
|
+
url=url,
|
|
331
|
+
provider="openai_compat",
|
|
332
|
+
) from exc
|
|
333
|
+
break
|
|
334
|
+
except httpx.TransportError as exc:
|
|
335
|
+
# httpx.TransportError covers all transient transport-layer
|
|
336
|
+
# failures: ConnectError, ReadError, WriteError, ConnectTimeout,
|
|
337
|
+
# ReadTimeout, WriteTimeout, PoolTimeout, RemoteProtocolError, etc.
|
|
338
|
+
# Retrying any of these with backoff is safe and obviates the
|
|
339
|
+
# need to keep extending an explicit allow-list.
|
|
340
|
+
last_exc = exc
|
|
341
|
+
if attempt < self._MAX_RETRIES:
|
|
342
|
+
wait = self._RETRY_BACKOFF * (2**attempt)
|
|
343
|
+
logger.warning(
|
|
344
|
+
f"Embedding request transport error ({type(exc).__name__}: {exc}) "
|
|
345
|
+
f"on attempt {attempt + 1}/{1 + self._MAX_RETRIES}, "
|
|
346
|
+
f"retrying in {wait:.1f}s..."
|
|
347
|
+
)
|
|
348
|
+
await asyncio.sleep(wait)
|
|
349
|
+
else:
|
|
350
|
+
logger.error(
|
|
351
|
+
f"Embedding request failed after {1 + self._MAX_RETRIES} attempts "
|
|
352
|
+
f"({type(exc).__name__}: {exc})"
|
|
353
|
+
)
|
|
354
|
+
raise
|
|
355
|
+
else:
|
|
356
|
+
if last_exc:
|
|
357
|
+
raise last_exc
|
|
358
|
+
|
|
359
|
+
embeddings = self._extract_embeddings_from_response(data)
|
|
360
|
+
if not embeddings:
|
|
361
|
+
raise ValueError("Embedding response parsed successfully but no vectors were found.")
|
|
362
|
+
|
|
363
|
+
actual_dims = len(embeddings[0]) if embeddings else 0
|
|
364
|
+
expected_dims = request.dimensions or self.dimensions
|
|
365
|
+
model_name = data.get("model") if isinstance(data, dict) else None
|
|
366
|
+
if not model_name:
|
|
367
|
+
model_name = model
|
|
368
|
+
|
|
369
|
+
if expected_dims and actual_dims != expected_dims:
|
|
370
|
+
logger.warning(
|
|
371
|
+
f"Dimension mismatch: expected {expected_dims}, got {actual_dims}. "
|
|
372
|
+
f"Model '{model_name}' may not support custom dimensions."
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
logger.info(
|
|
376
|
+
f"Successfully generated {len(embeddings)} embeddings "
|
|
377
|
+
f"(model: {model_name}, dimensions: {actual_dims})"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return EmbeddingResponse(
|
|
381
|
+
embeddings=embeddings,
|
|
382
|
+
model=model_name,
|
|
383
|
+
dimensions=actual_dims,
|
|
384
|
+
usage=data.get("usage", {}) if isinstance(data, dict) else {},
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def get_model_info(self) -> Dict[str, Any]:
|
|
388
|
+
model_info = self.MODELS_INFO.get(self.model, self.dimensions)
|
|
389
|
+
|
|
390
|
+
if isinstance(model_info, dict):
|
|
391
|
+
return {
|
|
392
|
+
"model": self.model,
|
|
393
|
+
"dimensions": model_info.get("default", self.dimensions),
|
|
394
|
+
"supported_dimensions": model_info.get("dimensions", []),
|
|
395
|
+
"supports_variable_dimensions": len(model_info.get("dimensions", [])) > 1,
|
|
396
|
+
"multimodal": looks_like_multimodal_embedding_model(self.model),
|
|
397
|
+
"provider": "openai_compatible",
|
|
398
|
+
}
|
|
399
|
+
else:
|
|
400
|
+
return {
|
|
401
|
+
"model": self.model,
|
|
402
|
+
"dimensions": model_info or self.dimensions,
|
|
403
|
+
"supports_variable_dimensions": False,
|
|
404
|
+
"multimodal": looks_like_multimodal_embedding_model(self.model),
|
|
405
|
+
"provider": "openai_compatible",
|
|
406
|
+
}
|