@erichll/pi-auto-review 0.15.3 → 0.17.0

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.
@@ -0,0 +1,583 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
4
+ import type { BoundaryRequest } from "../broker/index.ts";
5
+ import type { UserReviewUsage } from "../user-feedback.ts";
6
+ import type {
7
+ CompletionMessage,
8
+ Config,
9
+ ReviewUsage,
10
+ ReviewAttemptObservation,
11
+ ReviewErrorClass,
12
+ ReviewExecutionSummary,
13
+ ReviewResult,
14
+ ReviewerMeta,
15
+ ReviewerRuntime,
16
+ ReviewerTelemetryEvent,
17
+ UsageAvailability,
18
+ } from "./types.ts";
19
+ import {
20
+ FORMAT_RETRY_INSTRUCTION,
21
+ REVIEWER_FRAMING_RESERVE_TOKENS,
22
+ REVIEWER_MAX_RETRY_AFTER_MS,
23
+ REVIEWER_RETRY_DELAY_MS,
24
+ } from "./consts.ts";
25
+ import { REVIEWER_SYSTEM_PROMPT } from "./prompts.ts";
26
+ import { DEFAULT_CONFIG } from "./config.ts";
27
+ import { preflightPart } from "./input.ts";
28
+
29
+ export function finiteUsageValue(value: unknown): number | undefined {
30
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
31
+ ? value
32
+ : undefined;
33
+ }
34
+
35
+ export function observedUsage(message: CompletionMessage | undefined): {
36
+ availability: UsageAvailability;
37
+ usage: ReviewUsage;
38
+ } {
39
+ if (!message?.usage || typeof message.usage !== "object") {
40
+ return { availability: "unavailable", usage: {} };
41
+ }
42
+ const input = finiteUsageValue(message.usage.input);
43
+ const output = finiteUsageValue(message.usage.output);
44
+ const cacheRead = finiteUsageValue(message.usage.cacheRead);
45
+ const cacheWrite = finiteUsageValue(message.usage.cacheWrite);
46
+ const reasoning = finiteUsageValue(message.usage.reasoning);
47
+ const totalTokens = finiteUsageValue(message.usage.totalTokens);
48
+ const usage: ReviewUsage = {
49
+ ...(input === undefined ? {} : { input }),
50
+ ...(output === undefined ? {} : { output }),
51
+ ...(cacheRead === undefined ? {} : { cacheRead }),
52
+ ...(cacheWrite === undefined ? {} : { cacheWrite }),
53
+ ...(reasoning === undefined ? {} : { reasoning }),
54
+ ...(totalTokens === undefined ? {} : { totalTokens }),
55
+ };
56
+ if (
57
+ input !== undefined &&
58
+ cacheRead !== undefined &&
59
+ cacheWrite !== undefined
60
+ ) {
61
+ usage.observedInputTokens = input + cacheRead + cacheWrite;
62
+ }
63
+ // pi-ai always exposes a Usage-shaped object and initializes it with
64
+ // zeroes before provider data arrives, but does not expose provenance.
65
+ // Preserve valid values without claiming that framework zeroes were
66
+ // reported by the provider.
67
+ return {
68
+ availability:
69
+ Object.keys(usage).length > 0
70
+ ? "unknown_provenance"
71
+ : "unavailable",
72
+ usage,
73
+ };
74
+ }
75
+
76
+ export function normalizedStopReason(
77
+ value: string | undefined,
78
+ ): ReviewAttemptObservation["stopReason"] {
79
+ return ["stop", "length", "toolUse", "error", "aborted", "deferred"].includes(
80
+ value ?? "",
81
+ )
82
+ ? value as ReviewAttemptObservation["stopReason"]
83
+ : "unknown";
84
+ }
85
+
86
+ export function parseErrorClass(error: unknown): ReviewErrorClass {
87
+ if (!(error instanceof Error)) return "unknown";
88
+ if (error.message === "reviewer returned non-JSON output") return "non_json";
89
+ if (error.message.startsWith("reviewer returned") ||
90
+ error.message.startsWith("reviewer attempted")) return "schema";
91
+ return "unknown";
92
+ }
93
+
94
+ export type ProviderAttemptMetadata = {
95
+ status?: number;
96
+ retryAfterMs?: number;
97
+ };
98
+
99
+ export function numericErrorStatus(error: unknown): number | undefined {
100
+ if (!error || typeof error !== "object") return undefined;
101
+ const record = error as Record<string, unknown>;
102
+ for (const value of [record.statusCode, record.status]) {
103
+ if (typeof value === "number" && Number.isInteger(value)) return value;
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ export function errorCode(error: unknown): string | undefined {
109
+ if (!error || typeof error !== "object") return undefined;
110
+ const code = (error as Record<string, unknown>).code;
111
+ return typeof code === "string" ? code : undefined;
112
+ }
113
+
114
+ export function classifyProviderFailure(
115
+ message: CompletionMessage | undefined,
116
+ error: unknown,
117
+ metadata: ProviderAttemptMetadata,
118
+ ): ReviewErrorClass {
119
+ const status = metadata.status ?? numericErrorStatus(error);
120
+ if (status === 408) return "timeout";
121
+ if (status === 429) return "rate_limit";
122
+ if (status !== undefined && status >= 500 && status <= 599) {
123
+ return "transient_server";
124
+ }
125
+ if (status === 401 || status === 403) return "authentication";
126
+ if (status !== undefined && status >= 400 && status <= 499) {
127
+ return "request_configuration";
128
+ }
129
+
130
+ const code = errorCode(error);
131
+ if (
132
+ code &&
133
+ new Set([
134
+ "ECONNRESET",
135
+ "ECONNREFUSED",
136
+ "EPIPE",
137
+ "ENETDOWN",
138
+ "ENETRESET",
139
+ "ENETUNREACH",
140
+ "EHOSTDOWN",
141
+ "EHOSTUNREACH",
142
+ "EAI_AGAIN",
143
+ "ENOTFOUND",
144
+ "UND_ERR_CONNECT_TIMEOUT",
145
+ "UND_ERR_HEADERS_TIMEOUT",
146
+ "UND_ERR_SOCKET",
147
+ ]).has(code)
148
+ ) {
149
+ return "transient_connection";
150
+ }
151
+
152
+ const detail = message?.errorMessage ??
153
+ (error instanceof Error ? error.message : "");
154
+ if (/\b(?:401|403)\b|unauthori[sz]ed|forbidden|invalid[_ -]?(?:api[_ -]?)?key|authentication/i.test(detail)) {
155
+ return "authentication";
156
+ }
157
+ if (/\b429\b|rate.?limit|too many requests/i.test(detail)) {
158
+ return "rate_limit";
159
+ }
160
+ if (/\b5(?:00|02|03|04)\b|service.?unavailable|server.?error|internal.?error|overloaded/i.test(detail)) {
161
+ return "transient_server";
162
+ }
163
+ if (/unknown model|model not found|invalid model|unsupported model/i.test(detail)) {
164
+ return "model_resolution";
165
+ }
166
+ if (/timed? out|timeout/i.test(detail)) return "timeout";
167
+ if (/\b(?:400|404|405|409|413|415|422)\b|invalid request|configuration|context length|input (?:is )?too long/i.test(detail)) {
168
+ return "request_configuration";
169
+ }
170
+ if (/connection (?:reset|refused|lost)|socket hang up|fetch failed|network.?error|other side closed|stream ended|ended without|eai_again|enotfound/i.test(detail)) {
171
+ return "transient_connection";
172
+ }
173
+ return "unknown";
174
+ }
175
+
176
+ export function isFormatError(errorClass: ReviewErrorClass): boolean {
177
+ return ["non_json", "schema", "empty_output"].includes(errorClass);
178
+ }
179
+
180
+ export function isRetryableError(errorClass: ReviewErrorClass): boolean {
181
+ return isFormatError(errorClass) || [
182
+ "transient_connection",
183
+ "transient_server",
184
+ "rate_limit",
185
+ ].includes(errorClass);
186
+ }
187
+
188
+ export function retryDelayMs(
189
+ errorClass: ReviewErrorClass,
190
+ metadata: ProviderAttemptMetadata,
191
+ ): number {
192
+ if (isFormatError(errorClass)) return 0;
193
+ if (errorClass === "rate_limit" && metadata.retryAfterMs !== undefined) {
194
+ return metadata.retryAfterMs <= REVIEWER_MAX_RETRY_AFTER_MS
195
+ ? metadata.retryAfterMs
196
+ : Number.POSITIVE_INFINITY;
197
+ }
198
+ return REVIEWER_RETRY_DELAY_MS;
199
+ }
200
+
201
+ export function parseRetryAfterMs(headers: Readonly<Record<string, string>>): number | undefined {
202
+ const normalizedHeaders = Object.fromEntries(
203
+ Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
204
+ );
205
+ const retryAfterMs = normalizedHeaders["retry-after-ms"];
206
+ if (retryAfterMs !== undefined) {
207
+ const value = Number.parseFloat(retryAfterMs);
208
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
209
+ }
210
+ const retryAfter = normalizedHeaders["retry-after"];
211
+ if (retryAfter === undefined) return undefined;
212
+ const seconds = Number.parseFloat(retryAfter);
213
+ const value = Number.isNaN(seconds)
214
+ ? Date.parse(retryAfter) - Date.now()
215
+ : seconds * 1_000;
216
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
217
+ }
218
+
219
+ export function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
220
+ if (ms <= 0) return Promise.resolve();
221
+ return new Promise((resolve, reject) => {
222
+ if (signal.aborted) {
223
+ reject(new Error("review retry aborted"));
224
+ return;
225
+ }
226
+ const onAbort = () => {
227
+ clearTimeout(timeout);
228
+ reject(new Error("review retry aborted"));
229
+ };
230
+ const timeout = setTimeout(() => {
231
+ signal.removeEventListener("abort", onAbort);
232
+ resolve();
233
+ }, ms);
234
+ signal.addEventListener("abort", onAbort, { once: true });
235
+ });
236
+ }
237
+
238
+ export function abortableOperation<T>(
239
+ operation: Promise<T>,
240
+ signal: AbortSignal,
241
+ ): Promise<T> {
242
+ return new Promise((resolve, reject) => {
243
+ if (signal.aborted) {
244
+ void operation.catch(() => undefined);
245
+ reject(new Error("review operation aborted"));
246
+ return;
247
+ }
248
+ const onAbort = () => {
249
+ reject(new Error("review operation aborted"));
250
+ };
251
+ signal.addEventListener("abort", onAbort, { once: true });
252
+ operation.then(
253
+ (value) => {
254
+ signal.removeEventListener("abort", onAbort);
255
+ resolve(value);
256
+ },
257
+ (error: unknown) => {
258
+ signal.removeEventListener("abort", onAbort);
259
+ reject(error);
260
+ },
261
+ );
262
+ });
263
+ }
264
+
265
+ export function incrementError(
266
+ counts: ReviewExecutionSummary["errorCounts"],
267
+ errorClass: ReviewErrorClass,
268
+ ): void {
269
+ if (errorClass === "none") return;
270
+ counts[errorClass] = (counts[errorClass] ?? 0) + 1;
271
+ }
272
+
273
+ export function aggregateUsage(attempts: readonly ReviewAttemptObservation[]): {
274
+ availability: UsageAvailability;
275
+ usage: ReviewUsage;
276
+ } {
277
+ const withUsage = attempts.filter(
278
+ (attempt) => attempt.usageAvailability !== "unavailable",
279
+ );
280
+ if (withUsage.length === 0) {
281
+ return { availability: "unavailable", usage: {} };
282
+ }
283
+ const usage: ReviewUsage = {};
284
+ for (const key of [
285
+ "input",
286
+ "output",
287
+ "cacheRead",
288
+ "cacheWrite",
289
+ "reasoning",
290
+ "totalTokens",
291
+ "observedInputTokens",
292
+ ] as const) {
293
+ const values = withUsage
294
+ .map((attempt) => attempt.usage[key])
295
+ .filter((value): value is number => value !== undefined);
296
+ if (values.length > 0) {
297
+ usage[key] = values.reduce((total, value) => total + value, 0);
298
+ }
299
+ }
300
+ const availability = withUsage.every(
301
+ (attempt) => attempt.usageAvailability === "reported",
302
+ )
303
+ ? "reported"
304
+ : withUsage.every((attempt) => attempt.usageAvailability === "estimated")
305
+ ? "estimated"
306
+ : "unknown_provenance";
307
+ return { availability, usage };
308
+ }
309
+
310
+ export function userReviewMetaFromResult(
311
+ result: ReviewResult | undefined,
312
+ fallbackModel: string,
313
+ ): {
314
+ model?: string;
315
+ usage?: UserReviewUsage;
316
+ durationMs?: number;
317
+ attempts?: number;
318
+ } {
319
+ if (!result) return {};
320
+ const attempts = result.summary.attempts;
321
+ const aggregate = aggregateUsage(attempts);
322
+ return {
323
+ ...(attempts.length > 0
324
+ ? {
325
+ model: attempts.at(-1)?.model ?? fallbackModel,
326
+ usage: {
327
+ availability: aggregate.availability,
328
+ ...aggregate.usage,
329
+ },
330
+ }
331
+ : {}),
332
+ durationMs: result.durationMs,
333
+ attempts: result.attempts,
334
+ };
335
+ }
336
+
337
+ export function completeTelemetry(
338
+ request: BoundaryRequest,
339
+ config: Readonly<Config>,
340
+ summary: ReviewExecutionSummary,
341
+ outcome: "allow" | "deny" | "defer",
342
+ failureMode?: "deny" | "defer",
343
+ ): ReviewerTelemetryEvent {
344
+ const aggregate = aggregateUsage(summary.attempts);
345
+ return {
346
+ type: "review_complete",
347
+ requestId: request.id,
348
+ surface: request.surface,
349
+ model: summary.attempts.at(-1)?.model ?? config.model,
350
+ reasoning: config.reasoning,
351
+ outcome,
352
+ ...(failureMode ? { failureMode } : {}),
353
+ attempts: summary.attempts.length,
354
+ errorCounts: { ...summary.errorCounts },
355
+ durationMs: summary.durationMs,
356
+ usageAvailability: aggregate.availability,
357
+ usage: aggregate.usage,
358
+ transcript: {
359
+ userCharacters: summary.transcript.userCharacters,
360
+ toolCharacters: summary.transcript.toolCharacters,
361
+ relevantResultCharacters:
362
+ summary.transcript.relevantResultCharacters,
363
+ truncated: summary.transcript.truncated,
364
+ selectedCandidates: summary.transcript.selectedCandidates.map(
365
+ (candidate) => ({ ...candidate, secondaryReasons: [...candidate.secondaryReasons] }),
366
+ ),
367
+ ...(summary.transcript.failureCode
368
+ ? { failureCode: summary.transcript.failureCode }
369
+ : {}),
370
+ userAuthorizationCeiling: summary.transcript.userAuthorizationCeiling,
371
+ userConstraint: summary.transcript.userConstraint,
372
+ compactionState: summary.transcript.compactionState,
373
+ budgetRemovals: summary.transcript.budgetRemovals.map((item) => ({
374
+ ...item,
375
+ })),
376
+ },
377
+ preflight: summary.preflight,
378
+ };
379
+ }
380
+
381
+ export function noModelSummary(): ReviewExecutionSummary {
382
+ const zero = preflightPart("");
383
+ return {
384
+ attempts: [],
385
+ errorCounts: {},
386
+ durationMs: 0,
387
+ transcript: {
388
+ text: "(model not called)",
389
+ surfaceProfile: "generic",
390
+ reviewerEvidence: {
391
+ userMessages: [],
392
+ toolCalls: [],
393
+ relevantResults: [],
394
+ },
395
+ budgetRemovals: [],
396
+ userCharacters: 0,
397
+ toolCharacters: 0,
398
+ relevantResultCharacters: 0,
399
+ truncated: false,
400
+ selectedCandidates: [],
401
+ userAuthorizationCeiling: "unknown",
402
+ userConstraint: "none",
403
+ compactionState: "none",
404
+ },
405
+ preflight: {
406
+ estimator: "conservative:cjk-aware",
407
+ maxReviewerInputTokens: DEFAULT_CONFIG.maxReviewerInputTokens,
408
+ framingReserveTokens: REVIEWER_FRAMING_RESERVE_TOKENS,
409
+ fixedPrompt: zero,
410
+ canonicalRequest: zero,
411
+ override: zero,
412
+ user: zero,
413
+ tool: zero,
414
+ relevantResult: zero,
415
+ framing: zero,
416
+ total: zero,
417
+ },
418
+ };
419
+ }
420
+
421
+ export function parseModelRef(modelRef: string): {
422
+ provider?: string;
423
+ modelId: string;
424
+ } {
425
+ if (!modelRef.includes("/")) {
426
+ return { modelId: modelRef };
427
+ }
428
+ const [provider, ...idParts] = modelRef.split("/");
429
+ return { provider, modelId: idParts.join("/") };
430
+ }
431
+
432
+
433
+ export function reviewerSessionId(
434
+ ctx: ExtensionContext,
435
+ config: Config,
436
+ model: ReviewerMeta["model"],
437
+ auth: ReviewerRuntime["auth"],
438
+ ): string {
439
+ // Providers can use sessionId for prompt caches, routing, or affinity.
440
+ // Bind that identity to endpoint and authentication metadata so a live
441
+ // provider/auth refresh cannot reuse stale cache or routing state.
442
+ // Hash the complete identity and keep the result below pi-ai's 64-character
443
+ // prompt-cache-key limit, which would otherwise be able to truncate away a
444
+ // distinguishing suffix on long session IDs or base URLs.
445
+ const identity = JSON.stringify({
446
+ sessionId: ctx.sessionManager.getSessionId(),
447
+ modelRef: config.model,
448
+ provider: model.provider,
449
+ modelId: model.id,
450
+ api: model.api,
451
+ baseUrl: model.baseUrl,
452
+ modelHeaders: Object.entries(model.headers ?? {}).sort(([a], [b]) =>
453
+ a.localeCompare(b),
454
+ ),
455
+ auth: {
456
+ apiKey: auth.apiKey,
457
+ headers: Object.entries(auth.headers ?? {}).sort(([a], [b]) =>
458
+ a.localeCompare(b),
459
+ ),
460
+ env: Object.entries(auth.env ?? {}).sort(([a], [b]) =>
461
+ a.localeCompare(b),
462
+ ),
463
+ },
464
+ });
465
+ const fingerprint = createHash("sha256")
466
+ .update(identity)
467
+ .digest("hex")
468
+ .slice(0, 48);
469
+ return `pi-auto-review-${fingerprint}`;
470
+ }
471
+
472
+ export async function resolveReviewerMeta(
473
+ ctx: ExtensionContext,
474
+ config: Config,
475
+ ): Promise<ReviewerMeta> {
476
+ const { provider, modelId } = parseModelRef(config.model);
477
+ const available = ctx.modelRegistry.getAvailable();
478
+ const registeredModel = provider
479
+ ? ctx.modelRegistry.find(provider, modelId)
480
+ : available.find(
481
+ (candidate) =>
482
+ candidate.id === modelId || candidate.name === modelId,
483
+ );
484
+ const providerFallback = provider
485
+ ? available.find((candidate) => candidate.provider === provider)
486
+ : undefined;
487
+ const model =
488
+ registeredModel ||
489
+ (providerFallback
490
+ ? { ...providerFallback, id: modelId, name: modelId }
491
+ : undefined);
492
+ if (!model) {
493
+ throw new Error(
494
+ provider
495
+ ? `provider ${provider} is unavailable for custom model ${config.model}`
496
+ : `model ${config.model} is unavailable`,
497
+ );
498
+ }
499
+
500
+ const registered = (
501
+ ctx.modelRegistry as ExtensionContext["modelRegistry"] & {
502
+ getRegisteredProviderConfig?: (
503
+ name: string,
504
+ ) =>
505
+ | {
506
+ api?: string;
507
+ streamSimple?: ReviewerRuntime["streamSimple"];
508
+ }
509
+ | undefined;
510
+ }
511
+ ).getRegisteredProviderConfig?.(model.provider);
512
+
513
+ return {
514
+ model,
515
+ streamSimple:
516
+ registered?.api === model.api ? registered.streamSimple : undefined,
517
+ };
518
+ }
519
+
520
+ // pi resolves models.json auth and headers dynamically on every request;
521
+ // reacquire authentication per model call instead of pinning it for the
522
+ // session (rotating OAuth tokens would otherwise go stale and fail closed
523
+ // until session restart).
524
+ export async function resolveApiKeyAndHeaders(
525
+ ctx: ExtensionContext,
526
+ model: ReviewerMeta["model"],
527
+ ): Promise<ReviewerRuntime["auth"]> {
528
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
529
+ if (!auth.ok) throw new Error(`model authentication failed: ${auth.error}`);
530
+ return auth;
531
+ }
532
+
533
+ export async function modelCall(
534
+ runtime: ReviewerRuntime,
535
+ config: Config,
536
+ controller: AbortController,
537
+ sharedContext: string,
538
+ maxTokens: number,
539
+ timeoutMs: number,
540
+ formatRetry: boolean,
541
+ metadata: ProviderAttemptMetadata,
542
+ ): Promise<CompletionMessage> {
543
+ const context = {
544
+ systemPrompt: REVIEWER_SYSTEM_PROMPT,
545
+ messages: [
546
+ {
547
+ role: "user" as const,
548
+ content: formatRetry
549
+ ? `${sharedContext}\n\n${FORMAT_RETRY_INSTRUCTION}`
550
+ : sharedContext,
551
+ timestamp: Date.now(),
552
+ },
553
+ ],
554
+ };
555
+ const options = {
556
+ apiKey: runtime.auth.apiKey,
557
+ headers: runtime.auth.headers,
558
+ env: runtime.auth.env,
559
+ signal: controller.signal,
560
+ maxTokens,
561
+ maxRetries: 0,
562
+ reasoning: config.reasoning === "off" ? undefined : config.reasoning,
563
+ cacheRetention: "short" as const,
564
+ // A permission review is a complete independent request. In particular,
565
+ // do not let Codex WebSocket continuation attach a prior review response
566
+ // to the next approval merely because they share a cache identity.
567
+ transport: "sse" as const,
568
+ sessionId: runtime.sessionId,
569
+ timeoutMs,
570
+ onResponse: (response: { status: number; headers: Record<string, string> }) => {
571
+ metadata.status = response.status;
572
+ metadata.retryAfterMs = parseRetryAfterMs(response.headers);
573
+ },
574
+ };
575
+ return (runtime.streamSimple
576
+ ? await runtime.streamSimple(runtime.model, context, options).result()
577
+ : await completeSimple(runtime.model, context, options)) as CompletionMessage;
578
+ }
579
+
580
+ export type ReviewerResolver = (
581
+ ctx: ExtensionContext,
582
+ config: Config,
583
+ ) => Promise<ReviewerMeta>;