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/__init__.py +1 -1
- repr/__main__.py +6 -0
- repr/api.py +127 -1
- repr/auth.py +66 -2
- repr/cli.py +2143 -663
- repr/config.py +658 -32
- repr/discovery.py +5 -0
- repr/doctor.py +458 -0
- repr/hooks.py +634 -0
- repr/keychain.py +255 -0
- repr/llm.py +506 -0
- repr/openai_analysis.py +92 -21
- repr/privacy.py +333 -0
- repr/storage.py +527 -0
- repr/templates.py +229 -0
- repr/tools.py +202 -0
- repr/ui.py +79 -364
- repr_cli-0.2.2.dist-info/METADATA +263 -0
- repr_cli-0.2.2.dist-info/RECORD +24 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/licenses/LICENSE +1 -1
- repr/analyzer.py +0 -915
- repr/highlights.py +0 -712
- repr_cli-0.1.0.dist-info/METADATA +0 -326
- repr_cli-0.1.0.dist-info/RECORD +0 -18
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/WHEEL +0 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/entry_points.txt +0 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/top_level.txt +0 -0
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
|
|
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 '
|
|
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=
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 +
|
|
341
|
-
for i in range(0, len(commits),
|
|
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 ({
|
|
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)
|