repr-cli 0.1.0__py3-none-any.whl → 0.2.2__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.
repr/llm.py ADDED
@@ -0,0 +1,506 @@
1
+ """
2
+ LLM detection, configuration, and testing.
3
+
4
+ Supports:
5
+ - Local LLMs: Ollama, LM Studio, custom OpenAI-compatible endpoints
6
+ - Cloud: repr.dev managed inference
7
+ - BYOK: Bring your own key (OpenAI, Anthropic, etc.)
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+
16
+ @dataclass
17
+ class LocalLLMInfo:
18
+ """Information about a detected local LLM."""
19
+ provider: str # "ollama", "lmstudio", "custom"
20
+ name: str
21
+ url: str
22
+ models: list[str]
23
+ default_model: str | None
24
+
25
+
26
+ @dataclass
27
+ class LLMTestResult:
28
+ """Result of LLM connection test."""
29
+ success: bool
30
+ provider: str
31
+ endpoint: str
32
+ model: str | None
33
+ response_time_ms: float | None
34
+ error: str | None
35
+
36
+
37
+ # Known local LLM endpoints
38
+ LOCAL_ENDPOINTS = [
39
+ {
40
+ "provider": "ollama",
41
+ "name": "Ollama",
42
+ "url": "http://localhost:11434",
43
+ "api_path": "/v1",
44
+ "models_endpoint": "/api/tags",
45
+ },
46
+ {
47
+ "provider": "lmstudio",
48
+ "name": "LM Studio",
49
+ "url": "http://localhost:1234",
50
+ "api_path": "/v1",
51
+ "models_endpoint": "/v1/models",
52
+ },
53
+ ]
54
+
55
+
56
+ def detect_local_llm() -> LocalLLMInfo | None:
57
+ """
58
+ Detect available local LLM endpoints.
59
+
60
+ Returns:
61
+ LocalLLMInfo if found, None otherwise
62
+ """
63
+ for endpoint in LOCAL_ENDPOINTS:
64
+ try:
65
+ # Try to connect and get models
66
+ models_url = f"{endpoint['url']}{endpoint['models_endpoint']}"
67
+ resp = httpx.get(models_url, timeout=3)
68
+
69
+ if resp.status_code == 200:
70
+ data = resp.json()
71
+
72
+ # Parse models based on provider
73
+ if endpoint["provider"] == "ollama":
74
+ models = [m.get("name", m.get("model", "")) for m in data.get("models", [])]
75
+ else:
76
+ models = [m.get("id", "") for m in data.get("data", [])]
77
+
78
+ models = [m for m in models if m] # Filter empty
79
+
80
+ return LocalLLMInfo(
81
+ provider=endpoint["provider"],
82
+ name=endpoint["name"],
83
+ url=endpoint["url"],
84
+ models=models,
85
+ default_model=models[0] if models else None,
86
+ )
87
+
88
+ except Exception:
89
+ continue
90
+
91
+ return None
92
+
93
+
94
+ def detect_all_local_llms() -> list[LocalLLMInfo]:
95
+ """
96
+ Detect all available local LLM endpoints.
97
+
98
+ Returns:
99
+ List of LocalLLMInfo for all found providers
100
+ """
101
+ found = []
102
+
103
+ for endpoint in LOCAL_ENDPOINTS:
104
+ try:
105
+ models_url = f"{endpoint['url']}{endpoint['models_endpoint']}"
106
+ resp = httpx.get(models_url, timeout=3)
107
+
108
+ if resp.status_code == 200:
109
+ data = resp.json()
110
+
111
+ if endpoint["provider"] == "ollama":
112
+ models = [m.get("name", m.get("model", "")) for m in data.get("models", [])]
113
+ else:
114
+ models = [m.get("id", "") for m in data.get("data", [])]
115
+
116
+ models = [m for m in models if m]
117
+
118
+ found.append(LocalLLMInfo(
119
+ provider=endpoint["provider"],
120
+ name=endpoint["name"],
121
+ url=endpoint["url"],
122
+ models=models,
123
+ default_model=models[0] if models else None,
124
+ ))
125
+
126
+ except Exception:
127
+ continue
128
+
129
+ return found
130
+
131
+
132
+ def test_local_llm(
133
+ url: str | None = None,
134
+ model: str | None = None,
135
+ api_key: str | None = None,
136
+ ) -> LLMTestResult:
137
+ """
138
+ Test local LLM connection and generation.
139
+
140
+ Args:
141
+ url: LLM API base URL (auto-detect if None)
142
+ model: Model to test (use default if None)
143
+ api_key: API key if required
144
+
145
+ Returns:
146
+ LLMTestResult with test outcome
147
+ """
148
+ import time
149
+
150
+ # Auto-detect if no URL provided
151
+ if not url:
152
+ detected = detect_local_llm()
153
+ if not detected:
154
+ return LLMTestResult(
155
+ success=False,
156
+ provider="unknown",
157
+ endpoint="",
158
+ model=None,
159
+ response_time_ms=None,
160
+ error="No local LLM detected. Is Ollama or LM Studio running?",
161
+ )
162
+ url = detected.url
163
+ if not model:
164
+ model = detected.default_model
165
+ provider = detected.provider
166
+ else:
167
+ provider = "custom"
168
+
169
+ # Determine model
170
+ if not model:
171
+ model = "llama3.2" # Common default
172
+
173
+ # Test generation
174
+ try:
175
+ start = time.time()
176
+
177
+ headers = {"Content-Type": "application/json"}
178
+ if api_key:
179
+ headers["Authorization"] = f"Bearer {api_key}"
180
+
181
+ # Use chat completions endpoint
182
+ chat_url = f"{url}/v1/chat/completions"
183
+
184
+ resp = httpx.post(
185
+ chat_url,
186
+ headers=headers,
187
+ json={
188
+ "model": model,
189
+ "messages": [{"role": "user", "content": "Say 'hello' and nothing else."}],
190
+ "max_tokens": 10,
191
+ },
192
+ timeout=30,
193
+ )
194
+
195
+ elapsed_ms = (time.time() - start) * 1000
196
+
197
+ if resp.status_code == 200:
198
+ return LLMTestResult(
199
+ success=True,
200
+ provider=provider,
201
+ endpoint=url,
202
+ model=model,
203
+ response_time_ms=elapsed_ms,
204
+ error=None,
205
+ )
206
+ else:
207
+ return LLMTestResult(
208
+ success=False,
209
+ provider=provider,
210
+ endpoint=url,
211
+ model=model,
212
+ response_time_ms=elapsed_ms,
213
+ error=f"HTTP {resp.status_code}: {resp.text[:100]}",
214
+ )
215
+
216
+ except httpx.ConnectError:
217
+ return LLMTestResult(
218
+ success=False,
219
+ provider=provider,
220
+ endpoint=url,
221
+ model=model,
222
+ response_time_ms=None,
223
+ error=f"Connection failed: {url}",
224
+ )
225
+ except httpx.TimeoutException:
226
+ return LLMTestResult(
227
+ success=False,
228
+ provider=provider,
229
+ endpoint=url,
230
+ model=model,
231
+ response_time_ms=None,
232
+ error="Request timed out (30s)",
233
+ )
234
+ except Exception as e:
235
+ return LLMTestResult(
236
+ success=False,
237
+ provider=provider,
238
+ endpoint=url,
239
+ model=model,
240
+ response_time_ms=None,
241
+ error=str(e),
242
+ )
243
+
244
+
245
+ def test_byok_provider(provider: str, api_key: str, model: str | None = None) -> LLMTestResult:
246
+ """
247
+ Test BYOK provider connection.
248
+
249
+ Args:
250
+ provider: Provider name (openai, anthropic, etc.)
251
+ api_key: API key
252
+ model: Model to test
253
+
254
+ Returns:
255
+ LLMTestResult with test outcome
256
+ """
257
+ import time
258
+ from .config import BYOK_PROVIDERS
259
+
260
+ if provider not in BYOK_PROVIDERS:
261
+ return LLMTestResult(
262
+ success=False,
263
+ provider=provider,
264
+ endpoint="",
265
+ model=model,
266
+ response_time_ms=None,
267
+ error=f"Unknown provider: {provider}",
268
+ )
269
+
270
+ provider_info = BYOK_PROVIDERS[provider]
271
+ base_url = provider_info["base_url"]
272
+ if not model:
273
+ model = provider_info["default_model"]
274
+
275
+ try:
276
+ start = time.time()
277
+
278
+ if provider == "anthropic":
279
+ # Anthropic uses different API format
280
+ resp = httpx.post(
281
+ f"{base_url}/messages",
282
+ headers={
283
+ "x-api-key": api_key,
284
+ "anthropic-version": "2023-06-01",
285
+ "Content-Type": "application/json",
286
+ },
287
+ json={
288
+ "model": model,
289
+ "max_tokens": 10,
290
+ "messages": [{"role": "user", "content": "Say 'hello' and nothing else."}],
291
+ },
292
+ timeout=30,
293
+ )
294
+ else:
295
+ # OpenAI-compatible API
296
+ resp = httpx.post(
297
+ f"{base_url}/chat/completions",
298
+ headers={
299
+ "Authorization": f"Bearer {api_key}",
300
+ "Content-Type": "application/json",
301
+ },
302
+ json={
303
+ "model": model,
304
+ "messages": [{"role": "user", "content": "Say 'hello' and nothing else."}],
305
+ "max_tokens": 10,
306
+ },
307
+ timeout=30,
308
+ )
309
+
310
+ elapsed_ms = (time.time() - start) * 1000
311
+
312
+ if resp.status_code == 200:
313
+ return LLMTestResult(
314
+ success=True,
315
+ provider=provider,
316
+ endpoint=base_url,
317
+ model=model,
318
+ response_time_ms=elapsed_ms,
319
+ error=None,
320
+ )
321
+ elif resp.status_code == 401:
322
+ return LLMTestResult(
323
+ success=False,
324
+ provider=provider,
325
+ endpoint=base_url,
326
+ model=model,
327
+ response_time_ms=elapsed_ms,
328
+ error="Invalid API key",
329
+ )
330
+ else:
331
+ return LLMTestResult(
332
+ success=False,
333
+ provider=provider,
334
+ endpoint=base_url,
335
+ model=model,
336
+ response_time_ms=elapsed_ms,
337
+ error=f"HTTP {resp.status_code}: {resp.text[:100]}",
338
+ )
339
+
340
+ except Exception as e:
341
+ return LLMTestResult(
342
+ success=False,
343
+ provider=provider,
344
+ endpoint=base_url,
345
+ model=model,
346
+ response_time_ms=None,
347
+ error=str(e),
348
+ )
349
+
350
+
351
+ def list_ollama_models(url: str = "http://localhost:11434") -> list[str]:
352
+ """
353
+ List available Ollama models.
354
+
355
+ Args:
356
+ url: Ollama API URL
357
+
358
+ Returns:
359
+ List of model names
360
+ """
361
+ try:
362
+ resp = httpx.get(f"{url}/api/tags", timeout=5)
363
+ if resp.status_code == 200:
364
+ data = resp.json()
365
+ return [m.get("name", "") for m in data.get("models", []) if m.get("name")]
366
+ except Exception:
367
+ pass
368
+ return []
369
+
370
+
371
+ def list_openai_compatible_models(url: str, api_key: str | None = None) -> list[str]:
372
+ """
373
+ List models from OpenAI-compatible endpoint.
374
+
375
+ Args:
376
+ url: API base URL
377
+ api_key: Optional API key
378
+
379
+ Returns:
380
+ List of model IDs
381
+ """
382
+ try:
383
+ headers = {}
384
+ if api_key:
385
+ headers["Authorization"] = f"Bearer {api_key}"
386
+
387
+ resp = httpx.get(f"{url}/v1/models", headers=headers, timeout=5)
388
+ if resp.status_code == 200:
389
+ data = resp.json()
390
+ return [m.get("id", "") for m in data.get("data", []) if m.get("id")]
391
+ except Exception:
392
+ pass
393
+ return []
394
+
395
+
396
+ def get_llm_status() -> dict[str, Any]:
397
+ """
398
+ Get comprehensive LLM status.
399
+
400
+ Returns:
401
+ Dict with local, cloud, and BYOK status
402
+ """
403
+ from .config import (
404
+ get_llm_config,
405
+ get_default_llm_mode,
406
+ list_byok_providers,
407
+ is_authenticated,
408
+ is_cloud_allowed,
409
+ )
410
+
411
+ llm_config = get_llm_config()
412
+ default_mode = get_default_llm_mode()
413
+
414
+ # Check local LLM
415
+ local_info = detect_local_llm()
416
+ local_available = local_info is not None
417
+
418
+ # Check cloud
419
+ cloud_available = is_authenticated() and is_cloud_allowed()
420
+
421
+ # Check BYOK
422
+ byok_providers = list_byok_providers()
423
+
424
+ return {
425
+ "default_mode": default_mode,
426
+ "local": {
427
+ "available": local_available,
428
+ "provider": local_info.provider if local_info else None,
429
+ "name": local_info.name if local_info else None,
430
+ "url": local_info.url if local_info else llm_config.get("local_api_url"),
431
+ "model": llm_config.get("local_model") or (local_info.default_model if local_info else None),
432
+ "models_count": len(local_info.models) if local_info else 0,
433
+ },
434
+ "cloud": {
435
+ "available": cloud_available,
436
+ "model": llm_config.get("cloud_model", "gpt-4o-mini"),
437
+ "blocked_reason": None if cloud_available else (
438
+ "Not authenticated" if not is_authenticated() else "Local-only mode enabled"
439
+ ),
440
+ },
441
+ "byok": {
442
+ "providers": byok_providers,
443
+ "count": len(byok_providers),
444
+ },
445
+ "settings": {
446
+ "cloud_send_diffs": llm_config.get("cloud_send_diffs", False),
447
+ "cloud_redact_paths": llm_config.get("cloud_redact_paths", True),
448
+ "cloud_redact_emails": llm_config.get("cloud_redact_emails", False),
449
+ },
450
+ }
451
+
452
+
453
+ def get_effective_llm_mode() -> tuple[str, dict[str, Any]]:
454
+ """
455
+ Get the effective LLM mode that will be used.
456
+
457
+ Returns:
458
+ Tuple of (mode, config_dict)
459
+ Mode is one of: "local", "cloud", "byok:<provider>"
460
+ """
461
+ from .config import (
462
+ get_default_llm_mode,
463
+ get_llm_config,
464
+ get_byok_config,
465
+ is_authenticated,
466
+ is_cloud_allowed,
467
+ get_forced_mode,
468
+ )
469
+
470
+ # Check for forced mode
471
+ forced = get_forced_mode()
472
+ if forced:
473
+ if forced == "local":
474
+ llm_config = get_llm_config()
475
+ return "local", {
476
+ "url": llm_config.get("local_api_url"),
477
+ "model": llm_config.get("local_model"),
478
+ }
479
+
480
+ default_mode = get_default_llm_mode()
481
+ llm_config = get_llm_config()
482
+
483
+ # Handle BYOK mode
484
+ if default_mode.startswith("byok:"):
485
+ provider = default_mode.split(":", 1)[1]
486
+ byok_config = get_byok_config(provider)
487
+ if byok_config:
488
+ return default_mode, byok_config
489
+ # Fall back to local if BYOK not configured
490
+ default_mode = "local"
491
+
492
+ # Handle cloud mode
493
+ if default_mode == "cloud":
494
+ if is_authenticated() and is_cloud_allowed():
495
+ return "cloud", {
496
+ "model": llm_config.get("cloud_model", "gpt-4o-mini"),
497
+ }
498
+ # Fall back to local if cloud not available
499
+ default_mode = "local"
500
+
501
+ # Local mode
502
+ return "local", {
503
+ "url": llm_config.get("local_api_url"),
504
+ "model": llm_config.get("local_model"),
505
+ }
506
+
repr/openai_analysis.py CHANGED
@@ -22,39 +22,97 @@ DEFAULT_EXTRACTION_MODEL = "openai/gpt-5-nano-2025-08-07"
22
22
  DEFAULT_SYNTHESIS_MODEL = "openai/gpt-5.2-2025-12-11"
23
23
  EXTRACTION_TEMPERATURE = 0.3
24
24
  SYNTHESIS_TEMPERATURE = 0.7
25
- COMMITS_PER_BATCH = 25
25
+ COMMITS_PER_BATCH = 25 # Default fallback, use config value when possible
26
26
 
27
27
 
28
- def get_openai_client(api_key: str = None, base_url: str = None) -> AsyncOpenAI:
28
+ def estimate_tokens(commits: list[dict[str, Any]]) -> int:
29
+ """
30
+ Estimate token count for a list of commits.
31
+
32
+ Uses a rough heuristic: ~4 characters per token (GPT tokenization rule of thumb).
33
+ Includes commit messages, file paths, and diffs.
34
+
35
+ Args:
36
+ commits: List of commits with diffs
37
+
38
+ Returns:
39
+ Estimated token count
40
+ """
41
+ total_chars = 0
42
+
43
+ for commit in commits:
44
+ # Count commit message
45
+ total_chars += len(commit.get('message', ''))
46
+
47
+ # Count file information
48
+ for file_info in commit.get('files', []):
49
+ total_chars += len(file_info.get('path', ''))
50
+ if 'diff' in file_info and file_info['diff']:
51
+ total_chars += len(file_info['diff'])
52
+
53
+ # Rule of thumb: ~4 characters per token
54
+ estimated_tokens = total_chars // 4
55
+
56
+ # Add overhead for prompts and formatting (~2000 tokens)
57
+ return estimated_tokens + 2000
58
+
59
+
60
+ def get_batch_size() -> int:
61
+ """
62
+ Get batch size from config, with fallback to COMMITS_PER_BATCH constant.
63
+
64
+ Returns:
65
+ Batch size (max commits per batch)
66
+ """
67
+ try:
68
+ from .config import load_config
69
+ config = load_config()
70
+ return config.get("generation", {}).get("max_commits_per_batch", COMMITS_PER_BATCH)
71
+ except Exception:
72
+ return COMMITS_PER_BATCH
73
+
74
+
75
+ def get_openai_client(api_key: str = None, base_url: str = None, verbose: bool = False) -> AsyncOpenAI:
29
76
  """
30
77
  Get OpenAI-compatible client that proxies through our backend.
31
78
 
32
79
  Args:
33
80
  api_key: API key (optional, for local LLM mode)
34
81
  base_url: Base URL for API (optional, for local LLM mode)
82
+ verbose: Whether to print debug info
35
83
 
36
84
  Returns:
37
85
  AsyncOpenAI client
38
86
  """
87
+ import sys
88
+
39
89
  # If explicit parameters provided, use them (for local mode)
40
90
  if api_key:
41
91
  kwargs = {"api_key": api_key}
42
92
  if base_url:
43
93
  kwargs["base_url"] = base_url
94
+ if verbose:
95
+ print(f"[DEBUG] Using explicit API key with base_url: {base_url or 'OpenAI default'}", file=sys.stderr)
44
96
  return AsyncOpenAI(**kwargs)
45
97
 
46
98
  # Use our backend as the proxy - it will forward to LiteLLM
47
99
  # The rf_* token is used to authenticate with our backend
48
100
  _, litellm_key = get_litellm_config()
49
101
  if not litellm_key:
50
- raise ValueError("Not logged in. Please run 'rf login' first.")
102
+ raise ValueError("Not logged in. Please run 'repr login' first.")
51
103
 
52
104
  # Point to our backend's LLM proxy endpoint
53
105
  backend_url = get_api_base().replace("/api/cli", "")
106
+ proxy_url = f"{backend_url}/api/llm/v1"
107
+
108
+ if verbose:
109
+ print(f"[DEBUG] Backend URL: {backend_url}", file=sys.stderr)
110
+ print(f"[DEBUG] Proxy URL: {proxy_url}", file=sys.stderr)
111
+ print(f"[DEBUG] Token: {litellm_key[:15]}...", file=sys.stderr)
54
112
 
55
113
  return AsyncOpenAI(
56
114
  api_key=litellm_key,
57
- base_url=f"{backend_url}/api/llm/v1"
115
+ base_url=proxy_url
58
116
  )
59
117
 
60
118
 
@@ -146,17 +204,26 @@ List the specific technical work done in this batch. For each item:
146
204
 
147
205
  Focus on substance, not process."""
148
206
 
149
- response = await client.chat.completions.create(
150
- model=model,
151
- messages=[
152
- {"role": "system", "content": system_prompt},
153
- {"role": "user", "content": user_prompt},
154
- ],
155
- temperature=EXTRACTION_TEMPERATURE,
156
- max_tokens=16000, # Increased for reasoning models that use tokens for thinking
157
- )
158
-
159
- return response.choices[0].message.content or ""
207
+ try:
208
+ response = await client.chat.completions.create(
209
+ model=model,
210
+ messages=[
211
+ {"role": "system", "content": system_prompt},
212
+ {"role": "user", "content": user_prompt},
213
+ ],
214
+ temperature=EXTRACTION_TEMPERATURE,
215
+ max_tokens=16000, # Increased for reasoning models that use tokens for thinking
216
+ )
217
+
218
+ return response.choices[0].message.content or ""
219
+ except Exception as e:
220
+ error_msg = str(e).lower()
221
+ # Handle content moderation blocks gracefully
222
+ if "blocked" in error_msg or "content" in error_msg or "moderation" in error_msg:
223
+ # Skip this batch but continue with others
224
+ return f"[Batch {batch_num} skipped - content filter triggered]"
225
+ # Re-raise other errors
226
+ raise
160
227
 
161
228
 
162
229
  async def synthesize_profile(
@@ -290,6 +357,7 @@ async def analyze_repo_openai(
290
357
  synthesis_model: str = None,
291
358
  verbose: bool = False,
292
359
  progress_callback: callable = None,
360
+ since: str = None,
293
361
  ) -> str:
294
362
  """
295
363
  Analyze a single repository using OpenAI-compatible API.
@@ -303,11 +371,12 @@ async def analyze_repo_openai(
303
371
  verbose: Whether to print verbose output
304
372
  progress_callback: Optional callback for progress updates
305
373
  Signature: callback(step: str, detail: str, repo: str, progress: float)
374
+ since: Only analyze commits after this point (SHA or date like '2026-01-01')
306
375
 
307
376
  Returns:
308
377
  Repository analysis/narrative in markdown
309
378
  """
310
- client = get_openai_client(api_key=api_key, base_url=base_url)
379
+ client = get_openai_client(api_key=api_key, base_url=base_url, verbose=verbose)
311
380
 
312
381
  if progress_callback:
313
382
  progress_callback(
@@ -322,6 +391,7 @@ async def analyze_repo_openai(
322
391
  repo_path=repo.path,
323
392
  count=200, # Last 200 commits
324
393
  days=730, # Last 2 years
394
+ since=since,
325
395
  )
326
396
 
327
397
  if not commits:
@@ -335,10 +405,11 @@ async def analyze_repo_openai(
335
405
  progress=10.0,
336
406
  )
337
407
 
338
- # Split into batches
408
+ # Split into batches (use config value)
409
+ batch_size = get_batch_size()
339
410
  batches = [
340
- commits[i:i + COMMITS_PER_BATCH]
341
- for i in range(0, len(commits), COMMITS_PER_BATCH)
411
+ commits[i:i + batch_size]
412
+ for i in range(0, len(commits), batch_size)
342
413
  ]
343
414
 
344
415
  total_batches = len(batches)
@@ -346,7 +417,7 @@ async def analyze_repo_openai(
346
417
  if progress_callback:
347
418
  progress_callback(
348
419
  step="Analyzing",
349
- detail=f"Processing {total_batches} batches ({COMMITS_PER_BATCH} commits each)",
420
+ detail=f"Processing {total_batches} batches ({batch_size} commits each)",
350
421
  repo=repo.name,
351
422
  progress=15.0,
352
423
  )
@@ -501,7 +572,7 @@ async def analyze_repos_openai(
501
572
  progress=92.0,
502
573
  )
503
574
 
504
- client = get_openai_client(api_key=api_key, base_url=base_url)
575
+ client = get_openai_client(api_key=api_key, base_url=base_url, verbose=verbose)
505
576
 
506
577
  # Aggregate metadata from all repos (injected directly, not LLM-generated)
507
578
  total_commits = sum(r.commit_count for r in repos)