@evalguard/openai 1.1.1 → 1.2.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.
- package/README.md +235 -123
- package/dist/cost.d.ts +2 -0
- package/dist/cost.d.ts.map +1 -1
- package/dist/cost.js +5 -0
- package/dist/cost.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +420 -95
- package/dist/index.js.map +1 -1
- package/dist/surface.d.ts +83 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +328 -0
- package/dist/surface.js.map +1 -0
- package/package.json +9 -8
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// ──
|
|
1
|
+
// ── @evalguard/openai ─────────────────────────────────────────────────────────
|
|
2
2
|
// Drop-in OpenAI SDK wrapper that auto-intercepts chat.completions.create()
|
|
3
3
|
// to enforce guardrails, log traces, and track costs via EvalGuard.
|
|
4
4
|
import { GuardrailClient } from "./guardrail-client.js";
|
|
5
|
-
import {
|
|
5
|
+
import { estimateCostDetailed, normalizeOpenAIUsage, newTraceId, injectTraceHeader, resolveIdempotencyKey, runPostResponseEval, } from "@evalguard/wrapper-core";
|
|
6
6
|
// ── Re-exports ──────────────────────────────────────────────────────────────
|
|
7
7
|
// Public surface of v1.1.0:
|
|
8
8
|
// - EvalGuardConfig now widens `apiKey` to `ByokKeyResolver` (was `string`)
|
|
@@ -12,8 +12,9 @@ import { estimateCost, newTraceId, injectTraceHeader, resolveIdempotencyKey, run
|
|
|
12
12
|
// output score falls below failOnScore).
|
|
13
13
|
// - estimateCost / GuardrailClient / newTraceId etc. are unchanged shape.
|
|
14
14
|
export { GuardrailClient } from "./guardrail-client.js";
|
|
15
|
-
export { EvalGuardViolationError, EvalGuardOutputViolationError, estimateCost, } from "@evalguard/wrapper-core";
|
|
15
|
+
export { EvalGuardViolationError, EvalGuardOutputViolationError, estimateCost, estimateCostDetailed, isModelPriced, } from "@evalguard/wrapper-core";
|
|
16
16
|
import { EvalGuardViolationError } from "@evalguard/wrapper-core";
|
|
17
|
+
import { isSafePassthrough, extractModelInputText, extractModelId, } from "./surface.js";
|
|
17
18
|
// ── Helper: extract text from messages ──────────────────────────────────────
|
|
18
19
|
function extractPromptText(messages) {
|
|
19
20
|
return messages
|
|
@@ -33,6 +34,36 @@ function extractPromptText(messages) {
|
|
|
33
34
|
})
|
|
34
35
|
.join("\n");
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Scan text for an entry point that is ALWAYS gated (chat.completions.create /
|
|
39
|
+
* .stream / .parse, responses.create / .stream / .parse).
|
|
40
|
+
*
|
|
41
|
+
* B6 round 2. The GATE decision for these does not depend on extraction — they
|
|
42
|
+
* are gated unconditionally — but WHAT THE FIREWALL SEES did.
|
|
43
|
+
* `extractPromptText` reads `messages[].content` and nothing else, so
|
|
44
|
+
*
|
|
45
|
+
* chat.completions.create({ model, messages: [...], extra_context: { doc } })
|
|
46
|
+
*
|
|
47
|
+
* was gated while `extra_context` — model-visible, since the SDK forwards
|
|
48
|
+
* unknown body keys verbatim — never reached the scanner. That is the same
|
|
49
|
+
* "the inner check still enumerates known-good names" defect as the surface
|
|
50
|
+
* gate, inside the enumerated path. The whole-body walk is unioned on top of
|
|
51
|
+
* the original extraction, never in place of it, so nothing previously scanned
|
|
52
|
+
* is lost.
|
|
53
|
+
*
|
|
54
|
+
* Propagates `ScanBoundExceededError` — every caller of this is async, so it
|
|
55
|
+
* surfaces as a rejection and the call never reaches the provider.
|
|
56
|
+
*/
|
|
57
|
+
function fullScanText(headText, args) {
|
|
58
|
+
const walked = extractModelInputText(args);
|
|
59
|
+
if (!walked)
|
|
60
|
+
return headText;
|
|
61
|
+
if (!headText)
|
|
62
|
+
return walked;
|
|
63
|
+
const seen = new Set(headText.split("\n"));
|
|
64
|
+
const extra = walked.split("\n").filter((line) => line && !seen.has(line));
|
|
65
|
+
return extra.length > 0 ? `${headText}\n${extra.join("\n")}` : headText;
|
|
66
|
+
}
|
|
36
67
|
// ── Main wrapper ────────────────────────────────────────────────────────────
|
|
37
68
|
/**
|
|
38
69
|
* Wrap an existing OpenAI client to add EvalGuard guardrails, logging, and
|
|
@@ -41,7 +72,7 @@ function extractPromptText(messages) {
|
|
|
41
72
|
*
|
|
42
73
|
* ```ts
|
|
43
74
|
* import OpenAI from "openai";
|
|
44
|
-
* import { wrapOpenAI } from "
|
|
75
|
+
* import { wrapOpenAI } from "@evalguard/openai";
|
|
45
76
|
*
|
|
46
77
|
* const openai = wrapOpenAI(new OpenAI(), {
|
|
47
78
|
* apiKey: "eg_...",
|
|
@@ -66,21 +97,317 @@ export function wrapOpenAI(client, config) {
|
|
|
66
97
|
// exposes this as a deeply nested property chain. We use a Proxy on the
|
|
67
98
|
// top-level client that intercepts property access to build a chain of
|
|
68
99
|
// proxies down to the `create` method.
|
|
100
|
+
// B6 (audit 2026-07-29): shared context for the default-deny surface guard
|
|
101
|
+
// that backs every resource without a dedicated proxy. See surface.ts.
|
|
102
|
+
const ctx = { guardrail, blockOnViolation, enableLogging, config };
|
|
69
103
|
return new Proxy(client, {
|
|
70
104
|
get(target, prop, receiver) {
|
|
71
105
|
if (prop === "chat") {
|
|
72
106
|
return createChatProxy(target.chat, guardrail, blockOnViolation, enableLogging, config);
|
|
73
107
|
}
|
|
108
|
+
// 2026-07-29 (audit A279): `client.responses` — the Responses API,
|
|
109
|
+
// which OpenAI now documents as the default surface for new work and
|
|
110
|
+
// which the Agents SDK uses — was NOT intercepted. Every
|
|
111
|
+
// `responses.create/stream/parse` call skipped the firewall, the
|
|
112
|
+
// trace log and cost tracking, silently. Verified against
|
|
113
|
+
// openai@6.44.0: `typeof client.responses.create === "function"`,
|
|
114
|
+
// `.stream` and `.parse` likewise.
|
|
115
|
+
if (prop === "responses") {
|
|
116
|
+
const responses = Reflect.get(target, prop, receiver);
|
|
117
|
+
if (responses && typeof responses === "object") {
|
|
118
|
+
return createResponsesProxy(responses, guardrail, blockOnViolation, enableLogging, config);
|
|
119
|
+
}
|
|
120
|
+
return responses;
|
|
121
|
+
}
|
|
122
|
+
// B6 (audit 2026-07-29): everything else USED TO land on
|
|
123
|
+
// `Reflect.get(target, prop, receiver)` — i.e. straight at the raw SDK.
|
|
124
|
+
// `client.completions.create` (legacy /v1/completions), embeddings,
|
|
125
|
+
// images, audio.speech, videos, beta.assistants, vectorStores.search and
|
|
126
|
+
// conversations.items.create all reached the model ungated, and so did
|
|
127
|
+
// `client.post("/chat/completions", …)`. The branch list was a denylist
|
|
128
|
+
// of guarded resources; it is now a default-deny surface guard with an
|
|
129
|
+
// explicit passthrough allowlist. See surface.ts for the inventory.
|
|
130
|
+
if (typeof prop === "string") {
|
|
131
|
+
// A derived client is still our client. `withOptions()` returns a NEW
|
|
132
|
+
// OpenAI instance built from the raw target — handing that back
|
|
133
|
+
// unwrapped silently sheds the guard for every call made through it.
|
|
134
|
+
if (prop === "withOptions") {
|
|
135
|
+
const original = Reflect.get(target, prop, target);
|
|
136
|
+
if (typeof original === "function") {
|
|
137
|
+
const bound = original.bind(target);
|
|
138
|
+
return (...args) => wrapOpenAI(bound(...args), config);
|
|
139
|
+
}
|
|
140
|
+
return original;
|
|
141
|
+
}
|
|
142
|
+
return guardSurfaceProp(target, prop, ctx);
|
|
143
|
+
}
|
|
74
144
|
return Reflect.get(target, prop, receiver);
|
|
75
145
|
},
|
|
76
146
|
});
|
|
77
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Resolve one property on a resource under the default-deny rule.
|
|
150
|
+
*
|
|
151
|
+
* Passthrough happens for exactly two reasons, both of them provable:
|
|
152
|
+
* - the name is on `KNOWN_SAFE_PASSTHROUGH` (explicit, justified), or
|
|
153
|
+
* - the value is a primitive (nothing to guard).
|
|
154
|
+
* Functions are gated, nested resources are recursively guarded.
|
|
155
|
+
*/
|
|
156
|
+
function guardSurfaceProp(target, prop, ctx) {
|
|
157
|
+
// `target` is the receiver on purpose: OpenAI resources read private fields
|
|
158
|
+
// (`#client`), which throws when the receiver is the Proxy.
|
|
159
|
+
const value = Reflect.get(target, prop, target);
|
|
160
|
+
if (isSafePassthrough(prop, value)) {
|
|
161
|
+
return typeof value === "function"
|
|
162
|
+
? value.bind(target)
|
|
163
|
+
: value;
|
|
164
|
+
}
|
|
165
|
+
if (typeof value === "function") {
|
|
166
|
+
return gateSurfaceMethod(value.bind(target), prop, ctx);
|
|
167
|
+
}
|
|
168
|
+
if (value && typeof value === "object") {
|
|
169
|
+
return createSurfaceProxy(value, ctx);
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
/** Recursively guard a nested resource (`audio` → `speech` → `create`). */
|
|
174
|
+
function createSurfaceProxy(resource, ctx) {
|
|
175
|
+
return new Proxy(resource, {
|
|
176
|
+
get(target, prop, receiver) {
|
|
177
|
+
if (typeof prop !== "string")
|
|
178
|
+
return Reflect.get(target, prop, target);
|
|
179
|
+
return guardSurfaceProp(target, prop, ctx);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Wrap one resource method with the firewall gate.
|
|
185
|
+
*
|
|
186
|
+
* When the call carries no model-visible text the ORIGINAL function is
|
|
187
|
+
* returned unchanged — not an async shim — so management calls keep the SDK's
|
|
188
|
+
* `APIPromise` and its `.withResponse()` / `.asResponse()` helpers. Only a
|
|
189
|
+
* call that actually carries a prompt pays the async wrapper, which is the
|
|
190
|
+
* same trade the dedicated chat/responses proxies already make.
|
|
191
|
+
*/
|
|
192
|
+
function gateSurfaceMethod(original, label, ctx) {
|
|
193
|
+
return function gatedSurfaceMethod(...args) {
|
|
194
|
+
let promptText;
|
|
195
|
+
try {
|
|
196
|
+
promptText = extractModelInputText(args);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
// ScanBoundExceededError: the body could not be read in full. Refuse the
|
|
200
|
+
// call. Deliberately NOT routed through `blockOnViolation` — a
|
|
201
|
+
// report-only caller still must not forward a payload we never scanned.
|
|
202
|
+
return Promise.reject(err);
|
|
203
|
+
}
|
|
204
|
+
if (promptText === null) {
|
|
205
|
+
// No model-visible text in the body — structurally incapable of
|
|
206
|
+
// carrying a prompt. Pass through untouched.
|
|
207
|
+
return original(...args);
|
|
208
|
+
}
|
|
209
|
+
const model = extractModelId(args);
|
|
210
|
+
const traceId = newTraceId();
|
|
211
|
+
const upstreamArgs = injectTraceHeader(args, traceId);
|
|
212
|
+
return (async () => {
|
|
213
|
+
const guardrailResult = await gateInput(promptText, model, traceId, ctx.guardrail, ctx.blockOnViolation, ctx.config);
|
|
214
|
+
const startTime = performance.now();
|
|
215
|
+
const result = await original(...upstreamArgs);
|
|
216
|
+
if (ctx.enableLogging) {
|
|
217
|
+
logSurfaceTrace(result, model, promptText, label, startTime, ctx, guardrailResult, traceId);
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
})();
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** Fire-and-forget trace for a generically-guarded resource call. */
|
|
224
|
+
function logSurfaceTrace(result, model, promptText, label, startTime, ctx, guardrailResult, traceId) {
|
|
225
|
+
const latencyMs = Math.round(performance.now() - startTime);
|
|
226
|
+
const usage = normalizeOpenAIUsage(result?.usage);
|
|
227
|
+
const { costUsd: cost, pricingSource: costPricingSource } = estimateCostDetailed(model, usage.input, usage.output, { cachedInputTokens: usage.cached });
|
|
228
|
+
void ctx.guardrail
|
|
229
|
+
.logTrace({
|
|
230
|
+
model,
|
|
231
|
+
provider: "openai",
|
|
232
|
+
input: promptText,
|
|
233
|
+
output: null,
|
|
234
|
+
latencyMs,
|
|
235
|
+
tokenUsage: usage,
|
|
236
|
+
cost,
|
|
237
|
+
costPricingSource,
|
|
238
|
+
projectId: ctx.config.projectId,
|
|
239
|
+
metadata: { ...ctx.config.metadata, api: label },
|
|
240
|
+
guardrailResult,
|
|
241
|
+
traceId,
|
|
242
|
+
})
|
|
243
|
+
.catch(() => {
|
|
244
|
+
/* logging must never throw */
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// ── Shared input gate ───────────────────────────────────────────────────────
|
|
248
|
+
/**
|
|
249
|
+
* Run the pre-request firewall check and enforce `blockOnViolation`.
|
|
250
|
+
*
|
|
251
|
+
* Extracted 2026-07-29 (audit A279). This block was previously copy-pasted
|
|
252
|
+
* at three call sites in this file; adding two more entry points would have
|
|
253
|
+
* made five drifting copies. One implementation means an entry point cannot
|
|
254
|
+
* quietly get a weaker gate than its neighbours.
|
|
255
|
+
*
|
|
256
|
+
* Throws `EvalGuardViolationError` when the request must not proceed.
|
|
257
|
+
*/
|
|
258
|
+
async function gateInput(promptText, model, traceId, guardrail, blockOnViolation, config) {
|
|
259
|
+
let guardrailResult;
|
|
260
|
+
try {
|
|
261
|
+
guardrailResult = await guardrail.checkInput(promptText, { model, provider: "openai", projectId: config.projectId, ...config.metadata }, traceId);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
// An unreachable guardrail is itself a high-severity violation
|
|
265
|
+
// (fail-closed by default).
|
|
266
|
+
const unavailable = {
|
|
267
|
+
allowed: false,
|
|
268
|
+
violations: [
|
|
269
|
+
{
|
|
270
|
+
type: "guardrail_unavailable",
|
|
271
|
+
severity: "high",
|
|
272
|
+
message: err instanceof Error ? err.message : "guardrail check failed",
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
config.onViolation?.(unavailable);
|
|
277
|
+
if (blockOnViolation)
|
|
278
|
+
throw new EvalGuardViolationError(unavailable);
|
|
279
|
+
return { allowed: true, violations: [] };
|
|
280
|
+
}
|
|
281
|
+
if (!guardrailResult.allowed) {
|
|
282
|
+
config.onViolation?.(guardrailResult);
|
|
283
|
+
if (blockOnViolation)
|
|
284
|
+
throw new EvalGuardViolationError(guardrailResult);
|
|
285
|
+
}
|
|
286
|
+
return guardrailResult;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Collapse a Responses-API request body into scannable text.
|
|
290
|
+
*
|
|
291
|
+
* `input` is `string | ResponseInputItem[]`; items carry `content` that is
|
|
292
|
+
* itself `string | Array<{type, text}>`. `instructions` is a top-level
|
|
293
|
+
* system-prompt string. Both are model-visible, so both are scanned —
|
|
294
|
+
* and so are tool-output items, which is the channel an attacker controls
|
|
295
|
+
* in an Agents-SDK loop.
|
|
296
|
+
*/
|
|
297
|
+
function extractResponsesPromptText(body) {
|
|
298
|
+
const parts = [];
|
|
299
|
+
if (typeof body.instructions === "string" && body.instructions) {
|
|
300
|
+
parts.push(body.instructions);
|
|
301
|
+
}
|
|
302
|
+
const input = body.input;
|
|
303
|
+
if (typeof input === "string") {
|
|
304
|
+
if (input)
|
|
305
|
+
parts.push(input);
|
|
306
|
+
}
|
|
307
|
+
else if (Array.isArray(input)) {
|
|
308
|
+
parts.push(extractPromptText(input));
|
|
309
|
+
// Tool-output items have no `content` — their payload is on `output`.
|
|
310
|
+
for (const item of input) {
|
|
311
|
+
if (item && typeof item === "object" && item.content == null) {
|
|
312
|
+
if (typeof item.output === "string" && item.output)
|
|
313
|
+
parts.push(item.output);
|
|
314
|
+
else if (item.output != null) {
|
|
315
|
+
try {
|
|
316
|
+
parts.push(JSON.stringify(item.output));
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
/* unserialisable */
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return parts.filter(Boolean).join("\n");
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* A279: proxy `client.responses`, gating `create` / `stream` / `parse`.
|
|
329
|
+
*
|
|
330
|
+
* Every method that reaches the model is gated — not a subset. A cap or a
|
|
331
|
+
* gate added to some entry points and not others is the failure this repo
|
|
332
|
+
* has already shipped once (firewall input cap, 2 of 4 entry points).
|
|
333
|
+
*/
|
|
334
|
+
function createResponsesProxy(responses, guardrail, blockOnViolation, enableLogging, config) {
|
|
335
|
+
const GATED = new Set(["create", "stream", "parse"]);
|
|
336
|
+
return new Proxy(responses, {
|
|
337
|
+
get(target, prop, receiver) {
|
|
338
|
+
const value = Reflect.get(target, prop, target);
|
|
339
|
+
if (typeof prop === "string" && GATED.has(prop) && typeof value === "function") {
|
|
340
|
+
const original = value.bind(target);
|
|
341
|
+
return async function gatedResponsesMethod(...args) {
|
|
342
|
+
const body = (args[0] ?? {});
|
|
343
|
+
const model = body.model ?? "unknown";
|
|
344
|
+
const traceId = newTraceId();
|
|
345
|
+
const upstreamArgs = injectTraceHeader(args, traceId);
|
|
346
|
+
const guardrailResult = await gateInput(fullScanText(extractResponsesPromptText(body), args), model, traceId, guardrail, blockOnViolation, config);
|
|
347
|
+
const startTime = performance.now();
|
|
348
|
+
const result = await original(...upstreamArgs);
|
|
349
|
+
if (!enableLogging)
|
|
350
|
+
return result;
|
|
351
|
+
logResponsesTrace(result, model, body, startTime, guardrail, config, guardrailResult, traceId);
|
|
352
|
+
return result;
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
// B6: the same default-deny rule one level down. `responses` carries
|
|
356
|
+
// sub-resources (`inputItems`, `inputTokens` on openai@6.44.0) that the
|
|
357
|
+
// GATED set does not name; they must not fall through raw.
|
|
358
|
+
if (typeof prop === "string") {
|
|
359
|
+
return guardSurfaceProp(target, prop, {
|
|
360
|
+
guardrail,
|
|
361
|
+
blockOnViolation,
|
|
362
|
+
enableLogging,
|
|
363
|
+
config,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return Reflect.get(target, prop, receiver);
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
/** Fire-and-forget trace for a Responses-API call. */
|
|
371
|
+
function logResponsesTrace(result, model, body, startTime, guardrail, config, guardrailResult, traceId) {
|
|
372
|
+
const latencyMs = Math.round(performance.now() - startTime);
|
|
373
|
+
const res = (result ?? {});
|
|
374
|
+
// `usage` on a Response is { input_tokens, output_tokens, ... } — not the
|
|
375
|
+
// chat-completions { prompt_tokens, completion_tokens } shape.
|
|
376
|
+
const usage = (res.usage ?? {});
|
|
377
|
+
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
378
|
+
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
379
|
+
const outputText = typeof res.output_text === "string" ? res.output_text : null;
|
|
380
|
+
const { costUsd: cost, pricingSource: costPricingSource } = estimateCostDetailed(model, inputTokens, outputTokens);
|
|
381
|
+
void guardrail.logTrace({
|
|
382
|
+
model,
|
|
383
|
+
provider: "openai",
|
|
384
|
+
input: extractResponsesPromptText(body),
|
|
385
|
+
output: outputText,
|
|
386
|
+
latencyMs,
|
|
387
|
+
tokenUsage: { input: inputTokens, output: outputTokens },
|
|
388
|
+
cost,
|
|
389
|
+
costPricingSource,
|
|
390
|
+
projectId: config.projectId,
|
|
391
|
+
metadata: { ...config.metadata, api: "responses" },
|
|
392
|
+
guardrailResult,
|
|
393
|
+
traceId,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
78
396
|
function createChatProxy(chat, guardrail, blockOnViolation, enableLogging, config) {
|
|
79
397
|
return new Proxy(chat, {
|
|
80
398
|
get(target, prop, receiver) {
|
|
81
399
|
if (prop === "completions") {
|
|
82
400
|
return createCompletionsProxy(target.completions, guardrail, blockOnViolation, enableLogging, config);
|
|
83
401
|
}
|
|
402
|
+
// B6: default-deny the rest of `chat` rather than returning it raw.
|
|
403
|
+
if (typeof prop === "string") {
|
|
404
|
+
return guardSurfaceProp(target, prop, {
|
|
405
|
+
guardrail,
|
|
406
|
+
blockOnViolation,
|
|
407
|
+
enableLogging,
|
|
408
|
+
config,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
84
411
|
return Reflect.get(target, prop, receiver);
|
|
85
412
|
},
|
|
86
413
|
});
|
|
@@ -99,6 +426,29 @@ function createCompletionsProxy(completions, guardrail, blockOnViolation, enable
|
|
|
99
426
|
if (prop === "stream") {
|
|
100
427
|
return createInterceptedStream(target, guardrail, blockOnViolation, enableLogging, config);
|
|
101
428
|
}
|
|
429
|
+
// 2026-07-29 (audit A279): `chat.completions.parse()` — the structured-
|
|
430
|
+
// outputs helper (`zodResponseFormat`) — is a THIRD entry point to the
|
|
431
|
+
// same endpoint and was ungated. Verified `typeof
|
|
432
|
+
// client.chat.completions.parse === "function"` on openai@6.44.0.
|
|
433
|
+
// It resolves to a ParsedChatCompletion, so the non-streaming handler
|
|
434
|
+
// applies unchanged.
|
|
435
|
+
if (prop === "parse") {
|
|
436
|
+
const original = Reflect.get(target, prop, target);
|
|
437
|
+
if (typeof original === "function") {
|
|
438
|
+
return createInterceptedCreate(original.bind(target), guardrail, blockOnViolation, enableLogging, config);
|
|
439
|
+
}
|
|
440
|
+
return original;
|
|
441
|
+
}
|
|
442
|
+
// B6: default-deny the rest of `chat.completions` rather than returning
|
|
443
|
+
// it raw.
|
|
444
|
+
if (typeof prop === "string") {
|
|
445
|
+
return guardSurfaceProp(target, prop, {
|
|
446
|
+
guardrail,
|
|
447
|
+
blockOnViolation,
|
|
448
|
+
enableLogging,
|
|
449
|
+
config,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
102
452
|
return Reflect.get(target, prop, receiver);
|
|
103
453
|
},
|
|
104
454
|
});
|
|
@@ -120,47 +470,13 @@ function createInterceptedCreate(originalCreate, guardrail, blockOnViolation, en
|
|
|
120
470
|
const traceId = newTraceId();
|
|
121
471
|
const upstreamArgs = injectTraceHeader(args, traceId);
|
|
122
472
|
// ── Pre-request guardrail check ─────────────────────────────────────
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
...config.metadata,
|
|
131
|
-
}, traceId);
|
|
132
|
-
}
|
|
133
|
-
catch (err) {
|
|
134
|
-
// 2026-05-28: previously this catch silently set
|
|
135
|
-
// `{ allowed: true, violations: [] }` — meaning any guardrail
|
|
136
|
-
// outage (network blip, EvalGuard 5xx, DNS hiccup) became a
|
|
137
|
-
// free pass for the prompt. Per check.txt audit P1 finding.
|
|
138
|
-
//
|
|
139
|
-
// New contract: an unreachable guardrail is itself a
|
|
140
|
-
// "guardrail_unavailable" violation. Honor blockOnViolation —
|
|
141
|
-
// true (default) → throw; false → onViolation callback + allow.
|
|
142
|
-
const unavailable = {
|
|
143
|
-
allowed: false,
|
|
144
|
-
violations: [
|
|
145
|
-
{
|
|
146
|
-
type: "guardrail_unavailable",
|
|
147
|
-
severity: "high",
|
|
148
|
-
message: err instanceof Error ? err.message : "guardrail check failed",
|
|
149
|
-
},
|
|
150
|
-
],
|
|
151
|
-
};
|
|
152
|
-
config.onViolation?.(unavailable);
|
|
153
|
-
if (blockOnViolation) {
|
|
154
|
-
throw new EvalGuardViolationError(unavailable);
|
|
155
|
-
}
|
|
156
|
-
guardrailResult = { allowed: true, violations: [] };
|
|
157
|
-
}
|
|
158
|
-
if (!guardrailResult.allowed) {
|
|
159
|
-
config.onViolation?.(guardrailResult);
|
|
160
|
-
if (blockOnViolation) {
|
|
161
|
-
throw new EvalGuardViolationError(guardrailResult);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
473
|
+
// 2026-07-29 (A279): shared with responses.create / responses.stream /
|
|
474
|
+
// responses.parse / chat.completions.stream via gateInput() so no entry
|
|
475
|
+
// point can drift to a weaker gate. Contract (unchanged from 2026-05-28):
|
|
476
|
+
// an unreachable guardrail is itself a "guardrail_unavailable" violation
|
|
477
|
+
// — blockOnViolation true (default) → throw; false → onViolation + allow.
|
|
478
|
+
const promptText = fullScanText(extractPromptText(messages), args);
|
|
479
|
+
const guardrailResult = await gateInput(promptText, model, traceId, guardrail, blockOnViolation, config);
|
|
164
480
|
// ── Execute LLM call ────────────────────────────────────────────────
|
|
165
481
|
const startTime = performance.now();
|
|
166
482
|
if (isStreaming) {
|
|
@@ -173,10 +489,10 @@ function createInterceptedCreate(originalCreate, guardrail, blockOnViolation, en
|
|
|
173
489
|
async function handleNonStreamingResponse(originalCreate, args, model, messages, startTime, guardrail, enableLogging, config, guardrailResult, traceId) {
|
|
174
490
|
const response = await originalCreate(...args);
|
|
175
491
|
const latencyMs = Math.round(performance.now() - startTime);
|
|
176
|
-
const
|
|
177
|
-
const inputTokens =
|
|
178
|
-
const outputTokens =
|
|
179
|
-
const cost =
|
|
492
|
+
const usageBreakdown = normalizeOpenAIUsage(response.usage);
|
|
493
|
+
const inputTokens = usageBreakdown.input;
|
|
494
|
+
const outputTokens = usageBreakdown.output;
|
|
495
|
+
const { costUsd: cost, pricingSource: costPricingSource } = estimateCostDetailed(model, inputTokens, outputTokens, { cachedInputTokens: usageBreakdown.cached });
|
|
180
496
|
const outputContent = response.choices?.[0]
|
|
181
497
|
?.message ?? null;
|
|
182
498
|
const idempotencyKey = resolveIdempotencyKey(config);
|
|
@@ -201,8 +517,9 @@ async function handleNonStreamingResponse(originalCreate, args, model, messages,
|
|
|
201
517
|
input: messages,
|
|
202
518
|
output: outputContent,
|
|
203
519
|
latencyMs,
|
|
204
|
-
tokenUsage:
|
|
520
|
+
tokenUsage: usageBreakdown,
|
|
205
521
|
cost,
|
|
522
|
+
costPricingSource,
|
|
206
523
|
projectId: config.projectId,
|
|
207
524
|
metadata: config.metadata,
|
|
208
525
|
guardrailResult,
|
|
@@ -225,8 +542,9 @@ async function handleNonStreamingResponse(originalCreate, args, model, messages,
|
|
|
225
542
|
input: messages,
|
|
226
543
|
output: outputContent,
|
|
227
544
|
latencyMs,
|
|
228
|
-
tokenUsage:
|
|
545
|
+
tokenUsage: usageBreakdown,
|
|
229
546
|
cost,
|
|
547
|
+
costPricingSource,
|
|
230
548
|
projectId: config.projectId,
|
|
231
549
|
metadata: config.metadata,
|
|
232
550
|
guardrailResult,
|
|
@@ -251,9 +569,15 @@ async function handleStreamingResponse(originalCreate, args, model, messages, st
|
|
|
251
569
|
return wrapAsyncIterable(stream, model, messages, startTime, guardrail, config, guardrailResult, traceId);
|
|
252
570
|
}
|
|
253
571
|
function finalizeOpenAIStream(state) {
|
|
254
|
-
const { assembledContent, inputTokens, outputTokens, model, messages, startTime, guardrail, config, guardrailResult, traceId, } = state;
|
|
572
|
+
const { assembledContent, inputTokens, outputTokens, cachedTokens, reasoningTokens, model, messages, startTime, guardrail, config, guardrailResult, traceId, } = state;
|
|
255
573
|
const latencyMs = Math.round(performance.now() - startTime);
|
|
256
|
-
const cost =
|
|
574
|
+
const { costUsd: cost, pricingSource: costPricingSource } = estimateCostDetailed(model, inputTokens, outputTokens, { cachedInputTokens: cachedTokens });
|
|
575
|
+
const usageBreakdown = {
|
|
576
|
+
input: inputTokens,
|
|
577
|
+
output: outputTokens,
|
|
578
|
+
...(cachedTokens ? { cached: cachedTokens } : {}),
|
|
579
|
+
...(reasoningTokens ? { reasoning: reasoningTokens } : {}),
|
|
580
|
+
};
|
|
257
581
|
const idempotencyKey = resolveIdempotencyKey(config);
|
|
258
582
|
runPostResponseEval(guardrail, config, assembledContent, { model, provider: "openai", projectId: config.projectId, ...config.metadata }, traceId, idempotencyKey)
|
|
259
583
|
.catch((err) => {
|
|
@@ -277,8 +601,9 @@ function finalizeOpenAIStream(state) {
|
|
|
277
601
|
input: messages,
|
|
278
602
|
output: assembledContent,
|
|
279
603
|
latencyMs,
|
|
280
|
-
tokenUsage:
|
|
604
|
+
tokenUsage: usageBreakdown,
|
|
281
605
|
cost,
|
|
606
|
+
costPricingSource,
|
|
282
607
|
projectId: config.projectId,
|
|
283
608
|
metadata: config.metadata,
|
|
284
609
|
guardrailResult,
|
|
@@ -295,6 +620,8 @@ function wrapAsyncIterable(stream, model, messages, startTime, guardrail, config
|
|
|
295
620
|
let assembledContent = "";
|
|
296
621
|
let inputTokens = 0;
|
|
297
622
|
let outputTokens = 0;
|
|
623
|
+
let cachedTokens;
|
|
624
|
+
let reasoningTokens;
|
|
298
625
|
const originalIterator = Symbol.asyncIterator in stream
|
|
299
626
|
? stream[Symbol.asyncIterator]()
|
|
300
627
|
: stream;
|
|
@@ -309,6 +636,8 @@ function wrapAsyncIterable(stream, model, messages, startTime, guardrail, config
|
|
|
309
636
|
assembledContent,
|
|
310
637
|
inputTokens,
|
|
311
638
|
outputTokens,
|
|
639
|
+
cachedTokens,
|
|
640
|
+
reasoningTokens,
|
|
312
641
|
model,
|
|
313
642
|
messages,
|
|
314
643
|
startTime,
|
|
@@ -325,11 +654,17 @@ function wrapAsyncIterable(stream, model, messages, startTime, guardrail, config
|
|
|
325
654
|
if (choices?.[0]?.delta?.content) {
|
|
326
655
|
assembledContent += choices[0].delta.content;
|
|
327
656
|
}
|
|
328
|
-
// Capture usage if present (some models report on final chunk
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
657
|
+
// Capture usage if present (some models report on final chunk, only when
|
|
658
|
+
// the caller set stream_options.include_usage). Normalize to pick up the
|
|
659
|
+
// cached/reasoning detail for FinOps-accurate cost.
|
|
660
|
+
if (chunk.usage) {
|
|
661
|
+
const u = normalizeOpenAIUsage(chunk.usage);
|
|
662
|
+
inputTokens = u.input || inputTokens;
|
|
663
|
+
outputTokens = u.output || outputTokens;
|
|
664
|
+
if (u.cached)
|
|
665
|
+
cachedTokens = u.cached;
|
|
666
|
+
if (u.reasoning)
|
|
667
|
+
reasoningTokens = u.reasoning;
|
|
333
668
|
}
|
|
334
669
|
return result;
|
|
335
670
|
},
|
|
@@ -395,36 +730,9 @@ function createInterceptedStream(completions, guardrail, blockOnViolation, enabl
|
|
|
395
730
|
const traceId = newTraceId();
|
|
396
731
|
const upstreamArgs = injectTraceHeader(args, traceId);
|
|
397
732
|
// ── Pre-request guardrail check ─────────────────────────────────────
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
guardrailResult = await guardrail.checkInput(promptText, { model, provider: "openai", projectId: config.projectId, ...config.metadata }, traceId);
|
|
402
|
-
}
|
|
403
|
-
catch (err) {
|
|
404
|
-
// Unreachable guardrail is itself a high-severity violation
|
|
405
|
-
// (fail-closed by default) — symmetric with the create() path.
|
|
406
|
-
const unavailable = {
|
|
407
|
-
allowed: false,
|
|
408
|
-
violations: [
|
|
409
|
-
{
|
|
410
|
-
type: "guardrail_unavailable",
|
|
411
|
-
severity: "high",
|
|
412
|
-
message: err instanceof Error ? err.message : "guardrail check failed",
|
|
413
|
-
},
|
|
414
|
-
],
|
|
415
|
-
};
|
|
416
|
-
config.onViolation?.(unavailable);
|
|
417
|
-
if (blockOnViolation) {
|
|
418
|
-
throw new EvalGuardViolationError(unavailable);
|
|
419
|
-
}
|
|
420
|
-
guardrailResult = { allowed: true, violations: [] };
|
|
421
|
-
}
|
|
422
|
-
if (!guardrailResult.allowed) {
|
|
423
|
-
config.onViolation?.(guardrailResult);
|
|
424
|
-
if (blockOnViolation) {
|
|
425
|
-
throw new EvalGuardViolationError(guardrailResult);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
733
|
+
// Same gate as create() / responses.* — see gateInput().
|
|
734
|
+
const promptText = fullScanText(extractPromptText(messages), args);
|
|
735
|
+
const guardrailResult = await gateInput(promptText, model, traceId, guardrail, blockOnViolation, config);
|
|
428
736
|
const startTime = performance.now();
|
|
429
737
|
const stream = originalStream(...upstreamArgs);
|
|
430
738
|
const resolved = stream instanceof Promise ? await stream : stream;
|
|
@@ -444,6 +752,8 @@ function wrapChatCompletionStream(stream, model, messages, startTime, guardrail,
|
|
|
444
752
|
let assembledContent = "";
|
|
445
753
|
let inputTokens = 0;
|
|
446
754
|
let outputTokens = 0;
|
|
755
|
+
let cachedTokens;
|
|
756
|
+
let reasoningTokens;
|
|
447
757
|
let settled = false;
|
|
448
758
|
const finalizeOnce = (content, inTok, outTok) => {
|
|
449
759
|
if (settled)
|
|
@@ -453,6 +763,8 @@ function wrapChatCompletionStream(stream, model, messages, startTime, guardrail,
|
|
|
453
763
|
assembledContent: content,
|
|
454
764
|
inputTokens: inTok,
|
|
455
765
|
outputTokens: outTok,
|
|
766
|
+
cachedTokens,
|
|
767
|
+
reasoningTokens,
|
|
456
768
|
model,
|
|
457
769
|
messages,
|
|
458
770
|
startTime,
|
|
@@ -477,10 +789,14 @@ function wrapChatCompletionStream(stream, model, messages, startTime, guardrail,
|
|
|
477
789
|
if (choices?.[0]?.delta?.content) {
|
|
478
790
|
assembledContent += choices[0].delta.content;
|
|
479
791
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
inputTokens =
|
|
483
|
-
outputTokens =
|
|
792
|
+
if (chunk.usage) {
|
|
793
|
+
const u = normalizeOpenAIUsage(chunk.usage);
|
|
794
|
+
inputTokens = u.input || inputTokens;
|
|
795
|
+
outputTokens = u.output || outputTokens;
|
|
796
|
+
if (u.cached)
|
|
797
|
+
cachedTokens = u.cached;
|
|
798
|
+
if (u.reasoning)
|
|
799
|
+
reasoningTokens = u.reasoning;
|
|
484
800
|
}
|
|
485
801
|
return result;
|
|
486
802
|
},
|
|
@@ -512,8 +828,17 @@ function wrapChatCompletionStream(stream, model, messages, startTime, guardrail,
|
|
|
512
828
|
const completion = (await stream.finalChatCompletion(...a));
|
|
513
829
|
const choice = completion.choices?.[0];
|
|
514
830
|
const text = String(choice?.message?.content ?? "");
|
|
515
|
-
|
|
516
|
-
|
|
831
|
+
if (completion.usage) {
|
|
832
|
+
const u = normalizeOpenAIUsage(completion.usage);
|
|
833
|
+
if (u.cached)
|
|
834
|
+
cachedTokens = u.cached;
|
|
835
|
+
if (u.reasoning)
|
|
836
|
+
reasoningTokens = u.reasoning;
|
|
837
|
+
finalizeOnce(text, u.input || inputTokens, u.output || outputTokens);
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
finalizeOnce(text, inputTokens, outputTokens);
|
|
841
|
+
}
|
|
517
842
|
return completion;
|
|
518
843
|
};
|
|
519
844
|
}
|