coderag-ai 0.3.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 (73) hide show
  1. coderag/__init__.py +12 -0
  2. coderag/analyzers/__init__.py +0 -0
  3. coderag/analyzers/base.py +36 -0
  4. coderag/analyzers/flake8_adapter.py +43 -0
  5. coderag/analyzers/pylint_adapter.py +50 -0
  6. coderag/analyzers/workflow.py +107 -0
  7. coderag/api/__init__.py +0 -0
  8. coderag/api/app.py +351 -0
  9. coderag/api/dashboard.py +237 -0
  10. coderag/api/schemas.py +181 -0
  11. coderag/cli/__init__.py +0 -0
  12. coderag/cli/main.py +359 -0
  13. coderag/context/__init__.py +0 -0
  14. coderag/context/builder.py +206 -0
  15. coderag/context/package.py +100 -0
  16. coderag/core/__init__.py +0 -0
  17. coderag/core/config.py +100 -0
  18. coderag/core/logging.py +58 -0
  19. coderag/core/text.py +80 -0
  20. coderag/core/tokens.py +66 -0
  21. coderag/db/__init__.py +0 -0
  22. coderag/db/base.py +58 -0
  23. coderag/db/migrations/env.py +77 -0
  24. coderag/db/migrations/script.py.mako +26 -0
  25. coderag/db/migrations/versions/.gitkeep +0 -0
  26. coderag/db/migrations/versions/9a973932b94d_measure_baseline_tokens.py +41 -0
  27. coderag/db/migrations/versions/c8a40feeda41_initial_schema.py +227 -0
  28. coderag/db/models.py +275 -0
  29. coderag/embeddings/__init__.py +0 -0
  30. coderag/embeddings/base.py +53 -0
  31. coderag/embeddings/hashing.py +43 -0
  32. coderag/embeddings/pipeline.py +87 -0
  33. coderag/embeddings/registry.py +38 -0
  34. coderag/embeddings/sentence_transformer.py +47 -0
  35. coderag/evaluation/__init__.py +0 -0
  36. coderag/evaluation/datasets.py +36 -0
  37. coderag/evaluation/harness.py +246 -0
  38. coderag/git/__init__.py +0 -0
  39. coderag/git/repo.py +139 -0
  40. coderag/indexing/__init__.py +0 -0
  41. coderag/indexing/ignore.py +101 -0
  42. coderag/indexing/indexer.py +445 -0
  43. coderag/llm/__init__.py +0 -0
  44. coderag/llm/anthropic.py +117 -0
  45. coderag/llm/base.py +60 -0
  46. coderag/llm/null.py +16 -0
  47. coderag/llm/registry.py +17 -0
  48. coderag/localdb.py +100 -0
  49. coderag/mcp_server.py +209 -0
  50. coderag/parsing/__init__.py +0 -0
  51. coderag/parsing/base.py +76 -0
  52. coderag/parsing/python.py +294 -0
  53. coderag/parsing/registry.py +40 -0
  54. coderag/retrieval/__init__.py +0 -0
  55. coderag/retrieval/base.py +60 -0
  56. coderag/retrieval/engine.py +176 -0
  57. coderag/retrieval/fusion.py +41 -0
  58. coderag/retrieval/graph.py +139 -0
  59. coderag/retrieval/lexical.py +57 -0
  60. coderag/retrieval/reranking.py +99 -0
  61. coderag/retrieval/semantic.py +53 -0
  62. coderag/retrieval/symbol.py +91 -0
  63. coderag/security/__init__.py +0 -0
  64. coderag/security/authz.py +35 -0
  65. coderag/security/secrets.py +110 -0
  66. coderag/service.py +160 -0
  67. coderag/telemetry.py +72 -0
  68. coderag_ai-0.3.0.dist-info/METADATA +364 -0
  69. coderag_ai-0.3.0.dist-info/RECORD +73 -0
  70. coderag_ai-0.3.0.dist-info/WHEEL +4 -0
  71. coderag_ai-0.3.0.dist-info/entry_points.txt +3 -0
  72. coderag_ai-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. coderag_ai-0.3.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,101 @@
1
+ """Which files to index.
2
+
3
+ Responsibilities:
4
+ * skip vendored/generated/build directories (a configurable default list),
5
+ * skip binary files,
6
+ * skip secret files (delegated to ``security.secrets``),
7
+ * map file extensions to a language.
8
+
9
+ `.gitignore` is respected automatically for git repositories because we
10
+ enumerate via ``git ls-files`` (tracked files only) — see ``git/repo.py``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from coderag.security.secrets import is_secret_file
16
+
17
+ DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
18
+ {
19
+ "node_modules",
20
+ "vendor",
21
+ "dist",
22
+ "build",
23
+ ".venv",
24
+ "venv",
25
+ "__pycache__",
26
+ "generated",
27
+ "migrations",
28
+ ".git",
29
+ ".mypy_cache",
30
+ ".ruff_cache",
31
+ ".pytest_cache",
32
+ ".tox",
33
+ ".idea",
34
+ ".vscode",
35
+ "target",
36
+ "site-packages",
37
+ }
38
+ )
39
+
40
+ BINARY_EXTENSIONS: frozenset[str] = frozenset(
41
+ {
42
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".pdf",
43
+ ".zip", ".gz", ".tar", ".tgz", ".bz2", ".7z", ".rar",
44
+ ".so", ".dylib", ".dll", ".a", ".o", ".class", ".jar", ".pyc", ".pyd",
45
+ ".exe", ".bin", ".wasm", ".woff", ".woff2", ".ttf", ".eot",
46
+ ".mp3", ".mp4", ".mov", ".avi", ".wav", ".ogg", ".webp",
47
+ ".parquet", ".pkl", ".pt", ".onnx", ".npy", ".npz", ".db", ".sqlite",
48
+ }
49
+ )
50
+
51
+ # extension -> language name
52
+ LANGUAGE_BY_EXTENSION: dict[str, str] = {
53
+ ".py": "python",
54
+ }
55
+
56
+
57
+ def language_for_path(path: str) -> str | None:
58
+ dot = path.rfind(".")
59
+ if dot == -1:
60
+ return None
61
+ return LANGUAGE_BY_EXTENSION.get(path[dot:].lower())
62
+
63
+
64
+ def is_binary_extension(path: str) -> bool:
65
+ dot = path.rfind(".")
66
+ return dot != -1 and path[dot:].lower() in BINARY_EXTENSIONS
67
+
68
+
69
+ def in_ignored_dir(path: str, extra: frozenset[str] | None = None) -> bool:
70
+ parts = path.replace("\\", "/").split("/")
71
+ dirs = set(parts[:-1])
72
+ if dirs & DEFAULT_IGNORE_DIRS:
73
+ return True
74
+ if extra:
75
+ for frag in extra:
76
+ if frag and frag.strip("/") in dirs:
77
+ return True
78
+ for frag in extra:
79
+ if frag and frag in path:
80
+ return True
81
+ return False
82
+
83
+
84
+ def should_index(path: str, extra_ignore: list[str] | None = None) -> bool:
85
+ """Return True if the path is an indexable source file.
86
+
87
+ Skips ignored dirs, secret files, binaries, and files of unknown language.
88
+ """
89
+ extra = frozenset(extra_ignore or [])
90
+ if in_ignored_dir(path, extra):
91
+ return False
92
+ if is_secret_file(path):
93
+ return False
94
+ if is_binary_extension(path):
95
+ return False
96
+ return language_for_path(path) is not None
97
+
98
+
99
+ def looks_binary(content: bytes) -> bool:
100
+ """Heuristic: a NUL byte in the first chunk means binary."""
101
+ return b"\x00" in content[:8192]
@@ -0,0 +1,445 @@
1
+ """Repository indexing.
2
+
3
+ Full index: enumerate files (git-aware, .gitignore-respecting) -> filter
4
+ ignored/secret/binary -> redact residual secrets -> parse into symbols ->
5
+ persist. Embedding generation is layered on in Phase 3; incremental indexing in
6
+ Phase 8.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import time
13
+ from dataclasses import dataclass
14
+
15
+ from sqlalchemy import delete, func, select
16
+ from sqlalchemy.orm import Session
17
+
18
+ from coderag.core.config import Settings, get_settings
19
+ from coderag.core.logging import get_logger
20
+ from coderag.core.tokens import TokenCounter, get_token_counter
21
+ from coderag.db.models import (
22
+ IndexingRun,
23
+ Repository,
24
+ SourceFile,
25
+ Symbol,
26
+ SymbolRelationship,
27
+ )
28
+ from coderag.git.repo import GitRepo
29
+ from coderag.indexing.ignore import should_index
30
+ from coderag.parsing.base import CALLS, CONTAINS, TESTS
31
+ from coderag.parsing.registry import get_parser_for_path, module_qualified_name
32
+ from coderag.security.secrets import redact
33
+
34
+ log = get_logger("indexer")
35
+
36
+
37
+ @dataclass
38
+ class IndexStats:
39
+ files_indexed: int = 0
40
+ files_deleted: int = 0
41
+ symbols_indexed: int = 0
42
+ symbols_deleted: int = 0
43
+ files_skipped: int = 0
44
+ secrets_redacted: int = 0
45
+ embeddings_created: int = 0
46
+ relationships_created: int = 0
47
+ duration_seconds: float = 0.0
48
+ commit_sha: str | None = None
49
+
50
+
51
+ def _hash(text: str) -> str:
52
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
53
+
54
+
55
+ def get_or_create_repository(
56
+ session: Session, name: str, local_path: str, url: str | None = None
57
+ ) -> Repository:
58
+ repo = session.scalar(select(Repository).where(Repository.name == name))
59
+ git = GitRepo(local_path)
60
+ branch = git.default_branch() if git.is_git() else "main"
61
+ if repo is None:
62
+ repo = Repository(name=name, local_path=local_path, url=url, default_branch=branch)
63
+ session.add(repo)
64
+ session.flush()
65
+ else:
66
+ repo.local_path = local_path
67
+ if url:
68
+ repo.url = url
69
+ repo.default_branch = branch
70
+ return repo
71
+
72
+
73
+ class Indexer:
74
+ def __init__(
75
+ self,
76
+ session: Session,
77
+ settings: Settings | None = None,
78
+ token_counter: TokenCounter | None = None,
79
+ embed: bool = True,
80
+ ) -> None:
81
+ self.session = session
82
+ self.settings = settings or get_settings()
83
+ self.tokens = token_counter or get_token_counter(self.settings)
84
+ self.extra_ignore = self.settings.extra_ignore
85
+ self.embed = embed
86
+
87
+ # -- full index -------------------------------------------------------
88
+ def full_index(self, repo: Repository) -> tuple[IndexingRun, IndexStats]:
89
+ started = time.perf_counter()
90
+ git = GitRepo(repo.local_path)
91
+ commit = git.current_commit()
92
+ run = IndexingRun(repository_id=repo.id, mode="full", status="running",
93
+ to_commit=commit)
94
+ self.session.add(run)
95
+ self.session.flush()
96
+
97
+ # Full reindex: clear prior data for this repository (cascades to
98
+ # symbols/embeddings/relationships via FK ON DELETE CASCADE).
99
+ self.session.execute(
100
+ delete(SourceFile).where(SourceFile.repository_id == repo.id)
101
+ )
102
+ self.session.execute(delete(Symbol).where(Symbol.repository_id == repo.id))
103
+
104
+ # accumulate cross-file graph edges, resolved after all symbols exist
105
+ self._pending_contains: list[tuple[int, int]] = []
106
+ self._pending_rels: list[tuple[int, str, str, float, dict | None]] = []
107
+
108
+ stats = IndexStats(commit_sha=commit)
109
+ try:
110
+ for rel_path in git.list_files():
111
+ if not should_index(rel_path, self.extra_ignore):
112
+ stats.files_skipped += 1
113
+ continue
114
+ self._index_file(repo, rel_path, commit, git, stats)
115
+
116
+ stats.relationships_created = self._persist_relationships(repo)
117
+
118
+ if self.embed:
119
+ stats.embeddings_created = self._embed(repo)
120
+
121
+ stats.duration_seconds = time.perf_counter() - started
122
+ run.status = "success"
123
+ run.files_indexed = stats.files_indexed
124
+ run.symbols_indexed = stats.symbols_indexed
125
+ run.embeddings_created = stats.embeddings_created
126
+ run.duration_seconds = stats.duration_seconds
127
+
128
+ run.finished_at = func.now()
129
+ repo.indexed_commit_sha = commit
130
+ repo.updated_at = func.now()
131
+ log.info(
132
+ "full_index.done", repository=repo.name,
133
+ files=stats.files_indexed, symbols=stats.symbols_indexed,
134
+ skipped=stats.files_skipped, redactions=stats.secrets_redacted,
135
+ seconds=round(stats.duration_seconds, 3),
136
+ )
137
+ except Exception as exc: # pragma: no cover - defensive
138
+ run.status = "failed"
139
+ run.error = str(exc)[:500]
140
+ log.error("full_index.failed", repository=repo.name, error=str(exc)[:200])
141
+ raise
142
+ return run, stats
143
+
144
+ # -- incremental index ------------------------------------------------
145
+ def incremental_index(self, repo: Repository) -> tuple[IndexingRun, IndexStats]:
146
+ """Update only what changed since ``repo.indexed_commit_sha``.
147
+
148
+ Symbols/embeddings for unchanged files are preserved (unchanged code is
149
+ never re-embedded). The relationship graph is rebuilt from a fast parse
150
+ pass so cross-file edges stay correct. Falls back to a full index when
151
+ there is no prior commit or the path is not a git repo.
152
+ """
153
+ git = GitRepo(repo.local_path)
154
+ base = repo.indexed_commit_sha
155
+ head = git.current_commit()
156
+ if not git.is_git() or not base:
157
+ return self.full_index(repo)
158
+
159
+ started = time.perf_counter()
160
+ run = IndexingRun(repository_id=repo.id, mode="incremental", status="running",
161
+ from_commit=base, to_commit=head)
162
+ self.session.add(run)
163
+ self.session.flush()
164
+
165
+ self._pending_contains = []
166
+ self._pending_rels = []
167
+ stats = IndexStats(commit_sha=head)
168
+ try:
169
+ diff = git.diff(base, head or "HEAD")
170
+ changed = [p for p in diff.changed if should_index(p, self.extra_ignore)]
171
+ gone = set(changed) | set(diff.deleted)
172
+ for path in gone:
173
+ self._delete_file(repo, path, stats)
174
+
175
+ for path in changed:
176
+ self._index_file(repo, path, head, git, stats)
177
+
178
+ # rebuild the whole-repo graph: collect edges from unchanged files too
179
+ qual_to_id = self._qualified_index(repo)
180
+ changed_set = set(changed)
181
+ for path in self._indexed_paths(repo):
182
+ if path not in changed_set:
183
+ self._collect_edges(repo, path, git, qual_to_id)
184
+ self.session.execute(
185
+ delete(SymbolRelationship).where(
186
+ SymbolRelationship.repository_id == repo.id
187
+ )
188
+ )
189
+ stats.relationships_created = self._persist_relationships(repo)
190
+
191
+ if self.embed:
192
+ stats.embeddings_created = self._embed(repo)
193
+
194
+ stats.duration_seconds = time.perf_counter() - started
195
+
196
+ run.status = "success"
197
+ run.files_indexed = stats.files_indexed
198
+ run.files_deleted = stats.files_deleted
199
+ run.symbols_indexed = stats.symbols_indexed
200
+ run.symbols_deleted = stats.symbols_deleted
201
+ run.embeddings_created = stats.embeddings_created
202
+ run.duration_seconds = stats.duration_seconds
203
+ run.finished_at = func.now()
204
+ repo.indexed_commit_sha = head
205
+ repo.updated_at = func.now()
206
+ log.info(
207
+ "incremental_index.done", repository=repo.name,
208
+ changed=len(changed), deleted=len(diff.deleted),
209
+ symbols_added=stats.symbols_indexed, symbols_deleted=stats.symbols_deleted,
210
+ embedded=stats.embeddings_created, seconds=round(stats.duration_seconds, 3),
211
+ )
212
+ except Exception as exc: # pragma: no cover - defensive
213
+ run.status = "failed"
214
+ run.error = str(exc)[:500]
215
+ log.error("incremental_index.failed", repository=repo.name, error=str(exc)[:200])
216
+ raise
217
+ return run, stats
218
+
219
+ def _delete_file(self, repo: Repository, path: str, stats: IndexStats) -> None:
220
+ n = self.session.scalar(
221
+ select(func.count()).select_from(Symbol).where(
222
+ Symbol.repository_id == repo.id, Symbol.file_path == path
223
+ )
224
+ ) or 0
225
+ existed = self.session.scalar(
226
+ select(SourceFile.id).where(
227
+ SourceFile.repository_id == repo.id, SourceFile.path == path
228
+ )
229
+ ) is not None
230
+ self.session.execute(
231
+ delete(Symbol).where(Symbol.repository_id == repo.id, Symbol.file_path == path)
232
+ )
233
+ self.session.execute(
234
+ delete(SourceFile).where(
235
+ SourceFile.repository_id == repo.id, SourceFile.path == path
236
+ )
237
+ )
238
+ if existed:
239
+ stats.files_deleted += 1
240
+ stats.symbols_deleted += int(n)
241
+
242
+ def _qualified_index(self, repo: Repository) -> dict[str, int]:
243
+ return {
244
+ qual: sid
245
+ for sid, qual in self.session.execute(
246
+ select(Symbol.id, Symbol.qualified_name).where(
247
+ Symbol.repository_id == repo.id
248
+ )
249
+ ).all()
250
+ }
251
+
252
+ def _indexed_paths(self, repo: Repository) -> list[str]:
253
+ return list(
254
+ self.session.scalars(
255
+ select(SourceFile.path).where(SourceFile.repository_id == repo.id)
256
+ )
257
+ )
258
+
259
+ def _collect_edges(
260
+ self, repo: Repository, path: str, git: GitRepo, qual_to_id: dict[str, int]
261
+ ) -> None:
262
+ """Parse an unchanged file for its edges only, mapping to existing ids."""
263
+ raw = git.read_text(path)
264
+ parser = get_parser_for_path(path)
265
+ if raw is None or parser is None:
266
+ return
267
+ result = parser.parse(module_qualified_name(path), redact(raw).text)
268
+ local_to_db = {
269
+ ps.local_id: qual_to_id.get(ps.qualified_name) for ps in result.symbols
270
+ }
271
+ for ps in result.symbols:
272
+ db_id = local_to_db.get(ps.local_id)
273
+ if db_id is None:
274
+ continue
275
+ if ps.parent_local_id is not None:
276
+ parent_db = local_to_db.get(ps.parent_local_id)
277
+ if parent_db is not None:
278
+ self._pending_contains.append((parent_db, db_id))
279
+ for pr in result.relationships:
280
+ src_db = local_to_db.get(pr.source_local_id)
281
+ if src_db is not None:
282
+ self._pending_rels.append(
283
+ (src_db, pr.relationship_type, pr.target_name, pr.confidence,
284
+ pr.metadata)
285
+ )
286
+
287
+ # -- relationships ----------------------------------------------------
288
+ def _persist_relationships(self, repo: Repository) -> int:
289
+ rows = self.session.execute(
290
+ select(
291
+ Symbol.id, Symbol.symbol_name, Symbol.qualified_name,
292
+ Symbol.parent_symbol_id, Symbol.file_path,
293
+ ).where(Symbol.repository_id == repo.id)
294
+ ).all()
295
+ by_qual: dict[str, int] = {}
296
+ by_name: dict[str, list[int]] = {}
297
+ info: dict[int, tuple[int | None, str, str]] = {}
298
+ for sid, name, qual, parent_id, path in rows:
299
+ by_qual[qual.lower()] = sid
300
+ by_name.setdefault(name.lower(), []).append(sid)
301
+ info[sid] = (parent_id, name, path)
302
+
303
+ def resolve(target_name: str, via: str | None, source_id: int) -> int | None:
304
+ t = target_name.lower()
305
+ if t in by_qual:
306
+ return by_qual[t]
307
+ cands = by_name.get(t.split(".")[-1], [])
308
+ if not cands:
309
+ return None
310
+ if via == "self":
311
+ parent = info[source_id][0]
312
+ same = [c for c in cands if info[c][0] == parent]
313
+ if len(same) == 1:
314
+ return same[0]
315
+ return cands[0] if len(cands) == 1 else None
316
+
317
+ def is_test(sid: int) -> bool:
318
+ _parent, name, path = info[sid]
319
+ p = path.replace("\\", "/")
320
+ return (
321
+ name.lower().startswith("test")
322
+ or "/tests/" in p
323
+ or p.startswith("tests/")
324
+ or p.rsplit("/", 1)[-1].startswith("test_")
325
+ )
326
+
327
+ edges: dict[tuple[int, int | None, str], tuple[float, dict | None]] = {}
328
+
329
+ def add(source: int, target: int | None, rtype: str, conf: float, meta):
330
+ key = (source, target, rtype)
331
+ if key not in edges or conf > edges[key][0]:
332
+ edges[key] = (conf, meta)
333
+
334
+ for parent_db, child_db in self._pending_contains:
335
+ add(parent_db, child_db, CONTAINS, 1.0, None)
336
+
337
+ for src, rtype, target_name, conf, meta in self._pending_rels:
338
+ via = (meta or {}).get("via") if meta else None
339
+ tid = resolve(target_name, via, src)
340
+ if tid is None or tid == src:
341
+ continue # keep the graph correct: only in-repo, non-self-loop edges
342
+ add(src, tid, rtype, conf, meta)
343
+ if rtype == CALLS and is_test(src):
344
+ add(src, tid, TESTS, min(conf, 0.8), None)
345
+
346
+ for (source, target, rtype), (conf, meta) in edges.items():
347
+ self.session.add(
348
+ SymbolRelationship(
349
+ repository_id=repo.id, source_symbol_id=source,
350
+ target_symbol_id=target, relationship_type=rtype,
351
+ confidence=conf, meta=meta,
352
+ )
353
+ )
354
+ self.session.flush()
355
+ return len(edges)
356
+
357
+ def _embed(self, repo: Repository) -> int:
358
+ from coderag.embeddings.pipeline import EmbeddingPipeline
359
+ from coderag.embeddings.registry import get_embedding_provider
360
+
361
+ provider = get_embedding_provider(self.settings)
362
+ return EmbeddingPipeline(self.session, provider).embed_repository(repo)
363
+
364
+ # -- per-file ---------------------------------------------------------
365
+ def _index_file(
366
+ self, repo: Repository, rel_path: str, commit: str | None,
367
+ git: GitRepo, stats: IndexStats,
368
+ ) -> None:
369
+ raw = git.read_text(rel_path)
370
+ if raw is None:
371
+ stats.files_skipped += 1
372
+ return
373
+ red = redact(raw)
374
+ if red.count:
375
+ stats.secrets_redacted += red.count
376
+ log.warning("redacted_secret", path=rel_path, count=red.count,
377
+ labels=red.labels)
378
+ content = red.text
379
+
380
+ parser = get_parser_for_path(rel_path)
381
+ if parser is None:
382
+ stats.files_skipped += 1
383
+ return
384
+
385
+ sf = SourceFile(
386
+ repository_id=repo.id, path=rel_path, language=parser.language,
387
+ content_hash=_hash(content), size_bytes=len(content.encode("utf-8")),
388
+ token_count=self.tokens.count(content),
389
+ indexed_commit_sha=commit,
390
+ )
391
+ self.session.add(sf)
392
+ self.session.flush()
393
+
394
+ result = parser.parse(module_qualified_name(rel_path), content)
395
+ local_to_db: dict[int, int] = {}
396
+ for ps in result.symbols:
397
+ parent_db = (
398
+ local_to_db.get(ps.parent_local_id)
399
+ if ps.parent_local_id is not None
400
+ else None
401
+ )
402
+ sym = Symbol(
403
+ repository_id=repo.id,
404
+ source_file_id=sf.id,
405
+ file_path=rel_path,
406
+ language=parser.language,
407
+ symbol_name=ps.symbol_name,
408
+ qualified_name=ps.qualified_name,
409
+ symbol_type=ps.symbol_type,
410
+ signature=ps.signature,
411
+ start_line=ps.start_line,
412
+ end_line=ps.end_line,
413
+ parent_symbol_id=parent_db,
414
+ source_code=ps.source_code,
415
+ docstring=ps.docstring,
416
+ source_hash=ps.source_hash,
417
+ commit_sha=commit,
418
+ token_count=self.tokens.count(ps.source_code),
419
+ search_document=" ".join(ps.search_terms),
420
+ )
421
+ self.session.add(sym)
422
+ self.session.flush()
423
+ local_to_db[ps.local_id] = sym.id
424
+ if parent_db is not None:
425
+ self._pending_contains.append((parent_db, sym.id))
426
+ stats.symbols_indexed += 1
427
+
428
+ for pr in result.relationships:
429
+ src_db = local_to_db.get(pr.source_local_id)
430
+ if src_db is not None:
431
+ self._pending_rels.append(
432
+ (src_db, pr.relationship_type, pr.target_name, pr.confidence, pr.metadata)
433
+ )
434
+ stats.files_indexed += 1
435
+
436
+
437
+ def index_repository(
438
+ session: Session, name: str, local_path: str, url: str | None = None,
439
+ settings: Settings | None = None,
440
+ ) -> tuple[Repository, IndexingRun, IndexStats]:
441
+ """Convenience: register (or update) a repository and run a full index."""
442
+ repo = get_or_create_repository(session, name, local_path, url)
443
+ indexer = Indexer(session, settings=settings)
444
+ run, stats = indexer.full_index(repo)
445
+ return repo, run, stats
File without changes
@@ -0,0 +1,117 @@
1
+ """Anthropic Claude provider over the Messages API (HTTP).
2
+
3
+ Because the transport is plain HTTP via httpx, the same provider adapts to the
4
+ Anthropic API, an internal proxy (set ``anthropic_base_url``), or — with a
5
+ subclass swapping auth/signing — AWS Bedrock. Credentials come from config and
6
+ are never logged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from collections.abc import Iterator
14
+
15
+ import httpx
16
+
17
+ from coderag.core.config import Settings, get_settings
18
+ from coderag.llm.base import LLMProvider, LLMRequest, LLMResponse, Usage
19
+
20
+
21
+ class AnthropicClaudeProvider(LLMProvider):
22
+ name = "anthropic"
23
+
24
+ def __init__(
25
+ self,
26
+ settings: Settings | None = None,
27
+ client: httpx.Client | None = None,
28
+ transport: httpx.BaseTransport | None = None,
29
+ ) -> None:
30
+ self.settings = settings or get_settings()
31
+ if not self.settings.anthropic_api_key and client is None and transport is None:
32
+ raise RuntimeError(
33
+ "CODERAG_ANTHROPIC_API_KEY is not set. Retrieval works without an LLM; "
34
+ "only `ask` needs credentials."
35
+ )
36
+ self._client = client or httpx.Client(
37
+ base_url=self.settings.anthropic_base_url,
38
+ timeout=self.settings.llm_timeout_seconds,
39
+ transport=transport,
40
+ headers={
41
+ "x-api-key": self.settings.anthropic_api_key or "",
42
+ "anthropic-version": self.settings.anthropic_version,
43
+ "content-type": "application/json",
44
+ },
45
+ )
46
+ self._last_usage = Usage()
47
+
48
+ def _body(self, request: LLMRequest, stream: bool) -> dict:
49
+ return {
50
+ "model": request.model or self.settings.anthropic_model,
51
+ "max_tokens": request.max_tokens or self.settings.llm_max_output_tokens,
52
+ "temperature": request.temperature,
53
+ "system": request.system,
54
+ "messages": [{"role": "user", "content": request.prompt}],
55
+ "stream": stream,
56
+ }
57
+
58
+ def generate(self, request: LLMRequest) -> LLMResponse:
59
+ started = time.perf_counter()
60
+ model = request.model or self.settings.anthropic_model
61
+ try:
62
+ resp = self._client.post("/v1/messages", json=self._body(request, stream=False))
63
+ resp.raise_for_status()
64
+ data = resp.json()
65
+ text = "".join(
66
+ block.get("text", "")
67
+ for block in data.get("content", [])
68
+ if block.get("type") == "text"
69
+ )
70
+ usage_raw = data.get("usage", {})
71
+ usage = Usage(
72
+ input_tokens=usage_raw.get("input_tokens", 0),
73
+ output_tokens=usage_raw.get("output_tokens", 0),
74
+ cached_input_tokens=usage_raw.get("cache_read_input_tokens"),
75
+ model=data.get("model", model),
76
+ latency_ms=(time.perf_counter() - started) * 1000,
77
+ success=True,
78
+ )
79
+ self._last_usage = usage
80
+ return LLMResponse(text=text, usage=usage)
81
+ except Exception as exc:
82
+ usage = Usage(
83
+ model=model, latency_ms=(time.perf_counter() - started) * 1000,
84
+ success=False, error=type(exc).__name__ + ": " + str(exc)[:200],
85
+ )
86
+ self._last_usage = usage
87
+ raise
88
+
89
+ def generate_stream(self, request: LLMRequest) -> Iterator[str]:
90
+ started = time.perf_counter()
91
+ model = request.model or self.settings.anthropic_model
92
+ usage = Usage(model=model)
93
+ with self._client.stream(
94
+ "POST", "/v1/messages", json=self._body(request, stream=True)
95
+ ) as resp:
96
+ resp.raise_for_status()
97
+ for line in resp.iter_lines():
98
+ if not line or not line.startswith("data:"):
99
+ continue
100
+ payload = line[len("data:") :].strip()
101
+ if payload in ("", "[DONE]"):
102
+ continue
103
+ event = json.loads(payload)
104
+ etype = event.get("type")
105
+ if etype == "message_start":
106
+ usage.input_tokens = (
107
+ event.get("message", {}).get("usage", {}).get("input_tokens", 0)
108
+ )
109
+ elif etype == "content_block_delta":
110
+ delta = event.get("delta", {})
111
+ if delta.get("type") == "text_delta":
112
+ yield delta.get("text", "")
113
+ elif etype == "message_delta":
114
+ usage.output_tokens = event.get("usage", {}).get("output_tokens", 0)
115
+ usage.latency_ms = (time.perf_counter() - started) * 1000
116
+ usage.success = True
117
+ self._last_usage = usage