@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.
- package/CHANGELOG.md +25 -0
- package/package.json +8 -7
- package/src/broker/overrides.ts +1 -0
- package/src/index.ts +55 -1944
- package/src/policy-audit/classifier.ts +1 -1
- package/src/policy-audit/index.ts +2 -1
- package/src/policy.ts +19 -8
- package/src/review/audit.ts +14 -0
- package/src/review/complete.ts +347 -0
- package/src/review/config.ts +366 -0
- package/src/review/consts.ts +22 -0
- package/src/review/guards.ts +66 -0
- package/src/review/index.ts +61 -0
- package/src/review/input.ts +439 -0
- package/src/review/prompts.ts +33 -0
- package/src/review/provider.ts +583 -0
- package/src/review/types.ts +207 -0
- package/src/user-feedback.ts +1 -1
|
@@ -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>;
|