codebase-explorer 2.2.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 (47) hide show
  1. cbe/__init__.py +9 -0
  2. cbe/__main__.py +4 -0
  3. cbe/aggregation.py +183 -0
  4. cbe/api_completion.py +410 -0
  5. cbe/batching.py +283 -0
  6. cbe/catalog_query.py +376 -0
  7. cbe/checks.py +390 -0
  8. cbe/cli.py +434 -0
  9. cbe/concrete_facts.py +321 -0
  10. cbe/data/LICENSE.tiktoken +30 -0
  11. cbe/data/o200k_base.tiktoken +199998 -0
  12. cbe/declarations.py +537 -0
  13. cbe/docs.py +929 -0
  14. cbe/generate.py +316 -0
  15. cbe/grammars.py +741 -0
  16. cbe/graph.py +857 -0
  17. cbe/graph_backends/__init__.py +43 -0
  18. cbe/graph_backends/_calls.py +262 -0
  19. cbe/graph_backends/_paths.py +42 -0
  20. cbe/graph_backends/identity.py +93 -0
  21. cbe/graph_backends/imports.py +256 -0
  22. cbe/graph_backends/joern.py +1021 -0
  23. cbe/graph_backends/resolution.py +288 -0
  24. cbe/graph_backends/toolchain.py +396 -0
  25. cbe/grounding.py +57 -0
  26. cbe/host_run.py +1752 -0
  27. cbe/model_settings.py +80 -0
  28. cbe/projection.py +305 -0
  29. cbe/prompts.py +316 -0
  30. cbe/records.py +200 -0
  31. cbe/skeleton.py +474 -0
  32. cbe/source_context.py +709 -0
  33. cbe/symbols.py +136 -0
  34. cbe/token_budget.py +68 -0
  35. cbe/treename.py +398 -0
  36. cbe/usage.py +300 -0
  37. cbe_install/__init__.py +11 -0
  38. cbe_install/cli.py +434 -0
  39. cbe_install/resources/agents/cbe-writer.md +16 -0
  40. cbe_install/resources/codebase-explorer/SKILL.md +101 -0
  41. cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
  42. cbe_install/resources/codebase-explorer/references/codex.md +37 -0
  43. codebase_explorer-2.2.0.dist-info/METADATA +233 -0
  44. codebase_explorer-2.2.0.dist-info/RECORD +47 -0
  45. codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
  46. codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
  47. codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
cbe/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Codebase Explorer semantic documentation product."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("codebase-explorer")
7
+ except PackageNotFoundError:
8
+ # Source checkout without installed distribution metadata.
9
+ __version__ = "source"
cbe/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from cbe.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
cbe/aggregation.py ADDED
@@ -0,0 +1,183 @@
1
+ """Unit records and edges for the tree, and the relationship view of a finished tree.
2
+
3
+ The tree itself comes from ``skeleton`` (structure) and ``treename`` (names).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from collections import Counter, defaultdict
8
+
9
+ from cbe.records import build_records, frozen_sources
10
+ from cbe.docs import unit_ranges
11
+
12
+
13
+ def inputs(units: dict, dispositions: dict, inventory: dict, context: dict) -> tuple[dict, list[dict]]:
14
+ """Use the same source-coordinate ownership as the published catalog."""
15
+ ranges = {uid: unit_ranges(uid, unit, inventory) for uid, unit in units.items()}
16
+ edge_table = {}
17
+ records, _ = build_records(
18
+ units, dispositions, inventory, ranges, frozen_sources(context, list(inventory)),
19
+ {f["path"]: f.get("language", "unknown") for f in context.get("files", [])},
20
+ context, context.get("graph"), edge_table)
21
+ return records, unit_edges(records, edge_table)
22
+
23
+
24
+ def unit_edges(records: dict, edge_table: dict) -> list[dict]:
25
+ """Roll resolved raw edges through the catalog's folded-symbol ownership."""
26
+ owners = {sid: uid for uid, row in records.items()
27
+ for sid in [uid] + row.get("folded_members", [])}
28
+ counts = Counter()
29
+ for edge in edge_table.values():
30
+ source, target = owners.get(edge.get("source_symbol")), owners.get(edge.get("target_symbol"))
31
+ if edge.get("status") == "resolved" and source and target and source != target:
32
+ counts[source, target, edge["kind"]] += 1
33
+ # File-level imports without two source-coordinate unit owners are not
34
+ # unit edges: they are never attributed to an arbitrary function in the imported file.
35
+ for uid, unit in records.items():
36
+ for relation in unit.get("relations") or []:
37
+ target = owners.get(relation.get("target"))
38
+ if target and target != uid:
39
+ counts[uid, target, "relation"] += 1
40
+ return [{"from": a, "to": b, "kind": kind, "count": n,
41
+ **({"provenance": "model_inferred"} if kind == "relation" else {})}
42
+ for (a, b, kind), n in sorted(counts.items())]
43
+
44
+
45
+ def roll_up_edges(edges: list[dict], membership: dict[str, str]) -> list[dict]:
46
+ counts = Counter()
47
+ for e in edges:
48
+ a, b = membership.get(e["from"]), membership.get(e["to"])
49
+ if a and b and a != b:
50
+ counts[a, b, e["kind"]] += e["count"]
51
+ return [{"from": a, "to": b, "kind": k, "count": n,
52
+ **({"provenance": "model_inferred"} if k == "relation" else {})}
53
+ for (a, b, k), n in sorted(counts.items())]
54
+
55
+
56
+ def relationship_view(tree: dict, edges: list[dict], graph: dict | None, *, graph_only_limit: int = 40,
57
+ within_containment_limit: int = 40) -> dict:
58
+ """Compare disjoint subtrees; expose calls inside structural containment.
59
+
60
+ Tree containment pairs count ancestor direct units versus descendant subtree.
61
+ Graph-only pairs use direct primary ownership to avoid repeating edges at
62
+ every ancestor level. Only resolved call/import inputs are graph evidence.
63
+ """
64
+ from cbe.graph import graph_state
65
+
66
+ state, reason = graph_state(graph)
67
+ membership = {uid: n["id"] for n in tree.get("nodes", []) for uid in n.get("unit_ids", [])}
68
+ resolved = [e for e in edges if e["kind"] in {"call", "import"}
69
+ and e.get("provenance") != "model_inferred"] if state == "provided" else []
70
+ nodes = {n["id"]: n for n in tree.get("nodes", [])}
71
+ ancestors = {}
72
+ for nid in nodes:
73
+ lineage, parent = set(), nodes[nid].get("parent")
74
+ while parent in nodes and parent not in lineage:
75
+ lineage.add(parent)
76
+ parent = nodes[parent].get("parent")
77
+ ancestors[nid] = lineage
78
+ def contains(a, b):
79
+ return a in ancestors[b] or b in ancestors[a]
80
+ subtree_units = {nid: {uid for uid, owner in membership.items()
81
+ if owner == nid or nid in ancestors[owner]} for nid in nodes}
82
+ counts = Counter()
83
+ kinds = defaultdict(Counter)
84
+ for edge in roll_up_edges(resolved, membership):
85
+ pair = edge["from"], edge["to"]
86
+ counts[pair] += edge["count"]
87
+ kinds[pair][edge["kind"]] += edge["count"]
88
+ node_ids = set(nodes)
89
+ model = {(e["from"], e["to"]): e.get("basis", "") for e in tree.get("depends_on", [])
90
+ if e.get("from") in node_ids and e.get("to") in node_ids and e["from"] != e["to"]}
91
+ unmentioned = counts.keys() - model.keys()
92
+ graph_only = sorted((p for p in unmentioned if not contains(*p)), key=lambda p: (-counts[p], p))
93
+ within_containment = sorted((p for p in unmentioned if contains(*p)), key=lambda p: (-counts[p], p))
94
+ reverse_counts = {}
95
+ inferred_counts = {}
96
+ relations = [e for e in edges if e["kind"] == "relation"]
97
+ for a, b in model:
98
+ if contains(a, b):
99
+ ancestor, descendant = (a, b) if a in ancestors[b] else (b, a)
100
+ pair_membership = {uid: ancestor for uid in nodes[ancestor].get("unit_ids", [])}
101
+ pair_membership.update({uid: descendant for uid in subtree_units[descendant]})
102
+ else:
103
+ pair_membership = {uid: nid for nid in (a, b) for uid in subtree_units[nid]}
104
+ rolled = roll_up_edges(resolved, pair_membership)
105
+ pair_kinds = Counter()
106
+ reverse_counts[a, b] = sum(edge["count"] for edge in rolled
107
+ if (edge["from"], edge["to"]) == (b, a))
108
+ for edge in rolled:
109
+ if (edge["from"], edge["to"]) == (a, b):
110
+ pair_kinds[edge["kind"]] += edge["count"]
111
+ counts[a, b] = sum(pair_kinds.values())
112
+ kinds[a, b] = pair_kinds
113
+ inferred_counts[a, b] = sum(edge["count"] for edge in roll_up_edges(relations, pair_membership)
114
+ if (edge["from"], edge["to"]) == (a, b))
115
+ pairs = sorted(model) + graph_only[:graph_only_limit] + within_containment[:within_containment_limit]
116
+ dependencies = [{"from": a, "to": b,
117
+ "source": "containment" if contains(a, b) and (a, b) in model else
118
+ "graph_confirmed" if counts[a, b] and (a, b) in model else
119
+ "model_inferred" if (a, b) in model else
120
+ "within_containment" if contains(a, b) else "graph_only",
121
+ "count": counts[a, b],
122
+ **({"reverse_count": reverse_counts[a, b]} if contains(a, b) and (a, b) in model else {}),
123
+ "inferred_count": inferred_counts.get((a, b), 0),
124
+ "containment": contains(a, b), "kinds": dict(sorted(kinds[a, b].items())),
125
+ "basis": model.get((a, b), ""), "description": ""} for a, b in pairs]
126
+ totals = {source: sum(e["source"] == source for e in dependencies)
127
+ for source in ("graph_confirmed", "model_inferred", "graph_only")}
128
+ totals["graph_only"] = len(graph_only)
129
+ return {"dependencies": dependencies, "counts": totals,
130
+ "within_containment_count": len(within_containment),
131
+ "within_containment_omitted": max(0, len(within_containment) - within_containment_limit),
132
+ "graph_only_omitted": max(0, len(graph_only) - graph_only_limit),
133
+ "graph_state": state, "graph_reason": reason,
134
+ "candidate_flows": candidate_flows(resolved)}
135
+
136
+
137
+ def candidate_flows(edges: list[dict], *, max_entries: int = 12, max_depth: int = 6,
138
+ max_flows: int = 24) -> list[list[str]]:
139
+ """Stable bounded DFS from zero-incoming units, ranked by outgoing count.
140
+
141
+ Depth counts units (six steps). Cycles end at the last unique unit. Pure
142
+ cyclic components have no entry and intentionally yield no candidates.
143
+ """
144
+ adjacency = defaultdict(Counter)
145
+ incoming = set()
146
+ for e in edges:
147
+ if e["kind"] == "call" and e.get("provenance") != "model_inferred" and e["from"] != e["to"]:
148
+ adjacency[e["from"]][e["to"]] += e["count"]
149
+ incoming.add(e["to"])
150
+ entries = sorted(adjacency.keys() - incoming,
151
+ key=lambda uid: (-sum(adjacency[uid].values()), uid))[:max_entries]
152
+ result = []
153
+ def visit(path):
154
+ if len(result) >= max_flows:
155
+ return
156
+ targets = sorted((uid for uid in adjacency[path[-1]] if uid not in path),
157
+ key=lambda uid: (-adjacency[path[-1]][uid], uid))
158
+ if len(path) >= max_depth or not targets:
159
+ if len(path) > 1:
160
+ result.append(path)
161
+ return
162
+ for uid in targets:
163
+ if len(result) >= max_flows:
164
+ break
165
+ visit(path + [uid])
166
+ for uid in entries:
167
+ visit([uid])
168
+ return result
169
+
170
+
171
+ def annotate_relationships(view: dict, system: dict, edges: list[dict]) -> dict:
172
+ """Publish model prose separately from the program's evidence labels."""
173
+ descriptions = {(e["from"], e["to"]): e["description"]
174
+ for e in system.get("dependency_descriptions", [])}
175
+ calls = {(e["from"], e["to"]) for e in edges if e["kind"] == "call"
176
+ and e.get("provenance") != "model_inferred"} if view["graph_state"] == "provided" else set()
177
+ flows = []
178
+ for flow in system.get("key_flows", []):
179
+ steps = flow["steps"]
180
+ flows.append({**flow, "inferred_steps": [i for i in range(1, len(steps))
181
+ if (steps[i-1], steps[i]) not in calls]})
182
+ return {**view, "dependencies": [{**e, "description": descriptions.get((e["from"], e["to"]), "")}
183
+ for e in view["dependencies"]], "key_flows": flows}
cbe/api_completion.py ADDED
@@ -0,0 +1,410 @@
1
+ """OpenAI-compatible HTTP completion client for the CBE script driver.
2
+
3
+ This is a thin, tool-free client for any endpoint that speaks the OpenAI
4
+ ``POST {base_url}/chat/completions`` shape (OpenAI, Azure OpenAI-compatible
5
+ gateways, Command Code, local vLLM/Ollama OpenAI shims). It deliberately does
6
+ not touch the product's system provider entry or any agent harness; it is the
7
+ completion call of the ``generate.py`` driver, and it returns a
8
+ :class:`Completion` record that the shared state, retry and usage accounting
9
+ consume.
10
+
11
+ Secrets: the API key is read from an environment variable (default
12
+ ``OPENAI_API_KEY``) or from a standard config file path. The key is never
13
+ written to the request log, the raw archive, the CSV row or any error string,
14
+ and an error body that happens to reflect it is redacted. Endpoint and model
15
+ are both configurable; usage is reported when the endpoint returns it and left
16
+ unknown otherwise.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import csv
22
+ import json
23
+ import os
24
+ import random
25
+ import re
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from dataclasses import dataclass
30
+ from email.utils import parsedate_to_datetime
31
+ from pathlib import Path
32
+ from threading import Lock
33
+ from typing import Any
34
+
35
+ from cbe.checks import json_object
36
+
37
+ from cbe.model_settings import ApiConfigError, DEFAULT_API_CONFIG, OPTIONAL_REQUEST_KEYS, load_config
38
+
39
+
40
+ #: calls.csv columns; unknown usage stays an empty field, never zero.
41
+ FIELDS = ("task", "attempt", "input_tokens", "output_tokens", "cache_tokens", "result", "seconds")
42
+ CSV_LOCK = Lock()
43
+
44
+
45
+ @dataclass
46
+ class Completion:
47
+ value: dict[str, Any] | None
48
+ error: str | None
49
+ raw_text: str
50
+ usage: dict[str, int | None] | None
51
+ seconds: float
52
+
53
+
54
+ def _csv_row(path: Path, row: dict[str, Any]) -> None:
55
+ with CSV_LOCK:
56
+ first = not path.exists()
57
+ with path.open("a", encoding="utf-8", newline="") as stream:
58
+ writer = csv.DictWriter(stream, fieldnames=FIELDS)
59
+ if first:
60
+ writer.writeheader()
61
+ writer.writerow({field: row.get(field, "") for field in FIELDS})
62
+
63
+
64
+ #: HTTP 429, 502, 503 and 504 are provider pushback or a gateway hiccup, not answers: the call waits and repeats in place.
65
+ #: A repeat does not use up an answer attempt; each one is a ``rate_limited`` row in calls.csv.
66
+ RATE_LIMIT_STATUSES = (429, 502, 503, 504)
67
+ RATE_LIMIT_RETRIES = 6
68
+ BACKOFF_START_SECONDS = 5
69
+ BACKOFF_MAX_SECONDS = 120
70
+ RATE_LIMIT_BODY_CHARS = 300
71
+ _sleep = time.sleep
72
+
73
+
74
+ def _retry_delay(retry_after: str | None, retry: int) -> float:
75
+ """Seconds to wait before repeat number ``retry`` (from 0): the server's
76
+ ``Retry-After`` when it gives one, else 5 s doubling to 120 s; both with jitter."""
77
+ seconds: float | None = None
78
+ if retry_after:
79
+ try:
80
+ seconds = float(retry_after)
81
+ except ValueError:
82
+ try:
83
+ seconds = parsedate_to_datetime(retry_after).timestamp() - time.time()
84
+ except (TypeError, ValueError):
85
+ seconds = None
86
+ if seconds is None:
87
+ seconds = BACKOFF_START_SECONDS * 2 ** retry
88
+ seconds = min(max(seconds, 0.0), BACKOFF_MAX_SECONDS)
89
+ return min(seconds + random.uniform(0, max(1.0, seconds * 0.2)), BACKOFF_MAX_SECONDS)
90
+
91
+
92
+ def _post(request: urllib.request.Request, timeout: int, *, task: str, attempt: int, csv_path: Path,
93
+ secrets: list[str], pushback: list[dict[str, Any]], clock: dict[str, float]) -> tuple[int, bytes]:
94
+ """POST once, repeating in place after HTTP 429/502/503/504. ``pushback`` collects what each refusal said;
95
+ ``clock['started']`` is reset so a row's seconds cover its own request, not the wait before it."""
96
+ for retry in range(RATE_LIMIT_RETRIES + 1):
97
+ clock["started"] = time.monotonic()
98
+ try:
99
+ with urllib.request.urlopen(request, timeout=timeout) as response:
100
+ return response.status, response.read(MAX_RESPONSE_BYTES + 1)
101
+ except urllib.error.HTTPError as exc:
102
+ if exc.code not in RATE_LIMIT_STATUSES:
103
+ raise
104
+ try:
105
+ body = _redact(exc.read(2048).decode("utf-8", "replace"), secrets)[:RATE_LIMIT_BODY_CHARS]
106
+ except OSError:
107
+ body = ""
108
+ retry_after = exc.headers.get("Retry-After") if exc.headers else None
109
+ pushback.append({"status": exc.code, "retry_after": retry_after, "body": body})
110
+ if retry == RATE_LIMIT_RETRIES:
111
+ raise
112
+ delay = _retry_delay(retry_after, retry)
113
+ pushback[-1]["waited_seconds"] = round(delay, 1)
114
+ _csv_row(csv_path, {"task": task, "attempt": attempt, "result": "rate_limited",
115
+ "seconds": round(time.monotonic() - clock["started"], 3)})
116
+ _sleep(delay)
117
+ raise AssertionError("unreachable") # pragma: no cover
118
+
119
+
120
+ MAX_RESPONSE_BYTES = 8 * 1024 * 1024
121
+ #: Bounded copy of the provider response kept in the durable ``.api.json`` record.
122
+ #: Larger bodies are explicitly truncated, never silently dropped.
123
+ MAX_PRESERVED_RESPONSE_CHARS = 20000
124
+
125
+
126
+ def _api_key(config: dict[str, Any]) -> str | None:
127
+ """Read the key from the environment first, then an optional file.
128
+
129
+ The value is returned to build the Authorization header only; callers must
130
+ never log it."""
131
+ env_name = config.get("api_key_env") or "OPENAI_API_KEY"
132
+ key = os.environ.get(env_name)
133
+ if key:
134
+ return key.strip()
135
+ key_file = config.get("api_key_file")
136
+ if key_file:
137
+ try:
138
+ return Path(key_file).read_text(encoding="utf-8").strip() or None
139
+ except OSError:
140
+ return None
141
+ return None
142
+
143
+
144
+ def endpoint_url(base_url: str) -> str:
145
+ return base_url.rstrip("/") + "/chat/completions"
146
+
147
+
148
+ def _safe_endpoint(base_url: str) -> str:
149
+ """A log-safe endpoint: scheme, host and path only.
150
+
151
+ A base URL can carry userinfo or a credential query; neither belongs in a
152
+ durable artifact."""
153
+ from urllib.parse import urlsplit
154
+
155
+ parts = urlsplit(base_url)
156
+ host = parts.hostname or ""
157
+ if parts.port:
158
+ host = f"{host}:{parts.port}"
159
+ return f"{parts.scheme}://{host}{parts.path}" if parts.scheme else parts.path
160
+
161
+
162
+ def _redact(text: str, secrets: list[str]) -> str:
163
+ redacted = text
164
+ for secret in secrets:
165
+ if secret:
166
+ redacted = redacted.replace(secret, "[redacted]")
167
+ return redacted
168
+
169
+
170
+ def _request_body(model: str, prompt: str, config: dict[str, Any]) -> dict[str, Any]:
171
+ body: dict[str, Any] = {
172
+ "model": model,
173
+ "messages": [{"role": "user", "content": prompt}],
174
+ "temperature": config.get("temperature", 0),
175
+ }
176
+ if config.get("output_max_tokens") is not None:
177
+ body["max_tokens"] = config["output_max_tokens"]
178
+ for key in OPTIONAL_REQUEST_KEYS:
179
+ if key in config and config[key] is not None:
180
+ body[key] = config[key]
181
+ return body
182
+
183
+
184
+ def _usage(payload: dict[str, Any]) -> dict[str, int] | None:
185
+ """Map provider usage onto the product convention: ``input`` is non-cached
186
+ prompt tokens and ``cache`` is the cached portion.
187
+
188
+ OpenAI reports ``prompt_tokens`` as the total including cached tokens, so
189
+ subtracting the cached count avoids double counting it in delivery
190
+ accounting. The raw provider object is kept separately by the caller."""
191
+ usage = payload.get("usage")
192
+ if not isinstance(usage, dict):
193
+ return None
194
+ prompt = usage.get("prompt_tokens")
195
+ completion = usage.get("completion_tokens")
196
+ if type(prompt) is not int or type(completion) is not int:
197
+ return None
198
+ details = usage.get("prompt_tokens_details") or {}
199
+ cached = details.get("cached_tokens") if isinstance(details, dict) else None
200
+ if type(cached) is not int:
201
+ return {"input": prompt, "output": completion, "cache": None}
202
+ return {"input": max(prompt - cached, 0), "output": completion, "cache": cached}
203
+
204
+
205
+ def _extract_text(payload: dict[str, Any]) -> str:
206
+ choices = payload.get("choices")
207
+ if not isinstance(choices, list) or not choices:
208
+ raise ValueError("response has no choices")
209
+ choice = choices[0]
210
+ if not isinstance(choice, dict):
211
+ raise ValueError("response choice is not an object")
212
+ message = choice.get("message")
213
+ if isinstance(message, dict):
214
+ content = message.get("content")
215
+ if isinstance(content, str) and content.strip():
216
+ return content
217
+ if isinstance(content, list):
218
+ # OpenAI multimodal-style content parts: keep the text parts.
219
+ parts = [part.get("text") for part in content
220
+ if isinstance(part, dict) and isinstance(part.get("text"), str)]
221
+ joined = "".join(parts)
222
+ if joined.strip():
223
+ return joined
224
+ text = choice.get("text")
225
+ if isinstance(text, str) and text.strip():
226
+ return text
227
+ finish = choice.get("finish_reason")
228
+ raise ValueError(f"response choice has no text content (finish_reason={finish!r})")
229
+
230
+
231
+ def _choice_summary(payload: dict[str, Any]) -> dict[str, Any]:
232
+ """A small, secret-free view of the first choice for durable evidence."""
233
+ choices = payload.get("choices")
234
+ if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
235
+ return {"choices": len(choices) if isinstance(choices, list) else None}
236
+ choice = choices[0]
237
+ message = choice.get("message") if isinstance(choice.get("message"), dict) else {}
238
+ content = message.get("content")
239
+ tool_calls = message.get("tool_calls")
240
+ return {
241
+ "finish_reason": choice.get("finish_reason"),
242
+ "message_role": message.get("role"),
243
+ "content_type": type(content).__name__,
244
+ "content_chars": len(content) if isinstance(content, (str, list, dict)) else 0,
245
+ "content_nonempty": isinstance(content, str) and bool(content.strip()),
246
+ "reasoning_content_present": bool(message.get("reasoning_content")),
247
+ "tool_calls": len(tool_calls) if isinstance(tool_calls, list) else 0,
248
+ "refusal_present": message.get("refusal") is not None,
249
+ }
250
+
251
+
252
+ def _classify_no_text(choice: dict[str, Any] | None) -> str:
253
+ """Classify an empty-text response from what the provider actually said."""
254
+ choice = choice or {}
255
+ finish = choice.get("finish_reason")
256
+ if finish == "length":
257
+ return "output_limit:provider hit the output token limit and returned no usable text"
258
+ if finish == "content_filter":
259
+ return "content_filtered:provider filtered the response"
260
+ if choice.get("tool_calls"):
261
+ return "tool_call_only:provider returned tool calls but no text; this host is tool-free"
262
+ return (f"no_content:response choice has no text (finish_reason={finish!r}, "
263
+ f"content_type={choice.get('content_type')!r})")
264
+
265
+
266
+ def _provider_evidence(payload: Any, raw_body: str, secrets: list[str],
267
+ body_truncated: bool) -> dict[str, Any]:
268
+ """A bounded, redacted copy of what the provider returned, parsed or not.
269
+
270
+ This is written before text extraction so a failed/empty/malformed reply is
271
+ still diagnosable; the tiny view never carries a key or credential."""
272
+ rendered = raw_body
273
+ if payload is not None:
274
+ try:
275
+ rendered = json.dumps(payload, ensure_ascii=False)
276
+ except (TypeError, ValueError):
277
+ rendered = raw_body
278
+ redacted = _redact(rendered, secrets)
279
+ truncated = len(redacted) > MAX_PRESERVED_RESPONSE_CHARS
280
+ return {"body_truncated": body_truncated, "truncated": truncated,
281
+ "original_chars": len(redacted),
282
+ "text": redacted[:MAX_PRESERVED_RESPONSE_CHARS]}
283
+
284
+
285
+ def invoke(*, task: str, attempt: int, prompt: str, run_dir: Path,
286
+ model: str, config: Path, timeout: int, csv_path: Path,
287
+ overrides: dict[str, Any] | None = None) -> Completion:
288
+ safe_task = re.sub(r"[^a-zA-Z0-9_.-]", "_", task)
289
+ raw_dir = run_dir / "raw"
290
+ raw_dir.mkdir(parents=True, exist_ok=True)
291
+ clock = {"started": time.monotonic()}
292
+ pushback: list[dict[str, Any]] = []
293
+ error: str | None = None
294
+ text = ""
295
+ usage: dict[str, int] | None = None
296
+ provider_usage: dict[str, Any] | None = None
297
+ choice: dict[str, Any] | None = None
298
+ finish_reason: Any = None
299
+ provider_evidence: dict[str, Any] | None = None
300
+ actual_model: str | None = None
301
+ status: int | None = None
302
+ log_endpoint = ""
303
+ secrets: list[str] = []
304
+ settings: dict[str, Any] = {}
305
+ try:
306
+ settings = load_config(config, model)
307
+ if overrides:
308
+ settings.update(overrides)
309
+ base_url = settings.get("base_url")
310
+ if not isinstance(base_url, str) or not base_url:
311
+ raise ApiConfigError("API config needs a base_url")
312
+ if not model:
313
+ raise ApiConfigError("model is required for the API host")
314
+ log_endpoint = _safe_endpoint(base_url)
315
+ key = _api_key(settings)
316
+ request_target = endpoint_url(base_url)
317
+ body = json.dumps(_request_body(model, prompt, settings)).encode("utf-8")
318
+ headers = {"Content-Type": "application/json",
319
+ "User-Agent": "codebase-explorer/api-completion"}
320
+ # An endpoint may need no authentication at all (a local proxy). When
321
+ # no key is configured the Authorization header is omitted, and the
322
+ # server's real 401/429 is classified downstream; a dummy secret is
323
+ # never invented to force a header.
324
+ if key:
325
+ headers["Authorization"] = f"Bearer {key}"
326
+ secrets.append(key)
327
+ for name, value in (settings.get("extra_headers") or {}).items():
328
+ if isinstance(name, str) and isinstance(value, str):
329
+ headers[name] = value
330
+ secrets.append(value)
331
+ request = urllib.request.Request(request_target, data=body, headers=headers, method="POST")
332
+ status, raw = _post(request, timeout, task=task, attempt=attempt, csv_path=csv_path,
333
+ secrets=secrets, pushback=pushback, clock=clock)
334
+ body_truncated = len(raw) > MAX_RESPONSE_BYTES
335
+ raw_body = raw[:MAX_RESPONSE_BYTES].decode("utf-8", "replace")
336
+ payload: Any = None
337
+ try:
338
+ payload = json.loads(raw_body)
339
+ except (ValueError, json.JSONDecodeError):
340
+ payload = None
341
+ # Preserve the bounded, redacted provider evidence and usage BEFORE text
342
+ # extraction, so an empty/malformed reply is still diagnosable.
343
+ provider_evidence = _provider_evidence(payload, raw_body, secrets, body_truncated)
344
+ if isinstance(payload, dict):
345
+ actual_model = payload.get("model")
346
+ raw_usage = payload.get("usage")
347
+ provider_usage = raw_usage if isinstance(raw_usage, dict) else None
348
+ usage = _usage(payload)
349
+ choice = _choice_summary(payload)
350
+ finish_reason = choice.get("finish_reason")
351
+ try:
352
+ text = _extract_text(payload)
353
+ except ValueError:
354
+ error = _classify_no_text(choice)
355
+ if finish_reason in {"length", "max_tokens", "max_output_tokens"} or body_truncated:
356
+ error = "output_limit:provider response was truncated; retry with a larger limit or smaller batches"
357
+ else:
358
+ error = f"invalid_json:provider body is not a JSON object ({len(raw_body)} chars)"
359
+ except ApiConfigError as exc:
360
+ error = f"api_config:{_redact(str(exc), secrets)}"
361
+ except urllib.error.HTTPError as exc:
362
+ status = exc.code
363
+ try:
364
+ detail = _redact(exc.read(2048).decode("utf-8", "replace"), secrets)[:160]
365
+ except OSError:
366
+ detail = ""
367
+ # Never echo an Authorization header; the body is a provider message
368
+ # that may reflect the key, so it is redacted first.
369
+ if exc.code == 401:
370
+ error = "provider_auth"
371
+ elif exc.code in RATE_LIMIT_STATUSES:
372
+ error = "provider_limit" # only after every repeat in place was refused
373
+ else:
374
+ error = f"provider_error:http_{exc.code}:{detail}"
375
+ except urllib.error.URLError as exc:
376
+ error = f"api_network:{type(exc.reason).__name__ if exc.reason else 'URLError'}"
377
+ except TimeoutError:
378
+ error = "timeout"
379
+ except (ValueError, json.JSONDecodeError) as exc:
380
+ error = f"invalid_json:{_redact(str(exc), secrets)[:160]}"
381
+ except OSError as exc:
382
+ error = f"api_io:{type(exc).__name__}:{_redact(str(exc), secrets)[:120]}"
383
+ elapsed = time.monotonic() - clock["started"]
384
+
385
+ if error is None:
386
+ try:
387
+ value = json_object(text)
388
+ except (ValueError, json.JSONDecodeError) as exc:
389
+ error = f"invalid_json:{_redact(str(exc), secrets)[:160]}"
390
+ (raw_dir / f"{safe_task}-{attempt}.api.json").write_text(
391
+ json.dumps({"endpoint": log_endpoint, "model": model, "status": status,
392
+ "actual_model": actual_model,
393
+ "request_settings": {key: settings[key] for key in
394
+ ("output_max_tokens", "reasoning_effort", "response_format") if key in settings},
395
+ "timeout": timeout,
396
+ "finish_reason": finish_reason, "choice": choice,
397
+ "usage": usage, "provider_usage": provider_usage,
398
+ "provider_response": provider_evidence,
399
+ "rate_limited": pushback,
400
+ "usage_basis": "provider prompt_tokens minus cached_tokens maps to input; "
401
+ "cache holds the cached portion",
402
+ "error": error, "seconds": round(elapsed, 3)},
403
+ ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
404
+ (raw_dir / f"{safe_task}-{attempt}.stdout").write_text(text, encoding="utf-8")
405
+ _csv_row(csv_path, {"task": task, "attempt": attempt,
406
+ "input_tokens": usage["input"] if usage else "",
407
+ "output_tokens": usage["output"] if usage else "",
408
+ "cache_tokens": (usage["cache"] if usage and usage["cache"] is not None else ""),
409
+ "result": error or "ok", "seconds": round(elapsed, 3)})
410
+ return Completion(value if error is None else None, error, text, usage, elapsed)