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.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. 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
+ }