SourceIndex 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 (47) hide show
  1. sourceindex/__init__.py +30 -0
  2. sourceindex/build/__init__.py +592 -0
  3. sourceindex/build/indexer.py +403 -0
  4. sourceindex/build/linerange/__init__.py +24 -0
  5. sourceindex/build/linerange/python_ast.py +155 -0
  6. sourceindex/build/linerange/treesitter.py +397 -0
  7. sourceindex/build/prompts.py +76 -0
  8. sourceindex/build/state.py +261 -0
  9. sourceindex/build/walker.py +94 -0
  10. sourceindex/claudecode/__init__.py +0 -0
  11. sourceindex/claudecode/savings.py +325 -0
  12. sourceindex/claudecode/savings_summary.py +88 -0
  13. sourceindex/claudecode/statusline.py +116 -0
  14. sourceindex/cli/__init__.py +304 -0
  15. sourceindex/cli/__main__.py +10 -0
  16. sourceindex/cli/api_key.py +171 -0
  17. sourceindex/cli/commands.py +678 -0
  18. sourceindex/cli/install.py +378 -0
  19. sourceindex/daemon/__init__.py +83 -0
  20. sourceindex/daemon/client.py +141 -0
  21. sourceindex/daemon/crypto.py +48 -0
  22. sourceindex/daemon/keyring_store.py +129 -0
  23. sourceindex/daemon/lifecycle.py +237 -0
  24. sourceindex/daemon/protocol.py +134 -0
  25. sourceindex/daemon/server.py +389 -0
  26. sourceindex/daemon/store.py +426 -0
  27. sourceindex/lib/__init__.py +0 -0
  28. sourceindex/lib/backend.py +240 -0
  29. sourceindex/lib/cost.py +96 -0
  30. sourceindex/lib/env.py +30 -0
  31. sourceindex/lib/git.py +33 -0
  32. sourceindex/lib/languages.py +406 -0
  33. sourceindex/lib/llm.py +298 -0
  34. sourceindex/lib/log.py +228 -0
  35. sourceindex/lib/registry.py +75 -0
  36. sourceindex/lib/timing.py +10 -0
  37. sourceindex/search/__init__.py +251 -0
  38. sourceindex/search/experiments.py +276 -0
  39. sourceindex/search/imports.py +155 -0
  40. sourceindex/search/passes.py +51 -0
  41. sourceindex/search/prompts.py +379 -0
  42. sourceindex/search/roadmap.py +265 -0
  43. sourceindex/server.py +127 -0
  44. sourceindex-0.1.0.dist-info/METADATA +73 -0
  45. sourceindex-0.1.0.dist-info/RECORD +47 -0
  46. sourceindex-0.1.0.dist-info/WHEEL +4 -0
  47. sourceindex-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,426 @@
1
+ """Encrypted-at-rest store backing ``.sourceindex/``.
2
+
3
+ Both file CONTENTS and PATHS are hidden on disk. Logical paths like
4
+ ``index.md`` or ``details/foo/bar.py.md`` are translated to opaque random
5
+ chunk IDs via an encrypted manifest. Outside observers see only
6
+ ``manifest.enc`` and a churn of ``chunks/<random_id>.enc`` blobs; the
7
+ filesystem layout no longer betrays the repo's file tree.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import secrets
15
+ import tempfile
16
+ import threading
17
+ from pathlib import Path
18
+ from typing import Iterator
19
+
20
+ from . import crypto
21
+
22
+
23
+ SENTINEL_FILE = ".daemon_key_id"
24
+ ENC_SUFFIX = ".enc"
25
+ TIER1_FILENAME = "index.md"
26
+ MANIFEST_FILENAME = "manifest"
27
+ CHUNKS_DIRNAME = "chunks"
28
+ DETAILS_DIRNAME = "details"
29
+ MANIFEST_VERSION = 1
30
+ _CHUNK_ID_BYTES = 16
31
+
32
+
33
+ class PlaintextStore:
34
+ """Direct read/write of a ``.sourceindex/`` directory without encryption.
35
+ Mirrors the EncryptedStore API so callers can be agnostic."""
36
+
37
+ def __init__(self, index_dir: Path) -> None:
38
+ self.index_dir = Path(index_dir)
39
+
40
+ def _resolve(self, rel: str) -> Path:
41
+ return self.index_dir / rel.lstrip("/")
42
+
43
+ def path_for(self, rel: str) -> Path:
44
+ return self._resolve(rel)
45
+
46
+ def ensure_initialized(self) -> None:
47
+ self.index_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ def read_bytes(self, rel: str) -> bytes:
50
+ return self._resolve(rel).read_bytes()
51
+
52
+ def read_text(self, rel: str) -> str:
53
+ return self._resolve(rel).read_text()
54
+
55
+ def write_bytes(self, rel: str, data: bytes) -> None:
56
+ target = self._resolve(rel)
57
+ target.parent.mkdir(parents=True, exist_ok=True)
58
+ target.write_bytes(data)
59
+
60
+ def write_text(self, rel: str, data: str) -> None:
61
+ target = self._resolve(rel)
62
+ target.parent.mkdir(parents=True, exist_ok=True)
63
+ target.write_text(data)
64
+
65
+ def exists(self, rel: str) -> bool:
66
+ return self._resolve(rel).is_file()
67
+
68
+ def unlink(self, rel: str, *, missing_ok: bool = True) -> None:
69
+ try:
70
+ self._resolve(rel).unlink()
71
+ except FileNotFoundError:
72
+ if not missing_ok:
73
+ raise
74
+
75
+ def iter_relative(self, subdir: str = "") -> "Iterator[str]":
76
+ root = self.index_dir / subdir if subdir else self.index_dir
77
+ if not root.exists():
78
+ return
79
+ for p in root.rglob("*"):
80
+ if not p.is_file():
81
+ continue
82
+ if p.parent == self.index_dir and p.name == SENTINEL_FILE:
83
+ continue
84
+ yield p.relative_to(self.index_dir).as_posix()
85
+
86
+ def plaintext_dump(self, out_dir: Path) -> tuple[int, int]:
87
+ out_dir = Path(out_dir)
88
+ out_dir.mkdir(parents=True, exist_ok=True)
89
+ files = 0
90
+ total = 0
91
+ for rel in self.iter_relative():
92
+ data = self.read_bytes(rel)
93
+ target = out_dir / rel
94
+ target.parent.mkdir(parents=True, exist_ok=True)
95
+ target.write_bytes(data)
96
+ files += 1
97
+ total += len(data)
98
+ return files, total
99
+
100
+
101
+ class StoreError(RuntimeError):
102
+ pass
103
+
104
+
105
+ class KeyMismatchError(StoreError):
106
+ pass
107
+
108
+
109
+ class EncryptedStore:
110
+ def __init__(self, index_dir: Path, key: bytes, repo_hash: str) -> None:
111
+ self.index_dir = Path(index_dir)
112
+ self._key = key
113
+ self._repo_hash = repo_hash
114
+ self._lock = threading.Lock()
115
+ self._manifest: dict[str, str] | None = None
116
+
117
+ # ── path helpers ──────────────────────────────────────────────────────
118
+
119
+ def _manifest_path(self) -> Path:
120
+ return self.index_dir / (MANIFEST_FILENAME + ENC_SUFFIX)
121
+
122
+ def _chunks_dir(self) -> Path:
123
+ return self.index_dir / CHUNKS_DIRNAME
124
+
125
+ def _chunk_path(self, chunk_id: str) -> Path:
126
+ return self._chunks_dir() / (chunk_id + ENC_SUFFIX)
127
+
128
+ def _new_chunk_id(self) -> str:
129
+ return secrets.token_hex(_CHUNK_ID_BYTES)
130
+
131
+ def path_for(self, rel: str) -> Path:
132
+ """Logical path under ``index_dir`` for error-message use only;
133
+ the actual on-disk file is an opaque chunk under ``chunks/``."""
134
+ return self.index_dir / rel.lstrip("/")
135
+
136
+ # ── manifest plumbing ─────────────────────────────────────────────────
137
+
138
+ def _load_manifest_locked(self) -> dict[str, str]:
139
+ path = self._manifest_path()
140
+ if not path.is_file():
141
+ return {}
142
+ try:
143
+ blob = path.read_bytes()
144
+ plaintext = crypto.decrypt(blob, self._key)
145
+ except crypto.InvalidBlob as e:
146
+ raise KeyMismatchError(
147
+ f"Could not decrypt manifest at {path}: {e}. The on-disk "
148
+ "store and the loaded master key disagree — likely a stale "
149
+ "key file or a moved repository."
150
+ ) from e
151
+ try:
152
+ data = json.loads(plaintext.decode("utf-8"))
153
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
154
+ raise StoreError(f"Corrupt manifest at {path}: {e}") from e
155
+ if not isinstance(data, dict):
156
+ raise StoreError(f"Corrupt manifest at {path}: not a JSON object")
157
+ entries = data.get("entries", {})
158
+ if not isinstance(entries, dict):
159
+ raise StoreError(
160
+ f"Corrupt manifest at {path}: 'entries' is not a JSON object"
161
+ )
162
+ return {str(k): str(v) for k, v in entries.items()}
163
+
164
+ def _save_manifest_locked(self) -> None:
165
+ manifest = self._manifest if self._manifest is not None else {}
166
+ payload = {
167
+ "version": MANIFEST_VERSION,
168
+ "entries": dict(sorted(manifest.items())),
169
+ }
170
+ blob = crypto.encrypt(
171
+ json.dumps(payload, separators=(",", ":")).encode("utf-8"),
172
+ self._key,
173
+ )
174
+ path = self._manifest_path()
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ self._atomic_write_bytes(path, blob)
177
+
178
+ def _get_manifest_locked(self) -> dict[str, str]:
179
+ if self._manifest is None:
180
+ self._manifest = self._load_manifest_locked()
181
+ return self._manifest
182
+
183
+ @staticmethod
184
+ def _atomic_write_bytes(target: Path, blob: bytes) -> None:
185
+ fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=str(target.parent))
186
+ try:
187
+ with os.fdopen(fd, "wb") as f:
188
+ f.write(blob)
189
+ os.replace(tmp, target)
190
+ except Exception:
191
+ try:
192
+ os.unlink(tmp)
193
+ except OSError:
194
+ pass
195
+ raise
196
+
197
+ # ── lifecycle ─────────────────────────────────────────────────────────
198
+
199
+ def ensure_initialized(self) -> None:
200
+ self.index_dir.mkdir(parents=True, exist_ok=True)
201
+ sentinel = self.index_dir / SENTINEL_FILE
202
+ if sentinel.exists():
203
+ existing = sentinel.read_text().strip()
204
+ if existing and existing != self._repo_hash:
205
+ raise KeyMismatchError(
206
+ f"Store at {self.index_dir} carries repo_hash={existing!r} "
207
+ f"but this daemon is initialized for {self._repo_hash!r}. "
208
+ "Either you moved the repo (re-init to mint a new key) or "
209
+ "the keyring entry was deleted; nothing was modified."
210
+ )
211
+ else:
212
+ sentinel.write_text(self._repo_hash + "\n")
213
+
214
+ # ── core read / write API ─────────────────────────────────────────────
215
+
216
+ def read_bytes(self, rel: str) -> bytes:
217
+ rel = rel.lstrip("/")
218
+ with self._lock:
219
+ manifest = self._get_manifest_locked()
220
+ chunk_id = manifest.get(rel)
221
+ if chunk_id is None:
222
+ raise FileNotFoundError(
223
+ f"{rel!r} is not present in the encrypted index"
224
+ )
225
+ chunk_path = self._chunk_path(chunk_id)
226
+ try:
227
+ blob = chunk_path.read_bytes()
228
+ except FileNotFoundError as e:
229
+ raise StoreError(
230
+ f"Manifest references chunk {chunk_id!r} for {rel!r} but "
231
+ f"the chunk file is missing at {chunk_path}. The store "
232
+ "is corrupt."
233
+ ) from e
234
+ try:
235
+ return crypto.decrypt(blob, self._key)
236
+ except crypto.InvalidBlob as e:
237
+ raise KeyMismatchError(
238
+ f"Could not decrypt {rel!r}: {e}. The on-disk store and the "
239
+ "loaded master key disagree — likely a stale key file or a "
240
+ "moved repository."
241
+ ) from e
242
+
243
+ def read_text(self, rel: str) -> str:
244
+ return self.read_bytes(rel).decode("utf-8")
245
+
246
+ def write_bytes(self, rel: str, data: bytes) -> None:
247
+ rel = rel.lstrip("/")
248
+ chunks_dir = self._chunks_dir()
249
+ chunks_dir.mkdir(parents=True, exist_ok=True)
250
+ blob = crypto.encrypt(data, self._key)
251
+
252
+ with self._lock:
253
+ new_id = self._new_chunk_id()
254
+ new_chunk = self._chunk_path(new_id)
255
+ self._atomic_write_bytes(new_chunk, blob)
256
+
257
+ manifest = self._get_manifest_locked()
258
+ old_id = manifest.get(rel)
259
+ manifest[rel] = new_id
260
+ self._save_manifest_locked()
261
+
262
+ if old_id and old_id != new_id:
263
+ try:
264
+ self._chunk_path(old_id).unlink()
265
+ except FileNotFoundError:
266
+ pass
267
+
268
+ def write_text(self, rel: str, data: str) -> None:
269
+ self.write_bytes(rel, data.encode("utf-8"))
270
+
271
+ def exists(self, rel: str) -> bool:
272
+ rel = rel.lstrip("/")
273
+ with self._lock:
274
+ return rel in self._get_manifest_locked()
275
+
276
+ def unlink(self, rel: str, *, missing_ok: bool = True) -> None:
277
+ rel = rel.lstrip("/")
278
+ with self._lock:
279
+ manifest = self._get_manifest_locked()
280
+ chunk_id = manifest.pop(rel, None)
281
+ if chunk_id is None:
282
+ if not missing_ok:
283
+ raise FileNotFoundError(rel)
284
+ return
285
+ self._save_manifest_locked()
286
+ try:
287
+ self._chunk_path(chunk_id).unlink()
288
+ except FileNotFoundError:
289
+ pass
290
+
291
+ def iter_relative(self, subdir: str = "") -> Iterator[str]:
292
+ with self._lock:
293
+ keys = sorted(self._get_manifest_locked().keys())
294
+ if not subdir:
295
+ yield from keys
296
+ return
297
+ prefix = subdir.rstrip("/") + "/"
298
+ bare = subdir.rstrip("/")
299
+ for k in keys:
300
+ if k == bare or k.startswith(prefix):
301
+ yield k
302
+
303
+ def plaintext_dump(self, out_dir: Path) -> tuple[int, int]:
304
+ out_dir = Path(out_dir)
305
+ out_dir.mkdir(parents=True, exist_ok=True)
306
+ files = 0
307
+ total = 0
308
+ for rel in self.iter_relative():
309
+ data = self.read_bytes(rel)
310
+ target = out_dir / rel
311
+ target.parent.mkdir(parents=True, exist_ok=True)
312
+ target.write_bytes(data)
313
+ files += 1
314
+ total += len(data)
315
+ return files, total
316
+
317
+ # ── migration: plaintext → chunked ────────────────────────────────────
318
+
319
+ def looks_like_plaintext_layout(self) -> bool:
320
+ """True iff plaintext-named files exist that need migrating into the
321
+ chunked layout. Catches partial-migration state too (e.g. crash
322
+ between writing the manifest and unlinking the plaintext)."""
323
+ if not self.index_dir.exists():
324
+ return False
325
+ return bool(self._collect_plaintext_targets())
326
+
327
+ def migrate_plaintext_in_place(self) -> int:
328
+ migrated = 0
329
+ for path in self._collect_plaintext_targets():
330
+ rel = path.relative_to(self.index_dir).as_posix()
331
+ try:
332
+ data = path.read_bytes()
333
+ except OSError:
334
+ continue
335
+ self.write_bytes(rel, data)
336
+ try:
337
+ path.unlink()
338
+ except OSError:
339
+ pass
340
+ migrated += 1
341
+ self._prune_empty_details_dir()
342
+ # Sentinel last so a torn migration is still detectable on rerun.
343
+ self.ensure_initialized()
344
+ return migrated
345
+
346
+ def _collect_plaintext_targets(self) -> list[Path]:
347
+ owned_top = {
348
+ TIER1_FILENAME,
349
+ "_summary_cache.json",
350
+ "_file_index.json",
351
+ "build_meta.json",
352
+ ".env",
353
+ }
354
+ out: list[Path] = []
355
+ for name in owned_top:
356
+ p = self.index_dir / name
357
+ if p.is_file() and not p.name.endswith(ENC_SUFFIX):
358
+ out.append(p)
359
+ details = self.index_dir / DETAILS_DIRNAME
360
+ if details.is_dir():
361
+ for p in details.rglob("*.md"):
362
+ if p.is_file() and not p.name.endswith(ENC_SUFFIX):
363
+ out.append(p)
364
+ return out
365
+
366
+ # ── migration: legacy per-file encrypted → chunked ────────────────────
367
+
368
+ def looks_like_old_encrypted_layout(self) -> bool:
369
+ """True iff legacy per-file ``<rel>.enc`` blobs are present and need
370
+ migrating. Independent of whether ``manifest.enc`` already exists,
371
+ so a crashed partial migration resumes on the next start."""
372
+ if not self.index_dir.exists():
373
+ return False
374
+ return any(True for _ in self._iter_legacy_encrypted_files())
375
+
376
+ def migrate_old_encrypted_in_place(self) -> int:
377
+ migrated = 0
378
+ for path in list(self._iter_legacy_encrypted_files()):
379
+ rel = path.relative_to(self.index_dir).as_posix()
380
+ if not rel.endswith(ENC_SUFFIX):
381
+ continue
382
+ rel = rel[: -len(ENC_SUFFIX)]
383
+ try:
384
+ blob = path.read_bytes()
385
+ plaintext = crypto.decrypt(blob, self._key)
386
+ except (OSError, crypto.InvalidBlob):
387
+ continue
388
+ self.write_bytes(rel, plaintext)
389
+ try:
390
+ path.unlink()
391
+ except OSError:
392
+ pass
393
+ migrated += 1
394
+ self._prune_empty_details_dir()
395
+ self.ensure_initialized()
396
+ return migrated
397
+
398
+ def _iter_legacy_encrypted_files(self) -> Iterator[Path]:
399
+ manifest_path = self._manifest_path()
400
+ chunks_dir = self._chunks_dir()
401
+ for p in self.index_dir.rglob("*" + ENC_SUFFIX):
402
+ if not p.is_file():
403
+ continue
404
+ if p == manifest_path:
405
+ continue
406
+ try:
407
+ p.relative_to(chunks_dir)
408
+ except ValueError:
409
+ yield p
410
+
411
+ def _prune_empty_details_dir(self) -> None:
412
+ details = self.index_dir / DETAILS_DIRNAME
413
+ if not details.is_dir():
414
+ return
415
+ for sub in sorted(
416
+ details.rglob("*"), key=lambda p: len(p.parts), reverse=True
417
+ ):
418
+ if sub.is_dir():
419
+ try:
420
+ sub.rmdir()
421
+ except OSError:
422
+ pass
423
+ try:
424
+ details.rmdir()
425
+ except OSError:
426
+ pass
File without changes
@@ -0,0 +1,240 @@
1
+ """HTTP client for the sourceindex hosted backend.
2
+
3
+ Used opt-in: only fires when ``SOURCEINDEX_BACKEND_URL`` is set. Three helpers:
4
+
5
+ - ``fetch_config()`` — pull the central preamble + model selection assigned
6
+ to this user's cohort. Cached on disk with TTL from the response.
7
+ - ``apply_remote_config()`` — resolve the LLM model + (optionally) re-pin the
8
+ backend URL from the fetched config. Called once per CLI command.
9
+ - ``upload_telemetry()`` — send anonymized session metrics. Caller is
10
+ responsible for anonymization (no file paths, no source).
11
+
12
+ All three are best-effort. On any HTTP, network, or parse error they return
13
+ ``None``/``False``/the caller-supplied default and the caller falls back to
14
+ local defaults. We do NOT hard-fail sourceindex when the backend is unreachable.
15
+
16
+ Stdlib only — no new dependency on requests/httpx so the search subagent and
17
+ post-commit hook keep their lean import footprint.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import time
25
+ from pathlib import Path
26
+ from urllib.error import URLError
27
+ from urllib.request import Request, urlopen
28
+
29
+ from .log import get_logger
30
+
31
+ _log = get_logger(__name__)
32
+
33
+ _TIMEOUT_S = 5
34
+ _CONFIG_CACHE_FILENAME = ".config_cache.json"
35
+
36
+ # Hardcoded default — the operator's hosted deployment. Users who only paste
37
+ # a sourceindex bearer key during ``sourceindex init`` get routed here without
38
+ # ever seeing or configuring the URL. Override at runtime by exporting
39
+ # ``SOURCEINDEX_BACKEND_URL=<other-url>`` (e.g. http://localhost:8787 for
40
+ # local ``wrangler dev``), or ``SOURCEINDEX_BACKEND_URL=`` (empty string) to
41
+ # disable the backend entirely and talk to providers directly.
42
+ DEFAULT_BACKEND_URL = "https://sourceindex-backend.sourceindex.workers.dev"
43
+
44
+ # Direct-provider key prefixes. The hosted backend expects an ``sk-si-...``
45
+ # bearer and would reject these with a misleading auth error; when the user
46
+ # is holding one of these (e.g. an OpenRouter key for eval), ``backend_base``
47
+ # auto-opts out so the call hits the provider directly. Explicit
48
+ # ``SOURCEINDEX_BACKEND_URL`` (any value, including a custom URL) still wins.
49
+ _PROVIDER_KEY_PREFIXES = ("sk-or-", "sk-ant-", "gsk_", "AIza")
50
+
51
+
52
+ def looks_like_provider_key(key: str) -> bool:
53
+ return bool(key) and any(key.startswith(p) for p in _PROVIDER_KEY_PREFIXES)
54
+
55
+ # Process-scoped override set by ``apply_remote_config()`` when the backend
56
+ # response includes a ``url`` field. Higher priority than the env var; we
57
+ # avoid mutating ``os.environ`` because that would leak into Claude subagents
58
+ # and the post-commit hook (which we explicitly do NOT want to redirect).
59
+ _pinned_backend_url: str | None = None
60
+
61
+
62
+ def _normalize_backend_url(url: str) -> str:
63
+ """Strip trailing slashes and idempotently append ``/v1``. Accepts a bare
64
+ host (``https://x.workers.dev``) or a fully qualified base (``…/v1``)."""
65
+ url = url.rstrip("/")
66
+ if not url.endswith("/v1"):
67
+ url = f"{url}/v1"
68
+ return url
69
+
70
+
71
+ def _api_key() -> str | None:
72
+ # Lazy import: backend.py is imported on the backend code path from
73
+ # llm.py; resolve_api_key here would create a cycle at module load.
74
+ from .llm import resolve_api_key
75
+ return resolve_api_key(None)
76
+
77
+
78
+ def backend_base(api_key: str | None = None) -> str | None:
79
+ """Return ``<URL>/v1`` for the hosted backend, or ``None`` if disabled.
80
+
81
+ Resolution order:
82
+ 1. ``SOURCEINDEX_BACKEND_URL=""`` (explicit empty) → ``None`` (opt-out).
83
+ Checked first so a local opt-out beats admin pins — otherwise admins
84
+ could re-enable the backend on a user who explicitly disabled it.
85
+ 2. ``_pinned_backend_url`` (set by ``apply_remote_config()``) → use it.
86
+ 3. ``SOURCEINDEX_BACKEND_URL=<other>`` → use that.
87
+ 4. Default to ``DEFAULT_BACKEND_URL`` — unless ``api_key`` (explicit arg,
88
+ falling back to env-resolved) looks like a direct-provider key, in
89
+ which case auto-opt-out. The hosted backend would reject such a key;
90
+ auto-routing direct keeps "paste your OpenRouter key" zero-config.
91
+ """
92
+ env = os.environ.get("SOURCEINDEX_BACKEND_URL")
93
+ if env == "":
94
+ return None
95
+ if _pinned_backend_url is not None:
96
+ return _pinned_backend_url
97
+ if env is not None:
98
+ return _normalize_backend_url(env)
99
+ effective_key = api_key if api_key is not None else _api_key()
100
+ if effective_key and looks_like_provider_key(effective_key):
101
+ return None
102
+ return _normalize_backend_url(DEFAULT_BACKEND_URL)
103
+
104
+
105
+ def get_json(path: str, *, event: str) -> dict | None:
106
+ """Authenticated GET ``<backend>/v1/<path>`` returning the parsed JSON.
107
+ Returns ``None`` if the backend is disabled, no API key is available,
108
+ or the request fails — callers fall back to local defaults. ``event``
109
+ tags the debug log on failure for grep-ability."""
110
+ base = backend_base()
111
+ if not base:
112
+ return None
113
+ key = _api_key()
114
+ if not key:
115
+ return None
116
+ req = Request(f"{base}/{path}", headers={"authorization": f"Bearer {key}"})
117
+ try:
118
+ with urlopen(req, timeout=_TIMEOUT_S) as resp:
119
+ return json.loads(resp.read())
120
+ except (URLError, OSError, TimeoutError, ValueError) as e:
121
+ _log.debug(
122
+ f"backend {path} fetch failed",
123
+ extra={"event": event, "error": str(e)},
124
+ )
125
+ return None
126
+
127
+
128
+ def fetch_config(*, cache_dir: Path | None = None, force: bool = False) -> dict | None:
129
+ """GET ``/v1/config``. Returns the parsed dict, or ``None`` if no backend
130
+ is configured / the request fails / no API key is available.
131
+
132
+ Response shape (all fields optional, admin-controlled in the Worker
133
+ dashboard):
134
+
135
+ - ``model`` (str): LiteLLM model string to use for indexing + search.
136
+ Any provider LiteLLM understands works — ``bedrock/...``,
137
+ ``openrouter/...``, ``vertex_ai/...``, ``anthropic/...``, etc.
138
+ - ``url`` (str): alternate SourceIndex deployment URL. When set, future
139
+ config refreshes AND LLM ``api_base`` both redirect to this URL (they
140
+ stay coupled — the Worker handles provider routing internally).
141
+ - ``ttl_seconds`` (int): how long the client may reuse this response
142
+ before re-fetching. Default 60 if omitted.
143
+
144
+ When ``cache_dir`` is provided, the response is cached at
145
+ ``<cache_dir>/.config_cache.json`` and reused for ``ttl_seconds``.
146
+ The internal ``_fetched_at`` timestamp is added on cache write.
147
+ """
148
+ cache_path = cache_dir / _CONFIG_CACHE_FILENAME if cache_dir else None
149
+ if cache_path and not force:
150
+ try:
151
+ payload = json.loads(cache_path.read_text())
152
+ ttl = float(payload.get("ttl_seconds", 60))
153
+ if payload.get("_fetched_at", 0) + ttl > time.time():
154
+ return payload
155
+ except (OSError, ValueError):
156
+ pass # missing or malformed cache → fall through and re-fetch
157
+
158
+ data = get_json("config", event="backend.fetch_config_failed")
159
+ if data is None:
160
+ return None
161
+ data["_fetched_at"] = time.time()
162
+ if cache_path:
163
+ try:
164
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
165
+ cache_path.write_text(json.dumps(data))
166
+ except OSError:
167
+ pass
168
+ return data
169
+
170
+
171
+ def apply_remote_config(*, index_dir: Path | None, default_model: str) -> str:
172
+ """Fetch the admin-controlled config and apply its overrides.
173
+
174
+ Returns the model string to use — backend ``model`` if set, else
175
+ ``default_model``. As a side effect, pins ``_pinned_backend_url`` when
176
+ the response carries a non-empty ``url`` so subsequent ``backend_base()``
177
+ calls (both for further config refreshes and for LiteLLM ``api_base``)
178
+ target the new deployment.
179
+
180
+ Always returns ``default_model`` unchanged if the backend is disabled,
181
+ no API key is available, the request fails, or the response is empty —
182
+ callers can use the return value unconditionally.
183
+ """
184
+ global _pinned_backend_url
185
+ config = fetch_config(cache_dir=index_dir)
186
+ if not config:
187
+ return default_model
188
+
189
+ url_override = config.get("url")
190
+ model_override = config.get("model")
191
+
192
+ if isinstance(url_override, str) and url_override.strip():
193
+ _pinned_backend_url = _normalize_backend_url(url_override.strip())
194
+
195
+ resolved_model = (
196
+ model_override
197
+ if isinstance(model_override, str) and model_override.strip()
198
+ else default_model
199
+ )
200
+
201
+ _log.debug(
202
+ "remote config applied",
203
+ extra={
204
+ "event": "backend.config_applied",
205
+ "model_override": resolved_model if resolved_model != default_model else None,
206
+ "url_override": _pinned_backend_url,
207
+ },
208
+ )
209
+ return resolved_model
210
+
211
+
212
+ def upload_telemetry(payload: dict) -> bool:
213
+ """POST ``/v1/telemetry``. Returns True on 2xx, False otherwise.
214
+
215
+ Caller-supplied ``payload`` must already be anonymized — no file paths,
216
+ no raw source, no tool args. See plan §Anonymization.
217
+ """
218
+ base = backend_base()
219
+ key = _api_key()
220
+ if not base or not key:
221
+ return False
222
+ body = json.dumps(payload).encode()
223
+ req = Request(
224
+ f"{base}/telemetry",
225
+ data=body,
226
+ headers={
227
+ "authorization": f"Bearer {key}",
228
+ "content-type": "application/json",
229
+ },
230
+ method="POST",
231
+ )
232
+ try:
233
+ with urlopen(req, timeout=_TIMEOUT_S) as resp:
234
+ return 200 <= resp.status < 300
235
+ except (URLError, OSError, TimeoutError) as e:
236
+ _log.debug(
237
+ "telemetry upload failed",
238
+ extra={"event": "backend.telemetry_failed", "error": str(e)},
239
+ )
240
+ return False