-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmodels.py
More file actions
472 lines (419 loc) · 19.7 KB
/
models.py
File metadata and controls
472 lines (419 loc) · 19.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
"""Multi-model LLM abstraction layer.
Supports OpenAI, Azure OpenAI, Anthropic, and Ollama providers.
Each provider returns structured review output in a common format.
Includes retry logic with exponential backoff and shared HTTP clients.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
import httpx
from .config import ModelConfig
logger = logging.getLogger(__name__)
# Patterns that may contain secrets in error messages
_SECRET_PATTERNS = re.compile(
r"(api[_-]?key|authorization|bearer|x-api-key|password|secret|token)"
r"\s*[:=]\s*\S+",
re.IGNORECASE,
)
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
def _sanitize_error(exc: Exception) -> str:
"""Remove potential secrets from exception messages."""
msg = str(exc)
msg = _SECRET_PATTERNS.sub(r"\1=***REDACTED***", msg)
# Strip full URLs that may contain keys in query params
msg = re.sub(r"https?://\S*[?&](api[_-]?key|token)=\S+", "[REDACTED_URL]", msg, flags=re.IGNORECASE)
return msg[:500] # cap length
@dataclass
class ModelResponse:
"""Normalized response from any LLM provider."""
provider: str
model: str
content: str
findings: list[dict[str, Any]]
usage: dict[str, int]
error: str | None = None
REVIEW_SYSTEM_PROMPT = """\
You are an expert Business Central AL code reviewer. Your review is based on \
the official Microsoft Dynamics 365 Business Central documentation \
(https://learn.microsoft.com/en-us/dynamics365/business-central/welcome) and \
the three official AL code analyzers: CodeCop, AppSourceCop, and PerTenantExtensionCop.
Analyze AL source files and report issues across these categories:
1. **Security** (DataClassification & Privacy):
- Every table field of class 'Normal' MUST have DataClassification set \
(AS0016). Valid values: CustomerContent, EndUserIdentifiableInformation, \
AccountData, OrganizationIdentifiableInformation, SystemMetadata, ToBeClassified is NOT acceptable.
- Encryption key functions must not be invoked (PTE0006).
- Lowering permissions should only be used in tests (AA0087).
- Email/Phone must not be embedded in source code (AA0240).
- InternalsVisibleTo must not be used as a security feature (AS0081, PTE0012).
2. **Performance** (Partial Records & Query Optimization):
- ALWAYS use SetLoadFields() before FindSet/FindFirst/Find('-')/Find('+') for partial records \
(AA0242). This is critical for tables with extensions — it avoids joining unnecessary table extensions.
- Use FindFirst() instead of FindSet() when only one record is needed (AA0233).
- Do NOT use Get/FindFirst/FindLast with Next() (AA0233).
- Only find a record if you actually use it (AA0175).
- FlowFields on tables should be indexed (AA0232).
- Avoid filtering on non-indexed fields (AA0210).
- Do not use CalcFields on non-FlowField/non-Blob fields (AA0211).
- Avoid using SetLoadFields before Insert/Delete/Rename — these need all fields (per MS docs).
- SIFT index should not be on primary or unique key (AA0222).
- Limit JIT loads by selecting all needed fields upfront (AA0242).
3. **Best Practices & Readability** (CodeCop AA rules):
- Naming: Variable/parameter names must be suffixed with type or object name (AA0072). \
Temp variables must be prefixed with "Temp" (AA0073). Non-temp must NOT have Temp prefix (AA0237).
- TextConst/Label variable names should have approved suffix (AA0074).
- Use PascalCase for objects, camelCase for API page/query properties (AA0101-AA0104).
- Use lowercase for all reserved language keywords (AA0241).
- Identifiers must not have quotes in name (AA0100).
- Only use BEGIN..END to enclose compound statements (AA0005).
- Function calls must have parentheses even with no parameters (AA0008).
- BEGIN after THEN/ELSE/DO must be on the same line (AA0013).
- Variables must be ordered by type in declarations (AA0021).
- Avoid nested WITH statements (AA0040).
- Do not declare unused variables (AA0137).
- Do not write unreachable code (AA0136).
- Variables must be initialized before usage (AA0205).
- Assigned values must be used (AA0206).
- Do not give local/global variables the same name as fields, methods, or actions (AA0198, AA0202-AA0204).
- Do not give parameters same name as global variables (AA0244, AA0245).
- Use 'this' qualification for readability (AA0248).
- Use namespaces (AA0247).
- Unordered using statements (AA0477).
4. **Localizability** (Labels, Tooltips, Captions):
- Use a text constant or Label for user-facing strings; never concatenate in Error/Message (AA0216, AA0217, AA0231).
- Use StrSubstNo with Label variables, not inline strings (AA0217).
- All page fields and actions MUST have ToolTip property set (AA0218, AS0062, PTE0008).
- ToolTip for fields should start with 'Specifies' (AA0219).
- ToolTip property must not be empty (AA0220).
- Must specify Caption for page fields (AA0225, AA0226).
- Must specify OptionCaption for non-table-field source expressions (AA0221, AA0223, AA0224).
- Use FieldCaption instead of FieldName, TableCaption instead of TableName (AA0448).
- CalcDate should use DateFormula variables or <> syntax (AA0462).
- Placeholders in labels should have explanatory comments (AA0470).
- String parameters must match placeholders (AA0131).
- Table fields should also have tooltips (AA0234).
5. **Upgrade & Compatibility** (AppSourceCop AS + lifecycle rules):
- Published tables/fields/pages must not be deleted (AS0001, AS0002, AS0029).
- Published fields must not change type or name (AS0004, AS0005).
- Published tables must not be renamed (AS0006).
- Key fields must not be changed/deleted (AS0009, AS0010).
- Public API procedures cannot be removed (AS0018).
- Event attributes cannot be removed/changed (AS0019-AS0021).
- Obsolete objects must have state Pending/Removed with justification (AA0213).
- ObsoleteTag must be set (AS0073, AS0075).
- Obsolete state cannot jump from 'No' to 'Removed' (AS0115).
- Optional return value should not be omitted in upgrade codeunits (AA0227).
- Extension name/publisher cannot be changed (AS0096, AS0097).
6. **Error Handling & Safety**:
- Missing error messages on Insert/Modify/Delete (use Insert(true) for runtime error).
- Missing TESTFIELD before operations on key fields.
- Only use AssertError in Test Codeunits (AA0161, AS0058).
- The local record should be modified before saving to the database (AA0214).
- StrSubstNo or string concatenation must not be used as Error parameter (AA0231).
7. **Events & Extensibility**:
- EventSubscriber methods must be local (AA0207).
- Avoid tight coupling; use integration/business events for extensibility.
- External business events should have obsolete marker (AA0250, AA0251).
- An affix is required for procedures in extension objects (AS0079).
- Objects should be placed in a namespace with at least two levels (AS0127).
- Permission set extensions rules (AA0050-AA0053).
8. **Per-Tenant Extension Rules** (PTE):
- Object/Field IDs must be in free range (PTE0001, PTE0002).
- Procedures must not subscribe to CompanyOpen events (PTE0003, AS0061).
- Test assertion functions not allowed in non-test context (PTE0007).
- Page controls and actions must use ApplicationArea (PTE0008).
- Table definitions must have a matching permission set (PTE0004, AS0103).
Reference the specific rule ID (AA/AS/PTE codes) in your findings when applicable.
Respond ONLY with a JSON object (no markdown fences) in this exact schema:
{
"summary": "One-paragraph overall assessment referencing Microsoft BC documentation",
"score": <1-10 quality score>,
"findings": [
{
"category": "<Security|Performance|BestPractices|Localizability|Upgrade|ErrorHandling|Events|PTECompliance>",
"severity": "<critical|warning|info>",
"rule_id": "<AA0xxx|AS0xxx|PTE0xxx or null>",
"line": <line number or null>,
"code_snippet": "<relevant code fragment or null>",
"message": "<clear description referencing the official rule>",
"suggestion": "<how to fix it with correct AL syntax>",
"doc_reference": "<MS Learn URL for the rule or null>"
}
]
}
"""
class MultiModelClient:
"""Dispatches review requests to multiple LLM providers concurrently.
Uses a shared httpx.AsyncClient with connection pooling.
Includes retry logic with exponential backoff for transient errors.
"""
def __init__(
self,
models: list[ModelConfig],
timeout: float = 120.0,
retry_max_attempts: int = 3,
retry_backoff_base: float = 1.0,
) -> None:
self._models = [m for m in models if m.enabled]
self._timeout = timeout
self._retry_max = retry_max_attempts
self._retry_backoff = retry_backoff_base
self._http: httpx.AsyncClient | None = None
async def _get_http(self) -> httpx.AsyncClient:
if self._http is None or self._http.is_closed:
self._http = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout, connect=10.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
return self._http
async def close(self) -> None:
if self._http and not self._http.is_closed:
await self._http.aclose()
self._http = None
@property
def active_models(self) -> list[str]:
return [f"{m.provider}/{m.model}" for m in self._models]
@property
def model_count(self) -> int:
return len(self._models)
async def review_with_all(self, code: str, filename: str) -> list[ModelResponse]:
"""Send the code to all configured models and collect responses."""
if not self._models:
return [ModelResponse(
provider="none",
model="none",
content="",
findings=[],
usage={},
error="No LLM models configured. Set API keys in .env file.",
)]
tasks = [self._review_single(model, code, filename) for model in self._models]
try:
return await asyncio.gather(*tasks)
finally:
await self.close()
async def review_with_model(self, provider: str, code: str, filename: str) -> ModelResponse:
"""Review code using a specific provider."""
model = next((m for m in self._models if m.provider == provider), None)
if not model:
return ModelResponse(
provider=provider,
model="unknown",
content="",
findings=[],
usage={},
error=f"Provider '{provider}' is not configured.",
)
try:
return await self._review_single(model, code, filename)
finally:
await self.close()
async def _review_single(self, model: ModelConfig, code: str, filename: str) -> ModelResponse:
"""Route to the appropriate provider implementation with retry."""
last_error: Exception | None = None
for attempt in range(self._retry_max):
try:
if model.provider == "openai":
return await self._call_openai(model, code, filename)
elif model.provider == "azure_openai":
return await self._call_azure_openai(model, code, filename)
elif model.provider == "anthropic":
return await self._call_anthropic(model, code, filename)
elif model.provider == "ollama":
return await self._call_ollama(model, code, filename)
else:
return ModelResponse(
provider=model.provider, model=model.model, content="",
findings=[], usage={},
error=f"Unsupported provider: {model.provider}",
)
except httpx.HTTPStatusError as exc:
last_error = exc
if exc.response.status_code not in _RETRYABLE_STATUS_CODES:
break # non-retryable
wait = self._retry_backoff * (2 ** attempt)
logger.warning(
"LLM %s/%s returned %d, retry %d/%d in %.1fs",
model.provider, model.model, exc.response.status_code,
attempt + 1, self._retry_max, wait,
)
await asyncio.sleep(wait)
except (httpx.TimeoutException, httpx.ConnectError) as exc:
last_error = exc
wait = self._retry_backoff * (2 ** attempt)
logger.warning(
"LLM %s/%s network error, retry %d/%d in %.1fs: %s",
model.provider, model.model,
attempt + 1, self._retry_max, wait, type(exc).__name__,
)
await asyncio.sleep(wait)
except Exception as exc:
last_error = exc
break # unknown errors are not retried
safe_msg = _sanitize_error(last_error) if last_error else "Unknown error"
logger.error("LLM call failed for %s/%s after retries: %s", model.provider, model.model, safe_msg)
return ModelResponse(
provider=model.provider, model=model.model, content="",
findings=[], usage={}, error=safe_msg,
)
def _build_user_prompt(self, code: str, filename: str) -> str:
return f"Review this Business Central AL file: `{filename}`\n\n```al\n{code}\n```"
async def _call_openai(self, model: ModelConfig, code: str, filename: str) -> ModelResponse:
base_url = model.base_url or "https://api.openai.com/v1"
url = f"{base_url.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {model.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model.model,
"temperature": model.temperature,
"max_tokens": model.max_tokens,
"messages": [
{"role": "system", "content": REVIEW_SYSTEM_PROMPT},
{"role": "user", "content": self._build_user_prompt(code, filename)},
],
}
return await self._post_openai_compat(url, headers, payload, model)
async def _call_azure_openai(self, model: ModelConfig, code: str, filename: str) -> ModelResponse:
if not model.base_url:
return ModelResponse(
provider=model.provider, model=model.model, content="", findings=[], usage={},
error="AZURE_OPENAI_ENDPOINT not configured",
)
url = (
f"{model.base_url.rstrip('/')}/openai/deployments/{model.model}"
f"/chat/completions?api-version={model.api_version}"
)
headers = {
"api-key": model.api_key,
"Content-Type": "application/json",
}
payload = {
"temperature": model.temperature,
"max_tokens": model.max_tokens,
"messages": [
{"role": "system", "content": REVIEW_SYSTEM_PROMPT},
{"role": "user", "content": self._build_user_prompt(code, filename)},
],
}
return await self._post_openai_compat(url, headers, payload, model)
async def _post_openai_compat(
self, url: str, headers: dict, payload: dict, model: ModelConfig,
) -> ModelResponse:
http = await self._get_http()
resp = await http.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
findings = self._parse_findings(content)
return ModelResponse(
provider=model.provider,
model=model.model,
content=content,
findings=findings,
usage={
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
},
)
async def _call_anthropic(self, model: ModelConfig, code: str, filename: str) -> ModelResponse:
url = "https://api.anthropic.com/v1/messages"
headers = {
"x-api-key": model.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
}
payload = {
"model": model.model,
"max_tokens": model.max_tokens,
"temperature": model.temperature,
"system": REVIEW_SYSTEM_PROMPT,
"messages": [
{"role": "user", "content": self._build_user_prompt(code, filename)},
],
}
http = await self._get_http()
resp = await http.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
content = data["content"][0]["text"]
usage = data.get("usage", {})
findings = self._parse_findings(content)
return ModelResponse(
provider=model.provider,
model=model.model,
content=content,
findings=findings,
usage={
"prompt_tokens": usage.get("input_tokens", 0),
"completion_tokens": usage.get("output_tokens", 0),
},
)
async def _call_ollama(self, model: ModelConfig, code: str, filename: str) -> ModelResponse:
url = f"{model.base_url.rstrip('/')}/api/chat"
payload = {
"model": model.model,
"stream": False,
"options": {
"temperature": model.temperature,
"num_predict": model.max_tokens,
},
"messages": [
{"role": "system", "content": REVIEW_SYSTEM_PROMPT},
{"role": "user", "content": self._build_user_prompt(code, filename)},
],
}
http = await self._get_http()
resp = await http.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
content = data["message"]["content"]
eval_count = data.get("eval_count", 0)
prompt_count = data.get("prompt_eval_count", 0)
findings = self._parse_findings(content)
return ModelResponse(
provider=model.provider,
model=model.model,
content=content,
findings=findings,
usage={"prompt_tokens": prompt_count, "completion_tokens": eval_count},
)
@staticmethod
def _parse_findings(content: str) -> list[dict[str, Any]]:
"""Extract the findings array from LLM JSON output."""
text = content.strip()
# Strip markdown code fences if present
if text.startswith("```"):
first_nl = text.index("\n")
last_fence = text.rfind("```")
text = text[first_nl + 1 : last_fence].strip()
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed.get("findings", [])
except json.JSONDecodeError:
# Try to extract just the findings array
start = text.find('"findings"')
if start == -1:
return []
bracket_start = text.find("[", start)
if bracket_start == -1:
return []
depth = 0
for i in range(bracket_start, len(text)):
if text[i] == "[":
depth += 1
elif text[i] == "]":
depth -= 1
if depth == 0:
try:
return json.loads(text[bracket_start : i + 1])
except json.JSONDecodeError:
return []
return []