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.
- sourceindex/__init__.py +30 -0
- sourceindex/build/__init__.py +592 -0
- sourceindex/build/indexer.py +403 -0
- sourceindex/build/linerange/__init__.py +24 -0
- sourceindex/build/linerange/python_ast.py +155 -0
- sourceindex/build/linerange/treesitter.py +397 -0
- sourceindex/build/prompts.py +76 -0
- sourceindex/build/state.py +261 -0
- sourceindex/build/walker.py +94 -0
- sourceindex/claudecode/__init__.py +0 -0
- sourceindex/claudecode/savings.py +325 -0
- sourceindex/claudecode/savings_summary.py +88 -0
- sourceindex/claudecode/statusline.py +116 -0
- sourceindex/cli/__init__.py +304 -0
- sourceindex/cli/__main__.py +10 -0
- sourceindex/cli/api_key.py +171 -0
- sourceindex/cli/commands.py +678 -0
- sourceindex/cli/install.py +378 -0
- sourceindex/daemon/__init__.py +83 -0
- sourceindex/daemon/client.py +141 -0
- sourceindex/daemon/crypto.py +48 -0
- sourceindex/daemon/keyring_store.py +129 -0
- sourceindex/daemon/lifecycle.py +237 -0
- sourceindex/daemon/protocol.py +134 -0
- sourceindex/daemon/server.py +389 -0
- sourceindex/daemon/store.py +426 -0
- sourceindex/lib/__init__.py +0 -0
- sourceindex/lib/backend.py +240 -0
- sourceindex/lib/cost.py +96 -0
- sourceindex/lib/env.py +30 -0
- sourceindex/lib/git.py +33 -0
- sourceindex/lib/languages.py +406 -0
- sourceindex/lib/llm.py +298 -0
- sourceindex/lib/log.py +228 -0
- sourceindex/lib/registry.py +75 -0
- sourceindex/lib/timing.py +10 -0
- sourceindex/search/__init__.py +251 -0
- sourceindex/search/experiments.py +276 -0
- sourceindex/search/imports.py +155 -0
- sourceindex/search/passes.py +51 -0
- sourceindex/search/prompts.py +379 -0
- sourceindex/search/roadmap.py +265 -0
- sourceindex/server.py +127 -0
- sourceindex-0.1.0.dist-info/METADATA +73 -0
- sourceindex-0.1.0.dist-info/RECORD +47 -0
- sourceindex-0.1.0.dist-info/WHEEL +4 -0
- sourceindex-0.1.0.dist-info/entry_points.txt +2 -0
sourceindex/lib/llm.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""LiteLLM-backed LLM adapter for sourceindex.
|
|
2
|
+
|
|
3
|
+
Single touch-point for every LLM call in build + search. Accepts any model
|
|
4
|
+
string LiteLLM understands:
|
|
5
|
+
|
|
6
|
+
anthropic/claude-haiku-4-5 (direct Anthropic)
|
|
7
|
+
openrouter/anthropic/claude-haiku-4-5 (Anthropic via OpenRouter)
|
|
8
|
+
openrouter/deepseek/deepseek-chat (DeepSeek via OpenRouter)
|
|
9
|
+
deepseek/deepseek-chat (direct DeepSeek)
|
|
10
|
+
gemini/gemini-2.5-flash (direct Gemini)
|
|
11
|
+
|
|
12
|
+
The bare ``claude-haiku-4-5`` form (no provider prefix) is also accepted —
|
|
13
|
+
LiteLLM treats it as Anthropic.
|
|
14
|
+
|
|
15
|
+
Prompts are sent as flat user messages — no explicit prompt caching.
|
|
16
|
+
Providers that auto-cache (DeepSeek, Gemini, OpenAI) still do so server-side;
|
|
17
|
+
the adapter just doesn't ask for it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .log import get_logger
|
|
27
|
+
from .timing import elapsed_ms
|
|
28
|
+
|
|
29
|
+
_log = get_logger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def auth_error_to_systemexit(*, index_dir: Path | None = None, api_key: str | None = None):
|
|
34
|
+
"""Wrap a block that may raise ``litellm.AuthenticationError`` and convert
|
|
35
|
+
it to a single ``SystemExit`` with the backend-aware translated message.
|
|
36
|
+
Used by every CLI subcommand that fires LLM calls. ``api_key`` is the key
|
|
37
|
+
actually used for the call — needed so the routing diagnostic matches
|
|
38
|
+
reality when ``--api-key`` was passed without setting the env var."""
|
|
39
|
+
import litellm
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
except litellm.AuthenticationError as e:
|
|
43
|
+
raise SystemExit(translate_auth_error(e, index_dir=index_dir, api_key=api_key))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def translate_auth_error(e: Exception, *, index_dir: Path | None = None, api_key: str | None = None) -> str:
|
|
47
|
+
"""Build a user-facing message for an LLM auth failure.
|
|
48
|
+
|
|
49
|
+
LiteLLM relabels auth errors by the *model prefix* (``openrouter/...`` →
|
|
50
|
+
``OpenrouterException``) regardless of who actually replied. When the call
|
|
51
|
+
was routed through the hosted backend, the rejector is our own Worker —
|
|
52
|
+
not OpenRouter — and the user needs to be pointed at SOURCEINDEX_API_KEY,
|
|
53
|
+
not their provider account. Surface that distinction here so the caller
|
|
54
|
+
can raise SystemExit with a message that matches reality.
|
|
55
|
+
"""
|
|
56
|
+
from .backend import backend_base
|
|
57
|
+
base = backend_base(api_key=api_key)
|
|
58
|
+
env_hint = f" or the persisted key in {index_dir / '.env'}" if index_dir else ""
|
|
59
|
+
if base:
|
|
60
|
+
return (
|
|
61
|
+
f"[sourceindex] Hosted backend at {base} rejected the request — "
|
|
62
|
+
f"likely an invalid SOURCEINDEX_API_KEY{env_hint}. "
|
|
63
|
+
f"To talk to a provider directly with your own key instead, set "
|
|
64
|
+
f"SOURCEINDEX_BACKEND_URL=\"\" to opt out of the backend. "
|
|
65
|
+
f"(underlying: {e})"
|
|
66
|
+
)
|
|
67
|
+
return (
|
|
68
|
+
f"[sourceindex] Provider rejected credentials. Check SOURCEINDEX_API_KEY"
|
|
69
|
+
f"{env_hint}. (provider: {e})"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_api_key(cli_arg: str | None = None) -> str | None:
|
|
74
|
+
"""Pick up the API key from (in order): explicit CLI arg, or
|
|
75
|
+
SOURCEINDEX_API_KEY in the environment."""
|
|
76
|
+
return cli_arg or os.environ.get("SOURCEINDEX_API_KEY")
|
|
77
|
+
|
|
78
|
+
# Strip SOCKS proxy from the environment *before* importing litellm — it
|
|
79
|
+
# inherits any ALL_PROXY=socks://... via httpx, which can't speak SOCKS
|
|
80
|
+
# without an extension and crashes the model-cost-map fetch at import time
|
|
81
|
+
# (and later, completion calls). The previous anthropic-SDK path worked
|
|
82
|
+
# around this with httpx.Client(trust_env=False); the closest LiteLLM
|
|
83
|
+
# equivalent is just removing the leak from this process's env.
|
|
84
|
+
for _k in ("ALL_PROXY", "all_proxy"):
|
|
85
|
+
if os.environ.get(_k, "").lower().startswith("socks"):
|
|
86
|
+
os.environ.pop(_k, None)
|
|
87
|
+
|
|
88
|
+
import litellm # noqa: E402 (must follow the env scrub above)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class LLMUsage:
|
|
93
|
+
"""Per-call token + cost accounting. Aggregated by build.state into
|
|
94
|
+
build_meta.json. Search-side calls drop usage on the floor."""
|
|
95
|
+
input_tokens: int = 0
|
|
96
|
+
output_tokens: int = 0
|
|
97
|
+
cache_read_tokens: int = 0
|
|
98
|
+
cache_creation_tokens: int = 0
|
|
99
|
+
cost_usd: float = 0.0
|
|
100
|
+
|
|
101
|
+
def add(self, other: "LLMUsage") -> None:
|
|
102
|
+
self.input_tokens += other.input_tokens
|
|
103
|
+
self.output_tokens += other.output_tokens
|
|
104
|
+
self.cache_read_tokens += other.cache_read_tokens
|
|
105
|
+
self.cache_creation_tokens += other.cache_creation_tokens
|
|
106
|
+
self.cost_usd += other.cost_usd
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def model_name_candidates(model: str | None) -> list[str | None]:
|
|
110
|
+
"""Yield model-name forms to try against litellm's pricing JSON.
|
|
111
|
+
|
|
112
|
+
LiteLLM keys its model_cost map by canonical names (``gpt-5.4-nano``,
|
|
113
|
+
``claude-haiku-4-5``) — but we call through router-prefixed forms like
|
|
114
|
+
``openrouter/openai/gpt-5.4-nano``. Cost lookup needs to peel each
|
|
115
|
+
leading provider prefix until it lands on a known key. Empty/None
|
|
116
|
+
model returns ``[None]`` so the caller falls back to inferring from
|
|
117
|
+
the response object.
|
|
118
|
+
"""
|
|
119
|
+
if not model:
|
|
120
|
+
return [None]
|
|
121
|
+
out: list[str | None] = [model]
|
|
122
|
+
parts = model.split("/")
|
|
123
|
+
for i in range(1, len(parts)):
|
|
124
|
+
out.append("/".join(parts[i:]))
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def lookup_pricing(model: str | None) -> dict | None:
|
|
129
|
+
"""Find the pricing entry in ``litellm.model_cost`` by trying each
|
|
130
|
+
progressively-stripped form of ``model``. Returns the first hit
|
|
131
|
+
(a dict of per-token cost fields), or ``None`` if no form matches."""
|
|
132
|
+
if not model:
|
|
133
|
+
return None
|
|
134
|
+
for candidate in model_name_candidates(model):
|
|
135
|
+
if not candidate:
|
|
136
|
+
continue
|
|
137
|
+
info = litellm.model_cost.get(candidate)
|
|
138
|
+
if info:
|
|
139
|
+
return info
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _extract_usage(response, *, model: str | None = None) -> LLMUsage:
|
|
144
|
+
"""Best-effort token + cost extraction. Missing fields default to 0 —
|
|
145
|
+
accounting is a soft signal, not load-bearing.
|
|
146
|
+
|
|
147
|
+
Pricing is computed from ``litellm.model_cost`` directly rather than
|
|
148
|
+
via ``litellm.completion_cost``. The latter binds the lookup to the
|
|
149
|
+
response's ``custom_llm_provider`` (e.g. ``openrouter``), and
|
|
150
|
+
``openrouter+claude-haiku-4-5`` isn't a key in the price map even
|
|
151
|
+
though bare ``claude-haiku-4-5`` is. Manual lookup with
|
|
152
|
+
prefix-stripping bypasses that binding.
|
|
153
|
+
"""
|
|
154
|
+
usage = getattr(response, "usage", None)
|
|
155
|
+
if usage is None:
|
|
156
|
+
return LLMUsage()
|
|
157
|
+
|
|
158
|
+
def _g(name: str) -> int:
|
|
159
|
+
v = getattr(usage, name, None)
|
|
160
|
+
return int(v) if v is not None else 0
|
|
161
|
+
|
|
162
|
+
inp = _g("prompt_tokens")
|
|
163
|
+
out = _g("completion_tokens")
|
|
164
|
+
cr = _g("cache_read_input_tokens")
|
|
165
|
+
cc = _g("cache_creation_input_tokens")
|
|
166
|
+
|
|
167
|
+
cost = 0.0
|
|
168
|
+
info = lookup_pricing(model)
|
|
169
|
+
if info:
|
|
170
|
+
cost = (
|
|
171
|
+
inp * info.get("input_cost_per_token", 0.0)
|
|
172
|
+
+ out * info.get("output_cost_per_token", 0.0)
|
|
173
|
+
+ cr * info.get("cache_read_input_token_cost", 0.0)
|
|
174
|
+
+ cc * info.get("cache_creation_input_token_cost", 0.0)
|
|
175
|
+
)
|
|
176
|
+
elif model:
|
|
177
|
+
_log.debug(
|
|
178
|
+
"LLM cost lookup failed",
|
|
179
|
+
extra={
|
|
180
|
+
"event": "llm.cost_unknown",
|
|
181
|
+
"model": model,
|
|
182
|
+
"tried": [c for c in model_name_candidates(model) if c],
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return LLMUsage(
|
|
187
|
+
input_tokens=_g("prompt_tokens"),
|
|
188
|
+
output_tokens=_g("completion_tokens"),
|
|
189
|
+
cache_read_tokens=_g("cache_read_input_tokens"),
|
|
190
|
+
cache_creation_tokens=_g("cache_creation_input_tokens"),
|
|
191
|
+
cost_usd=cost,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# A self-signed cert from a localhost MITM proxy (e.g. the ``inspect`` proxy
|
|
196
|
+
# used during evals) would otherwise fail TLS verification. Match the gated
|
|
197
|
+
# behaviour of the previous Anthropic-SDK call site.
|
|
198
|
+
_PROXY_URL = (
|
|
199
|
+
os.environ.get("HTTPS_PROXY")
|
|
200
|
+
or os.environ.get("https_proxy")
|
|
201
|
+
or ""
|
|
202
|
+
)
|
|
203
|
+
if "127.0.0.1" in _PROXY_URL or "localhost" in _PROXY_URL:
|
|
204
|
+
litellm.ssl_verify = False
|
|
205
|
+
|
|
206
|
+
# Silently drop standardized params the upstream provider doesn't support
|
|
207
|
+
# (e.g. ``reasoning_effort`` on DeepSeek). Without this, LiteLLM raises
|
|
208
|
+
# UnsupportedParamsError and the call fails.
|
|
209
|
+
litellm.drop_params = True
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def call_llm(
|
|
213
|
+
prompt: str,
|
|
214
|
+
*,
|
|
215
|
+
model: str,
|
|
216
|
+
api_key: str,
|
|
217
|
+
max_tokens: int = 16384,
|
|
218
|
+
) -> tuple[str | None, LLMUsage]:
|
|
219
|
+
"""Sync LiteLLM completion. Returns (text, usage).
|
|
220
|
+
|
|
221
|
+
``text`` is the stripped assistant text, or ``None`` if empty. ``usage``
|
|
222
|
+
is always an ``LLMUsage`` (zeroed when the provider returned no usage
|
|
223
|
+
block) so callers can sum unconditionally without None-checking.
|
|
224
|
+
|
|
225
|
+
Callers MUST pass ``api_key`` explicitly — there is no implicit env-var
|
|
226
|
+
fallback inside this function. Resolve it via ``resolve_api_key()``
|
|
227
|
+
(CLI arg → ``SOURCEINDEX_API_KEY``) and pass the result in.
|
|
228
|
+
"""
|
|
229
|
+
assert api_key, (
|
|
230
|
+
"call_llm requires an explicit api_key. Use resolve_api_key() "
|
|
231
|
+
"(CLI arg or SOURCEINDEX_API_KEY) and pass the result in."
|
|
232
|
+
)
|
|
233
|
+
# Pin OpenRouter to the model's official primary provider — disables
|
|
234
|
+
# fallback to resold/quantized/rate-limited alternates. Quality stays
|
|
235
|
+
# consistent and slow detours are avoided.
|
|
236
|
+
#
|
|
237
|
+
# NOTE on reasoning: do NOT send ``reasoning.max_tokens=0``. Empirically
|
|
238
|
+
# (gpt-5.4-nano via OpenRouter, 2026-05-04) sending that flag *triggers*
|
|
239
|
+
# ~2.5K reasoning tokens per call and pushes wall time from ~4s → ~23s.
|
|
240
|
+
# The default behaviour with no reasoning flag is reasoning_tokens=0.
|
|
241
|
+
# If a future model needs a budget cap, use ``reasoning_effort="minimal"``
|
|
242
|
+
# instead — never the max_tokens form.
|
|
243
|
+
extra: dict = {}
|
|
244
|
+
if model.startswith("openrouter/"):
|
|
245
|
+
extra["extra_body"] = {"provider": {"allow_fallbacks": False}}
|
|
246
|
+
# Lazy import so the SOCKS-proxy scrub above runs first and so this
|
|
247
|
+
# module can still be imported without hitting backend.py's stdlib deps.
|
|
248
|
+
from .backend import backend_base
|
|
249
|
+
backend_url = backend_base(api_key=api_key)
|
|
250
|
+
if backend_url:
|
|
251
|
+
extra["api_base"] = backend_url
|
|
252
|
+
session_id = os.environ.get("SOURCEINDEX_SESSION_ID")
|
|
253
|
+
if session_id:
|
|
254
|
+
extra.setdefault("extra_headers", {})["x-session-id"] = session_id
|
|
255
|
+
t0 = time.perf_counter()
|
|
256
|
+
try:
|
|
257
|
+
response = litellm.completion(
|
|
258
|
+
model=model,
|
|
259
|
+
messages=[{"role": "user", "content": prompt}],
|
|
260
|
+
max_tokens=max_tokens,
|
|
261
|
+
api_key=api_key,
|
|
262
|
+
**extra,
|
|
263
|
+
)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
_log.debug(
|
|
266
|
+
"LLM call failed",
|
|
267
|
+
extra={
|
|
268
|
+
"event": "llm.call",
|
|
269
|
+
"model": model,
|
|
270
|
+
"prompt_len": len(prompt),
|
|
271
|
+
"max_tokens": max_tokens,
|
|
272
|
+
"latency_ms": elapsed_ms(t0),
|
|
273
|
+
"success": False,
|
|
274
|
+
"error_type": type(e).__name__,
|
|
275
|
+
"error": str(e),
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
raise
|
|
279
|
+
text = (response.choices[0].message.content or "").strip()
|
|
280
|
+
usage = _extract_usage(response, model=model)
|
|
281
|
+
_log.debug(
|
|
282
|
+
"LLM call complete",
|
|
283
|
+
extra={
|
|
284
|
+
"event": "llm.call",
|
|
285
|
+
"model": model,
|
|
286
|
+
"prompt_len": len(prompt),
|
|
287
|
+
"response_len": len(text),
|
|
288
|
+
"max_tokens": max_tokens,
|
|
289
|
+
"latency_ms": elapsed_ms(t0),
|
|
290
|
+
"input_tokens": usage.input_tokens,
|
|
291
|
+
"output_tokens": usage.output_tokens,
|
|
292
|
+
"cache_read_tokens": usage.cache_read_tokens,
|
|
293
|
+
"cache_creation_tokens": usage.cache_creation_tokens,
|
|
294
|
+
"cost_usd": round(usage.cost_usd, 6),
|
|
295
|
+
"success": True,
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
return (text or None), usage
|
sourceindex/lib/log.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Centralized logging for the sourceindex package.
|
|
2
|
+
|
|
3
|
+
All sourceindex modules obtain their logger via ``get_logger(__name__)``.
|
|
4
|
+
The root logger is ``sourceindex``; child loggers (``sourceindex.build``,
|
|
5
|
+
``sourceindex.search``, …) inherit handlers and level from it, so a single
|
|
6
|
+
``configure()`` call governs the whole package.
|
|
7
|
+
|
|
8
|
+
Two destinations:
|
|
9
|
+
|
|
10
|
+
- **stderr** — plain text. Reproduces the pre-logging
|
|
11
|
+
``print(f"[sourceindex] ...", file=sys.stderr)`` surface byte-for-byte at
|
|
12
|
+
INFO so log scrapers / eval harnesses keep working. Level controlled by
|
|
13
|
+
``--log-level`` / ``SOURCEINDEX_LOG_LEVEL`` (default INFO).
|
|
14
|
+
- **<repo>/.sourceindex/sourceindex.log** — JSONL, one event per line.
|
|
15
|
+
Always at DEBUG. Rotated via ``RotatingFileHandler`` (10 MB × 5 backups).
|
|
16
|
+
Attached only when ``repo_root`` is supplied to ``configure()`` — the
|
|
17
|
+
``statusline`` / ``report-savings`` subcommands pass nothing and stay
|
|
18
|
+
file-silent.
|
|
19
|
+
|
|
20
|
+
Disable the file handler with ``SOURCEINDEX_NO_LOG_FILE=1`` or pass
|
|
21
|
+
``--no-log-file``. Override the path via ``--log-file PATH`` or
|
|
22
|
+
``SOURCEINDEX_LOG_FILE=...``.
|
|
23
|
+
|
|
24
|
+
JSONL schema: every line carries ``ts`` / ``level`` / ``logger`` /
|
|
25
|
+
``message``. Callers can tag analytics-worthy events with structured
|
|
26
|
+
fields by passing ``extra={"event": "build.done", "elapsed_s": 12.4, ...}``
|
|
27
|
+
to the logger call — extras land at the top level of the JSON object
|
|
28
|
+
alongside the standard fields.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import logging.handlers
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from .env import env_truthy
|
|
42
|
+
|
|
43
|
+
ROOT_NAME = "sourceindex"
|
|
44
|
+
ENV_LEVEL = "SOURCEINDEX_LOG_LEVEL"
|
|
45
|
+
ENV_LOG_FILE = "SOURCEINDEX_LOG_FILE"
|
|
46
|
+
ENV_NO_FILE = "SOURCEINDEX_NO_LOG_FILE"
|
|
47
|
+
LOG_FILENAME = "sourceindex.log"
|
|
48
|
+
DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
|
49
|
+
DEFAULT_BACKUP_COUNT = 5
|
|
50
|
+
|
|
51
|
+
_PREFIX = "[sourceindex]"
|
|
52
|
+
|
|
53
|
+
# Snapshot the built-in LogRecord field names by constructing a placeholder
|
|
54
|
+
# record at import time. Anything *not* in this set was added via
|
|
55
|
+
# ``extra={...}`` and gets surfaced as a top-level field in the JSONL
|
|
56
|
+
# output. Snapshotting (vs. a hand-written list) tracks stdlib additions
|
|
57
|
+
# automatically — e.g. ``taskName`` arrived in Python 3.12.
|
|
58
|
+
_STD_RECORD_ATTRS = frozenset(
|
|
59
|
+
logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys()
|
|
60
|
+
) | {"asctime", "message"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _StderrFormatter(logging.Formatter):
|
|
64
|
+
"""``[sourceindex] message`` for INFO, ``[sourceindex] LEVEL: message`` else."""
|
|
65
|
+
|
|
66
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
67
|
+
msg = record.getMessage()
|
|
68
|
+
if record.exc_info:
|
|
69
|
+
msg = f"{msg}\n{self.formatException(record.exc_info)}"
|
|
70
|
+
if record.levelno == logging.INFO:
|
|
71
|
+
return f"{_PREFIX} {msg}"
|
|
72
|
+
return f"{_PREFIX} {record.levelname}: {msg}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _JsonlFormatter(logging.Formatter):
|
|
76
|
+
"""One JSON object per record. ``ts``/``level``/``logger``/``message``
|
|
77
|
+
are always emitted; any extras supplied via ``extra={...}`` become
|
|
78
|
+
top-level fields. Non-JSON-serializable extras fall back to ``repr``
|
|
79
|
+
so a bad value can't sink the line."""
|
|
80
|
+
|
|
81
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
82
|
+
obj: dict = {
|
|
83
|
+
"ts": datetime.fromtimestamp(record.created, timezone.utc).strftime(
|
|
84
|
+
"%Y-%m-%dT%H:%M:%S.%fZ"
|
|
85
|
+
),
|
|
86
|
+
"level": record.levelname,
|
|
87
|
+
"logger": record.name,
|
|
88
|
+
"message": record.getMessage(),
|
|
89
|
+
}
|
|
90
|
+
if record.exc_info:
|
|
91
|
+
obj["traceback"] = self.formatException(record.exc_info)
|
|
92
|
+
for key, val in record.__dict__.items():
|
|
93
|
+
if key not in _STD_RECORD_ATTRS and not key.startswith("_"):
|
|
94
|
+
obj[key] = val
|
|
95
|
+
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=repr)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def configure(
|
|
99
|
+
level: str | int | None = None,
|
|
100
|
+
*,
|
|
101
|
+
repo_root: Path | str | None = None,
|
|
102
|
+
log_file: Path | str | None = None,
|
|
103
|
+
enable_file: bool = True,
|
|
104
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
105
|
+
backup_count: int = DEFAULT_BACKUP_COUNT,
|
|
106
|
+
) -> logging.Logger:
|
|
107
|
+
"""Install handlers + set the root level. Idempotent."""
|
|
108
|
+
logger = logging.getLogger(ROOT_NAME)
|
|
109
|
+
stderr_level = _resolve_level(level)
|
|
110
|
+
file_path = _resolve_log_path(log_file, repo_root) if enable_file else None
|
|
111
|
+
file_attached = _attach_file_handler(logger, file_path, max_bytes, backup_count)
|
|
112
|
+
_attach_stderr_handler(logger, stderr_level)
|
|
113
|
+
|
|
114
|
+
# Logger level is the floor — must allow DEBUG when the file is on so
|
|
115
|
+
# DEBUG records reach the file handler. The stderr handler does its own
|
|
116
|
+
# level filtering downstream.
|
|
117
|
+
logger.setLevel(logging.DEBUG if file_attached else stderr_level)
|
|
118
|
+
|
|
119
|
+
litellm_logger = logging.getLogger("LiteLLM")
|
|
120
|
+
if litellm_logger.level == 0:
|
|
121
|
+
litellm_logger.setLevel(logging.WARNING)
|
|
122
|
+
|
|
123
|
+
return logger
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_logger(name: str = ROOT_NAME) -> logging.Logger:
|
|
127
|
+
"""Return a logger for ``name`` (typically ``__name__``)."""
|
|
128
|
+
return logging.getLogger(name)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def log_file_error(
|
|
132
|
+
logger: logging.Logger,
|
|
133
|
+
path: str | Path,
|
|
134
|
+
exc: BaseException,
|
|
135
|
+
*,
|
|
136
|
+
phase: str,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Emit a ``file.read_error`` DEBUG event for a swallowed file IO failure.
|
|
139
|
+
|
|
140
|
+
Centralizes the OSError-on-read pattern (read/stat/etc. fails, caller
|
|
141
|
+
continues with the file dropped from the index) so every site reports
|
|
142
|
+
the same shape: path, phase, error type, error message.
|
|
143
|
+
"""
|
|
144
|
+
logger.debug(
|
|
145
|
+
f"File operation failed ({phase}): {path}",
|
|
146
|
+
extra={
|
|
147
|
+
"event": "file.read_error",
|
|
148
|
+
"phase": phase,
|
|
149
|
+
"path": str(path),
|
|
150
|
+
"error_type": type(exc).__name__,
|
|
151
|
+
"error": str(exc),
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── internals ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_level(level: str | int | None) -> int:
|
|
160
|
+
if level is None:
|
|
161
|
+
level = os.environ.get(ENV_LEVEL) or "INFO"
|
|
162
|
+
if isinstance(level, int):
|
|
163
|
+
return level
|
|
164
|
+
return logging._nameToLevel.get(str(level).upper(), logging.INFO)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _resolve_log_path(
|
|
168
|
+
explicit_path: Path | str | None, repo_root: Path | str | None
|
|
169
|
+
) -> Path | None:
|
|
170
|
+
if env_truthy(ENV_NO_FILE):
|
|
171
|
+
return None
|
|
172
|
+
if explicit_path:
|
|
173
|
+
return Path(explicit_path).expanduser().resolve()
|
|
174
|
+
env_path = os.environ.get(ENV_LOG_FILE)
|
|
175
|
+
if env_path:
|
|
176
|
+
return Path(env_path).expanduser().resolve()
|
|
177
|
+
if repo_root is not None:
|
|
178
|
+
# Lazy import — log.py is foundational and used by every module.
|
|
179
|
+
from ..daemon import IndexDir
|
|
180
|
+
return IndexDir.for_repo(Path(repo_root)).path / LOG_FILENAME
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _attach_stderr_handler(logger: logging.Logger, level: int) -> None:
|
|
185
|
+
for h in logger.handlers:
|
|
186
|
+
if getattr(h, "_si_stderr", False):
|
|
187
|
+
h.setLevel(level)
|
|
188
|
+
return
|
|
189
|
+
h = logging.StreamHandler(sys.stderr)
|
|
190
|
+
h.setFormatter(_StderrFormatter())
|
|
191
|
+
h.setLevel(level)
|
|
192
|
+
h._si_stderr = True # type: ignore[attr-defined]
|
|
193
|
+
logger.addHandler(h)
|
|
194
|
+
logger.propagate = False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _attach_file_handler(
|
|
198
|
+
logger: logging.Logger,
|
|
199
|
+
path: Path | None,
|
|
200
|
+
max_bytes: int,
|
|
201
|
+
backup_count: int,
|
|
202
|
+
) -> bool:
|
|
203
|
+
"""Attach a JSONL rotating file handler at ``path``.
|
|
204
|
+
|
|
205
|
+
Returns True iff a handler is now attached (already-present or freshly
|
|
206
|
+
installed). False on disabled or on an OSError that prevents opening
|
|
207
|
+
the file (silently degrades to stderr-only).
|
|
208
|
+
"""
|
|
209
|
+
if path is None:
|
|
210
|
+
return False
|
|
211
|
+
for h in logger.handlers:
|
|
212
|
+
if getattr(h, "_si_file", False):
|
|
213
|
+
return True
|
|
214
|
+
try:
|
|
215
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
217
|
+
path,
|
|
218
|
+
maxBytes=max_bytes,
|
|
219
|
+
backupCount=backup_count,
|
|
220
|
+
encoding="utf-8",
|
|
221
|
+
)
|
|
222
|
+
except OSError:
|
|
223
|
+
return False
|
|
224
|
+
handler.setFormatter(_JsonlFormatter())
|
|
225
|
+
handler.setLevel(logging.DEBUG)
|
|
226
|
+
handler._si_file = True # type: ignore[attr-defined]
|
|
227
|
+
logger.addHandler(handler)
|
|
228
|
+
return True
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Local registry of repos this user has indexed.
|
|
2
|
+
|
|
3
|
+
`sourceindex init` appends here; `sourceindex deinit` removes. The login-time
|
|
4
|
+
sweep (`sourceindex deinit --all`) walks the file looking for entries whose
|
|
5
|
+
`repo_path` no longer exists and tears down their per-repo state.
|
|
6
|
+
|
|
7
|
+
The hash → path direction is one-way (sha256), so without this registry we
|
|
8
|
+
have no way to discover orphaned keys after a reboot wipes `XDG_RUNTIME_DIR`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Entry = tuple[str, str] # (repo_hash, repo_path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _registry_path() -> Path:
|
|
21
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
22
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
23
|
+
return base / "sourceindex" / "repos.tsv"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read() -> list[Entry]:
|
|
27
|
+
path = _registry_path()
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
return []
|
|
30
|
+
entries: list[Entry] = []
|
|
31
|
+
for raw in path.read_text().splitlines():
|
|
32
|
+
line = raw.strip()
|
|
33
|
+
if not line or line.startswith("#"):
|
|
34
|
+
continue
|
|
35
|
+
parts = line.split("\t", 1)
|
|
36
|
+
if len(parts) != 2:
|
|
37
|
+
continue
|
|
38
|
+
entries.append((parts[0], parts[1]))
|
|
39
|
+
return entries
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def add(repo_hash: str, repo_path: str) -> None:
|
|
43
|
+
"""Record (repo_hash, repo_path). If the hash already exists, update its
|
|
44
|
+
path — handles re-init after a path rename."""
|
|
45
|
+
entries = read()
|
|
46
|
+
found = False
|
|
47
|
+
updated: list[Entry] = []
|
|
48
|
+
for h, p in entries:
|
|
49
|
+
if h == repo_hash:
|
|
50
|
+
updated.append((h, repo_path))
|
|
51
|
+
found = True
|
|
52
|
+
else:
|
|
53
|
+
updated.append((h, p))
|
|
54
|
+
if not found:
|
|
55
|
+
updated.append((repo_hash, repo_path))
|
|
56
|
+
_write(updated)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def remove(repo_hash: str) -> None:
|
|
60
|
+
"""Drop the entry for repo_hash. No-op if absent."""
|
|
61
|
+
entries = [(h, p) for h, p in read() if h != repo_hash]
|
|
62
|
+
_write(entries)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write(entries: list[Entry]) -> None:
|
|
66
|
+
path = _registry_path()
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
try:
|
|
69
|
+
path.parent.chmod(0o700)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
text = "".join(f"{h}\t{p}\n" for h, p in entries)
|
|
73
|
+
tmp = path.with_suffix(".tsv.tmp")
|
|
74
|
+
tmp.write_text(text)
|
|
75
|
+
tmp.replace(path)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Timing helpers shared by callers that emit ``latency_ms`` log fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def elapsed_ms(start: float) -> int:
|
|
9
|
+
"""Whole milliseconds between ``start`` (a ``time.perf_counter()`` value) and now."""
|
|
10
|
+
return int((time.perf_counter() - start) * 1000)
|