@better-internet/oss-verify 0.1.2 → 0.1.3
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/dist/src/checks/llm-audit.js +82 -39
- package/package.json +1 -1
|
@@ -62,6 +62,15 @@ export async function runLlmAudit(ctx, opts) {
|
|
|
62
62
|
// SPEC §7.4: three independent calls at temperature=0; majority verdict wins.
|
|
63
63
|
// "Block" must be a strict majority — a 1:1:1 outcome (one of each + an error
|
|
64
64
|
// or unparseable) defaults to block, since the audit is a veto layer.
|
|
65
|
+
//
|
|
66
|
+
// The three calls run *sequentially* rather than in parallel. Parallel
|
|
67
|
+
// firing creates a 3x burst within ~1s against Anthropic's per-minute
|
|
68
|
+
// token rate limit (30k tpm on default tier). Posthog-sized envelopes
|
|
69
|
+
// (~10k input tokens × 3) reliably tripped that ceiling. Sequential adds
|
|
70
|
+
// ~20s of wallclock per project — invisible for an offline watchlist —
|
|
71
|
+
// and lets the per-minute window relax between passes. callAnthropic
|
|
72
|
+
// also retries with backoff on 429, so even sequential bursts that
|
|
73
|
+
// exceed the limit recover instead of failing.
|
|
65
74
|
const apiKey = opts.apiKey;
|
|
66
75
|
const callOnce = () => callAnthropic({
|
|
67
76
|
modelId: opts.modelId,
|
|
@@ -70,7 +79,9 @@ export async function runLlmAudit(ctx, opts) {
|
|
|
70
79
|
system: SYSTEM_PROMPT,
|
|
71
80
|
envelope: envelope.text,
|
|
72
81
|
});
|
|
73
|
-
const verdicts =
|
|
82
|
+
const verdicts = [];
|
|
83
|
+
for (let i = 0; i < 3; i++)
|
|
84
|
+
verdicts.push(await callOnce());
|
|
74
85
|
const verdict = majorityVerdict(verdicts);
|
|
75
86
|
return { verdict, promptHash, modelId: opts.modelId };
|
|
76
87
|
}
|
|
@@ -141,47 +152,79 @@ function containsNul(buf, max) {
|
|
|
141
152
|
return true;
|
|
142
153
|
return false;
|
|
143
154
|
}
|
|
155
|
+
// Retry policy for transient Anthropic failures. 429 (rate-limit) and 5xx
|
|
156
|
+
// (gateway / server) are retryable; 4xx-except-429 (auth, bad request, etc.)
|
|
157
|
+
// are not. Honors `retry-after` (seconds) and `retry-after-ms` headers when
|
|
158
|
+
// present; otherwise exponential backoff capped at 30s. Max 4 attempts.
|
|
159
|
+
const MAX_RETRIES = 3;
|
|
160
|
+
const BASE_DELAY_MS = 1000;
|
|
161
|
+
const MAX_DELAY_MS = 30_000;
|
|
162
|
+
function isRetryable(status) {
|
|
163
|
+
return status === 429 || (status >= 500 && status < 600);
|
|
164
|
+
}
|
|
165
|
+
function backoffDelay(attempt, res) {
|
|
166
|
+
const ms = res.headers.get("retry-after-ms");
|
|
167
|
+
if (ms && /^\d+$/.test(ms))
|
|
168
|
+
return Math.min(Number(ms), MAX_DELAY_MS);
|
|
169
|
+
const sec = res.headers.get("retry-after");
|
|
170
|
+
if (sec && /^\d+$/.test(sec))
|
|
171
|
+
return Math.min(Number(sec) * 1000, MAX_DELAY_MS);
|
|
172
|
+
// Exponential with full jitter — 1s, 2s, 4s, 8s capped at 30s.
|
|
173
|
+
const exp = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
|
|
174
|
+
return Math.floor(Math.random() * exp);
|
|
175
|
+
}
|
|
176
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
144
177
|
async function callAnthropic(args) {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
},
|
|
152
|
-
body: JSON.stringify({
|
|
153
|
-
model: args.modelId,
|
|
154
|
-
max_tokens: 256,
|
|
155
|
-
temperature: 0,
|
|
156
|
-
system: args.system,
|
|
157
|
-
messages: [{ role: "user", content: args.envelope }],
|
|
158
|
-
}),
|
|
178
|
+
const body = JSON.stringify({
|
|
179
|
+
model: args.modelId,
|
|
180
|
+
max_tokens: 256,
|
|
181
|
+
temperature: 0,
|
|
182
|
+
system: args.system,
|
|
183
|
+
messages: [{ role: "user", content: args.envelope }],
|
|
159
184
|
});
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
let lastStatus = 0;
|
|
186
|
+
let lastBody = "";
|
|
187
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
188
|
+
const res = await fetch(args.endpoint, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: {
|
|
191
|
+
"x-api-key": args.apiKey,
|
|
192
|
+
"anthropic-version": "2023-06-01",
|
|
193
|
+
"content-type": "application/json",
|
|
194
|
+
},
|
|
195
|
+
body,
|
|
196
|
+
});
|
|
197
|
+
if (res.ok) {
|
|
198
|
+
const data = (await res.json());
|
|
199
|
+
const text = data.content?.find((b) => b.type === "text")?.text?.trim() ?? "";
|
|
200
|
+
const parsed = parseModelVerdict(text);
|
|
201
|
+
// Belt-and-braces: if the response.model field doesn't match what we
|
|
202
|
+
// asked for, block. Vendors can route to fallback models; we need
|
|
203
|
+
// the exact one for predicate integrity.
|
|
204
|
+
if (data.model && data.model !== args.modelId) {
|
|
205
|
+
return {
|
|
206
|
+
verdict: "block",
|
|
207
|
+
rationale: `response.model '${data.model}' != requested '${args.modelId}'`,
|
|
208
|
+
passes: 1,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return { ...parsed, passes: 1 };
|
|
212
|
+
}
|
|
213
|
+
lastStatus = res.status;
|
|
214
|
+
lastBody = await res.text();
|
|
215
|
+
if (!isRetryable(res.status) || attempt === MAX_RETRIES)
|
|
216
|
+
break;
|
|
217
|
+
await sleep(backoffDelay(attempt, res));
|
|
183
218
|
}
|
|
184
|
-
|
|
219
|
+
// Network/auth failure must BLOCK — we have no opinion if the audit
|
|
220
|
+
// didn't actually run, and the predicate must not be emitted on a
|
|
221
|
+
// silent fallback. The "retried N times" suffix flags this as the
|
|
222
|
+
// outcome of a giving-up retry vs a single-shot failure.
|
|
223
|
+
return {
|
|
224
|
+
verdict: "block",
|
|
225
|
+
rationale: `Anthropic API call failed (${lastStatus}) after ${MAX_RETRIES + 1} attempts: ${lastBody.slice(0, 200)}`,
|
|
226
|
+
passes: 0,
|
|
227
|
+
};
|
|
185
228
|
}
|
|
186
229
|
function parseModelVerdict(text) {
|
|
187
230
|
// Per the system prompt, the model returns one JSON object. Be forgiving
|