@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/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- // ── evalguardai-openai ───────────────────────────────────────────────────────
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 { estimateCost, newTraceId, injectTraceHeader, resolveIdempotencyKey, runPostResponseEval, } from "@evalguard/wrapper-core";
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 "evalguardai-openai";
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
- const promptText = extractPromptText(messages);
124
- let guardrailResult;
125
- try {
126
- guardrailResult = await guardrail.checkInput(promptText, {
127
- model,
128
- provider: "openai",
129
- projectId: config.projectId,
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 usage = response.usage;
177
- const inputTokens = usage?.prompt_tokens ?? 0;
178
- const outputTokens = usage?.completion_tokens ?? 0;
179
- const cost = estimateCost(model, inputTokens, outputTokens);
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: { input: inputTokens, output: outputTokens },
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: { input: inputTokens, output: outputTokens },
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 = estimateCost(model, inputTokens, outputTokens);
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: { input: inputTokens, output: outputTokens },
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
- const usage = chunk.usage;
330
- if (usage) {
331
- inputTokens = usage.prompt_tokens ?? inputTokens;
332
- outputTokens = usage.completion_tokens ?? outputTokens;
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
- const promptText = extractPromptText(messages);
399
- let guardrailResult;
400
- try {
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
- const usage = chunk.usage;
481
- if (usage) {
482
- inputTokens = usage.prompt_tokens ?? inputTokens;
483
- outputTokens = usage.completion_tokens ?? 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
- const u = completion.usage;
516
- finalizeOnce(text, u?.prompt_tokens ?? inputTokens, u?.completion_tokens ?? outputTokens);
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
  }