pisces-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.
pisces/llm_client.py ADDED
@@ -0,0 +1,263 @@
1
+ """
2
+ pisces.llm_client
3
+ ~~~~~~~~~~~~~~~~~
4
+ Multi-provider LLM client abstraction.
5
+
6
+ Supported providers:
7
+ openai — OpenAI API (GPT-4o, GPT-4o-mini, o1, ...)
8
+ anthropic — Anthropic API (Claude 3.5 Haiku/Sonnet, ...)
9
+ gemini — Google Gemini API (gemini-2.0-flash, ...)
10
+ groq — Groq API (llama-3.3-70b, mixtral, ...) [OpenAI-compatible]
11
+ ollama — Local Ollama server [OpenAI-compatible]
12
+ lmstudio — Local LM Studio server [OpenAI-compatible]
13
+
14
+ Security: credentials come from environment only (see config_manager.get_api_key).
15
+
16
+ Design:
17
+ - Lazy imports: only import a provider's SDK when it's actually used.
18
+ - Single unified LLMRequest / LLMResponse model.
19
+ - Retry with exponential backoff for rate-limit / transient errors.
20
+ - Timeout enforced on every call.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import time
26
+
27
+ from pisces.config_manager import get_api_key
28
+ from pisces.models import LLMRequest, LLMResponse
29
+
30
+ # ─────────────────────────────────────────────────────────────
31
+ # Provider base URLs for OpenAI-compatible providers
32
+ # ─────────────────────────────────────────────────────────────
33
+
34
+ _OPENAI_COMPAT_BASE_URLS: dict[str, str] = {
35
+ "groq": "https://api.groq.com/openai/v1",
36
+ "ollama": "http://localhost:11434/v1",
37
+ "lmstudio": "http://localhost:1234/v1",
38
+ }
39
+
40
+ # ─────────────────────────────────────────────────────────────
41
+ # Retry settings
42
+ # ─────────────────────────────────────────────────────────────
43
+
44
+ _MAX_RETRIES = 3
45
+ _RETRY_DELAY_S = 2.0 # seconds, doubled on each retry
46
+ _REQUEST_TIMEOUT = 120 # seconds
47
+
48
+
49
+ # ─────────────────────────────────────────────────────────────
50
+ # Dispatch
51
+ # ─────────────────────────────────────────────────────────────
52
+
53
+
54
+ def call_llm(
55
+ request: LLMRequest,
56
+ provider: str,
57
+ model: str,
58
+ ) -> LLMResponse:
59
+ """
60
+ Dispatch an LLM call to the correct provider function.
61
+
62
+ Raises:
63
+ EnvironmentError — API key not found
64
+ RuntimeError — SDK not installed, or all retries exhausted
65
+ """
66
+ provider = provider.lower()
67
+
68
+ dispatch = {
69
+ "openai": _call_openai_compat,
70
+ "groq": _call_openai_compat,
71
+ "ollama": _call_openai_compat,
72
+ "lmstudio": _call_openai_compat,
73
+ "anthropic": _call_anthropic,
74
+ "gemini": _call_gemini,
75
+ }
76
+
77
+ fn = dispatch.get(provider)
78
+ if fn is None:
79
+ raise RuntimeError(f"Unknown provider: '{provider}'")
80
+
81
+ return fn(request, provider=provider, model=model)
82
+
83
+
84
+ # ─────────────────────────────────────────────────────────────
85
+ # OpenAI-compatible (openai, groq, ollama, lmstudio)
86
+ # ─────────────────────────────────────────────────────────────
87
+
88
+
89
+ def _call_openai_compat(
90
+ request: LLMRequest,
91
+ provider: str,
92
+ model: str,
93
+ ) -> LLMResponse:
94
+ try:
95
+ from openai import APIError, OpenAI, RateLimitError # lazy import
96
+ except ImportError:
97
+ raise RuntimeError("openai SDK not installed. Run: pip install openai")
98
+
99
+ api_key = get_api_key(provider) or "ollama" # ollama/lmstudio don't need a key
100
+ base_url = _OPENAI_COMPAT_BASE_URLS.get(provider)
101
+
102
+ client_kwargs: dict = {
103
+ "api_key": api_key,
104
+ "timeout": _REQUEST_TIMEOUT,
105
+ }
106
+ if base_url:
107
+ client_kwargs["base_url"] = base_url
108
+
109
+ client = OpenAI(**client_kwargs)
110
+
111
+ delay = _RETRY_DELAY_S
112
+ last_error: Exception | None = None
113
+
114
+ for attempt in range(1, _MAX_RETRIES + 1):
115
+ try:
116
+ response = client.chat.completions.create(
117
+ model=model,
118
+ messages=[
119
+ {"role": "system", "content": request.system_prompt},
120
+ {"role": "user", "content": request.user_prompt},
121
+ ],
122
+ temperature=request.temperature,
123
+ max_tokens=request.max_tokens,
124
+ )
125
+ content = response.choices[0].message.content or ""
126
+ usage = response.usage
127
+
128
+ return LLMResponse(
129
+ content=content,
130
+ model=model,
131
+ provider=provider,
132
+ input_tokens=usage.prompt_tokens if usage else 0,
133
+ output_tokens=usage.completion_tokens if usage else 0,
134
+ )
135
+
136
+ except RateLimitError as e:
137
+ last_error = e
138
+ if attempt < _MAX_RETRIES:
139
+ time.sleep(delay)
140
+ delay *= 2
141
+ continue
142
+ except APIError as e:
143
+ raise RuntimeError(f"[{provider}] API error: {e}") from e
144
+
145
+ raise RuntimeError(
146
+ f"[{provider}] Rate limit hit — all {_MAX_RETRIES} retries exhausted. "
147
+ f"Last error: {last_error}"
148
+ )
149
+
150
+
151
+ # ─────────────────────────────────────────────────────────────
152
+ # Anthropic
153
+ # ─────────────────────────────────────────────────────────────
154
+
155
+
156
+ def _call_anthropic(
157
+ request: LLMRequest,
158
+ provider: str,
159
+ model: str,
160
+ ) -> LLMResponse:
161
+ try:
162
+ import anthropic as ant # lazy import
163
+ except ImportError:
164
+ raise RuntimeError("anthropic SDK not installed. Run: pip install anthropic")
165
+
166
+ api_key = get_api_key("anthropic")
167
+ client = ant.Anthropic(api_key=api_key, timeout=_REQUEST_TIMEOUT)
168
+
169
+ delay = _RETRY_DELAY_S
170
+ last_error: Exception | None = None
171
+
172
+ for attempt in range(1, _MAX_RETRIES + 1):
173
+ try:
174
+ msg = client.messages.create(
175
+ model=model,
176
+ max_tokens=request.max_tokens,
177
+ system=request.system_prompt,
178
+ messages=[{"role": "user", "content": request.user_prompt}],
179
+ temperature=request.temperature,
180
+ )
181
+ content = msg.content[0].text if msg.content else ""
182
+ return LLMResponse(
183
+ content=content,
184
+ model=model,
185
+ provider="anthropic",
186
+ input_tokens=msg.usage.input_tokens,
187
+ output_tokens=msg.usage.output_tokens,
188
+ )
189
+ except ant.RateLimitError as e:
190
+ last_error = e
191
+ if attempt < _MAX_RETRIES:
192
+ time.sleep(delay)
193
+ delay *= 2
194
+ continue
195
+ except ant.APIError as e:
196
+ raise RuntimeError(f"[anthropic] API error: {e}") from e
197
+
198
+ raise RuntimeError(
199
+ f"[anthropic] Rate limit hit — {_MAX_RETRIES} retries exhausted. Last: {last_error}"
200
+ )
201
+
202
+
203
+ # ─────────────────────────────────────────────────────────────
204
+ # Google Gemini
205
+ # ─────────────────────────────────────────────────────────────
206
+
207
+
208
+ def _call_gemini(
209
+ request: LLMRequest,
210
+ provider: str,
211
+ model: str,
212
+ ) -> LLMResponse:
213
+ try:
214
+ import google.generativeai as genai # lazy import
215
+ except ImportError:
216
+ raise RuntimeError(
217
+ "google-generativeai SDK not installed. Run: pip install google-generativeai"
218
+ )
219
+
220
+ api_key = get_api_key("gemini")
221
+ genai.configure(api_key=api_key)
222
+
223
+ generation_config = genai.types.GenerationConfig(
224
+ temperature=request.temperature,
225
+ max_output_tokens=request.max_tokens,
226
+ )
227
+
228
+ gemini_model = genai.GenerativeModel(
229
+ model_name=model,
230
+ system_instruction=request.system_prompt,
231
+ generation_config=generation_config,
232
+ )
233
+
234
+ delay = _RETRY_DELAY_S
235
+ last_error: Exception | None = None
236
+
237
+ for attempt in range(1, _MAX_RETRIES + 1):
238
+ try:
239
+ response = gemini_model.generate_content(request.user_prompt)
240
+ content = response.text or ""
241
+ return LLMResponse(
242
+ content=content,
243
+ model=model,
244
+ provider="gemini",
245
+ input_tokens=response.usage_metadata.prompt_token_count
246
+ if response.usage_metadata
247
+ else 0,
248
+ output_tokens=response.usage_metadata.candidates_token_count
249
+ if response.usage_metadata
250
+ else 0,
251
+ )
252
+ except Exception as e:
253
+ if "quota" in str(e).lower() or "rate" in str(e).lower():
254
+ last_error = e
255
+ if attempt < _MAX_RETRIES:
256
+ time.sleep(delay)
257
+ delay *= 2
258
+ continue
259
+ raise RuntimeError(f"[gemini] API error: {e}") from e
260
+
261
+ raise RuntimeError(
262
+ f"[gemini] Rate limit hit — {_MAX_RETRIES} retries exhausted. Last: {last_error}"
263
+ )
pisces/models.py ADDED
@@ -0,0 +1,413 @@
1
+ """
2
+ pisces.models
3
+ ~~~~~~~~~~~~~
4
+ All shared data models for pisces.
5
+
6
+ Rule: every other module imports FROM here. Nothing in here imports
7
+ from other pisces modules. This prevents circular imports.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
15
+ from enum import Enum
16
+
17
+ # ─────────────────────────────────────────────────────────────
18
+ # Severity
19
+ # ─────────────────────────────────────────────────────────────
20
+
21
+
22
+ class Severity(str, Enum):
23
+ """
24
+ Security finding severity levels.
25
+
26
+ Inherits from str so Severity.HIGH == "HIGH" works,
27
+ and JSON serialisation is automatic.
28
+ """
29
+
30
+ CRITICAL = "CRITICAL"
31
+ HIGH = "HIGH"
32
+ MEDIUM = "MEDIUM"
33
+ LOW = "LOW"
34
+ INFO = "INFO"
35
+
36
+ @classmethod
37
+ def from_str(cls, value: str) -> Severity:
38
+ try:
39
+ return cls(value.upper())
40
+ except ValueError:
41
+ return cls.INFO
42
+
43
+ def __lt__(self, other: Severity) -> bool:
44
+ _order = {
45
+ Severity.CRITICAL: 0,
46
+ Severity.HIGH: 1,
47
+ Severity.MEDIUM: 2,
48
+ Severity.LOW: 3,
49
+ Severity.INFO: 4,
50
+ }
51
+ return _order[self] < _order[other]
52
+
53
+
54
+ # ─────────────────────────────────────────────────────────────
55
+ # Repair state machine
56
+ # ─────────────────────────────────────────────────────────────
57
+
58
+
59
+ class RepairState(str, Enum):
60
+ """
61
+ Lifecycle states of a single repair attempt.
62
+
63
+ DETECTED → EXPLAINED → PROPOSED → JUDGED → AWAITING_APPROVAL
64
+ → APPROVED → CHECKPOINTED → APPLIED → VALIDATING
65
+ → SUCCEEDED (terminal ✓)
66
+ → FAILED → HEALING → PROPOSED (retry loop)
67
+ → ROLLED_BACK (terminal, all attempts exhausted)
68
+ → REJECTED (terminal, human said N)
69
+ """
70
+
71
+ DETECTED = "DETECTED"
72
+ EXPLAINED = "EXPLAINED"
73
+ PROPOSED = "PROPOSED"
74
+ JUDGED = "JUDGED"
75
+ AWAITING_APPROVAL = "AWAITING_APPROVAL"
76
+ APPROVED = "APPROVED"
77
+ CHECKPOINTED = "CHECKPOINTED"
78
+ APPLIED = "APPLIED"
79
+ VALIDATING = "VALIDATING"
80
+ SUCCEEDED = "SUCCEEDED"
81
+ FAILED = "FAILED"
82
+ HEALING = "HEALING"
83
+ REJECTED = "REJECTED"
84
+ ROLLED_BACK = "ROLLED_BACK"
85
+
86
+
87
+ # ─────────────────────────────────────────────────────────────
88
+ # AST-extracted file intelligence
89
+ # ─────────────────────────────────────────────────────────────
90
+
91
+
92
+ @dataclass
93
+ class FunctionInfo:
94
+ """Information extracted from a function definition."""
95
+
96
+ name: str
97
+ line_start: int
98
+ line_end: int
99
+ is_async: bool = False
100
+ decorators: list[str] = field(default_factory=list)
101
+ calls: list[str] = field(default_factory=list) # function names called inside
102
+ args: list[str] = field(default_factory=list) # parameter names
103
+ docstring: str | None = None
104
+
105
+
106
+ @dataclass
107
+ class ClassInfo:
108
+ """Information extracted from a class definition."""
109
+
110
+ name: str
111
+ line_start: int
112
+ line_end: int
113
+ bases: list[str] = field(default_factory=list)
114
+ methods: list[str] = field(default_factory=list)
115
+ decorators: list[str] = field(default_factory=list)
116
+
117
+
118
+ @dataclass
119
+ class ImportInfo:
120
+ """A single import statement."""
121
+
122
+ module: str # e.g. "os.path" or "subprocess"
123
+ names: list[str] # e.g. ["run", "Popen"] or ["*"]
124
+ line: int
125
+ is_from: bool = False # True = "from X import Y"
126
+
127
+
128
+ @dataclass
129
+ class SecurityConstruct:
130
+ """
131
+ A security-relevant code construct spotted during AST analysis.
132
+ Not a full finding — just a hint for the scanner.
133
+ """
134
+
135
+ kind: str # e.g. "subprocess_call", "pickle_load", "eval_call"
136
+ line: int
137
+ code: str # source snippet (best-effort)
138
+ details: str = ""
139
+
140
+
141
+ @dataclass
142
+ class FileContext:
143
+ """
144
+ Serialised intelligence for a single Python file.
145
+ Stored as one entry in context.json["files"].
146
+ """
147
+
148
+ path: str # relative to project root
149
+ hash: str # SHA-256 hex digest
150
+ functions: list[FunctionInfo] = field(default_factory=list)
151
+ classes: list[ClassInfo] = field(default_factory=list)
152
+ imports: list[ImportInfo] = field(default_factory=list)
153
+ calls: list[str] = field(default_factory=list)
154
+ security_constructs: list[SecurityConstruct] = field(default_factory=list)
155
+ parse_error: str | None = None # set if AST failed
156
+
157
+ def to_dict(self) -> dict:
158
+ return {
159
+ "path": self.path,
160
+ "hash": self.hash,
161
+ "functions": [
162
+ {
163
+ "name": f.name,
164
+ "line_start": f.line_start,
165
+ "line_end": f.line_end,
166
+ "is_async": f.is_async,
167
+ "decorators": f.decorators,
168
+ "calls": f.calls,
169
+ "args": f.args,
170
+ "docstring": f.docstring,
171
+ }
172
+ for f in self.functions
173
+ ],
174
+ "classes": [
175
+ {
176
+ "name": c.name,
177
+ "line_start": c.line_start,
178
+ "line_end": c.line_end,
179
+ "bases": c.bases,
180
+ "methods": c.methods,
181
+ "decorators": c.decorators,
182
+ }
183
+ for c in self.classes
184
+ ],
185
+ "imports": [
186
+ {
187
+ "module": i.module,
188
+ "names": i.names,
189
+ "line": i.line,
190
+ "is_from": i.is_from,
191
+ }
192
+ for i in self.imports
193
+ ],
194
+ "calls": self.calls,
195
+ "security_constructs": [
196
+ {
197
+ "kind": s.kind,
198
+ "line": s.line,
199
+ "code": s.code,
200
+ "details": s.details,
201
+ }
202
+ for s in self.security_constructs
203
+ ],
204
+ "parse_error": self.parse_error,
205
+ }
206
+
207
+ @classmethod
208
+ def from_dict(cls, data: dict) -> FileContext:
209
+ fc = cls(path=data["path"], hash=data["hash"])
210
+ fc.functions = [
211
+ FunctionInfo(
212
+ name=f["name"],
213
+ line_start=f["line_start"],
214
+ line_end=f["line_end"],
215
+ is_async=f.get("is_async", False),
216
+ decorators=f.get("decorators", []),
217
+ calls=f.get("calls", []),
218
+ args=f.get("args", []),
219
+ docstring=f.get("docstring"),
220
+ )
221
+ for f in data.get("functions", [])
222
+ ]
223
+ fc.classes = [
224
+ ClassInfo(
225
+ name=c["name"],
226
+ line_start=c["line_start"],
227
+ line_end=c["line_end"],
228
+ bases=c.get("bases", []),
229
+ methods=c.get("methods", []),
230
+ decorators=c.get("decorators", []),
231
+ )
232
+ for c in data.get("classes", [])
233
+ ]
234
+ fc.imports = [
235
+ ImportInfo(
236
+ module=i["module"],
237
+ names=i["names"],
238
+ line=i["line"],
239
+ is_from=i.get("is_from", False),
240
+ )
241
+ for i in data.get("imports", [])
242
+ ]
243
+ fc.calls = data.get("calls", [])
244
+ fc.security_constructs = [
245
+ SecurityConstruct(
246
+ kind=s["kind"],
247
+ line=s["line"],
248
+ code=s["code"],
249
+ details=s.get("details", ""),
250
+ )
251
+ for s in data.get("security_constructs", [])
252
+ ]
253
+ fc.parse_error = data.get("parse_error")
254
+ return fc
255
+
256
+
257
+ # ─────────────────────────────────────────────────────────────
258
+ # Finding (security scanner output)
259
+ # ─────────────────────────────────────────────────────────────
260
+
261
+
262
+ @dataclass
263
+ class Finding:
264
+ """
265
+ A single security finding produced by the scanner.
266
+
267
+ id — unique, stable identifier: "<rule>:<file>:<line>"
268
+ rule — rule name, e.g. "HARDCODED_SECRET"
269
+ severity — Severity enum value
270
+ file — relative path to the file
271
+ line — line number (1-indexed)
272
+ code — the offending source line(s)
273
+ description — short one-line description
274
+ impact — why this matters
275
+ remediation — what to do about it
276
+ """
277
+
278
+ rule: str
279
+ severity: Severity
280
+ file: str
281
+ line: int
282
+ code: str
283
+ description: str
284
+ impact: str = ""
285
+ remediation: str = ""
286
+ id: str = field(default_factory=lambda: "")
287
+
288
+ def __post_init__(self) -> None:
289
+ if not self.id:
290
+ self.id = f"{self.rule}:{self.file}:{self.line}"
291
+
292
+ def to_dict(self) -> dict:
293
+ return {
294
+ "id": self.id,
295
+ "rule": self.rule,
296
+ "severity": self.severity.value,
297
+ "file": self.file,
298
+ "line": self.line,
299
+ "code": self.code,
300
+ "description": self.description,
301
+ "impact": self.impact,
302
+ "remediation": self.remediation,
303
+ }
304
+
305
+ @classmethod
306
+ def from_dict(cls, data: dict) -> Finding:
307
+ return cls(
308
+ id=data.get("id", ""),
309
+ rule=data["rule"],
310
+ severity=Severity.from_str(data["severity"]),
311
+ file=data["file"],
312
+ line=data["line"],
313
+ code=data["code"],
314
+ description=data["description"],
315
+ impact=data.get("impact", ""),
316
+ remediation=data.get("remediation", ""),
317
+ )
318
+
319
+
320
+ # ─────────────────────────────────────────────────────────────
321
+ # LLM request / response
322
+ # ─────────────────────────────────────────────────────────────
323
+
324
+
325
+ @dataclass
326
+ class LLMRequest:
327
+ system_prompt: str
328
+ user_prompt: str
329
+ temperature: float = 0.2 # low temp for deterministic patches
330
+ max_tokens: int = 4096
331
+
332
+
333
+ @dataclass
334
+ class LLMResponse:
335
+ content: str
336
+ model: str
337
+ provider: str
338
+ input_tokens: int = 0
339
+ output_tokens: int = 0
340
+
341
+
342
+ # ─────────────────────────────────────────────────────────────
343
+ # Judge result
344
+ # ─────────────────────────────────────────────────────────────
345
+
346
+
347
+ @dataclass
348
+ class JudgeResult:
349
+ decision: str # "APPROVED" | "REJECTED"
350
+ security_ok: bool
351
+ functionality_ok: bool
352
+ scope_ok: bool
353
+ regression_risk: str # "LOW" | "MEDIUM" | "HIGH"
354
+ patch_quality: str # "GOOD" | "ACCEPTABLE" | "POOR"
355
+ completeness: str # "COMPLETE" | "PARTIAL" | "INCOMPLETE"
356
+ reasoning: str
357
+
358
+ @property
359
+ def approved(self) -> bool:
360
+ return self.decision.upper() == "APPROVED"
361
+
362
+ def to_dict(self) -> dict:
363
+ return {
364
+ "decision": self.decision,
365
+ "security_ok": self.security_ok,
366
+ "functionality_ok": self.functionality_ok,
367
+ "scope_ok": self.scope_ok,
368
+ "regression_risk": self.regression_risk,
369
+ "patch_quality": self.patch_quality,
370
+ "completeness": self.completeness,
371
+ "reasoning": self.reasoning,
372
+ }
373
+
374
+
375
+ # ─────────────────────────────────────────────────────────────
376
+ # Repair record (history entry)
377
+ # ─────────────────────────────────────────────────────────────
378
+
379
+
380
+ @dataclass
381
+ class RepairRecord:
382
+ """Stored in .pisces/history/<repair_id>/metadata.json"""
383
+
384
+ repair_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
385
+ state: RepairState = RepairState.DETECTED
386
+ finding_id: str = ""
387
+ file: str = ""
388
+ rule: str = ""
389
+ severity: str = Severity.HIGH.value
390
+ provider: str = ""
391
+ model: str = ""
392
+ judge_result: dict | None = None
393
+ human_approved: bool | None = None
394
+ attempts: int = 0
395
+ started_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
396
+ finished_at: str | None = None
397
+
398
+ def to_dict(self) -> dict:
399
+ return {
400
+ "repair_id": self.repair_id,
401
+ "state": self.state.value,
402
+ "finding_id": self.finding_id,
403
+ "file": self.file,
404
+ "rule": self.rule,
405
+ "severity": self.severity,
406
+ "provider": self.provider,
407
+ "model": self.model,
408
+ "judge_result": self.judge_result,
409
+ "human_approved": self.human_approved,
410
+ "attempts": self.attempts,
411
+ "started_at": self.started_at,
412
+ "finished_at": self.finished_at,
413
+ }