code-review-ai-cli 1.0.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.
- code_review_ai_cli-1.0.0.dist-info/METADATA +441 -0
- code_review_ai_cli-1.0.0.dist-info/RECORD +13 -0
- code_review_ai_cli-1.0.0.dist-info/WHEEL +5 -0
- code_review_ai_cli-1.0.0.dist-info/entry_points.txt +2 -0
- code_review_ai_cli-1.0.0.dist-info/top_level.txt +1 -0
- src/__init__.py +7 -0
- src/ai_review.py +946 -0
- src/config.py +361 -0
- src/formatter.py +474 -0
- src/git_utils.py +487 -0
- src/llm_client.py +1008 -0
- src/prompts/config.yaml.template +124 -0
- src/tfs_client.py +751 -0
src/llm_client.py
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Client Module - AI Code Review
|
|
3
|
+
=====================================
|
|
4
|
+
Responsible for communication with LLM APIs for code analysis.
|
|
5
|
+
|
|
6
|
+
Supported providers:
|
|
7
|
+
- Google Gemini (gemini-pro, gemini-1.5-pro, gemini-2.0-flash)
|
|
8
|
+
- Anthropic Claude (claude-3-opus, claude-3-sonnet, claude-3-haiku)
|
|
9
|
+
- OpenAI GPT-4 (gpt-4, gpt-4-turbo, gpt-4o)
|
|
10
|
+
- Ollama (local models via local API)
|
|
11
|
+
- GitHub Copilot (GPT-4o, Claude 3.5 Sonnet, etc. via GitHub)
|
|
12
|
+
- AWS Bedrock (Claude, Llama, Mistral, etc. via Runtime API)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from .config import ReviewConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LLMError(Exception):
|
|
22
|
+
"""Exception for LLM communication errors."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# System Prompts
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
SYSTEM_PROMPTS = {
|
|
30
|
+
"quick": {
|
|
31
|
+
"pt": (
|
|
32
|
+
"És um code reviewer experiente. Analisa o diff de código fornecido "
|
|
33
|
+
"e dá um review CONCISO e direto. Foca-te nos problemas mais críticos:\n"
|
|
34
|
+
"- Bugs e erros lógicos\n"
|
|
35
|
+
"- Problemas de segurança\n"
|
|
36
|
+
"- Problemas de performance graves\n\n"
|
|
37
|
+
"Formato: Lista de bullet points com o ficheiro e linha quando possível. "
|
|
38
|
+
"Se o código estiver bom, diz isso brevemente. Responde em português."
|
|
39
|
+
),
|
|
40
|
+
"en": (
|
|
41
|
+
"You are an experienced code reviewer. Analyze the provided code diff "
|
|
42
|
+
"and give a CONCISE review. Focus on critical issues:\n"
|
|
43
|
+
"- Bugs and logic errors\n"
|
|
44
|
+
"- Security issues\n"
|
|
45
|
+
"- Major performance problems\n\n"
|
|
46
|
+
"Format: Bullet points with file and line when possible. "
|
|
47
|
+
"If the code looks good, say so briefly."
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
"detailed": {
|
|
51
|
+
"pt": (
|
|
52
|
+
"Analisa detalhadamente o "
|
|
53
|
+
"diff de código fornecido e produz um review completo e estruturado.\n\n"
|
|
54
|
+
"O teu review DEVE incluir as seguintes secções:\n\n"
|
|
55
|
+
"## Resumo Geral\n"
|
|
56
|
+
"Breve resumo das alterações e opinião geral.\n\n"
|
|
57
|
+
"## Bugs e Erros Potenciais\n"
|
|
58
|
+
"Identifica bugs, erros lógicos ou comportamentos inesperados. "
|
|
59
|
+
"Indica o ficheiro e linha.\n\n"
|
|
60
|
+
"## Segurança\n"
|
|
61
|
+
"Problemas de segurança (SQL injection, XSS, credenciais hardcoded, etc.)\n\n"
|
|
62
|
+
"## Performance\n"
|
|
63
|
+
"Problemas de performance ou oportunidades de otimização.\n\n"
|
|
64
|
+
"## Arquitetura e Design\n"
|
|
65
|
+
"Sugestões sobre design patterns, SOLID, separação de responsabilidades.\n\n"
|
|
66
|
+
"## Code Style e Boas Práticas\n"
|
|
67
|
+
"Naming conventions, código duplicado, complexidade, legibilidade.\n\n"
|
|
68
|
+
"## Pontos Positivos\n"
|
|
69
|
+
"O que está bem feito no código.\n\n"
|
|
70
|
+
"## Sugestões de Melhoria\n"
|
|
71
|
+
"Sugestões concretas com exemplos de código quando possível.\n\n"
|
|
72
|
+
"Escreve de forma direta e objetiva, sem saudações e sem emojis. "
|
|
73
|
+
"Não incluas introduções como 'Olá' ou 'Como code reviewer sénior'. "
|
|
74
|
+
"Indica sempre o ficheiro e número de linha quando referenciares código específico. "
|
|
75
|
+
"Responde em português."
|
|
76
|
+
),
|
|
77
|
+
"en": (
|
|
78
|
+
"Analyze the provided "
|
|
79
|
+
"code diff in detail and produce a complete, structured review.\n\n"
|
|
80
|
+
"Your review MUST include these sections:\n\n"
|
|
81
|
+
"## General Summary\n"
|
|
82
|
+
"Brief summary of changes and overall opinion.\n\n"
|
|
83
|
+
"## Potential Bugs and Errors\n"
|
|
84
|
+
"Identify bugs, logic errors, or unexpected behaviors. "
|
|
85
|
+
"Include file and line number.\n\n"
|
|
86
|
+
"## Security\n"
|
|
87
|
+
"Security issues (SQL injection, XSS, hardcoded credentials, etc.)\n\n"
|
|
88
|
+
"## Performance\n"
|
|
89
|
+
"Performance issues or optimization opportunities.\n\n"
|
|
90
|
+
"## Architecture and Design\n"
|
|
91
|
+
"Suggestions on design patterns, SOLID, separation of concerns.\n\n"
|
|
92
|
+
"## Code Style and Best Practices\n"
|
|
93
|
+
"Naming conventions, duplicated code, complexity, readability.\n\n"
|
|
94
|
+
"## Positive Aspects\n"
|
|
95
|
+
"What's done well in the code.\n\n"
|
|
96
|
+
"## Improvement Suggestions\n"
|
|
97
|
+
"Concrete suggestions with code examples when possible.\n\n"
|
|
98
|
+
"Write in a direct, objective tone with no greetings and no emojis. "
|
|
99
|
+
"Do not include intros like 'Hello' or 'As a senior reviewer'. "
|
|
100
|
+
"Always include file and line number when referencing specific code."
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
"security": {
|
|
104
|
+
"pt": (
|
|
105
|
+
"És um especialista em segurança de aplicações (AppSec). Analisa o diff "
|
|
106
|
+
"de código fornecido com foco EXCLUSIVO em segurança.\n\n"
|
|
107
|
+
"Procura por:\n"
|
|
108
|
+
"- SQL Injection\n"
|
|
109
|
+
"- Cross-Site Scripting (XSS)\n"
|
|
110
|
+
"- Cross-Site Request Forgery (CSRF)\n"
|
|
111
|
+
"- Credenciais hardcoded ou secrets expostos\n"
|
|
112
|
+
"- Vulnerabilidades de autenticação/autorização\n"
|
|
113
|
+
"- Insecure deserialization\n"
|
|
114
|
+
"- Path traversal\n"
|
|
115
|
+
"- Command injection\n"
|
|
116
|
+
"- Dependências com vulnerabilidades conhecidas\n"
|
|
117
|
+
"- Logging de informação sensível\n"
|
|
118
|
+
"- Configurações inseguras\n\n"
|
|
119
|
+
"Classifica cada problema encontrado por severidade: "
|
|
120
|
+
"🔴 CRÍTICO, 🟠 ALTO, 🟡 MÉDIO, 🟢 BAIXO.\n"
|
|
121
|
+
"Fornece recomendações de correção para cada problema. "
|
|
122
|
+
"Responde em português."
|
|
123
|
+
),
|
|
124
|
+
"en": (
|
|
125
|
+
"You are an application security (AppSec) specialist. Analyze the "
|
|
126
|
+
"provided code diff with EXCLUSIVE focus on security.\n\n"
|
|
127
|
+
"Look for:\n"
|
|
128
|
+
"- SQL Injection\n"
|
|
129
|
+
"- Cross-Site Scripting (XSS)\n"
|
|
130
|
+
"- Cross-Site Request Forgery (CSRF)\n"
|
|
131
|
+
"- Hardcoded credentials or exposed secrets\n"
|
|
132
|
+
"- Authentication/authorization vulnerabilities\n"
|
|
133
|
+
"- Insecure deserialization\n"
|
|
134
|
+
"- Path traversal\n"
|
|
135
|
+
"- Command injection\n"
|
|
136
|
+
"- Dependencies with known vulnerabilities\n"
|
|
137
|
+
"- Logging of sensitive information\n"
|
|
138
|
+
"- Insecure configurations\n\n"
|
|
139
|
+
"Classify each issue by severity: "
|
|
140
|
+
"🔴 CRITICAL, 🟠 HIGH, 🟡 MEDIUM, 🟢 LOW.\n"
|
|
141
|
+
"Provide fix recommendations for each issue."
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Special prompt for PR review with structured comments
|
|
147
|
+
PR_COMMENT_PROMPT = {
|
|
148
|
+
"pt": (
|
|
149
|
+
"Analisa o diff de código de um Pull Request "
|
|
150
|
+
"e retorna os teus comentários em formato JSON estruturado.\n\n"
|
|
151
|
+
"Para CADA problema encontrado, retorna um objeto JSON com:\n"
|
|
152
|
+
'- "file": caminho do ficheiro (ex: "src/auth.py")\n'
|
|
153
|
+
'- "line": número da linha no diff (inteiro, ou 0 se geral)\n'
|
|
154
|
+
'- "type": tipo de issue ("bug", "security", "performance", "style", "suggestion", "praise")\n'
|
|
155
|
+
'- "severity": severidade ("critical", "high", "medium", "low", "info")\n'
|
|
156
|
+
'- "comment": descrição direta do problema em português, sem saudações e sem emojis\n'
|
|
157
|
+
'- "suggestion": sugestão de correção (opcional, string vazia se não aplicável)\n'
|
|
158
|
+
'- "reference": fonte ou referência para o problema (ex: "OWASP Top 10", "PEP 8", URL de documentação, padrão ou princípio). Importante: incluir SEMPRE uma referência relevante.\n\n'
|
|
159
|
+
"No campo 'comment', escreve de forma objetiva e curta. "
|
|
160
|
+
"Não uses introduções como 'Olá' ou 'Como code reviewer sénior'.\n"
|
|
161
|
+
"No campo 'reference', inclui uma fonte confiável, padrão ou link para documentação relevante.\n\n"
|
|
162
|
+
"Responde APENAS com um JSON array válido. Exemplo:\n"
|
|
163
|
+
'[\n'
|
|
164
|
+
' {\n'
|
|
165
|
+
' "file": "src/auth.py",\n'
|
|
166
|
+
' "line": 42,\n'
|
|
167
|
+
' "type": "security",\n'
|
|
168
|
+
' "severity": "high",\n'
|
|
169
|
+
' "comment": "Password armazenada em texto simples sem hashing",\n'
|
|
170
|
+
' "suggestion": "Usar bcrypt ou argon2 para hash de passwords",\n'
|
|
171
|
+
' "reference": "OWASP - Password Storage Cheat Sheet (https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)"\n'
|
|
172
|
+
' }\n'
|
|
173
|
+
']\n\n'
|
|
174
|
+
"Se o código estiver bom, retorna um array com um único comentário de tipo "
|
|
175
|
+
'"praise". Responde APENAS com JSON válido, sem markdown ou texto extra.'
|
|
176
|
+
),
|
|
177
|
+
"en": (
|
|
178
|
+
"Analyze the Pull Request code diff "
|
|
179
|
+
"and return your comments in structured JSON format.\n\n"
|
|
180
|
+
"For EACH issue found, return a JSON object with:\n"
|
|
181
|
+
'- "file": file path (e.g., "src/auth.py")\n'
|
|
182
|
+
'- "line": line number in diff (integer, or 0 if general)\n'
|
|
183
|
+
'- "type": issue type ("bug", "security", "performance", "style", "suggestion", "praise")\n'
|
|
184
|
+
'- "severity": severity ("critical", "high", "medium", "low", "info")\n'
|
|
185
|
+
'- "comment": direct description of the issue, with no greetings and no emojis\n'
|
|
186
|
+
'- "suggestion": fix suggestion (optional, empty string if not applicable)\n'
|
|
187
|
+
'- "reference": source or reference for the issue (e.g., "OWASP Top 10", "PEP 8", documentation URL, standard or principle). Important: ALWAYS include a relevant reference.\n\n'
|
|
188
|
+
"In 'comment', use a short and objective tone. "
|
|
189
|
+
"Do not include intros like 'Hello' or 'As a senior reviewer'.\n"
|
|
190
|
+
"In 'reference', include a trusted source, standard or link to relevant documentation.\n\n"
|
|
191
|
+
"Respond ONLY with a valid JSON array. If the code looks good, return an "
|
|
192
|
+
'array with a single "praise" type comment. Respond ONLY with valid JSON.'
|
|
193
|
+
),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_system_prompt(verbosity: str, language: str) -> str:
|
|
198
|
+
"""Returns the appropriate system prompt."""
|
|
199
|
+
prompts = SYSTEM_PROMPTS.get(verbosity, SYSTEM_PROMPTS["detailed"])
|
|
200
|
+
return prompts.get(language, prompts["pt"])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_pr_comment_prompt(language: str) -> str:
|
|
204
|
+
"""Returns the prompt for structured PR comments."""
|
|
205
|
+
return PR_COMMENT_PROMPT.get(language, PR_COMMENT_PROMPT["pt"])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_scope_guidance(review_scope: str, language: str, structured: bool = False) -> str:
|
|
209
|
+
"""Returns additional instructions based on the review scope."""
|
|
210
|
+
scope = (review_scope or "diff_only").lower()
|
|
211
|
+
|
|
212
|
+
if scope == "full_code":
|
|
213
|
+
if language == "en":
|
|
214
|
+
return (
|
|
215
|
+
"Review scope: full_code. The diff contains only added lines (+) for each file. "
|
|
216
|
+
"Analyze the complete content of the changed files and identify issues in the new code. "
|
|
217
|
+
"Do not comment on deleted or absent code."
|
|
218
|
+
)
|
|
219
|
+
return (
|
|
220
|
+
"Escopo de review: full_code. O diff contém apenas linhas adicionadas (+) de cada ficheiro. "
|
|
221
|
+
"Analisa o conteúdo completo dos ficheiros alterados e identifica problemas no novo código. "
|
|
222
|
+
"Não comentes código eliminado ou ausente."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if structured:
|
|
226
|
+
if language == "en":
|
|
227
|
+
return (
|
|
228
|
+
"Review scope: diff_only. The diff contains only added lines (+) — context and deletions were removed. "
|
|
229
|
+
"Focus exclusively on issues introduced by the new lines in this PR. "
|
|
230
|
+
"For every problem, you MUST provide a valid file and line (>0) to allow inline comments. "
|
|
231
|
+
"Do not emit general problem comments without file/line."
|
|
232
|
+
)
|
|
233
|
+
return (
|
|
234
|
+
"Escopo de review: diff_only. O diff contém apenas linhas adicionadas (+) — contexto e eliminações foram removidos. "
|
|
235
|
+
"Foca exclusivamente em problemas introduzidos pelas novas linhas do PR. "
|
|
236
|
+
"Para cada problema, DEVE ser fornecido file e line válidos (>0) para comentário inline. "
|
|
237
|
+
"Não emitas comentários gerais de problema sem file/line."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if language == "en":
|
|
241
|
+
return (
|
|
242
|
+
"Review scope: diff_only. The diff contains only added lines (+). "
|
|
243
|
+
"Focus only on issues introduced by the new lines in this PR."
|
|
244
|
+
)
|
|
245
|
+
return (
|
|
246
|
+
"Escopo de review: diff_only. O diff contém apenas linhas adicionadas (+). "
|
|
247
|
+
"Foca apenas problemas introduzidos pelas novas linhas deste PR."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_user_message(diff: str, files_summary: list[dict], context: str = "") -> str:
|
|
252
|
+
"""
|
|
253
|
+
Builds the user message with the diff and context.
|
|
254
|
+
"""
|
|
255
|
+
parts = []
|
|
256
|
+
|
|
257
|
+
if files_summary:
|
|
258
|
+
parts.append("### Changed Files:")
|
|
259
|
+
for f in files_summary:
|
|
260
|
+
parts.append(
|
|
261
|
+
f" - `{f['file']}` (+{f['additions']}/-{f['deletions']})"
|
|
262
|
+
)
|
|
263
|
+
parts.append("")
|
|
264
|
+
|
|
265
|
+
if context:
|
|
266
|
+
parts.append(f"### Additional context:\n{context}\n")
|
|
267
|
+
|
|
268
|
+
parts.append("### Diff for review:")
|
|
269
|
+
parts.append(f"```diff\n{diff}\n```")
|
|
270
|
+
|
|
271
|
+
return "\n".join(parts)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Main LLM client class
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
class LLMClient:
|
|
278
|
+
"""Client for communication with LLM APIs."""
|
|
279
|
+
|
|
280
|
+
def __init__(self, config: ReviewConfig):
|
|
281
|
+
self.config = config
|
|
282
|
+
|
|
283
|
+
def _load_custom_prompt_text(self) -> str:
|
|
284
|
+
"""Loads extra instructions from a configurable Markdown file."""
|
|
285
|
+
path = (self.config.custom_prompt_file or "").strip()
|
|
286
|
+
if not path:
|
|
287
|
+
return ""
|
|
288
|
+
|
|
289
|
+
abs_path = os.path.abspath(path)
|
|
290
|
+
if not os.path.isfile(abs_path):
|
|
291
|
+
return ""
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
with open(abs_path, "r", encoding="utf-8") as f:
|
|
295
|
+
return f.read().strip()
|
|
296
|
+
except Exception:
|
|
297
|
+
return ""
|
|
298
|
+
|
|
299
|
+
def review(self, diff: str, files_summary: list[dict],
|
|
300
|
+
context: str = "", review_scope: str = "diff_only") -> str:
|
|
301
|
+
"""
|
|
302
|
+
Sends the diff to the LLM and returns the review as text.
|
|
303
|
+
"""
|
|
304
|
+
base_prompt = get_system_prompt(
|
|
305
|
+
self.config.verbosity,
|
|
306
|
+
self.config.review_language,
|
|
307
|
+
)
|
|
308
|
+
custom_prompt = self._load_custom_prompt_text()
|
|
309
|
+
|
|
310
|
+
scope_guidance = get_scope_guidance(
|
|
311
|
+
review_scope=review_scope,
|
|
312
|
+
language=self.config.review_language,
|
|
313
|
+
structured=False,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if custom_prompt:
|
|
317
|
+
system_prompt = (
|
|
318
|
+
f"{base_prompt}\n\n"
|
|
319
|
+
f"{scope_guidance}\n\n"
|
|
320
|
+
"---\n"
|
|
321
|
+
"Custom user instructions (follow with priority):\n"
|
|
322
|
+
f"{custom_prompt}"
|
|
323
|
+
)
|
|
324
|
+
merged_context = (
|
|
325
|
+
f"{context}\n\n[Custom context loaded from {self.config.custom_prompt_file}]"
|
|
326
|
+
if context else
|
|
327
|
+
f"[Custom context loaded from {self.config.custom_prompt_file}]"
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
system_prompt = f"{base_prompt}\n\n{scope_guidance}"
|
|
331
|
+
merged_context = context
|
|
332
|
+
|
|
333
|
+
user_message = build_user_message(diff, files_summary, merged_context)
|
|
334
|
+
|
|
335
|
+
provider = self.config.llm_provider.lower()
|
|
336
|
+
|
|
337
|
+
if provider == "openai":
|
|
338
|
+
return self._call_openai(system_prompt, user_message)
|
|
339
|
+
elif provider == "azure_openai":
|
|
340
|
+
return self._call_openai(system_prompt, user_message, azure=True)
|
|
341
|
+
elif provider == "gemini":
|
|
342
|
+
return self._call_gemini(system_prompt, user_message)
|
|
343
|
+
elif provider == "claude":
|
|
344
|
+
return self._call_claude(system_prompt, user_message)
|
|
345
|
+
elif provider == "ollama":
|
|
346
|
+
return self._call_ollama(system_prompt, user_message)
|
|
347
|
+
elif provider == "copilot":
|
|
348
|
+
return self._call_copilot(system_prompt, user_message)
|
|
349
|
+
elif provider == "bedrock":
|
|
350
|
+
return self._call_bedrock(system_prompt, user_message)
|
|
351
|
+
else:
|
|
352
|
+
raise LLMError(
|
|
353
|
+
f"Unsupported provider: '{provider}'.\n"
|
|
354
|
+
"Available providers: openai, azure_openai, gemini, claude, ollama, copilot, bedrock"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def review_pr_structured(self, diff: str, files_summary: list[dict],
|
|
358
|
+
context: str = "", review_scope: str = "diff_only") -> list[dict]:
|
|
359
|
+
"""
|
|
360
|
+
Sends the diff to the LLM and returns structured PR comments.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
List of dicts with keys: file, line, type, severity, comment, suggestion
|
|
364
|
+
"""
|
|
365
|
+
base_prompt = get_pr_comment_prompt(self.config.review_language)
|
|
366
|
+
custom_prompt = self._load_custom_prompt_text()
|
|
367
|
+
|
|
368
|
+
scope_guidance = get_scope_guidance(
|
|
369
|
+
review_scope=review_scope,
|
|
370
|
+
language=self.config.review_language,
|
|
371
|
+
structured=True,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if custom_prompt:
|
|
375
|
+
system_prompt = (
|
|
376
|
+
f"{base_prompt}\n\n"
|
|
377
|
+
f"{scope_guidance}\n\n"
|
|
378
|
+
"---\n"
|
|
379
|
+
"Custom user instructions (follow with priority):\n"
|
|
380
|
+
f"{custom_prompt}"
|
|
381
|
+
)
|
|
382
|
+
merged_context = (
|
|
383
|
+
f"{context}\n\n[Custom context loaded from {self.config.custom_prompt_file}]"
|
|
384
|
+
if context else
|
|
385
|
+
f"[Custom context loaded from {self.config.custom_prompt_file}]"
|
|
386
|
+
)
|
|
387
|
+
else:
|
|
388
|
+
system_prompt = f"{base_prompt}\n\n{scope_guidance}"
|
|
389
|
+
merged_context = context
|
|
390
|
+
|
|
391
|
+
user_message = build_user_message(diff, files_summary, merged_context)
|
|
392
|
+
|
|
393
|
+
provider = self.config.llm_provider.lower()
|
|
394
|
+
|
|
395
|
+
if provider == "openai":
|
|
396
|
+
raw = self._call_openai(system_prompt, user_message)
|
|
397
|
+
elif provider == "azure_openai":
|
|
398
|
+
raw = self._call_openai(system_prompt, user_message, azure=True)
|
|
399
|
+
elif provider == "gemini":
|
|
400
|
+
raw = self._call_gemini(system_prompt, user_message)
|
|
401
|
+
elif provider == "claude":
|
|
402
|
+
raw = self._call_claude(system_prompt, user_message)
|
|
403
|
+
elif provider == "ollama":
|
|
404
|
+
raw = self._call_ollama(system_prompt, user_message)
|
|
405
|
+
elif provider == "copilot":
|
|
406
|
+
raw = self._call_copilot(system_prompt, user_message)
|
|
407
|
+
elif provider == "bedrock":
|
|
408
|
+
raw = self._call_bedrock(system_prompt, user_message)
|
|
409
|
+
else:
|
|
410
|
+
raise LLMError(f"Unsupported provider: '{provider}'")
|
|
411
|
+
|
|
412
|
+
return self._parse_structured_comments(raw)
|
|
413
|
+
|
|
414
|
+
def _parse_structured_comments(self, raw_response: str) -> list[dict]:
|
|
415
|
+
"""Parses the LLM JSON response."""
|
|
416
|
+
# Try to extract JSON from possible markdown
|
|
417
|
+
text = raw_response.strip()
|
|
418
|
+
if text.startswith("```"):
|
|
419
|
+
# Remove markdown code blocks
|
|
420
|
+
lines = text.split("\n")
|
|
421
|
+
json_lines = []
|
|
422
|
+
in_block = False
|
|
423
|
+
for line in lines:
|
|
424
|
+
if line.strip().startswith("```"):
|
|
425
|
+
in_block = not in_block
|
|
426
|
+
continue
|
|
427
|
+
if in_block or not line.strip().startswith("```"):
|
|
428
|
+
json_lines.append(line)
|
|
429
|
+
text = "\n".join(json_lines).strip()
|
|
430
|
+
|
|
431
|
+
# Try to find JSON array
|
|
432
|
+
start = text.find("[")
|
|
433
|
+
end = text.rfind("]")
|
|
434
|
+
if start != -1 and end != -1:
|
|
435
|
+
text = text[start:end + 1]
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
comments = json.loads(text)
|
|
439
|
+
if not isinstance(comments, list):
|
|
440
|
+
comments = [comments]
|
|
441
|
+
except json.JSONDecodeError:
|
|
442
|
+
# Fallback: return as a general comment
|
|
443
|
+
return [{
|
|
444
|
+
"file": "",
|
|
445
|
+
"line": 0,
|
|
446
|
+
"type": "suggestion",
|
|
447
|
+
"severity": "info",
|
|
448
|
+
"comment": raw_response,
|
|
449
|
+
"suggestion": "",
|
|
450
|
+
"reference": "",
|
|
451
|
+
}]
|
|
452
|
+
|
|
453
|
+
# Validate and normalize each comment
|
|
454
|
+
validated = []
|
|
455
|
+
for c in comments:
|
|
456
|
+
validated.append({
|
|
457
|
+
"file": str(c.get("file", "")),
|
|
458
|
+
"line": int(c.get("line", 0)),
|
|
459
|
+
"type": str(c.get("type", "suggestion")),
|
|
460
|
+
"severity": str(c.get("severity", "info")),
|
|
461
|
+
"comment": str(c.get("comment", "")),
|
|
462
|
+
"suggestion": str(c.get("suggestion", "")),
|
|
463
|
+
"reference": str(c.get("reference", "")),
|
|
464
|
+
})
|
|
465
|
+
return validated
|
|
466
|
+
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
# OpenAI / Azure OpenAI
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
def _call_openai(self, system_prompt: str, user_message: str,
|
|
471
|
+
azure: bool = False) -> str:
|
|
472
|
+
"""
|
|
473
|
+
Calls the OpenAI API (GPT-4, GPT-4-turbo, GPT-4o).
|
|
474
|
+
Also supports Azure OpenAI.
|
|
475
|
+
"""
|
|
476
|
+
try:
|
|
477
|
+
import requests
|
|
478
|
+
except ImportError:
|
|
479
|
+
raise LLMError("Module 'requests' not installed: pip install requests")
|
|
480
|
+
|
|
481
|
+
api_key = self.config.api_key or self.config.openai_api_key
|
|
482
|
+
if not api_key:
|
|
483
|
+
raise LLMError(
|
|
484
|
+
"OpenAI API key not configured.\n"
|
|
485
|
+
"Configure llm.api_key or openai.api_key in config.yaml"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if azure:
|
|
489
|
+
base_url = self.config.api_base_url
|
|
490
|
+
if not base_url:
|
|
491
|
+
raise LLMError(
|
|
492
|
+
"Azure OpenAI requires API_BASE_URL to be configured.\n"
|
|
493
|
+
"E.g., https://your-resource.openai.azure.com/openai/deployments/your-deploy"
|
|
494
|
+
)
|
|
495
|
+
url = f"{base_url}/chat/completions?api-version=2024-02-01"
|
|
496
|
+
headers = {
|
|
497
|
+
"api-key": api_key,
|
|
498
|
+
"Content-Type": "application/json",
|
|
499
|
+
}
|
|
500
|
+
else:
|
|
501
|
+
base_url = self.config.api_base_url or "https://api.openai.com/v1"
|
|
502
|
+
url = f"{base_url}/chat/completions"
|
|
503
|
+
headers = {
|
|
504
|
+
"Authorization": f"Bearer {api_key}",
|
|
505
|
+
"Content-Type": "application/json",
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
payload = {
|
|
509
|
+
"model": self.config.model,
|
|
510
|
+
"messages": [
|
|
511
|
+
{"role": "system", "content": system_prompt},
|
|
512
|
+
{"role": "user", "content": user_message},
|
|
513
|
+
],
|
|
514
|
+
"max_tokens": self.config.max_tokens,
|
|
515
|
+
"temperature": self.config.temperature,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return self._http_openai_compatible(url, headers, payload)
|
|
519
|
+
|
|
520
|
+
# ------------------------------------------------------------------
|
|
521
|
+
# Google Gemini
|
|
522
|
+
# ------------------------------------------------------------------
|
|
523
|
+
def _call_gemini(self, system_prompt: str, user_message: str) -> str:
|
|
524
|
+
"""
|
|
525
|
+
Calls the Google Gemini API (gemini-pro, gemini-1.5-pro, gemini-2.0-flash).
|
|
526
|
+
Uses the Google AI Generative Language API.
|
|
527
|
+
"""
|
|
528
|
+
try:
|
|
529
|
+
import requests
|
|
530
|
+
except ImportError:
|
|
531
|
+
raise LLMError("Module 'requests' not installed: pip install requests")
|
|
532
|
+
|
|
533
|
+
api_key = self.config.api_key or self.config.gemini_api_key
|
|
534
|
+
if not api_key:
|
|
535
|
+
raise LLMError(
|
|
536
|
+
"Google Gemini API key not configured.\n"
|
|
537
|
+
"Get it at: https://aistudio.google.com/app/apikey\n"
|
|
538
|
+
"Configure llm.api_key or gemini.api_key in config.yaml"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
model = self.config.model or "gemini-1.5-pro"
|
|
542
|
+
base_url = (
|
|
543
|
+
self.config.api_base_url
|
|
544
|
+
or "https://generativelanguage.googleapis.com/v1beta"
|
|
545
|
+
)
|
|
546
|
+
url = f"{base_url}/models/{model}:generateContent?key={api_key}"
|
|
547
|
+
|
|
548
|
+
headers = {"Content-Type": "application/json"}
|
|
549
|
+
|
|
550
|
+
payload = {
|
|
551
|
+
"contents": [
|
|
552
|
+
{
|
|
553
|
+
"role": "user",
|
|
554
|
+
"parts": [{"text": f"{system_prompt}\n\n{user_message}"}],
|
|
555
|
+
}
|
|
556
|
+
],
|
|
557
|
+
"systemInstruction": {
|
|
558
|
+
"parts": [{"text": system_prompt}]
|
|
559
|
+
},
|
|
560
|
+
"generationConfig": {
|
|
561
|
+
"temperature": self.config.temperature,
|
|
562
|
+
"maxOutputTokens": self.config.max_tokens,
|
|
563
|
+
},
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
resp = requests.post(url, headers=headers, json=payload, timeout=180)
|
|
568
|
+
|
|
569
|
+
if resp.status_code == 400:
|
|
570
|
+
error_data = resp.json()
|
|
571
|
+
msg = error_data.get("error", {}).get("message", resp.text[:500])
|
|
572
|
+
raise LLMError(f"Gemini error (400): {msg}")
|
|
573
|
+
elif resp.status_code == 403:
|
|
574
|
+
raise LLMError(
|
|
575
|
+
"Gemini API key invalid or insufficient permissions.\n"
|
|
576
|
+
"Check at: https://aistudio.google.com/app/apikey"
|
|
577
|
+
)
|
|
578
|
+
elif resp.status_code == 429:
|
|
579
|
+
raise LLMError("Gemini rate limit exceeded. Wait and try again.")
|
|
580
|
+
elif resp.status_code >= 400:
|
|
581
|
+
raise LLMError(f"Gemini API error ({resp.status_code}): {resp.text[:500]}")
|
|
582
|
+
|
|
583
|
+
data = resp.json()
|
|
584
|
+
|
|
585
|
+
# Extract text from response
|
|
586
|
+
candidates = data.get("candidates", [])
|
|
587
|
+
if candidates:
|
|
588
|
+
content = candidates[0].get("content", {})
|
|
589
|
+
parts = content.get("parts", [])
|
|
590
|
+
if parts:
|
|
591
|
+
return parts[0].get("text", "")
|
|
592
|
+
|
|
593
|
+
raise LLMError(f"Unexpected Gemini response: {json.dumps(data)[:500]}")
|
|
594
|
+
|
|
595
|
+
except requests.exceptions.ConnectionError:
|
|
596
|
+
raise LLMError(
|
|
597
|
+
f"Could not connect to Gemini ({url[:80]}).\n"
|
|
598
|
+
"Check your network connection."
|
|
599
|
+
)
|
|
600
|
+
except requests.exceptions.Timeout:
|
|
601
|
+
raise LLMError("Gemini request timed out. Try again.")
|
|
602
|
+
except requests.exceptions.RequestException as exc:
|
|
603
|
+
raise LLMError(f"HTTP error calling Gemini: {exc}")
|
|
604
|
+
|
|
605
|
+
# ------------------------------------------------------------------
|
|
606
|
+
# Anthropic Claude
|
|
607
|
+
# ------------------------------------------------------------------
|
|
608
|
+
def _call_claude(self, system_prompt: str, user_message: str) -> str:
|
|
609
|
+
"""
|
|
610
|
+
Calls the Anthropic Claude API (claude-3-opus, claude-3-sonnet, claude-3-haiku).
|
|
611
|
+
Uses the Anthropic Messages API.
|
|
612
|
+
"""
|
|
613
|
+
try:
|
|
614
|
+
import requests
|
|
615
|
+
except ImportError:
|
|
616
|
+
raise LLMError("Module 'requests' not installed: pip install requests")
|
|
617
|
+
|
|
618
|
+
api_key = self.config.api_key or self.config.anthropic_api_key
|
|
619
|
+
if not api_key:
|
|
620
|
+
raise LLMError(
|
|
621
|
+
"Anthropic Claude API key not configured.\n"
|
|
622
|
+
"Get it at: https://console.anthropic.com/settings/keys\n"
|
|
623
|
+
"Configure llm.api_key or claude.api_key in config.yaml"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
model = self.config.model or "claude-3-5-sonnet-latest"
|
|
627
|
+
base_url = self.config.api_base_url or "https://api.anthropic.com"
|
|
628
|
+
url = f"{base_url}/v1/messages"
|
|
629
|
+
|
|
630
|
+
headers = {
|
|
631
|
+
"x-api-key": api_key,
|
|
632
|
+
"Content-Type": "application/json",
|
|
633
|
+
"anthropic-version": "2023-06-01",
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
payload = {
|
|
637
|
+
"model": model,
|
|
638
|
+
"max_tokens": self.config.max_tokens,
|
|
639
|
+
"temperature": self.config.temperature,
|
|
640
|
+
"system": system_prompt,
|
|
641
|
+
"messages": [
|
|
642
|
+
{"role": "user", "content": user_message},
|
|
643
|
+
],
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
resp = requests.post(url, headers=headers, json=payload, timeout=180)
|
|
648
|
+
|
|
649
|
+
if resp.status_code == 401:
|
|
650
|
+
raise LLMError(
|
|
651
|
+
"Claude API key invalid.\n"
|
|
652
|
+
"Check at: https://console.anthropic.com/settings/keys"
|
|
653
|
+
)
|
|
654
|
+
elif resp.status_code == 429:
|
|
655
|
+
raise LLMError("Claude rate limit exceeded. Wait and try again.")
|
|
656
|
+
elif resp.status_code >= 400:
|
|
657
|
+
error_data = {}
|
|
658
|
+
try:
|
|
659
|
+
error_data = resp.json()
|
|
660
|
+
except Exception:
|
|
661
|
+
pass
|
|
662
|
+
msg = error_data.get("error", {}).get("message", resp.text[:500])
|
|
663
|
+
raise LLMError(f"Claude API error ({resp.status_code}): {msg}")
|
|
664
|
+
|
|
665
|
+
data = resp.json()
|
|
666
|
+
|
|
667
|
+
# Extract text from response
|
|
668
|
+
content = data.get("content", [])
|
|
669
|
+
if content:
|
|
670
|
+
text_parts = [
|
|
671
|
+
block.get("text", "")
|
|
672
|
+
for block in content
|
|
673
|
+
if block.get("type") == "text"
|
|
674
|
+
]
|
|
675
|
+
if text_parts:
|
|
676
|
+
return "\n".join(text_parts)
|
|
677
|
+
|
|
678
|
+
raise LLMError(f"Unexpected Claude response: {json.dumps(data)[:500]}")
|
|
679
|
+
|
|
680
|
+
except requests.exceptions.ConnectionError:
|
|
681
|
+
raise LLMError(
|
|
682
|
+
"Could not connect to Anthropic Claude.\n"
|
|
683
|
+
"Check your network connection."
|
|
684
|
+
)
|
|
685
|
+
except requests.exceptions.Timeout:
|
|
686
|
+
raise LLMError("Claude request timed out. Try again.")
|
|
687
|
+
except requests.exceptions.RequestException as exc:
|
|
688
|
+
raise LLMError(f"HTTP error calling Claude: {exc}")
|
|
689
|
+
|
|
690
|
+
# ------------------------------------------------------------------
|
|
691
|
+
# Ollama (local models)
|
|
692
|
+
# ------------------------------------------------------------------
|
|
693
|
+
def _call_ollama(self, system_prompt: str, user_message: str) -> str:
|
|
694
|
+
"""
|
|
695
|
+
Calls the Ollama API (local models).
|
|
696
|
+
Uses the OpenAI-compatible endpoint.
|
|
697
|
+
"""
|
|
698
|
+
try:
|
|
699
|
+
import requests
|
|
700
|
+
except ImportError:
|
|
701
|
+
raise LLMError("Module 'requests' not installed: pip install requests")
|
|
702
|
+
|
|
703
|
+
base_url = self.config.api_base_url or "http://localhost:11434"
|
|
704
|
+
model = self.config.model or "llama3"
|
|
705
|
+
|
|
706
|
+
# Ollama supports the OpenAI-compatible endpoint
|
|
707
|
+
url = f"{base_url}/v1/chat/completions"
|
|
708
|
+
|
|
709
|
+
headers = {"Content-Type": "application/json"}
|
|
710
|
+
|
|
711
|
+
payload = {
|
|
712
|
+
"model": model,
|
|
713
|
+
"messages": [
|
|
714
|
+
{"role": "system", "content": system_prompt},
|
|
715
|
+
{"role": "user", "content": user_message},
|
|
716
|
+
],
|
|
717
|
+
"temperature": self.config.temperature,
|
|
718
|
+
"stream": False,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
# Ollama does not require an API key, but we add max_tokens if configured
|
|
722
|
+
if self.config.max_tokens:
|
|
723
|
+
payload["max_tokens"] = self.config.max_tokens
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
resp = requests.post(url, headers=headers, json=payload, timeout=300)
|
|
727
|
+
|
|
728
|
+
if resp.status_code == 404:
|
|
729
|
+
# Try Ollama native endpoint as fallback
|
|
730
|
+
return self._call_ollama_native(base_url, model, system_prompt, user_message)
|
|
731
|
+
elif resp.status_code >= 400:
|
|
732
|
+
raise LLMError(
|
|
733
|
+
f"Ollama error ({resp.status_code}): {resp.text[:500]}\n"
|
|
734
|
+
"Check if Ollama is running and the model is installed.\n"
|
|
735
|
+
f"Install the model with: ollama pull {model}"
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
data = resp.json()
|
|
739
|
+
|
|
740
|
+
if "choices" in data and data["choices"]:
|
|
741
|
+
return data["choices"][0]["message"]["content"]
|
|
742
|
+
else:
|
|
743
|
+
raise LLMError(f"Unexpected Ollama response: {json.dumps(data)[:500]}")
|
|
744
|
+
|
|
745
|
+
except requests.exceptions.ConnectionError:
|
|
746
|
+
raise LLMError(
|
|
747
|
+
f"Could not connect to Ollama at {base_url}.\n"
|
|
748
|
+
"Check if Ollama is running:\n"
|
|
749
|
+
" 1. Install: https://ollama.ai\n"
|
|
750
|
+
" 2. Start: ollama serve\n"
|
|
751
|
+
f" 3. Install the model: ollama pull {model}"
|
|
752
|
+
)
|
|
753
|
+
except requests.exceptions.Timeout:
|
|
754
|
+
raise LLMError(
|
|
755
|
+
"Ollama request timed out. Local models may take longer "
|
|
756
|
+
"depending on hardware."
|
|
757
|
+
)
|
|
758
|
+
except requests.exceptions.RequestException as exc:
|
|
759
|
+
raise LLMError(f"HTTP error calling Ollama: {exc}")
|
|
760
|
+
|
|
761
|
+
def _call_ollama_native(self, base_url: str, model: str,
|
|
762
|
+
system_prompt: str, user_message: str) -> str:
|
|
763
|
+
"""Fallback for Ollama native API (/api/chat)."""
|
|
764
|
+
import requests
|
|
765
|
+
|
|
766
|
+
url = f"{base_url}/api/chat"
|
|
767
|
+
|
|
768
|
+
payload = {
|
|
769
|
+
"model": model,
|
|
770
|
+
"messages": [
|
|
771
|
+
{"role": "system", "content": system_prompt},
|
|
772
|
+
{"role": "user", "content": user_message},
|
|
773
|
+
],
|
|
774
|
+
"stream": False,
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
try:
|
|
778
|
+
resp = requests.post(url, json=payload, timeout=300)
|
|
779
|
+
resp.raise_for_status()
|
|
780
|
+
data = resp.json()
|
|
781
|
+
return data.get("message", {}).get("content", str(data))
|
|
782
|
+
except Exception as exc:
|
|
783
|
+
raise LLMError(f"Error in Ollama native call: {exc}")
|
|
784
|
+
|
|
785
|
+
# ------------------------------------------------------------------
|
|
786
|
+
# GitHub Copilot
|
|
787
|
+
# ------------------------------------------------------------------
|
|
788
|
+
def _call_copilot(self, system_prompt: str, user_message: str) -> str:
|
|
789
|
+
"""
|
|
790
|
+
Calls the GitHub Copilot API.
|
|
791
|
+
|
|
792
|
+
Uses the GitHub Models API which requires:
|
|
793
|
+
- GitHub token (PAT) with adequate permissions
|
|
794
|
+
- Active GitHub Copilot subscription
|
|
795
|
+
|
|
796
|
+
The endpoint is compatible with OpenAI Chat Completions format.
|
|
797
|
+
Available models: gpt-4o, gpt-4o-mini, o1, o1-mini,
|
|
798
|
+
claude-3.5-sonnet (via GitHub), etc.
|
|
799
|
+
"""
|
|
800
|
+
try:
|
|
801
|
+
import requests
|
|
802
|
+
except ImportError:
|
|
803
|
+
raise LLMError("Module 'requests' not installed: pip install requests")
|
|
804
|
+
|
|
805
|
+
api_key = (
|
|
806
|
+
self.config.api_key
|
|
807
|
+
or self.config.github_token
|
|
808
|
+
)
|
|
809
|
+
if not api_key:
|
|
810
|
+
raise LLMError(
|
|
811
|
+
"GitHub token not configured for the Copilot provider.\n"
|
|
812
|
+
"Configure llm.api_key or copilot.github_token in config.yaml.\n"
|
|
813
|
+
"The token must have the necessary permissions and an active\n"
|
|
814
|
+
"GitHub Copilot subscription is required.\n"
|
|
815
|
+
"Create at: https://github.com/settings/tokens"
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
model = self.config.model or "gpt-4o"
|
|
819
|
+
base_url = (
|
|
820
|
+
self.config.api_base_url
|
|
821
|
+
or "https://models.github.ai/inference"
|
|
822
|
+
)
|
|
823
|
+
url = f"{base_url}/chat/completions"
|
|
824
|
+
|
|
825
|
+
headers = {
|
|
826
|
+
"Authorization": f"Bearer {api_key}",
|
|
827
|
+
"Content-Type": "application/json",
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
payload = {
|
|
831
|
+
"model": model,
|
|
832
|
+
"messages": [
|
|
833
|
+
{"role": "system", "content": system_prompt},
|
|
834
|
+
{"role": "user", "content": user_message},
|
|
835
|
+
],
|
|
836
|
+
"temperature": self.config.temperature,
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
# Add max_tokens if configured (some Copilot models
|
|
840
|
+
# may not support this parameter)
|
|
841
|
+
if self.config.max_tokens:
|
|
842
|
+
payload["max_tokens"] = self.config.max_tokens
|
|
843
|
+
|
|
844
|
+
try:
|
|
845
|
+
resp = requests.post(url, headers=headers, json=payload, timeout=180)
|
|
846
|
+
|
|
847
|
+
if resp.status_code == 401:
|
|
848
|
+
raise LLMError(
|
|
849
|
+
"GitHub token invalid or insufficient permissions.\n"
|
|
850
|
+
"Check:\n"
|
|
851
|
+
" 1. The token is correct\n"
|
|
852
|
+
" 2. You have an active GitHub Copilot subscription\n"
|
|
853
|
+
" 3. The token has the required permissions\n"
|
|
854
|
+
"Create/check at: https://github.com/settings/tokens"
|
|
855
|
+
)
|
|
856
|
+
elif resp.status_code == 403:
|
|
857
|
+
raise LLMError(
|
|
858
|
+
"Access denied to GitHub Copilot.\n"
|
|
859
|
+
"Check:\n"
|
|
860
|
+
" 1. You have an active GitHub Copilot subscription\n"
|
|
861
|
+
" 2. API access is enabled in your organization\n"
|
|
862
|
+
" 3. The token has the correct permissions"
|
|
863
|
+
)
|
|
864
|
+
elif resp.status_code == 429:
|
|
865
|
+
retry_after = resp.headers.get("Retry-After", "60")
|
|
866
|
+
raise LLMError(
|
|
867
|
+
f"GitHub Copilot rate limit exceeded.\n"
|
|
868
|
+
f"Wait {retry_after}s and try again.\n"
|
|
869
|
+
"Copilot has usage limits that vary by plan."
|
|
870
|
+
)
|
|
871
|
+
elif resp.status_code >= 400:
|
|
872
|
+
error_msg = resp.text[:500]
|
|
873
|
+
try:
|
|
874
|
+
error_data = resp.json()
|
|
875
|
+
error_msg = error_data.get("error", {}).get("message", error_msg)
|
|
876
|
+
except Exception:
|
|
877
|
+
pass
|
|
878
|
+
raise LLMError(
|
|
879
|
+
f"GitHub Copilot API error ({resp.status_code}): {error_msg}"
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
data = resp.json()
|
|
883
|
+
|
|
884
|
+
if "choices" in data and data["choices"]:
|
|
885
|
+
return data["choices"][0]["message"]["content"]
|
|
886
|
+
else:
|
|
887
|
+
raise LLMError(
|
|
888
|
+
f"Unexpected GitHub Copilot response: {json.dumps(data)[:500]}"
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
except requests.exceptions.ConnectionError:
|
|
892
|
+
raise LLMError(
|
|
893
|
+
f"Could not connect to GitHub Copilot ({base_url}).\n"
|
|
894
|
+
"Check your network connection."
|
|
895
|
+
)
|
|
896
|
+
except requests.exceptions.Timeout:
|
|
897
|
+
raise LLMError(
|
|
898
|
+
"GitHub Copilot request timed out. Try again."
|
|
899
|
+
)
|
|
900
|
+
except requests.exceptions.RequestException as exc:
|
|
901
|
+
raise LLMError(f"HTTP error calling GitHub Copilot: {exc}")
|
|
902
|
+
|
|
903
|
+
# ------------------------------------------------------------------
|
|
904
|
+
# AWS Bedrock
|
|
905
|
+
# ------------------------------------------------------------------
|
|
906
|
+
def _call_bedrock(self, system_prompt: str, user_message: str) -> str:
|
|
907
|
+
"""Calls the AWS Bedrock Runtime via the Converse API."""
|
|
908
|
+
try:
|
|
909
|
+
import boto3
|
|
910
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
911
|
+
except ImportError:
|
|
912
|
+
raise LLMError(
|
|
913
|
+
"AWS dependency not installed.\n"
|
|
914
|
+
"Install with: pip install boto3"
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
region = self.config.bedrock_region
|
|
918
|
+
if not region:
|
|
919
|
+
raise LLMError(
|
|
920
|
+
"Provider 'bedrock' requires bedrock.region in config.yaml."
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
try:
|
|
924
|
+
session_kwargs = {}
|
|
925
|
+
if self.config.bedrock_profile:
|
|
926
|
+
session_kwargs["profile_name"] = self.config.bedrock_profile
|
|
927
|
+
|
|
928
|
+
# Allows explicit credentials in YAML or AWS default credential chain.
|
|
929
|
+
if self.config.bedrock_access_key_id and self.config.bedrock_secret_access_key:
|
|
930
|
+
session_kwargs["aws_access_key_id"] = self.config.bedrock_access_key_id
|
|
931
|
+
session_kwargs["aws_secret_access_key"] = self.config.bedrock_secret_access_key
|
|
932
|
+
if self.config.bedrock_session_token:
|
|
933
|
+
session_kwargs["aws_session_token"] = self.config.bedrock_session_token
|
|
934
|
+
|
|
935
|
+
session = boto3.Session(**session_kwargs)
|
|
936
|
+
client = session.client("bedrock-runtime", region_name=region)
|
|
937
|
+
|
|
938
|
+
response = client.converse(
|
|
939
|
+
modelId=self.config.model,
|
|
940
|
+
system=[{"text": system_prompt}],
|
|
941
|
+
messages=[
|
|
942
|
+
{
|
|
943
|
+
"role": "user",
|
|
944
|
+
"content": [{"text": user_message}],
|
|
945
|
+
}
|
|
946
|
+
],
|
|
947
|
+
inferenceConfig={
|
|
948
|
+
"temperature": self.config.temperature,
|
|
949
|
+
"maxTokens": self.config.max_tokens,
|
|
950
|
+
},
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
content = (
|
|
954
|
+
response.get("output", {})
|
|
955
|
+
.get("message", {})
|
|
956
|
+
.get("content", [])
|
|
957
|
+
)
|
|
958
|
+
text_parts = [item.get("text", "") for item in content if "text" in item]
|
|
959
|
+
text = "\n".join([part for part in text_parts if part]).strip()
|
|
960
|
+
if not text:
|
|
961
|
+
raise LLMError(
|
|
962
|
+
f"Unexpected Bedrock response: {json.dumps(response)[:500]}"
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
return text
|
|
966
|
+
|
|
967
|
+
except (BotoCoreError, ClientError) as exc:
|
|
968
|
+
raise LLMError(f"Error calling AWS Bedrock: {exc}")
|
|
969
|
+
|
|
970
|
+
# ------------------------------------------------------------------
|
|
971
|
+
# HTTP helper for OpenAI-compatible APIs
|
|
972
|
+
# ------------------------------------------------------------------
|
|
973
|
+
def _http_openai_compatible(self, url: str, headers: dict, payload: dict) -> str:
|
|
974
|
+
"""Makes an HTTP call to OpenAI-compatible format APIs."""
|
|
975
|
+
import requests
|
|
976
|
+
|
|
977
|
+
try:
|
|
978
|
+
resp = requests.post(url, headers=headers, json=payload, timeout=180)
|
|
979
|
+
|
|
980
|
+
if resp.status_code == 401:
|
|
981
|
+
raise LLMError(
|
|
982
|
+
"API key invalid or expired. Check your configuration."
|
|
983
|
+
)
|
|
984
|
+
elif resp.status_code == 429:
|
|
985
|
+
raise LLMError(
|
|
986
|
+
"Rate limit exceeded. Wait a few seconds and try again."
|
|
987
|
+
)
|
|
988
|
+
elif resp.status_code >= 400:
|
|
989
|
+
raise LLMError(
|
|
990
|
+
f"API error ({resp.status_code}): {resp.text[:500]}"
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
data = resp.json()
|
|
994
|
+
|
|
995
|
+
if "choices" in data and data["choices"]:
|
|
996
|
+
return data["choices"][0]["message"]["content"]
|
|
997
|
+
else:
|
|
998
|
+
raise LLMError(f"Unexpected API response: {json.dumps(data)[:500]}")
|
|
999
|
+
|
|
1000
|
+
except requests.exceptions.ConnectionError:
|
|
1001
|
+
raise LLMError(
|
|
1002
|
+
f"Could not connect to {url}.\n"
|
|
1003
|
+
"Check the URL and your network connection."
|
|
1004
|
+
)
|
|
1005
|
+
except requests.exceptions.Timeout:
|
|
1006
|
+
raise LLMError("API request timed out. Try again.")
|
|
1007
|
+
except requests.exceptions.RequestException as exc:
|
|
1008
|
+
raise LLMError(f"HTTP request error: {exc}")
|