kiwimatecoder 0.1.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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,376 @@
1
+ """Incremental, gitignore-aware builder for the codebase index.
2
+
3
+ A build walks the workspace using the same ignore rules as the file tools,
4
+ compares each file's ``mtime``/``size`` against the stored entry, and only
5
+ re-tokenizes files that are new or changed. Deleted files are dropped. Large
6
+ files are chunked (with overlap) so embeddings can be stored per chunk when an
7
+ embedding provider is configured.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import threading
15
+ from collections import Counter
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from kiwimatecoder import config
22
+ from kiwimatecoder.index import embeddings
23
+ from kiwimatecoder.index.store import load_store, save_store
24
+ from kiwimatecoder.tools.paths import get_workspace_ignore
25
+
26
+ MIN_TOKEN_LENGTH = 2
27
+ CHUNK_LINES = 60
28
+ CHUNK_OVERLAP = 10
29
+ HEAD_BYTES = 1024
30
+
31
+ Embedder = Callable[[list[str]], list[list[float]]]
32
+
33
+ _WORD_RE = re.compile(r"[A-Za-z0-9_]+")
34
+ _CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
35
+
36
+ _BUILD_LOCK = threading.Lock()
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class BuildStats:
41
+ """What one incremental build changed."""
42
+
43
+ added: int = 0
44
+ updated: int = 0
45
+ removed: int = 0
46
+ unchanged: int = 0
47
+ files: int = 0
48
+ terms: int = 0
49
+
50
+
51
+ def tokenize(text: str) -> list[str]:
52
+ """Split text into lowercase index terms.
53
+
54
+ Runs of alphanumerics and underscores are kept, camelCase and snake_case
55
+ are split into their parts, and tokens shorter than
56
+ :data:`MIN_TOKEN_LENGTH` are dropped.
57
+ """
58
+ tokens: list[str] = []
59
+ for word in _WORD_RE.findall(text):
60
+ for part in _CAMEL_RE.split(word):
61
+ for piece in part.split("_"):
62
+ piece = piece.lower()
63
+ if len(piece) >= MIN_TOKEN_LENGTH:
64
+ tokens.append(piece)
65
+ return tokens
66
+
67
+
68
+ def count_tokens(text: str) -> dict[str, int]:
69
+ """Return term -> frequency for ``text``."""
70
+ return dict(Counter(tokenize(text)))
71
+
72
+
73
+ def chunk_text(
74
+ text: str, *, size: int = CHUNK_LINES, overlap: int = CHUNK_OVERLAP
75
+ ) -> list[tuple[int, str]]:
76
+ """Split text into overlapping line windows for embeddings.
77
+
78
+ Returns 1-based ``(start_line, chunk)`` pairs; a file shorter than
79
+ ``size`` lines yields a single chunk and empty text yields none.
80
+ """
81
+ lines = text.splitlines()
82
+ if not lines:
83
+ return []
84
+ step = max(1, size - overlap)
85
+ chunks: list[tuple[int, str]] = []
86
+ start = 0
87
+ while start < len(lines):
88
+ window = lines[start : start + size]
89
+ if not window:
90
+ break
91
+ chunks.append((start + 1, "\n".join(window)))
92
+ if start + size >= len(lines):
93
+ break
94
+ start += step
95
+ return chunks
96
+
97
+
98
+ def embeddings_configured(settings: dict[str, Any] | None = None) -> bool:
99
+ """Whether a provider and model are configured for embeddings."""
100
+ settings = settings or config.get_index()
101
+ embeddings_settings = settings.get("embeddings") or {}
102
+ return bool(
103
+ str(embeddings_settings.get("provider") or "").strip()
104
+ and str(embeddings_settings.get("model") or "").strip()
105
+ )
106
+
107
+
108
+ def _collect_files(root: Path, max_files: int) -> tuple[list[Path], bool]:
109
+ """Collect indexable files, honoring ignore rules and the file cap.
110
+
111
+ Returns ``(files, capped)``; ``capped`` is True when the walk stopped at
112
+ ``max_files`` with potentially more files still unseen.
113
+ """
114
+ ignore = get_workspace_ignore(root)
115
+ collected: list[Path] = []
116
+ for dirpath, dirnames, filenames in os.walk(
117
+ root, topdown=True, followlinks=False
118
+ ):
119
+ dir_p = Path(dirpath)
120
+ dirnames[:] = [
121
+ name for name in dirnames if not ignore.is_ignored(dir_p / name, True)
122
+ ]
123
+ dirnames.sort()
124
+ for name in sorted(filenames):
125
+ file_p = dir_p / name
126
+ if ignore.is_ignored(file_p, is_dir=False):
127
+ continue
128
+ collected.append(file_p)
129
+ if len(collected) >= max_files:
130
+ return collected, True
131
+ return collected, False
132
+
133
+
134
+ def _is_binary(path: Path) -> bool:
135
+ try:
136
+ with path.open("rb") as handle:
137
+ head = handle.read(HEAD_BYTES)
138
+ except OSError:
139
+ return True
140
+ return b"\x00" in head
141
+
142
+
143
+ def _remove_entry(
144
+ files: dict[str, dict[str, Any]], df: dict[str, int], rel: str
145
+ ) -> None:
146
+ entry = files.pop(rel, None)
147
+ if entry is None:
148
+ return
149
+ tokens = entry.get("tokens")
150
+ if not isinstance(tokens, dict):
151
+ return
152
+ for term in tokens:
153
+ remaining = df.get(term, 0) - 1
154
+ if remaining > 0:
155
+ df[term] = remaining
156
+ else:
157
+ df.pop(term, None)
158
+
159
+
160
+ def _add_df(df: dict[str, int], tokens: dict[str, int]) -> None:
161
+ for term in tokens:
162
+ df[term] = df.get(term, 0) + 1
163
+
164
+
165
+ def _has_vectors(entry: dict[str, Any]) -> bool:
166
+ vectors = entry.get("vectors")
167
+ chunks = entry.get("chunks")
168
+ return (
169
+ isinstance(vectors, list)
170
+ and isinstance(chunks, list)
171
+ and len(vectors) == len(chunks)
172
+ and len(vectors) > 0
173
+ )
174
+
175
+
176
+ def _entry_chunks(entry: dict[str, Any]) -> list[tuple[int, str]] | None:
177
+ chunks = entry.get("chunks")
178
+ if not isinstance(chunks, list) or not chunks:
179
+ return None
180
+ result: list[tuple[int, str]] = []
181
+ for chunk in chunks:
182
+ if not isinstance(chunk, dict) or not isinstance(chunk.get("text"), str):
183
+ return None
184
+ try:
185
+ start = int(chunk.get("start") or 1)
186
+ except (TypeError, ValueError):
187
+ start = 1
188
+ result.append((max(1, start), chunk["text"]))
189
+ return result
190
+
191
+
192
+ def _file_chunks(path: Path) -> list[tuple[int, str]] | None:
193
+ try:
194
+ data = path.read_bytes()
195
+ except OSError:
196
+ return None
197
+ if b"\x00" in data[:HEAD_BYTES]:
198
+ return None
199
+ return chunk_text(data.decode("utf-8", "replace"))
200
+
201
+
202
+ def _make_embedder(settings: dict[str, Any]) -> Embedder:
203
+ embedding_settings = settings["embeddings"]
204
+ provider = str(embedding_settings["provider"])
205
+ model = str(embedding_settings["model"])
206
+ batch_size = int(embedding_settings["batch_size"])
207
+
208
+ def embedder(texts: list[str]) -> list[list[float]]:
209
+ return embeddings.embed_texts(
210
+ texts, provider, model, batch_size=batch_size
211
+ )
212
+
213
+ return embedder
214
+
215
+
216
+ def _assign_vectors(
217
+ files: dict[str, dict[str, Any]],
218
+ pending: list[tuple[str, list[tuple[int, str]]]],
219
+ embed: Embedder,
220
+ ) -> None:
221
+ """Embed pending chunks and attach vectors, silently falling back."""
222
+ texts = [text for _rel, chunks in pending for _start, text in chunks]
223
+ try:
224
+ vectors = embed(texts)
225
+ except Exception:
226
+ vectors = []
227
+ if len(vectors) != len(texts):
228
+ for rel, _chunks in pending:
229
+ files[rel].pop("vectors", None)
230
+ return
231
+ offset = 0
232
+ for rel, chunks in pending:
233
+ count = len(chunks)
234
+ files[rel]["vectors"] = vectors[offset : offset + count]
235
+ offset += count
236
+
237
+
238
+ def build_index(
239
+ workspace_root: Path,
240
+ *,
241
+ cfg: dict[str, Any] | None = None,
242
+ embedder: Embedder | None = None,
243
+ ) -> BuildStats:
244
+ """Incrementally update the index for ``workspace_root``.
245
+
246
+ ``embedder`` overrides the configured embedding client (used by tests and
247
+ callers that already hold vectors). Embedding failures are swallowed: the
248
+ index simply stays lexical for those files. Never raises for a file that
249
+ disappears or cannot be read.
250
+ """
251
+ root = Path(workspace_root).resolve()
252
+ settings = config.get_index(cfg)
253
+ if not settings["enabled"]:
254
+ return BuildStats()
255
+ with _BUILD_LOCK:
256
+ return _build(root, settings, embedder)
257
+
258
+
259
+ def _build(
260
+ root: Path,
261
+ settings: dict[str, Any],
262
+ embedder: Embedder | None,
263
+ ) -> BuildStats:
264
+ store = load_store(root)
265
+ files: dict[str, dict[str, Any]] = store["files"]
266
+ df: dict[str, int] = store["df"]
267
+
268
+ embed = embedder
269
+ if embed is None and embeddings_configured(settings):
270
+ embed = _make_embedder(settings)
271
+
272
+ max_files = int(settings["max_files"])
273
+ max_file_bytes = int(settings["max_file_bytes"])
274
+ collected, capped = _collect_files(root, max_files)
275
+
276
+ added = updated = removed = unchanged = 0
277
+ visited: set[str] = set()
278
+ fresh_chunks: dict[str, list[tuple[int, str]]] = {}
279
+
280
+ for file_p in collected:
281
+ rel = file_p.relative_to(root).as_posix()
282
+ visited.add(rel)
283
+ try:
284
+ stat = file_p.stat()
285
+ except OSError:
286
+ continue
287
+ if stat.st_size > max_file_bytes:
288
+ if rel in files:
289
+ _remove_entry(files, df, rel)
290
+ removed += 1
291
+ continue
292
+
293
+ old = files.get(rel)
294
+ if (
295
+ old is not None
296
+ and old.get("mtime") == stat.st_mtime
297
+ and old.get("size") == stat.st_size
298
+ and isinstance(old.get("tokens"), dict)
299
+ ):
300
+ unchanged += 1
301
+ continue
302
+
303
+ if _is_binary(file_p):
304
+ if old is not None:
305
+ _remove_entry(files, df, rel)
306
+ removed += 1
307
+ continue
308
+
309
+ try:
310
+ text = file_p.read_text(encoding="utf-8", errors="replace")
311
+ except OSError:
312
+ continue
313
+
314
+ if old is not None:
315
+ _remove_entry(files, df, rel)
316
+ updated += 1
317
+ else:
318
+ added += 1
319
+
320
+ tokens = count_tokens(text)
321
+ entry: dict[str, Any] = {
322
+ "mtime": stat.st_mtime,
323
+ "size": stat.st_size,
324
+ "tokens": tokens,
325
+ }
326
+ if embed is not None:
327
+ chunks = chunk_text(text)
328
+ entry["chunks"] = [
329
+ {"start": start, "text": chunk} for start, chunk in chunks
330
+ ]
331
+ fresh_chunks[rel] = chunks
332
+ files[rel] = entry
333
+ _add_df(df, tokens)
334
+
335
+ if not capped:
336
+ # Only prune files we actually visited: when the file cap stopped the
337
+ # walk early, untouched entries may still exist further down the tree.
338
+ for rel in [name for name in files if name not in visited]:
339
+ _remove_entry(files, df, rel)
340
+ removed += 1
341
+
342
+ pending: list[tuple[str, list[tuple[int, str]]]] = []
343
+ if embed is not None:
344
+ for rel, entry in files.items():
345
+ if _has_vectors(entry):
346
+ continue
347
+ stored_chunks: list[tuple[int, str]] | None = fresh_chunks.get(
348
+ rel
349
+ ) or _entry_chunks(entry)
350
+ if stored_chunks is None:
351
+ stored_chunks = _file_chunks(root / rel)
352
+ if stored_chunks is None:
353
+ continue
354
+ entry["chunks"] = [
355
+ {"start": start, "text": chunk}
356
+ for start, chunk in stored_chunks
357
+ ]
358
+ if not stored_chunks:
359
+ continue
360
+ pending.append((rel, stored_chunks))
361
+ if pending:
362
+ _assign_vectors(files, pending, embed)
363
+
364
+ if added or updated or removed or pending or store.get("built_at") is None:
365
+ try:
366
+ save_store(root, store)
367
+ except OSError:
368
+ pass # the index is a cache; never fail a build over a write
369
+ return BuildStats(
370
+ added=added,
371
+ updated=updated,
372
+ removed=removed,
373
+ unchanged=unchanged,
374
+ files=len(files),
375
+ terms=len(df),
376
+ )
@@ -0,0 +1,151 @@
1
+ """Optional OpenAI-compatible embeddings for semantic search.
2
+
3
+ Embeddings are an enhancement, never a requirement: any failure raises
4
+ :class:`EmbeddingError`, which callers turn into a lexical-only fallback so a
5
+ bad key or an offline provider can never break a search.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Sequence
12
+
13
+ import httpx
14
+
15
+ from kiwimatecoder import config, network
16
+ from kiwimatecoder.providers import ProviderConfig
17
+
18
+ DEFAULT_TIMEOUT = 30.0
19
+ MAX_BATCH_SIZE = 256
20
+ MAX_ERROR_DETAIL = 200
21
+
22
+
23
+ class EmbeddingError(Exception):
24
+ """A user-facing embedding failure with a ready-to-show message."""
25
+
26
+
27
+ def cosine(left: Sequence[float], right: Sequence[float]) -> float:
28
+ """Return the cosine similarity of two vectors (0.0 when either is empty)."""
29
+ size = min(len(left), len(right))
30
+ if size == 0:
31
+ return 0.0
32
+ dot = sum(float(left[i]) * float(right[i]) for i in range(size))
33
+ norm_left = math.sqrt(sum(float(left[i]) ** 2 for i in range(size)))
34
+ norm_right = math.sqrt(sum(float(right[i]) ** 2 for i in range(size)))
35
+ if norm_left == 0.0 or norm_right == 0.0:
36
+ return 0.0
37
+ return dot / (norm_left * norm_right)
38
+
39
+
40
+ def _provider_and_key(provider_id: str) -> tuple[ProviderConfig, str | None]:
41
+ try:
42
+ provider = config.get_provider_config(provider_id)
43
+ except KeyError as exc:
44
+ raise EmbeddingError(str(exc)) from exc
45
+ if provider.compat != "openai":
46
+ raise EmbeddingError(
47
+ f"Provider '{provider_id}' is not OpenAI-compatible; semantic "
48
+ "embeddings need a provider that serves POST /embeddings."
49
+ )
50
+ api_key = config.get_key(provider_id)
51
+ if provider.needs_key and not api_key:
52
+ raise EmbeddingError(
53
+ f"No API key for {provider.name} ({provider.key_env}) to embed with."
54
+ )
55
+ return provider, api_key
56
+
57
+
58
+ def _vector_batch(payload: object, expected: int) -> list[list[float]]:
59
+ data = payload.get("data") if isinstance(payload, dict) else None
60
+ if not isinstance(data, list) or len(data) != expected:
61
+ raise EmbeddingError(
62
+ "Embedding provider returned an unexpected number of vectors."
63
+ )
64
+ ordered = sorted(
65
+ (item for item in data if isinstance(item, dict)),
66
+ key=lambda item: int(item.get("index") or 0),
67
+ )
68
+ vectors: list[list[float]] = []
69
+ for item in ordered:
70
+ vector = item.get("embedding")
71
+ if not isinstance(vector, list):
72
+ raise EmbeddingError("Embedding provider returned a malformed vector.")
73
+ try:
74
+ vectors.append([float(value) for value in vector])
75
+ except (TypeError, ValueError) as exc:
76
+ raise EmbeddingError(
77
+ "Embedding provider returned a malformed vector."
78
+ ) from exc
79
+ return vectors
80
+
81
+
82
+ def embed_texts(
83
+ texts: Sequence[str],
84
+ provider_id: str,
85
+ model: str,
86
+ *,
87
+ batch_size: int | None = None,
88
+ timeout: float = DEFAULT_TIMEOUT,
89
+ transport: httpx.BaseTransport | None = None,
90
+ ) -> list[list[float]]:
91
+ """Embed ``texts`` with an OpenAI-compatible provider.
92
+
93
+ Requests are issued in bounded batches and results come back in input
94
+ order. Raises :class:`EmbeddingError` for a missing provider/model/key, an
95
+ unsupported provider, an HTTP error, or a malformed response.
96
+ """
97
+ if not texts:
98
+ return []
99
+ provider_id = str(provider_id or "").strip()
100
+ model = str(model or "").strip()
101
+ if not provider_id or not model:
102
+ raise EmbeddingError("Embeddings need both a provider and a model.")
103
+ provider, api_key = _provider_and_key(provider_id)
104
+ if not provider.is_local and network.offline_enabled():
105
+ raise EmbeddingError(network.offline_message("embeddings"))
106
+
107
+ if batch_size is None:
108
+ batch_size = int(config.get_index()["embeddings"]["batch_size"])
109
+ size = max(1, min(int(batch_size), MAX_BATCH_SIZE))
110
+
111
+ headers = {"Content-Type": "application/json", **provider.extra_headers}
112
+ if api_key:
113
+ headers[provider.key_header] = f"{provider.key_prefix}{api_key}"
114
+ url = provider.versioned_url(f"{provider.base_url.rstrip('/')}/embeddings")
115
+
116
+ vectors: list[list[float]] = []
117
+ try:
118
+ with httpx.Client(
119
+ timeout=timeout,
120
+ transport=transport,
121
+ **network.current_options(),
122
+ ) as client:
123
+ for start in range(0, len(texts), size):
124
+ batch = [str(text) for text in texts[start : start + size]]
125
+ response = client.post(
126
+ url, json={"model": model, "input": batch}, headers=headers
127
+ )
128
+ if response.status_code >= 400:
129
+ detail = response.text.strip()
130
+ if len(detail) > MAX_ERROR_DETAIL:
131
+ detail = detail[: MAX_ERROR_DETAIL - 3] + "..."
132
+ message = f"Embedding request failed with HTTP {response.status_code}"
133
+ raise EmbeddingError(message + (f": {detail}" if detail else "."))
134
+ try:
135
+ payload = response.json()
136
+ except ValueError as exc:
137
+ raise EmbeddingError(
138
+ "Embedding provider returned invalid JSON."
139
+ ) from exc
140
+ vectors.extend(_vector_batch(payload, len(batch)))
141
+ except EmbeddingError:
142
+ raise
143
+ except httpx.HTTPError as exc:
144
+ raise EmbeddingError(
145
+ f"Embedding request failed: {exc.__class__.__name__}."
146
+ ) from exc
147
+ if len(vectors) != len(texts):
148
+ raise EmbeddingError(
149
+ "Embedding provider returned an unexpected number of vectors."
150
+ )
151
+ return vectors