codedd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. codedd_cli/__init__.py +3 -0
  2. codedd_cli/__main__.py +19 -0
  3. codedd_cli/api/__init__.py +4 -0
  4. codedd_cli/api/client.py +118 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +25 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1241 -0
  9. codedd_cli/auditor/architecture_prompts.py +171 -0
  10. codedd_cli/auditor/complexity_analyzer.py +942 -0
  11. codedd_cli/auditor/dependency_scanner.py +2478 -0
  12. codedd_cli/auditor/file_auditor.py +572 -0
  13. codedd_cli/auditor/git_stats_collector.py +332 -0
  14. codedd_cli/auditor/response_parser.py +487 -0
  15. codedd_cli/auditor/vulnerability_validator.py +324 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +41 -0
  18. codedd_cli/auth/token_manager.py +87 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1877 -0
  22. codedd_cli/commands/audits_cmd.py +273 -0
  23. codedd_cli/commands/auth_cmd.py +230 -0
  24. codedd_cli/commands/config_cmd.py +454 -0
  25. codedd_cli/commands/scope_cmd.py +967 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +369 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +271 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +32 -0
  34. codedd_cli/models/local_directory.py +26 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +247 -0
  37. codedd_cli/scanner/file_walker.py +207 -0
  38. codedd_cli/scanner/line_counter.py +75 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +179 -0
  41. codedd_cli/utils/display.py +493 -0
  42. codedd_cli/utils/payload_inspector.py +180 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.0.dist-info/METADATA +276 -0
  46. codedd_cli-0.1.0.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.0.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
  49. codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,572 @@
1
+ """
2
+ Local file auditor for the CodeDD CLI.
3
+
4
+ Reads source files from disk, sends them to the configured LLM provider
5
+ via raw ``httpx`` calls (no SDK dependency), parses the structured
6
+ response, and returns a typed audit-data dictionary ready for submission
7
+ to CodeDD.
8
+
9
+ The retry/fallback strategy mirrors the server-side ``AI_Auditor``:
10
+ - Anthropic is the primary provider.
11
+ - OpenAI is the fallback.
12
+ - 3 outer retries, up to 2 attempts per provider per retry cycle.
13
+ - Exponential backoff with jitter between outer retries.
14
+
15
+ **Security invariants**
16
+ - The system prompt is kept in memory only — never written to disk.
17
+ - File content is never logged at any verbosity level.
18
+ - LLM API keys are retrieved from the OS keyring at runtime.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import random
25
+ import time
26
+ from concurrent.futures import ThreadPoolExecutor, as_completed
27
+ from pathlib import Path
28
+ from typing import Any, Callable, Optional
29
+
30
+ # Callback for debug dump: (full_prompt, response_text, audit_data, none_count)
31
+ OnDumpLLMCallback = Callable[[str, str, dict | None, int], None]
32
+
33
+ import httpx
34
+
35
+ from codedd_cli.auditor.response_parser import (
36
+ is_audit_data_valid,
37
+ is_response_complete,
38
+ parse_audit_response,
39
+ )
40
+ from codedd_cli.llm.key_manager import LLMKeyManager, PROVIDER_MODELS
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # LLM API endpoints (same as key_manager uses for validation)
45
+ _ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages"
46
+ _OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"
47
+
48
+ # Request timeouts (seconds) — audit calls are larger than validation pings
49
+ _LLM_TIMEOUT = 180.0
50
+
51
+ # Content size limit (chars) — matches server-side max_content_chars
52
+ _MAX_CONTENT_CHARS = 250_000
53
+
54
+
55
+ class AuditFileResult:
56
+ """Container for a single file's audit outcome."""
57
+
58
+ __slots__ = (
59
+ "file_path",
60
+ "relative_path",
61
+ "audit_data",
62
+ "provider_used",
63
+ "error",
64
+ )
65
+
66
+ def __init__(
67
+ self,
68
+ file_path: str,
69
+ relative_path: str,
70
+ audit_data: dict | None = None,
71
+ provider_used: str | None = None,
72
+ error: str | None = None,
73
+ ) -> None:
74
+ self.file_path = file_path
75
+ self.relative_path = relative_path
76
+ self.audit_data = audit_data
77
+ self.provider_used = provider_used
78
+ self.error = error
79
+
80
+ @property
81
+ def success(self) -> bool:
82
+ return self.audit_data is not None and self.error is None
83
+
84
+
85
+ class LocalFileAuditor:
86
+ """
87
+ Read local source files, send them to an LLM with the server-provided
88
+ system prompt, parse the structured response, and return audit data.
89
+
90
+ Usage::
91
+
92
+ auditor = LocalFileAuditor(
93
+ anthropic_key="sk-ant-...",
94
+ openai_key="sk-...",
95
+ system_prompt="...",
96
+ provider_preference="both",
97
+ max_concurrent=8,
98
+ )
99
+ results = auditor.audit_batch(files, on_progress=callback)
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ anthropic_key: str | None,
105
+ openai_key: str | None,
106
+ system_prompt: str,
107
+ provider_preference: str = "both",
108
+ max_concurrent: int = 8,
109
+ on_debug: Callable[[str], None] | None = None,
110
+ on_dump_llm: OnDumpLLMCallback | None = None,
111
+ ) -> None:
112
+ """
113
+ Initialise the auditor with API keys and the system prompt.
114
+
115
+ Args:
116
+ anthropic_key: Anthropic API key (may be None).
117
+ openai_key: OpenAI API key (may be None).
118
+ system_prompt: The file-audit system prompt from the server.
119
+ provider_preference: ``"anthropic"``, ``"openai"``, or ``"both"``.
120
+ max_concurrent: Maximum concurrent LLM calls.
121
+ on_debug: Optional callback for real-time debug messages.
122
+ on_dump_llm: Optional callback for full prompt/response/parse dump (debug).
123
+ """
124
+ self._anthropic_key = anthropic_key
125
+ self._openai_key = openai_key
126
+ self._system_prompt = system_prompt
127
+ self._preference = provider_preference
128
+ self._max_concurrent = max_concurrent
129
+ self._on_debug = on_debug
130
+ self._on_dump_llm = on_dump_llm
131
+
132
+ # Determine provider order
133
+ self._primary, self._fallback = self._resolve_providers()
134
+
135
+ # Shared httpx clients (connection pooling)
136
+ self._anthropic_client: httpx.Client | None = None
137
+ self._openai_client: httpx.Client | None = None
138
+
139
+ if self._anthropic_key:
140
+ self._anthropic_client = httpx.Client(
141
+ base_url="https://api.anthropic.com",
142
+ headers={
143
+ "x-api-key": self._anthropic_key,
144
+ "anthropic-version": "2023-06-01",
145
+ "content-type": "application/json",
146
+ },
147
+ timeout=_LLM_TIMEOUT,
148
+ )
149
+
150
+ if self._openai_key:
151
+ self._openai_client = httpx.Client(
152
+ base_url="https://api.openai.com",
153
+ headers={
154
+ "Authorization": f"Bearer {self._openai_key}",
155
+ "Content-Type": "application/json",
156
+ },
157
+ timeout=_LLM_TIMEOUT,
158
+ )
159
+
160
+ # ------------------------------------------------------------------
161
+ # Lifecycle
162
+ # ------------------------------------------------------------------
163
+
164
+ def close(self) -> None:
165
+ """Release HTTP connection pools."""
166
+ if self._anthropic_client:
167
+ self._anthropic_client.close()
168
+ if self._openai_client:
169
+ self._openai_client.close()
170
+
171
+ def __enter__(self) -> LocalFileAuditor:
172
+ return self
173
+
174
+ def __exit__(self, *_: Any) -> None:
175
+ self.close()
176
+
177
+ # ------------------------------------------------------------------
178
+ # Provider resolution
179
+ # ------------------------------------------------------------------
180
+
181
+ def _resolve_providers(self) -> tuple[str | None, str | None]:
182
+ """
183
+ Determine the primary and fallback provider based on preference
184
+ and available keys.
185
+ """
186
+ has_anthropic = self._anthropic_key is not None
187
+ has_openai = self._openai_key is not None
188
+
189
+ if self._preference == "anthropic":
190
+ primary = "anthropic" if has_anthropic else ("openai" if has_openai else None)
191
+ fallback = "openai" if has_openai and primary == "anthropic" else None
192
+ elif self._preference == "openai":
193
+ primary = "openai" if has_openai else ("anthropic" if has_anthropic else None)
194
+ fallback = "anthropic" if has_anthropic and primary == "openai" else None
195
+ else: # "both"
196
+ primary = "anthropic" if has_anthropic else ("openai" if has_openai else None)
197
+ fallback = "openai" if has_openai and primary == "anthropic" else (
198
+ "anthropic" if has_anthropic and primary == "openai" else None
199
+ )
200
+
201
+ return primary, fallback
202
+
203
+ # ------------------------------------------------------------------
204
+ # Single-file audit
205
+ # ------------------------------------------------------------------
206
+
207
+ def audit_file(
208
+ self,
209
+ local_path: str,
210
+ file_path: str,
211
+ relative_path: str,
212
+ ) -> AuditFileResult:
213
+ """
214
+ Audit a single file: read from disk, call LLM, parse response.
215
+
216
+ Args:
217
+ local_path: Absolute path on disk to read.
218
+ file_path: The ``cli://...`` path registered with CodeDD.
219
+ relative_path: Repo-relative path for display.
220
+
221
+ Returns:
222
+ ``AuditFileResult`` with ``audit_data`` on success or ``error`` on failure.
223
+ """
224
+ # --- Read file content ---
225
+ try:
226
+ content = Path(local_path).read_text(encoding="utf-8", errors="replace")
227
+ except Exception as exc:
228
+ return AuditFileResult(
229
+ file_path=file_path,
230
+ relative_path=relative_path,
231
+ error=f"Cannot read file: {exc}",
232
+ )
233
+
234
+ if not content or not content.strip():
235
+ return AuditFileResult(
236
+ file_path=file_path,
237
+ relative_path=relative_path,
238
+ error="File is empty",
239
+ )
240
+
241
+ # Truncate oversized files (same threshold as server)
242
+ if len(content) > _MAX_CONTENT_CHARS:
243
+ content = content[:_MAX_CONTENT_CHARS]
244
+
245
+ # --- Construct prompts ---
246
+ intro = "--- Following the Code to be audited based on the system prompt ---"
247
+ user_prompt = f"{intro}\n\nCode Content:\n{content}"
248
+ combined_prompt = f"{self._system_prompt}\n\n{user_prompt}"
249
+
250
+ # --- Retry loop (mirrors server-side ai_auditor.audit_content) ---
251
+ retry_limit = 3
252
+ max_retries_per_provider = 2
253
+ anthropic_retries = 0
254
+ openai_retries = 0
255
+
256
+ self._debug(
257
+ f"[{relative_path}] Starting audit — "
258
+ f"primary={self._primary}, fallback={self._fallback}, "
259
+ f"content_len={len(content):,} chars"
260
+ )
261
+
262
+ for retry_count in range(retry_limit):
263
+ audit_data: dict | None = None
264
+
265
+ self._debug(f"[{relative_path}] Retry cycle {retry_count + 1}/{retry_limit}")
266
+
267
+ # --- Try primary provider ---
268
+ result = self._try_provider(
269
+ self._primary, user_prompt, combined_prompt,
270
+ anthropic_retries, openai_retries, max_retries_per_provider,
271
+ file_path, relative_path,
272
+ )
273
+ if result is not None:
274
+ if isinstance(result, AuditFileResult):
275
+ return result
276
+ # Unpack updated retry counters + audit_data
277
+ anthropic_retries, openai_retries, audit_data = result
278
+
279
+ # --- Try fallback provider ---
280
+ result = self._try_provider(
281
+ self._fallback, user_prompt, combined_prompt,
282
+ anthropic_retries, openai_retries, max_retries_per_provider,
283
+ file_path, relative_path,
284
+ )
285
+ if result is not None:
286
+ if isinstance(result, AuditFileResult):
287
+ return result
288
+ anthropic_retries, openai_retries, audit_data = result
289
+
290
+ # If we got a "not a script" in any attempt, accept it
291
+ if audit_data and str(audit_data.get("is_script", "")).lower() == "no":
292
+ self._debug(f"[{relative_path}] Accepted as non-script")
293
+ return AuditFileResult(
294
+ file_path=file_path,
295
+ relative_path=relative_path,
296
+ audit_data=audit_data,
297
+ provider_used=self._primary or "unknown",
298
+ )
299
+
300
+ # Exponential backoff with jitter before next outer retry
301
+ if retry_count < retry_limit - 1:
302
+ sleep_time = min(60, (2 ** retry_count) * 5 + random.uniform(0, 2))
303
+ self._debug(f"[{relative_path}] Backing off {sleep_time:.1f}s before next retry")
304
+ time.sleep(sleep_time)
305
+
306
+ self._debug(f"[{relative_path}] FAILED — all LLM attempts exhausted")
307
+ return AuditFileResult(
308
+ file_path=file_path,
309
+ relative_path=relative_path,
310
+ error="All LLM attempts exhausted",
311
+ )
312
+
313
+ def _try_provider(
314
+ self,
315
+ provider: str | None,
316
+ user_prompt: str,
317
+ combined_prompt: str,
318
+ anthropic_retries: int,
319
+ openai_retries: int,
320
+ max_retries: int,
321
+ file_path: str,
322
+ relative_path: str,
323
+ ) -> AuditFileResult | tuple[int, int, dict | None] | None:
324
+ """
325
+ Attempt one LLM call for the given provider.
326
+
327
+ Returns:
328
+ ``AuditFileResult`` on definitive success,
329
+ ``(anthropic_retries, openai_retries, audit_data)`` if the call
330
+ was made but the response needs retry,
331
+ or ``None`` if the provider was skipped (not configured / exhausted).
332
+ """
333
+ if provider is None:
334
+ return None
335
+
336
+ if provider == "anthropic" and anthropic_retries >= max_retries:
337
+ self._debug(f"[{relative_path}] Anthropic: retries exhausted ({anthropic_retries}/{max_retries})")
338
+ return None
339
+ if provider == "openai" and openai_retries >= max_retries:
340
+ self._debug(f"[{relative_path}] OpenAI: retries exhausted ({openai_retries}/{max_retries})")
341
+ return None
342
+
343
+ if provider == "anthropic":
344
+ success, response_text = self._call_anthropic(user_prompt)
345
+ anthropic_retries += 1
346
+ else:
347
+ success, response_text = self._call_openai(combined_prompt)
348
+ openai_retries += 1
349
+
350
+ if not success or not response_text:
351
+ self._debug(f"[{relative_path}] {provider}: call failed or empty response")
352
+ return (anthropic_retries, openai_retries, None)
353
+
354
+ audit_data, none_count = parse_audit_response(response_text)
355
+
356
+ # Debug dump: full prompt, raw response, and parse result
357
+ if self._on_dump_llm:
358
+ self._on_dump_llm(combined_prompt, response_text, audit_data, none_count)
359
+
360
+ if not audit_data:
361
+ self._debug(f"[{relative_path}] {provider}: parse returned None")
362
+ return (anthropic_retries, openai_retries, None)
363
+
364
+ # Early return for non-script content
365
+ if str(audit_data.get("is_script", "")).lower() == "no":
366
+ self._debug(f"[{relative_path}] {provider}: non-script content — accepted")
367
+ return AuditFileResult(
368
+ file_path=file_path,
369
+ relative_path=relative_path,
370
+ audit_data=audit_data,
371
+ provider_used=provider,
372
+ )
373
+
374
+ is_valid = is_audit_data_valid(audit_data)
375
+ is_complete = is_response_complete(response_text, audit_data)
376
+
377
+ self._debug(
378
+ f"[{relative_path}] {provider}: parsed — "
379
+ f"valid={is_valid}, complete={is_complete}, none_count={none_count}"
380
+ )
381
+
382
+ if is_valid and is_complete and none_count < 10:
383
+ self._debug(f"[{relative_path}] {provider}: SUCCESS")
384
+ return AuditFileResult(
385
+ file_path=file_path,
386
+ relative_path=relative_path,
387
+ audit_data=audit_data,
388
+ provider_used=provider,
389
+ )
390
+
391
+ return (anthropic_retries, openai_retries, audit_data)
392
+
393
+ # ------------------------------------------------------------------
394
+ # Batch audit
395
+ # ------------------------------------------------------------------
396
+
397
+ def audit_batch(
398
+ self,
399
+ files: list[dict],
400
+ scope_dirs: dict[str, str],
401
+ on_progress: Callable[[AuditFileResult], None] | None = None,
402
+ ) -> list[AuditFileResult]:
403
+ """
404
+ Audit a list of files concurrently using a thread pool.
405
+
406
+ Args:
407
+ files: List of file dicts from the audit plan. Each must have
408
+ ``file_path`` (cli:// path), ``relative_path``, and the
409
+ sub-audit's ``root_path``.
410
+ scope_dirs: Mapping of ``repo_name`` → local directory path.
411
+ on_progress: Optional callback invoked after each file completes.
412
+
413
+ Returns:
414
+ List of ``AuditFileResult`` objects (one per file).
415
+ """
416
+ results: list[AuditFileResult] = []
417
+
418
+ with ThreadPoolExecutor(max_workers=self._max_concurrent) as pool:
419
+ future_map = {}
420
+ for f in files:
421
+ file_path = f["file_path"]
422
+ relative_path = f["relative_path"]
423
+ repo_name = f.get("repo_name", "")
424
+ local_dir = scope_dirs.get(repo_name, "")
425
+
426
+ # Build local disk path from scope directory + relative path
427
+ local_path = str(Path(local_dir) / relative_path) if local_dir else ""
428
+
429
+ future = pool.submit(
430
+ self.audit_file,
431
+ local_path=local_path,
432
+ file_path=file_path,
433
+ relative_path=relative_path,
434
+ )
435
+ future_map[future] = f
436
+
437
+ for future in as_completed(future_map):
438
+ try:
439
+ result = future.result()
440
+ except Exception as exc:
441
+ info = future_map[future]
442
+ result = AuditFileResult(
443
+ file_path=info["file_path"],
444
+ relative_path=info["relative_path"],
445
+ error=str(exc),
446
+ )
447
+ results.append(result)
448
+ if on_progress:
449
+ on_progress(result)
450
+
451
+ return results
452
+
453
+ # ------------------------------------------------------------------
454
+ # LLM HTTP calls
455
+ # ------------------------------------------------------------------
456
+
457
+ def _call_anthropic(self, user_prompt: str) -> tuple[bool, str | None]:
458
+ """
459
+ Call the Anthropic Messages API with the cached system prompt.
460
+
461
+ Returns ``(success, response_text)``.
462
+ """
463
+ if not self._anthropic_client:
464
+ self._debug("Anthropic: no client configured — skipping")
465
+ return False, None
466
+
467
+ model = PROVIDER_MODELS.get("anthropic", "claude-sonnet-4-6")
468
+ prompt_len = len(user_prompt)
469
+ self._debug(f"Anthropic: calling model={model}, prompt_len={prompt_len:,} chars")
470
+
471
+ payload = {
472
+ "model": model,
473
+ "max_tokens": 8192,
474
+ "system": self._system_prompt,
475
+ "messages": [{"role": "user", "content": user_prompt}],
476
+ }
477
+
478
+ try:
479
+ resp = self._anthropic_client.post("/v1/messages", json=payload)
480
+ if resp.status_code == 200:
481
+ body = resp.json()
482
+ content_blocks = body.get("content", [])
483
+ text_parts = [
484
+ b["text"] for b in content_blocks if b.get("type") == "text"
485
+ ]
486
+ result_text = "\n".join(text_parts) if text_parts else None
487
+ resp_len = len(result_text) if result_text else 0
488
+ self._debug(f"Anthropic: OK — response_len={resp_len:,} chars")
489
+ return True, result_text
490
+
491
+ # Non-200 — log the error body for diagnostics
492
+ error_detail = self._extract_error_body(resp)
493
+ self._debug(
494
+ f"Anthropic: HTTP {resp.status_code} — {error_detail}"
495
+ )
496
+ return False, None
497
+ except httpx.TimeoutException:
498
+ self._debug("Anthropic: request timed out")
499
+ return False, None
500
+ except Exception as exc:
501
+ self._debug(f"Anthropic: exception — {type(exc).__name__}: {exc}")
502
+ return False, None
503
+
504
+ def _call_openai(self, combined_prompt: str) -> tuple[bool, str | None]:
505
+ """
506
+ Call the OpenAI Chat Completions API with the combined prompt.
507
+
508
+ Returns ``(success, response_text)``.
509
+ """
510
+ if not self._openai_client:
511
+ self._debug("OpenAI: no client configured — skipping")
512
+ return False, None
513
+
514
+ model = PROVIDER_MODELS.get("openai", "gpt-5.2")
515
+ prompt_len = len(combined_prompt)
516
+ self._debug(f"OpenAI: calling model={model}, prompt_len={prompt_len:,} chars")
517
+
518
+ payload = {
519
+ "model": model,
520
+ "messages": [{"role": "user", "content": combined_prompt}],
521
+ "max_tokens": 8192,
522
+ }
523
+
524
+ try:
525
+ resp = self._openai_client.post("/v1/chat/completions", json=payload)
526
+ if resp.status_code == 200:
527
+ body = resp.json()
528
+ choices = body.get("choices", [])
529
+ if choices:
530
+ result_text = choices[0].get("message", {}).get("content")
531
+ resp_len = len(result_text) if result_text else 0
532
+ self._debug(f"OpenAI: OK — response_len={resp_len:,} chars")
533
+ return True, result_text
534
+ self._debug("OpenAI: 200 but no choices in response")
535
+ return True, None
536
+
537
+ # Non-200 — log the error body for diagnostics
538
+ error_detail = self._extract_error_body(resp)
539
+ self._debug(
540
+ f"OpenAI: HTTP {resp.status_code} — {error_detail}"
541
+ )
542
+ return False, None
543
+ except httpx.TimeoutException:
544
+ self._debug("OpenAI: request timed out")
545
+ return False, None
546
+ except Exception as exc:
547
+ self._debug(f"OpenAI: exception — {type(exc).__name__}: {exc}")
548
+ return False, None
549
+
550
+ # ------------------------------------------------------------------
551
+ # Debug helpers
552
+ # ------------------------------------------------------------------
553
+
554
+ def _debug(self, msg: str) -> None:
555
+ """Emit a debug message via the logger and via the on_debug callback."""
556
+ logger.debug(msg)
557
+ if self._on_debug:
558
+ self._on_debug(msg)
559
+
560
+ @staticmethod
561
+ def _extract_error_body(resp: httpx.Response) -> str:
562
+ """Extract a short error description from a non-200 response."""
563
+ try:
564
+ body = resp.json()
565
+ # Anthropic format
566
+ err = body.get("error", {})
567
+ if isinstance(err, dict):
568
+ return err.get("message", str(body)[:200])
569
+ # OpenAI format
570
+ return str(body)[:200]
571
+ except Exception:
572
+ return resp.text[:200] if resp.text else "(empty body)"