@krotovm/gitlab-ai-review 1.0.38 → 1.0.39

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.
@@ -7,6 +7,9 @@ import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFIC
7
7
  /** Hard deadline per model call, retries included. Must exceed the gateway's own
8
8
  * proxy timeout so the SDK's 5xx retries stay the first line of defense. */
9
9
  const COMPLETION_TIMEOUT_MS = 180_000;
10
+ /** Whether to request token usage in streamed completions. Flipped off for the
11
+ * rest of the run the first time a gateway rejects stream_options with a 400. */
12
+ let streamUsageSupported = true;
10
13
  /** Tool-result caps. Every tool response is re-sent as prompt on each following
11
14
  * round, so oversized results multiply prefill time on self-hosted models. */
12
15
  const MAX_TOOL_FILE_CHARS = 12_000;
@@ -74,12 +77,27 @@ async function createCompletionWithDebug(params) {
74
77
  // The SDK's own timeout only covers time-to-headers; a stalled SSE body would hang
75
78
  // forever without the explicit AbortSignal deadline.
76
79
  const { stream: _stream, ...createParams } = request;
80
+ const attempt = (withUsage) => openaiInstance.chat.completions
81
+ .stream(withUsage
82
+ ? { ...createParams, stream_options: { include_usage: true } }
83
+ : createParams, { signal: AbortSignal.timeout(COMPLETION_TIMEOUT_MS) })
84
+ .finalChatCompletion();
77
85
  try {
78
- const completion = await openaiInstance.chat.completions
79
- .stream(createParams, {
80
- signal: AbortSignal.timeout(COMPLETION_TIMEOUT_MS),
81
- })
82
- .finalChatCompletion();
86
+ let completion;
87
+ try {
88
+ completion = await attempt(streamUsageSupported);
89
+ }
90
+ catch (error) {
91
+ // Strict OpenAI-compatible gateways 400 on unknown params. Drop the
92
+ // usage request for the rest of the run and retry this call once.
93
+ const rejectedStreamOptions = streamUsageSupported &&
94
+ error?.status === 400 &&
95
+ String(error?.message ?? "").includes("stream_options");
96
+ if (!rejectedStreamOptions)
97
+ throw error;
98
+ streamUsageSupported = false;
99
+ completion = await attempt(false);
100
+ }
83
101
  await appendDebugDump(debugDumpFile, debugRecordWriter, {
84
102
  kind: "openai_response",
85
103
  label: requestLabel,
@@ -1,5 +1,6 @@
1
1
  /** @format */
2
2
  import { writeFile } from "node:fs/promises";
3
+ import { parseReviewFindings } from "../prompt/utils.js";
3
4
  function escapeHtml(value) {
4
5
  return value
5
6
  .replaceAll("&", "&")
@@ -11,31 +12,94 @@ function escapeHtml(value) {
11
12
  export async function renderDebugArtifactsHtml(params) {
12
13
  const { records, artifactHtmlFile, cliVersion, aiModel } = params;
13
14
  const responses = records.filter((r) => r.kind === "openai_response");
15
+ const requests = records.filter((r) => r.kind === "openai_request");
16
+ const errors = records.filter((r) => r.kind === "openai_error");
14
17
  const byLabel = new Map();
15
18
  for (const response of responses) {
16
19
  if (typeof response.label === "string")
17
20
  byLabel.set(response.label, response);
18
21
  }
19
- const totalTokens = responses.reduce((sum, r) => sum + Number(r?.response?.usage?.total_tokens ?? 0), 0);
20
- const tokenLine = responses
21
- .map((r) => {
22
- const label = String(r.label ?? "unknown");
23
- const tokens = Number(r?.response?.usage?.total_tokens ?? 0);
24
- return `${label}: ${tokens}`;
25
- })
26
- .join(" • ");
22
+ const requestTsByLabel = new Map();
23
+ for (const request of requests) {
24
+ if (typeof request.label === "string" && !requestTsByLabel.has(request.label))
25
+ requestTsByLabel.set(request.label, String(request.ts ?? ""));
26
+ }
27
+ function usageOf(record) {
28
+ const u = record?.response?.usage;
29
+ if (u == null)
30
+ return null;
31
+ return {
32
+ prompt: Number(u.prompt_tokens ?? 0),
33
+ completion: Number(u.completion_tokens ?? 0),
34
+ total: Number(u.total_tokens ?? 0),
35
+ };
36
+ }
37
+ function sumUsage(labels) {
38
+ const usage = { prompt: 0, completion: 0, total: 0 };
39
+ let reported = 0;
40
+ for (const label of labels) {
41
+ const u = usageOf(byLabel.get(label));
42
+ if (u == null)
43
+ continue;
44
+ reported += 1;
45
+ usage.prompt += u.prompt;
46
+ usage.completion += u.completion;
47
+ usage.total += u.total;
48
+ }
49
+ return { usage, reported };
50
+ }
51
+ function tokensText(labels) {
52
+ const { usage, reported } = sumUsage(labels);
53
+ if (reported === 0)
54
+ return "tokens: not reported by backend";
55
+ return `prompt: ${usage.prompt.toLocaleString()} • completion: ${usage.completion.toLocaleString()} • total: ${usage.total.toLocaleString()}`;
56
+ }
57
+ function parseTs(value) {
58
+ const ms = Date.parse(String(value ?? ""));
59
+ return Number.isFinite(ms) ? ms : null;
60
+ }
61
+ function durationText(labels) {
62
+ const starts = labels
63
+ .map((label) => parseTs(requestTsByLabel.get(label)))
64
+ .filter((n) => n != null);
65
+ const ends = labels
66
+ .map((label) => parseTs(byLabel.get(label)?.ts))
67
+ .filter((n) => n != null);
68
+ if (starts.length === 0 || ends.length === 0)
69
+ return "";
70
+ return `${((Math.max(...ends) - Math.min(...starts)) / 1000).toFixed(1)}s`;
71
+ }
72
+ const allLabels = Array.from(byLabel.keys());
73
+ const triageLabels = allLabels.filter((l) => l.startsWith("triage_pass"));
74
+ const consolidateLabels = allLabels.filter((l) => l === "consolidate_pass");
75
+ const verificationLabels = allLabels.filter((l) => l.startsWith("verification_pass"));
76
+ const mainLabels = allLabels.filter((l) => l.startsWith("main_review"));
77
+ // Per-file review request groups, in first-seen (chronological) order.
78
+ const fileGroups = new Map();
79
+ const fileLabelRe = /^file_review_(.+?)_(?:round_\d+|final_after_tool_limit)$/;
80
+ for (const response of responses) {
81
+ const label = typeof response.label === "string" ? response.label : "";
82
+ const match = label.match(fileLabelRe);
83
+ if (match == null)
84
+ continue;
85
+ const group = fileGroups.get(match[1]) ?? [];
86
+ group.push(label);
87
+ fileGroups.set(match[1], group);
88
+ }
27
89
  function getContent(label) {
28
90
  const content = byLabel.get(label)?.response?.choices?.[0]?.message?.content;
29
91
  return typeof content === "string" ? content : "";
30
92
  }
31
- function getTokenTriplet(label) {
32
- const usage = byLabel.get(label)?.response?.usage;
33
- if (usage == null)
34
- return "prompt: 0 • completion: 0 • total: 0";
35
- return `prompt: ${usage.prompt_tokens ?? 0} • completion: ${usage.completion_tokens ?? 0} • total: ${usage.total_tokens ?? 0}`;
36
- }
37
- function findTs(label) {
38
- return String(byLabel.get(label)?.ts ?? "");
93
+ function lastContent(labels) {
94
+ const finalLabel = labels.find((l) => l.endsWith("_final_after_tool_limit"));
95
+ if (finalLabel != null && getContent(finalLabel).trim() !== "")
96
+ return getContent(finalLabel);
97
+ for (let i = labels.length - 1; i >= 0; i -= 1) {
98
+ const content = getContent(labels[i]);
99
+ if (content.trim() !== "")
100
+ return content;
101
+ }
102
+ return "";
39
103
  }
40
104
  function formatAsPrettyJsonIfPossible(value) {
41
105
  const trimmed = value.trim();
@@ -106,28 +170,22 @@ export async function renderDebugArtifactsHtml(params) {
106
170
  const trimmed = markdown.trim();
107
171
  if (trimmed === "")
108
172
  return "<pre>No data</pre>";
109
- const blocks = trimmed.split(/\n\s*\n/);
110
- const items = [];
111
- for (const block of blocks) {
112
- const lines = block.split("\n").map((l) => l.trimEnd());
113
- const title = lines[0] ?? "";
114
- const file = lines.find((l) => l.trimStart().startsWith("File:")) ?? "";
115
- const line = lines.find((l) => l.trimStart().startsWith("Line:")) ?? "";
116
- const why = lines.find((l) => l.trimStart().startsWith("Why:")) ?? "";
117
- if (!title.startsWith("- ["))
118
- continue;
119
- const isHigh = title.toLowerCase().includes("[high]");
120
- items.push(`<div class="finding${isHigh ? " high" : ""}"><div class="title">${escapeHtml(title)}</div><div class="meta">${escapeHtml(file)} • ${escapeHtml(line)}</div><div>${escapeHtml(why)}</div></div>`);
121
- }
122
- if (items.length === 0)
173
+ const findings = parseReviewFindings(trimmed);
174
+ if (findings.length === 0)
123
175
  return `<pre>${escapeHtml(trimmed)}</pre>`;
124
- return items.join("\n");
176
+ return findings
177
+ .map((f) => `<div class="finding${f.severity === "high" ? " high" : ""}"><div class="title">${escapeHtml(`[${f.severity}] ${f.title}`)}</div><div class="meta">${escapeHtml(`${f.file}:${f.line}`)}</div><div>${escapeHtml(f.why)}</div></div>`)
178
+ .join("\n");
179
+ }
180
+ function sectionHeader(badge, labels) {
181
+ const duration = durationText(labels);
182
+ const parts = [
183
+ tokensText(labels),
184
+ duration === "" ? "" : `took ${duration}`,
185
+ `${labels.length} request(s)`,
186
+ ].filter((p) => p !== "");
187
+ return `<div class="row"><span class="badge">${escapeHtml(badge)}</span><span class="tokens">${escapeHtml(parts.join(" • "))}</span></div>`;
125
188
  }
126
- const triageContent = getContent("triage_pass");
127
- const triageContentPretty = formatAsPrettyJsonIfPossible(triageContent);
128
- const fileServerLabel = Array.from(byLabel.keys()).find((k) => k.startsWith("file_review_server.js_round_"));
129
- const fileCiLabel = Array.from(byLabel.keys()).find((k) => k.startsWith("file_review_.gitlab-ci.yml_round_"));
130
- const consolidateLabel = "consolidate_pass";
131
189
  function pickVerificationSection() {
132
190
  const afterLimit = getContent("verification_pass_final_after_tool_limit");
133
191
  if (afterLimit.trim() !== "")
@@ -135,7 +193,7 @@ export async function renderDebugArtifactsHtml(params) {
135
193
  label: "verification_pass_final_after_tool_limit",
136
194
  content: afterLimit,
137
195
  };
138
- const roundLabels = Array.from(byLabel.keys())
196
+ const roundLabels = verificationLabels
139
197
  .filter((k) => k.startsWith("verification_pass_round_"))
140
198
  .sort((a, b) => {
141
199
  const na = Number(a.replace("verification_pass_round_", ""));
@@ -154,8 +212,53 @@ export async function renderDebugArtifactsHtml(params) {
154
212
  return { label: "verification_pass_round_1", content: "" };
155
213
  }
156
214
  const verificationSection = pickVerificationSection();
157
- const verificationLabel = verificationSection.label;
158
215
  const finalStatus = verificationSection.content.trim() !== "" ? "Verified" : "Fallback";
216
+ // Per-pass token/duration summary table.
217
+ const passRows = [
218
+ ["Triage", triageLabels],
219
+ ...Array.from(fileGroups.entries()).map(([path, labels]) => [`File review — ${path}`, labels]),
220
+ ["Consolidation", consolidateLabels],
221
+ ["Verification", verificationLabels],
222
+ ["Single-pass fallback", mainLabels],
223
+ ].filter(([, labels]) => labels.length > 0);
224
+ const cell = (value, opts) => `<td style="padding:6px 8px;border-bottom:1px solid var(--line);${opts?.strong ? "font-weight:700;" : ""}">${value}</td>`;
225
+ const usageCells = (labels, strong) => {
226
+ const { usage, reported } = sumUsage(labels);
227
+ if (reported === 0)
228
+ return `${cell("n/a", { strong })}${cell("n/a", { strong })}${cell("n/a", { strong })}`;
229
+ return [usage.prompt, usage.completion, usage.total]
230
+ .map((n) => cell(escapeHtml(n.toLocaleString()), { strong }))
231
+ .join("");
232
+ };
233
+ const passTableRows = passRows
234
+ .map(([name, labels]) => `<tr>${cell(escapeHtml(name))}${cell(String(labels.length))}${usageCells(labels)}${cell(escapeHtml(durationText(labels) || "—"))}</tr>`)
235
+ .join("\n");
236
+ const totalRow = `<tr>${cell("Total", { strong: true })}${cell(String(allLabels.length), { strong: true })}${usageCells(allLabels, true)}${cell(escapeHtml(durationText(allLabels) || "—"), { strong: true })}</tr>`;
237
+ const totalUsage = sumUsage(allLabels);
238
+ const totalTokensText = totalUsage.reported === 0 ? "n/a" : totalUsage.usage.total.toLocaleString();
239
+ const wallStarts = requests
240
+ .map((r) => parseTs(r.ts))
241
+ .filter((n) => n != null);
242
+ const wallEnds = [...responses, ...errors]
243
+ .map((r) => parseTs(r.ts))
244
+ .filter((n) => n != null);
245
+ const wallText = wallStarts.length > 0 && wallEnds.length > 0
246
+ ? `${((Math.max(...wallEnds) - Math.min(...wallStarts)) / 1000).toFixed(1)}s`
247
+ : "n/a";
248
+ const fileSections = Array.from(fileGroups.entries())
249
+ .map(([path, labels]) => `<div class="section">${sectionHeader(`file: ${path}`, labels)}${renderFindings(lastContent(labels))}</div>`)
250
+ .join("\n");
251
+ const triageRawSections = triageLabels
252
+ .map((label) => `<div class="section">${sectionHeader(`label: ${label}`, [label])}<pre>${escapeHtml(formatAsPrettyJsonIfPossible(getContent(label)))}</pre></div>`)
253
+ .join("\n");
254
+ const errorSection = errors.length === 0
255
+ ? ""
256
+ : `<h2>Errors</h2>
257
+ <div class="section">
258
+ ${errors
259
+ .map((e) => `<div class="finding high"><div class="title">${escapeHtml(String(e.label ?? "unknown"))}</div><div class="meta">${escapeHtml(String(e.ts ?? ""))}</div><div>${escapeHtml(`${e?.error?.name ?? ""} ${e?.error?.status ?? ""} ${e?.error?.message ?? ""}`.trim())}</div></div>`)
260
+ .join("\n")}
261
+ </div>`;
159
262
  const html = `<!doctype html>
160
263
  <html lang="en">
161
264
  <head>
@@ -167,13 +270,14 @@ export async function renderDebugArtifactsHtml(params) {
167
270
  * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 Inter,system-ui,sans-serif; padding:24px; }
168
271
  .wrap{max-width:1100px;margin:0 auto;} h1,h2{margin:0 0 10px;} h1{font-size:24px;} h2{font-size:18px;margin-top:26px;} .sub{color:var(--muted);margin-bottom:18px;}
169
272
  .grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0 22px;} .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px;}
170
- .k{color:var(--muted);font-size:12px;} .v{font-size:20px;font-weight:700;margin-top:4px;} .ok{color:var(--ok);}
273
+ .k{color:var(--muted);font-size:12px;} .v{font-size:20px;font-weight:700;margin-top:4px;} .ok{color:var(--ok);} .bad{color:var(--high);}
171
274
  .section{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;}
172
275
  .row{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
173
276
  .badge{border:1px solid var(--line);background:#16223a;border-radius:999px;padding:2px 10px;font-size:12px;color:var(--muted);}
174
277
  .tokens{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:var(--muted);}
175
278
  .finding{border-left:3px solid var(--med);background:#131f36;padding:10px 12px;border-radius:8px;margin:8px 0;} .finding.high{border-left-color:var(--high);}
176
279
  .finding .title{font-weight:700;} .meta{color:var(--muted);font-size:12px;margin:4px 0;}
280
+ table{width:100%;border-collapse:collapse;font-size:13px;}
177
281
  pre{margin:8px 0 0;white-space:pre-wrap;word-break:break-word;background:var(--mono-bg);border:1px solid var(--line);padding:10px;border-radius:8px;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d7e3ff;}
178
282
  @media (max-width:900px){.grid{grid-template-columns:1fr 1fr;}} @media (max-width:520px){.grid{grid-template-columns:1fr;}}
179
283
  </style>
@@ -185,16 +289,34 @@ export async function renderDebugArtifactsHtml(params) {
185
289
 
186
290
  <div class="grid">
187
291
  <div class="card"><div class="k">Model</div><div class="v">${escapeHtml(aiModel)}</div></div>
188
- <div class="card"><div class="k">Requests</div><div class="v">${escapeHtml(String(records.filter((r) => r.kind === "openai_request").length))}</div></div>
189
- <div class="card"><div class="k">Responses</div><div class="v">${escapeHtml(String(responses.length))}</div></div>
292
+ <div class="card"><div class="k">Requests</div><div class="v">${escapeHtml(String(requests.length))}</div></div>
293
+ <div class="card"><div class="k">Files reviewed</div><div class="v">${escapeHtml(String(fileGroups.size))}</div></div>
294
+ <div class="card"><div class="k">Errors</div><div class="v${errors.length > 0 ? " bad" : ""}">${escapeHtml(String(errors.length))}</div></div>
295
+ <div class="card"><div class="k">Total tokens</div><div class="v">${escapeHtml(totalTokensText)}</div></div>
296
+ <div class="card"><div class="k">Wall time</div><div class="v">${escapeHtml(wallText)}</div></div>
190
297
  <div class="card"><div class="k">Final Status</div><div class="v ok">${escapeHtml(finalStatus)}</div></div>
191
298
  </div>
192
299
 
193
- <h2>Token Usage</h2>
300
+ <h2>Token Usage by Pass</h2>
194
301
  <div class="section">
195
- <div class="tokens">${escapeHtml(tokenLine)}</div>
196
- <div class="tokens" style="margin-top:6px;"><strong>Total:</strong> ${escapeHtml(totalTokens.toLocaleString())} tokens</div>
302
+ <table>
303
+ <thead>
304
+ <tr style="text-align:left;color:var(--muted);">
305
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Pass</th>
306
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Requests</th>
307
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Prompt</th>
308
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Completion</th>
309
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Total</th>
310
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Duration</th>
311
+ </tr>
312
+ </thead>
313
+ <tbody>
314
+ ${passTableRows}
315
+ ${totalRow}
316
+ </tbody>
317
+ </table>
197
318
  </div>
319
+ ${errorSection}
198
320
 
199
321
  <h2>Pass 1 — Triage Decisions</h2>
200
322
  <div class="section">
@@ -202,28 +324,20 @@ export async function renderDebugArtifactsHtml(params) {
202
324
  </div>
203
325
 
204
326
  <h2>Pass 1 — Triage (raw model output)</h2>
205
- <div class="section">
206
- <div class="row"><span class="badge">label: triage_pass</span><span class="tokens">${escapeHtml(findTs("triage_pass"))}</span></div>
207
- <pre>${escapeHtml(triageContentPretty)}</pre>
208
- </div>
327
+ ${triageRawSections}
209
328
 
210
329
  <h2>Pass 2 — File Reviews</h2>
211
- ${fileServerLabel == null
212
- ? ""
213
- : `<div class="section"><div class="row"><span class="badge">label: ${escapeHtml(fileServerLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(fileServerLabel))}</span></div>${renderFindings(getContent(fileServerLabel))}</div>`}
214
- ${fileCiLabel == null
215
- ? ""
216
- : `<div class="section"><div class="row"><span class="badge">label: ${escapeHtml(fileCiLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(fileCiLabel))}</span></div>${renderFindings(getContent(fileCiLabel))}</div>`}
330
+ ${fileSections === "" ? "<div class=\"section\"><pre>No file review records</pre></div>" : fileSections}
217
331
 
218
332
  <h2>Pass 3 — Consolidation</h2>
219
333
  <div class="section">
220
- <div class="row"><span class="badge">label: ${escapeHtml(consolidateLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(consolidateLabel))}</span></div>
221
- ${renderFindings(getContent(consolidateLabel))}
334
+ ${sectionHeader("label: consolidate_pass", consolidateLabels)}
335
+ ${renderFindings(getContent("consolidate_pass"))}
222
336
  </div>
223
337
 
224
338
  <h2>Pass 4 — Verification</h2>
225
339
  <div class="section">
226
- <div class="row"><span class="badge">label: ${escapeHtml(verificationLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(verificationLabel))}</span></div>
340
+ ${sectionHeader(`label: ${verificationSection.label}`, verificationLabels)}
227
341
  ${renderFindings(verificationSection.content)}
228
342
  </div>
229
343
  </div>
@@ -55,10 +55,10 @@ export function sanitizeGitLabMarkdown(input) {
55
55
  return withClosedFence;
56
56
  }
57
57
  const NO_ISSUES_SENTENCE = "No confirmed bugs or high-value optimizations found.";
58
- export function normalizeReviewFindingsMarkdown(input) {
58
+ /** Parse labeled (`- [high] … File:/Line:/Why:`) or pretty (`**🔴 High — …**`)
59
+ * finding blocks out of arbitrary model output. Returns [] when none match. */
60
+ export function parseReviewFindings(input) {
59
61
  const normalized = input.replace(/\r\n/g, "\n").trim();
60
- if (normalized === "" || normalized === NO_ISSUES_SENTENCE)
61
- return normalized;
62
62
  // Some providers collapse each finding into one long line.
63
63
  // Expand inline markers so parser can recover structured blocks.
64
64
  const expanded = normalized
@@ -141,6 +141,13 @@ export function normalizeReviewFindingsMarkdown(input) {
141
141
  i = j - 1;
142
142
  }
143
143
  }
144
+ return findings;
145
+ }
146
+ export function normalizeReviewFindingsMarkdown(input) {
147
+ const normalized = input.replace(/\r\n/g, "\n").trim();
148
+ if (normalized === "" || normalized === NO_ISSUES_SENTENCE)
149
+ return normalized;
150
+ const findings = parseReviewFindings(normalized);
144
151
  if (findings.length === 0)
145
152
  return normalized;
146
153
  const severityLabel = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@krotovm/gitlab-ai-review",
4
- "version": "1.0.38",
4
+ "version": "1.0.39",
5
5
  "description": "CLI tool to generate AI code reviews for GitLab merge requests.",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {