pi-openai-toolkit 0.20.8 → 0.21.1

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.
@@ -1,161 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import type { Api, Model } from "@earendil-works/pi-ai";
3
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
4
- import { parseModelSpec } from "../runtime";
5
- import { CLASSIFIER_SYSTEM_PROMPT } from "./prompt";
6
- import { boundReviewText, type ClassifierRisk, type FastDecisionReason } from "./types";
7
-
8
- /** One completed classification of the trajectory, tagged to a tool-call position. */
9
- export type ScoreRecord = {
10
- risk: ClassifierRisk;
11
- /** Tool-call counter value when the sample was taken. */
12
- scoredAtCall: number;
13
- /** Fingerprint of the user turns the score was based on. */
14
- authorizationVersion: string;
15
- sampledAt: number;
16
- };
17
-
18
- /**
19
- * Mutable per-session tracker for the non-blocking pre-scorer.
20
- *
21
- * The counters reproduce Codex's staleness rule: the agent keeps running while a
22
- * classification is in flight, so a score is only trustworthy if it is recent
23
- * enough and nothing the user said has changed underneath it.
24
- */
25
- export type ScoreTracker = {
26
- latestCallIndex: number;
27
- latestScoredIndex: number;
28
- latestFailedIndex: number;
29
- score?: ScoreRecord;
30
- };
31
-
32
- export function createScoreTracker(): ScoreTracker {
33
- return { latestCallIndex: 0, latestScoredIndex: 0, latestFailedIndex: 0 };
34
- }
35
-
36
- /** Reset everything: a new turn or a disengaged mode invalidates prior scoring. */
37
- export function resetScoreTracker(tracker: ScoreTracker): void {
38
- tracker.latestCallIndex = 0;
39
- tracker.latestScoredIndex = 0;
40
- tracker.latestFailedIndex = 0;
41
- tracker.score = undefined;
42
- }
43
-
44
- /**
45
- * Fingerprint of the user-authored conversation. Any new or edited user turn makes
46
- * an existing score stale, because the score's authorization premise changed.
47
- */
48
- export function authorizationVersion(userText: string): string {
49
- return createHash("sha256").update(userText).digest("hex");
50
- }
51
-
52
- export function recordScoredCall(tracker: ScoreTracker, score: ScoreRecord): void {
53
- tracker.score = score;
54
- tracker.latestScoredIndex = Math.max(tracker.latestScoredIndex, score.scoredAtCall);
55
- }
56
-
57
- export function recordFailedCall(tracker: ScoreTracker, scoredAtCall: number): void {
58
- tracker.latestFailedIndex = Math.max(tracker.latestFailedIndex, scoredAtCall);
59
- }
60
-
61
- export type FastDecisionInput = {
62
- tracker: ScoreTracker;
63
- /** Tool-call counter value for the call being gated. */
64
- currentCallIndex: number;
65
- authorizationVersion: string;
66
- maxLag: number;
67
- /** Retries after a denial and widened gates must not take the fast path. */
68
- requiresSynchronousReview?: boolean;
69
- };
70
-
71
- /**
72
- * Decide whether a cached classification can satisfy this gated call without
73
- * running the blocking reviewer. Anything other than `low_risk` defers to the
74
- * synchronous review; the classifier never denies an action on its own.
75
- */
76
- export function fastApprovalEligible(input: FastDecisionInput): {
77
- eligible: boolean;
78
- reason: FastDecisionReason;
79
- } {
80
- const defer = (reason: FastDecisionReason) => ({ eligible: false, reason });
81
-
82
- if (input.requiresSynchronousReview) return defer("requires_synchronous_review");
83
- if (input.tracker.latestFailedIndex > input.tracker.latestScoredIndex) return defer("scoring_failure");
84
-
85
- const score = input.tracker.score;
86
- if (!score) return defer("missing_score");
87
-
88
- const lag = input.currentCallIndex - score.scoredAtCall;
89
- if (lag < 0 || lag > input.maxLag) return defer("stale_score");
90
- if (score.authorizationVersion !== input.authorizationVersion) return defer("authorization_changed");
91
- if (score.risk === "low") return { eligible: true, reason: "low_risk" };
92
- return defer("elevated_risk");
93
- }
94
-
95
- export type ClassificationResult =
96
- | { kind: ClassifierRisk; model: string }
97
- | { kind: "failed"; reason: string };
98
-
99
- /**
100
- * Run one non-blocking classification. Output is a single token; anything else is a
101
- * failed sample rather than a verdict, so a confused classifier costs a review and
102
- * never an approval or a denial.
103
- */
104
- export async function classifyTrajectory(params: {
105
- registry: Pick<ModelRegistry, "find" | "complete">;
106
- modelSpec: string;
107
- prompt: string;
108
- timeoutMs: number;
109
- signal?: AbortSignal;
110
- }): Promise<ClassificationResult> {
111
- const parsed = parseModelSpec(params.modelSpec);
112
- if (!parsed) {
113
- return { kind: "failed", reason: `autoMode.classifier.model "${params.modelSpec}" is not provider/model-id.` };
114
- }
115
- const model = params.registry.find(parsed.provider, parsed.modelId) as Model<Api> | undefined;
116
- if (!model) {
117
- return { kind: "failed", reason: `Classifier model ${params.modelSpec} is not available.` };
118
- }
119
- const label = boundReviewText(`${model.provider}/${model.id}`, 256);
120
-
121
- const controller = new AbortController();
122
- const abortFromCaller = () => controller.abort();
123
- if (params.signal?.aborted) controller.abort();
124
- else params.signal?.addEventListener("abort", abortFromCaller);
125
- const timer = setTimeout(() => controller.abort(), params.timeoutMs);
126
-
127
- try {
128
- const response = await params.registry.complete(
129
- model,
130
- {
131
- systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
132
- messages: [
133
- {
134
- role: "user",
135
- content: [{ type: "text", text: params.prompt }],
136
- timestamp: Date.now(),
137
- },
138
- ],
139
- },
140
- { signal: controller.signal, cacheRetention: "none" },
141
- );
142
- if (controller.signal.aborted) return { kind: "failed", reason: "Classifier was cancelled or timed out." };
143
- if (response.stopReason === "error" || response.stopReason === "aborted") {
144
- return { kind: "failed", reason: response.errorMessage ?? `Classifier stopped: ${response.stopReason}` };
145
- }
146
- const text = response.content
147
- .filter((block): block is { type: "text"; text: string } => block.type === "text")
148
- .map((block) => block.text)
149
- .join("")
150
- .trim()
151
- .toLowerCase();
152
- if (/^high\b/.test(text)) return { kind: "high", model: label };
153
- if (/^low\b/.test(text)) return { kind: "low", model: label };
154
- return { kind: "failed", reason: `Classifier returned unreadable output: ${boundReviewText(text, 80)}` };
155
- } catch (error) {
156
- return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
157
- } finally {
158
- clearTimeout(timer);
159
- params.signal?.removeEventListener("abort", abortFromCaller);
160
- }
161
- }