graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/io.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Safe file-writing helpers for Graphite artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
12
|
+
"""Atomically replace a text file after fsyncing the temporary file."""
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
|
|
15
|
+
tmp_path = Path(tmp_name)
|
|
16
|
+
try:
|
|
17
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
18
|
+
f.write(text)
|
|
19
|
+
f.flush()
|
|
20
|
+
os.fsync(f.fileno())
|
|
21
|
+
os.replace(tmp_path, path)
|
|
22
|
+
except Exception:
|
|
23
|
+
try:
|
|
24
|
+
tmp_path.unlink(missing_ok=True)
|
|
25
|
+
finally:
|
|
26
|
+
raise
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def atomic_write_json(path: Path, data: Any, *, indent: int | None = 2) -> None:
|
|
30
|
+
"""Atomically write JSON with deterministic UTF-8 encoding."""
|
|
31
|
+
text = json.dumps(data, ensure_ascii=False, indent=indent)
|
|
32
|
+
atomic_write_text(path, text)
|
graphite/listing.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Render a bounded list so that emptiness and truncation are always visible.
|
|
2
|
+
|
|
3
|
+
Every human-output listing goes through :func:`listing_lines`. Two guarantees
|
|
4
|
+
hold by construction: a header is never printed over an empty body, and a
|
|
5
|
+
capped list always names how many items it dropped.
|
|
6
|
+
|
|
7
|
+
See docs/superpowers/specs/2026-07-26-listing-marker-contract-design.md.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = ["listing_lines"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def listing_lines(
|
|
19
|
+
values: Sequence[Any],
|
|
20
|
+
render: Callable[[Any], str] = str,
|
|
21
|
+
*,
|
|
22
|
+
header: str | None = None,
|
|
23
|
+
cap: int | None = None,
|
|
24
|
+
indent: str = " ",
|
|
25
|
+
empty: str | None = "none found",
|
|
26
|
+
more_hint: str | None = None,
|
|
27
|
+
) -> list[str]:
|
|
28
|
+
"""Lines for one bounded listing: header, body, and a truncation marker.
|
|
29
|
+
|
|
30
|
+
``cap`` of None (or <= 0) means no cap. ``empty=None`` means the whole
|
|
31
|
+
listing is omitted when there is nothing to show -- header included, since
|
|
32
|
+
a header over an empty body is the defect this module exists to prevent.
|
|
33
|
+
``more_hint`` is appended verbatim; callers supply their own separator.
|
|
34
|
+
"""
|
|
35
|
+
if not values and empty is None:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
lines: list[str] = []
|
|
39
|
+
if header is not None:
|
|
40
|
+
lines.append(header)
|
|
41
|
+
|
|
42
|
+
if not values:
|
|
43
|
+
lines.append(f"{indent}- {empty}")
|
|
44
|
+
return lines
|
|
45
|
+
|
|
46
|
+
shown = list(values) if cap is None or cap <= 0 else list(values[:cap])
|
|
47
|
+
lines.extend(f"{indent}- {render(value)}" for value in shown)
|
|
48
|
+
dropped = len(values) - len(shown)
|
|
49
|
+
if dropped > 0:
|
|
50
|
+
lines.append(f"{indent}... {dropped} more{more_hint or ''}")
|
|
51
|
+
return lines
|
graphite/llm.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""Provider-agnostic LLM enrichment primitives for explicit overlays only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Literal, Protocol
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
|
|
15
|
+
CANONICAL_ENRICHMENT_MIGRATION_MESSAGE = (
|
|
16
|
+
"[graphite] canonical graph commands do not accept LLM enrichment; "
|
|
17
|
+
"use 'graphite overlay build' after building the canonical graph"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
21
|
+
DEFAULT_MAX_OUTPUT_TOKENS = 512
|
|
22
|
+
PROBE_MAX_OUTPUT_TOKENS = 16
|
|
23
|
+
MAX_OUTPUT_TOKENS = 4096
|
|
24
|
+
ProviderErrorCategory = Literal[
|
|
25
|
+
"configuration",
|
|
26
|
+
"authentication",
|
|
27
|
+
"timeout",
|
|
28
|
+
"connection",
|
|
29
|
+
"provider_error",
|
|
30
|
+
]
|
|
31
|
+
_PROVIDER_ERROR_CATEGORIES = frozenset(
|
|
32
|
+
{"configuration", "authentication", "timeout", "connection", "provider_error"}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CompletionResult:
|
|
38
|
+
text: str
|
|
39
|
+
input_tokens: int = 0
|
|
40
|
+
output_tokens: int = 0
|
|
41
|
+
total_tokens: int = 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CompletionProvider(Protocol):
|
|
45
|
+
name: str
|
|
46
|
+
|
|
47
|
+
def complete(self, system: str, user: str) -> CompletionResult:
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LLMConfigurationError(ValueError):
|
|
52
|
+
"""Raised when optional LLM enrichment is enabled but misconfigured."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LLMProviderError(RuntimeError):
|
|
56
|
+
"""Fixed-category provider failure that never contains remote response data."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, category: ProviderErrorCategory) -> None:
|
|
59
|
+
self.category: ProviderErrorCategory = (
|
|
60
|
+
category if category in _PROVIDER_ERROR_CATEGORIES else "provider_error"
|
|
61
|
+
)
|
|
62
|
+
super().__init__(f"LLM provider failure: {self.category}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _bounded_output_tokens(value: object) -> int:
|
|
66
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
67
|
+
return DEFAULT_MAX_OUTPUT_TOKENS
|
|
68
|
+
return min(max(value, 1), MAX_OUTPUT_TOKENS)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
72
|
+
"""Turn every redirect into an HTTPError without forwarding credentials."""
|
|
73
|
+
|
|
74
|
+
def redirect_request(
|
|
75
|
+
self,
|
|
76
|
+
req: urllib.request.Request,
|
|
77
|
+
fp: Any,
|
|
78
|
+
code: int,
|
|
79
|
+
msg: str,
|
|
80
|
+
headers: Any,
|
|
81
|
+
newurl: str,
|
|
82
|
+
) -> None:
|
|
83
|
+
del req, fp, code, msg, headers, newurl
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _open_no_redirect(request: urllib.request.Request, timeout: float) -> Any:
|
|
88
|
+
opener = urllib.request.build_opener(_NoRedirectHandler())
|
|
89
|
+
return opener.open(request, timeout=timeout)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OpenAICompatibleProvider:
|
|
93
|
+
"""Chat-completions adapter for OpenAI-compatible HTTP APIs.
|
|
94
|
+
|
|
95
|
+
This intentionally avoids a vendor SDK. It works with OpenAI-compatible gateways such as
|
|
96
|
+
OpenAI, OpenRouter, LM Studio, vLLM, Groq-style endpoints, and local servers that expose
|
|
97
|
+
`/v1/chat/completions`.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
name = "openai-compatible"
|
|
101
|
+
|
|
102
|
+
def __init__(self, cfg: Config) -> None:
|
|
103
|
+
base_url = cfg.llm_base_url or _default_openai_base_url(cfg.llm_provider)
|
|
104
|
+
if not base_url:
|
|
105
|
+
raise LLMConfigurationError(
|
|
106
|
+
"GRAPHITE_LLM_BASE_URL is required for openai-compatible providers"
|
|
107
|
+
)
|
|
108
|
+
self.base_url = base_url.rstrip("/")
|
|
109
|
+
_validate_llm_base_url(self.base_url, provider=cfg.llm_provider)
|
|
110
|
+
self.model = cfg.llm_model or _default_model(cfg.llm_provider)
|
|
111
|
+
self.api_key = cfg.llm_api_key
|
|
112
|
+
self.timeout = cfg.llm_timeout_seconds
|
|
113
|
+
self.seed = cfg.seed
|
|
114
|
+
self.max_output_tokens = _bounded_output_tokens(cfg.llm_max_output_tokens)
|
|
115
|
+
|
|
116
|
+
def complete(self, system: str, user: str) -> CompletionResult:
|
|
117
|
+
payload = {
|
|
118
|
+
"model": self.model,
|
|
119
|
+
"messages": [
|
|
120
|
+
{"role": "system", "content": system},
|
|
121
|
+
{"role": "user", "content": user},
|
|
122
|
+
],
|
|
123
|
+
"temperature": 0.1,
|
|
124
|
+
"stream": False,
|
|
125
|
+
"max_tokens": self.max_output_tokens,
|
|
126
|
+
}
|
|
127
|
+
if self.seed is not None:
|
|
128
|
+
payload["seed"] = self.seed
|
|
129
|
+
data = json.dumps(payload).encode("utf-8")
|
|
130
|
+
headers = {"Content-Type": "application/json"}
|
|
131
|
+
if self.api_key:
|
|
132
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
133
|
+
request = urllib.request.Request(
|
|
134
|
+
f"{self.base_url}/chat/completions",
|
|
135
|
+
data=data,
|
|
136
|
+
headers=headers,
|
|
137
|
+
method="POST",
|
|
138
|
+
)
|
|
139
|
+
body = _urlopen_json(request, self.timeout)
|
|
140
|
+
choices = body.get("choices") or []
|
|
141
|
+
text = ""
|
|
142
|
+
if choices:
|
|
143
|
+
message = choices[0].get("message") or {}
|
|
144
|
+
text = str(message.get("content") or "").strip()
|
|
145
|
+
usage = body.get("usage") or {}
|
|
146
|
+
return CompletionResult(
|
|
147
|
+
text=text,
|
|
148
|
+
input_tokens=int(usage.get("prompt_tokens") or 0),
|
|
149
|
+
output_tokens=int(usage.get("completion_tokens") or 0),
|
|
150
|
+
total_tokens=int(usage.get("total_tokens") or 0),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class OllamaProvider:
|
|
155
|
+
"""Native Ollama chat adapter using `/api/chat`."""
|
|
156
|
+
|
|
157
|
+
name = "ollama"
|
|
158
|
+
|
|
159
|
+
def __init__(self, cfg: Config) -> None:
|
|
160
|
+
self.base_url = (cfg.llm_base_url or "http://localhost:11434").rstrip("/")
|
|
161
|
+
_validate_llm_base_url(self.base_url, provider=cfg.llm_provider)
|
|
162
|
+
self.model = cfg.llm_model or "llama3.1"
|
|
163
|
+
self.timeout = cfg.llm_timeout_seconds
|
|
164
|
+
self.seed = cfg.seed
|
|
165
|
+
self.max_output_tokens = _bounded_output_tokens(cfg.llm_max_output_tokens)
|
|
166
|
+
|
|
167
|
+
def complete(self, system: str, user: str) -> CompletionResult:
|
|
168
|
+
payload = {
|
|
169
|
+
"model": self.model,
|
|
170
|
+
"messages": [
|
|
171
|
+
{"role": "system", "content": system},
|
|
172
|
+
{"role": "user", "content": user},
|
|
173
|
+
],
|
|
174
|
+
"stream": False,
|
|
175
|
+
"options": {
|
|
176
|
+
"temperature": 0.1,
|
|
177
|
+
"seed": self.seed,
|
|
178
|
+
"num_predict": self.max_output_tokens,
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
request = urllib.request.Request(
|
|
182
|
+
f"{self.base_url}/api/chat",
|
|
183
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
184
|
+
headers={"Content-Type": "application/json"},
|
|
185
|
+
method="POST",
|
|
186
|
+
)
|
|
187
|
+
body = _urlopen_json(request, self.timeout)
|
|
188
|
+
message = body.get("message") or {}
|
|
189
|
+
input_tokens = int(body.get("prompt_eval_count") or 0)
|
|
190
|
+
output_tokens = int(body.get("eval_count") or 0)
|
|
191
|
+
return CompletionResult(
|
|
192
|
+
text=str(message.get("content") or "").strip(),
|
|
193
|
+
input_tokens=input_tokens,
|
|
194
|
+
output_tokens=output_tokens,
|
|
195
|
+
total_tokens=input_tokens + output_tokens,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def make_provider(cfg: Config) -> CompletionProvider:
|
|
200
|
+
provider = cfg.llm_provider.strip().lower().replace("_", "-")
|
|
201
|
+
if provider in {"ollama", "local"}:
|
|
202
|
+
return OllamaProvider(cfg)
|
|
203
|
+
if provider in {"openai", "openai-compatible", "compatible", "lmstudio", "lm-studio", "vllm", "openrouter", "groq"}:
|
|
204
|
+
return OpenAICompatibleProvider(cfg)
|
|
205
|
+
raise LLMConfigurationError(
|
|
206
|
+
f"unsupported Graphite LLM provider '{cfg.llm_provider}'. Use 'ollama' or 'openai-compatible'."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def canonical_provider_name(provider: str) -> Literal[
|
|
211
|
+
"ollama", "openai-compatible", "custom/unknown"
|
|
212
|
+
]:
|
|
213
|
+
"""Return a fixed public identifier without reflecting configuration input."""
|
|
214
|
+
normalized = provider.strip().lower().replace("_", "-")
|
|
215
|
+
if normalized in {"ollama", "local"}:
|
|
216
|
+
return "ollama"
|
|
217
|
+
if normalized in {
|
|
218
|
+
"openai",
|
|
219
|
+
"openai-compatible",
|
|
220
|
+
"compatible",
|
|
221
|
+
"lmstudio",
|
|
222
|
+
"lm-studio",
|
|
223
|
+
"vllm",
|
|
224
|
+
"openrouter",
|
|
225
|
+
"groq",
|
|
226
|
+
}:
|
|
227
|
+
return "openai-compatible"
|
|
228
|
+
return "custom/unknown"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _provider_requires_api_key(provider: str) -> bool:
|
|
232
|
+
normalized = provider.strip().lower().replace("_", "-")
|
|
233
|
+
return normalized in {"openai", "openrouter", "groq"}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _provider_requires_secure_egress(provider: str) -> bool:
|
|
237
|
+
normalized = provider.strip().lower().replace("_", "-")
|
|
238
|
+
return normalized in {"openai", "openrouter", "groq"}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# RFC 6598 carrier-grade NAT / shared address space. Not classified as private by
|
|
242
|
+
# is_private on every CPython patch version, so reject it explicitly rather than relying
|
|
243
|
+
# on the stdlib's version-dependent classification.
|
|
244
|
+
_CGNAT_SHARED_IPV4 = ipaddress.ip_network("100.64.0.0/10")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _validate_llm_base_url(base_url: str, *, provider: str) -> None:
|
|
248
|
+
"""Apply the provider-class egress policy to an operator-supplied base URL.
|
|
249
|
+
|
|
250
|
+
Every provider rejects non-HTTP(S) schemes. The keyed cloud providers
|
|
251
|
+
(openai/openrouter/groq) additionally require HTTPS and reject IP-literal
|
|
252
|
+
loopback/private/link-local/reserved hosts and the name 'localhost' -- those
|
|
253
|
+
providers have no legitimate internal target, so an internal target there is a
|
|
254
|
+
misconfiguration that would leak the bearer token. Local and generic
|
|
255
|
+
openai-compatible providers keep http + loopback/private, which are intended.
|
|
256
|
+
DNS-name hosts are accepted; name-based private targets are out of scope
|
|
257
|
+
because base_url is operator-controlled (no DNS-rebinding adversary).
|
|
258
|
+
"""
|
|
259
|
+
parts = urlsplit(base_url)
|
|
260
|
+
scheme = parts.scheme.lower()
|
|
261
|
+
if scheme not in {"http", "https"}:
|
|
262
|
+
raise LLMConfigurationError("GRAPHITE_LLM_BASE_URL must use http or https")
|
|
263
|
+
|
|
264
|
+
if not _provider_requires_secure_egress(provider):
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
if scheme != "https":
|
|
268
|
+
raise LLMConfigurationError("this LLM provider requires an https GRAPHITE_LLM_BASE_URL")
|
|
269
|
+
host = parts.hostname or ""
|
|
270
|
+
if host.lower() == "localhost":
|
|
271
|
+
raise LLMConfigurationError("this LLM provider may not target a loopback or private host")
|
|
272
|
+
try:
|
|
273
|
+
address = ipaddress.ip_address(host)
|
|
274
|
+
except ValueError:
|
|
275
|
+
return # A DNS name; name-based private targets are out of scope.
|
|
276
|
+
if (
|
|
277
|
+
address.is_loopback
|
|
278
|
+
or address.is_private
|
|
279
|
+
or address.is_link_local
|
|
280
|
+
or address.is_reserved
|
|
281
|
+
or address.is_multicast
|
|
282
|
+
or address.is_unspecified
|
|
283
|
+
or (address.version == 4 and address in _CGNAT_SHARED_IPV4)
|
|
284
|
+
):
|
|
285
|
+
raise LLMConfigurationError("this LLM provider may not target a loopback or private host")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _provider_has_default_base_url(provider: str) -> bool:
|
|
289
|
+
normalized = provider.strip().lower().replace("_", "-")
|
|
290
|
+
return normalized in {"openai", "openrouter", "groq", "lmstudio", "lm-studio", "vllm"}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _auto_effective_mode(provider: str) -> str:
|
|
294
|
+
normalized = provider.strip().lower().replace("_", "-")
|
|
295
|
+
if normalized in {"ollama", "local", "lmstudio", "lm-studio", "vllm"}:
|
|
296
|
+
return "local"
|
|
297
|
+
return "cloud"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def decide_auto_llm(
|
|
301
|
+
graph_data: dict[str, Any],
|
|
302
|
+
clusters: dict[str, Any],
|
|
303
|
+
analysis: dict[str, Any],
|
|
304
|
+
cfg: Config,
|
|
305
|
+
) -> dict[str, Any]:
|
|
306
|
+
"""Decide whether optional LLM enrichment is worth running for this graph."""
|
|
307
|
+
metadata = graph_data.get("metadata") or {}
|
|
308
|
+
node_count = int(metadata.get("node_count") or len(graph_data.get("nodes") or []))
|
|
309
|
+
edge_count = int(metadata.get("edge_count") or len(graph_data.get("edges") or []))
|
|
310
|
+
cluster_count = int(clusters.get("count") or len(clusters.get("clusters") or []))
|
|
311
|
+
god_node_count = len(analysis.get("god_nodes") or [])
|
|
312
|
+
surprising_count = len(analysis.get("surprising_connections") or [])
|
|
313
|
+
top_link_count = len(analysis.get("top_files_by_links") or [])
|
|
314
|
+
|
|
315
|
+
signals: list[str] = []
|
|
316
|
+
if node_count >= 250:
|
|
317
|
+
signals.append("node_count>=250")
|
|
318
|
+
if edge_count >= 350:
|
|
319
|
+
signals.append("edge_count>=350")
|
|
320
|
+
if cluster_count >= 8:
|
|
321
|
+
signals.append("cluster_count>=8")
|
|
322
|
+
if god_node_count >= 2:
|
|
323
|
+
signals.append("god_nodes>=2")
|
|
324
|
+
if surprising_count >= 2:
|
|
325
|
+
signals.append("surprising_connections>=2")
|
|
326
|
+
if top_link_count >= 10 and edge_count >= 200:
|
|
327
|
+
signals.append("high_linked_files")
|
|
328
|
+
|
|
329
|
+
provider = cfg.llm_provider.strip().lower().replace("_", "-")
|
|
330
|
+
base_url_ready = bool(cfg.llm_base_url) or _provider_has_default_base_url(provider) or provider in {"ollama", "local"}
|
|
331
|
+
api_key_ready = bool(cfg.llm_api_key) or not _provider_requires_api_key(provider)
|
|
332
|
+
provider_ready = base_url_ready and api_key_ready
|
|
333
|
+
|
|
334
|
+
decision = {
|
|
335
|
+
"enabled": bool(signals) and provider_ready,
|
|
336
|
+
"signals": signals,
|
|
337
|
+
"provider": cfg.llm_provider,
|
|
338
|
+
"model": cfg.llm_model or _default_model(cfg.llm_provider),
|
|
339
|
+
"effective_mode": _auto_effective_mode(cfg.llm_provider),
|
|
340
|
+
"node_count": node_count,
|
|
341
|
+
"edge_count": edge_count,
|
|
342
|
+
"cluster_count": cluster_count,
|
|
343
|
+
"god_node_count": god_node_count,
|
|
344
|
+
"surprising_connection_count": surprising_count,
|
|
345
|
+
"provider_ready": provider_ready,
|
|
346
|
+
}
|
|
347
|
+
if not signals:
|
|
348
|
+
decision["reason"] = "graph_below_auto_threshold"
|
|
349
|
+
elif not base_url_ready:
|
|
350
|
+
decision["reason"] = "missing_llm_base_url"
|
|
351
|
+
elif not api_key_ready:
|
|
352
|
+
decision["reason"] = "missing_llm_api_key"
|
|
353
|
+
else:
|
|
354
|
+
decision["reason"] = "auto_threshold_met"
|
|
355
|
+
return decision
|
|
356
|
+
|
|
357
|
+
def enrich_report(
|
|
358
|
+
graph_data: dict[str, Any],
|
|
359
|
+
clusters: dict[str, Any],
|
|
360
|
+
analysis: dict[str, Any],
|
|
361
|
+
cfg: Config,
|
|
362
|
+
) -> dict[str, Any]:
|
|
363
|
+
"""Return an optional annotation payload for the explicit overlay pipeline."""
|
|
364
|
+
mode = cfg.llm_mode.strip().lower()
|
|
365
|
+
if mode in {"", "0", "false", "none", "off", "disabled"}:
|
|
366
|
+
return {"enabled": False, "mode": "none", "tokens": 0}
|
|
367
|
+
|
|
368
|
+
auto_decision: dict[str, Any] | None = None
|
|
369
|
+
effective_cfg = cfg
|
|
370
|
+
if mode == "auto":
|
|
371
|
+
auto_decision = decide_auto_llm(graph_data, clusters, analysis, cfg)
|
|
372
|
+
if not auto_decision["enabled"]:
|
|
373
|
+
return {
|
|
374
|
+
"enabled": False,
|
|
375
|
+
"status": "skipped",
|
|
376
|
+
"mode": "auto",
|
|
377
|
+
"tokens": 0,
|
|
378
|
+
"provider": cfg.llm_provider,
|
|
379
|
+
"model": auto_decision["model"],
|
|
380
|
+
"reason": auto_decision["reason"],
|
|
381
|
+
"auto": auto_decision,
|
|
382
|
+
}
|
|
383
|
+
effective_cfg = replace(cfg, llm_mode=str(auto_decision["effective_mode"]))
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
provider = make_provider(effective_cfg)
|
|
387
|
+
system, user = build_report_prompt(graph_data, clusters, analysis, effective_cfg)
|
|
388
|
+
completion = provider.complete(system, user)
|
|
389
|
+
return {
|
|
390
|
+
"enabled": True,
|
|
391
|
+
"status": "ok",
|
|
392
|
+
"mode": mode,
|
|
393
|
+
"effective_mode": effective_cfg.llm_mode,
|
|
394
|
+
"provider": provider.name,
|
|
395
|
+
"model": cfg.llm_model or _default_model(cfg.llm_provider),
|
|
396
|
+
"summary": completion.text,
|
|
397
|
+
"auto": auto_decision,
|
|
398
|
+
"input_tokens": completion.input_tokens,
|
|
399
|
+
"output_tokens": completion.output_tokens,
|
|
400
|
+
"tokens": completion.total_tokens or completion.input_tokens + completion.output_tokens,
|
|
401
|
+
}
|
|
402
|
+
except Exception as exc: # Overlay failures remain isolated from canonical graphs.
|
|
403
|
+
if isinstance(exc, LLMConfigurationError):
|
|
404
|
+
category: ProviderErrorCategory = "configuration"
|
|
405
|
+
elif isinstance(exc, LLMProviderError):
|
|
406
|
+
category = exc.category
|
|
407
|
+
elif isinstance(exc, TimeoutError):
|
|
408
|
+
category = "timeout"
|
|
409
|
+
else:
|
|
410
|
+
category = "provider_error"
|
|
411
|
+
return {
|
|
412
|
+
"enabled": True,
|
|
413
|
+
"status": "error",
|
|
414
|
+
"mode": mode,
|
|
415
|
+
"effective_mode": effective_cfg.llm_mode,
|
|
416
|
+
"provider": cfg.llm_provider,
|
|
417
|
+
"model": cfg.llm_model or _default_model(cfg.llm_provider),
|
|
418
|
+
"auto": auto_decision,
|
|
419
|
+
"tokens": 0,
|
|
420
|
+
"error_category": category,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def build_report_prompt(
|
|
425
|
+
graph_data: dict[str, Any],
|
|
426
|
+
clusters: dict[str, Any],
|
|
427
|
+
analysis: dict[str, Any],
|
|
428
|
+
cfg: Config,
|
|
429
|
+
) -> tuple[str, str]:
|
|
430
|
+
"""Build a bounded prompt from graph metadata only, not source code."""
|
|
431
|
+
payload = {
|
|
432
|
+
"metadata": graph_data.get("metadata", {}),
|
|
433
|
+
"top_files_by_links": analysis.get("top_files_by_links", [])[:12],
|
|
434
|
+
"god_nodes": analysis.get("god_nodes", [])[:12],
|
|
435
|
+
"entry_points": analysis.get("entry_points", [])[:12],
|
|
436
|
+
"surprising_connections": analysis.get("surprising_connections", [])[:12],
|
|
437
|
+
"clusters": [
|
|
438
|
+
{
|
|
439
|
+
"id": c.get("id"),
|
|
440
|
+
"size": c.get("size"),
|
|
441
|
+
"file_count": c.get("file_count"),
|
|
442
|
+
"function_count": c.get("function_count"),
|
|
443
|
+
"class_count": c.get("class_count"),
|
|
444
|
+
"labels": c.get("labels", []),
|
|
445
|
+
}
|
|
446
|
+
for c in clusters.get("clusters", [])[:12]
|
|
447
|
+
],
|
|
448
|
+
}
|
|
449
|
+
data = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
450
|
+
if len(data) > cfg.llm_max_input_chars:
|
|
451
|
+
data = data[: cfg.llm_max_input_chars] + "\n... truncated by GRAPHITE_LLM_MAX_INPUT_CHARS"
|
|
452
|
+
system = (
|
|
453
|
+
"You are Graphite's optional code-graph analyst. Use only the provided graph metrics. "
|
|
454
|
+
"Do not claim to have inspected source code. Focus on architecture risks, likely hotspots, "
|
|
455
|
+
"and practical next checks. Keep the answer concise."
|
|
456
|
+
)
|
|
457
|
+
user = (
|
|
458
|
+
"Analyze this code graph summary. Return concise bullets under: Hotspots, Coupling risks, "
|
|
459
|
+
"Suggested next checks.\n\n"
|
|
460
|
+
f"{data}"
|
|
461
|
+
)
|
|
462
|
+
return system, user
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _urlopen_json(request: urllib.request.Request, timeout: float) -> dict[str, Any]:
|
|
466
|
+
try:
|
|
467
|
+
with _open_no_redirect(request, timeout) as response: # nosec: opt-in user URL
|
|
468
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
469
|
+
except urllib.error.HTTPError as exc:
|
|
470
|
+
try:
|
|
471
|
+
exc.read(MAX_RESPONSE_BYTES + 1)
|
|
472
|
+
except Exception:
|
|
473
|
+
pass
|
|
474
|
+
category: ProviderErrorCategory = (
|
|
475
|
+
"authentication" if exc.code in {401, 403} else "provider_error"
|
|
476
|
+
)
|
|
477
|
+
raise LLMProviderError(category) from None
|
|
478
|
+
except (TimeoutError, urllib.error.URLError) as exc:
|
|
479
|
+
reason = getattr(exc, "reason", None)
|
|
480
|
+
category = "timeout" if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError) else "connection"
|
|
481
|
+
raise LLMProviderError(category) from None
|
|
482
|
+
except OSError:
|
|
483
|
+
raise LLMProviderError("connection") from None
|
|
484
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
485
|
+
raise LLMProviderError("provider_error")
|
|
486
|
+
try:
|
|
487
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
488
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
489
|
+
raise LLMProviderError("provider_error") from None
|
|
490
|
+
if not isinstance(payload, dict):
|
|
491
|
+
raise LLMProviderError("provider_error")
|
|
492
|
+
return payload
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _default_openai_base_url(provider: str) -> str | None:
|
|
496
|
+
normalized = provider.strip().lower().replace("_", "-")
|
|
497
|
+
if normalized == "openai":
|
|
498
|
+
return "https://api.openai.com/v1"
|
|
499
|
+
if normalized in {"lmstudio", "lm-studio"}:
|
|
500
|
+
return "http://localhost:1234/v1"
|
|
501
|
+
if normalized == "vllm":
|
|
502
|
+
return "http://localhost:8000/v1"
|
|
503
|
+
if normalized == "openrouter":
|
|
504
|
+
return "https://openrouter.ai/api/v1"
|
|
505
|
+
if normalized == "groq":
|
|
506
|
+
return "https://api.groq.com/openai/v1"
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _default_model(provider: str) -> str:
|
|
511
|
+
normalized = provider.strip().lower().replace("_", "-")
|
|
512
|
+
if normalized in {"ollama", "local"}:
|
|
513
|
+
return "llama3.1"
|
|
514
|
+
if normalized == "openrouter":
|
|
515
|
+
return "moonshotai/kimi-k2.7-code"
|
|
516
|
+
if normalized == "groq":
|
|
517
|
+
return "llama-3.1-8b-instant"
|
|
518
|
+
return "gpt-4o-mini"
|