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