Skip to content

Commit 3290c43

Browse files
committed
feat: response template merging with JSON auto-stringify
Add ResponseOverrides interface for overriding envelope fields (id, created, model, usage, finishReason, role, systemFingerprint) in fixture responses. Thread overrides through all 4 provider handlers (OpenAI Chat, Responses API, Claude Messages, Gemini) for both streaming and non-streaming paths. Add normalizeResponse() for auto-stringifying object-valued content and toolCalls[].arguments in fixtures — lets authors write plain JSON objects instead of escaped strings. FixtureFile* types accept relaxed input while runtime types remain strict. Type safety improvements: - Add system_fingerprint to SSEChunk/ChatCompletion types - Make type guards mutually exclusive (isTextResponse excludes toolCalls) - Narrow extractOverrides parameter type - Remove unnecessary as-casts Cross-provider consistency: - finishReason mappings: length, content_filter for all providers - Usage auto-sum: total_tokens computed from components when omitted - Reasoning support for ContentWithToolCallsResponse in all providers - webSearches warnings for unsupported providers Validation: ResponseOverrides field type checks, unknown field detection, unknown usage key detection, ContentWithToolCallsResponse validation.
1 parent 687a493 commit 3290c43

9 files changed

Lines changed: 822 additions & 234 deletions

File tree

src/fixture-loader.ts

Lines changed: 214 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,53 @@
11
import { readFileSync, readdirSync, statSync } from "node:fs";
22
import { join } from "node:path";
3-
import type { Fixture, FixtureFile, FixtureFileEntry } from "./types.js";
3+
import type {
4+
Fixture,
5+
FixtureFile,
6+
FixtureFileEntry,
7+
FixtureFileResponse,
8+
FixtureResponse,
9+
ResponseOverrides,
10+
} from "./types.js";
411
import {
512
isTextResponse,
613
isToolCallResponse,
14+
isContentWithToolCallsResponse,
715
isErrorResponse,
816
isEmbeddingResponse,
17+
isImageResponse,
18+
isAudioResponse,
19+
isTranscriptionResponse,
20+
isVideoResponse,
921
} from "./helpers.js";
1022
import type { Logger } from "./logger.js";
1123

24+
/**
25+
* Auto-stringify object-valued `content` and `toolCalls[].arguments` fields.
26+
* This lets fixture authors write plain JSON objects instead of escaped strings.
27+
* All other fields (including ResponseOverrides) pass through unmodified.
28+
*/
29+
export function normalizeResponse(raw: FixtureFileResponse): FixtureResponse {
30+
// Shallow-clone so we don't mutate the parsed JSON input.
31+
const response = { ...raw } as Record<string, unknown>;
32+
33+
// Auto-stringify object content (e.g. structured output)
34+
if (typeof response.content === "object" && response.content !== null) {
35+
response.content = JSON.stringify(response.content);
36+
}
37+
38+
// Auto-stringify object arguments in toolCalls
39+
if (Array.isArray(response.toolCalls)) {
40+
response.toolCalls = (response.toolCalls as Array<Record<string, unknown>>).map((tc) => {
41+
if (typeof tc.arguments === "object" && tc.arguments !== null) {
42+
return { ...tc, arguments: JSON.stringify(tc.arguments) };
43+
}
44+
return tc;
45+
});
46+
}
47+
48+
return response as unknown as FixtureResponse;
49+
}
50+
1251
export function entryToFixture(entry: FixtureFileEntry): Fixture {
1352
return {
1453
match: {
@@ -21,7 +60,7 @@ export function entryToFixture(entry: FixtureFileEntry): Fixture {
2160
endpoint: entry.match.endpoint,
2261
...(entry.match.sequenceIndex !== undefined && { sequenceIndex: entry.match.sequenceIndex }),
2362
},
24-
response: entry.response,
63+
response: normalizeResponse(entry.response),
2564
...(entry.latency !== undefined && { latency: entry.latency }),
2665
...(entry.chunkSize !== undefined && { chunkSize: entry.chunkSize }),
2766
...(entry.truncateAfterChunks !== undefined && {
@@ -120,6 +159,68 @@ export interface ValidationResult {
120159
message: string;
121160
}
122161

162+
function validateReasoning(
163+
response: { reasoning?: unknown },
164+
fixtureIndex: number,
165+
results: ValidationResult[],
166+
): void {
167+
if (response.reasoning !== undefined) {
168+
if (typeof response.reasoning !== "string") {
169+
results.push({
170+
severity: "error",
171+
fixtureIndex,
172+
message: "reasoning must be a string",
173+
});
174+
} else if (response.reasoning === "") {
175+
results.push({
176+
severity: "warning",
177+
fixtureIndex,
178+
message: "reasoning is empty string — no reasoning events will be emitted",
179+
});
180+
}
181+
}
182+
}
183+
184+
function validateWebSearches(
185+
response: { webSearches?: unknown },
186+
fixtureIndex: number,
187+
results: ValidationResult[],
188+
): void {
189+
if (response.webSearches !== undefined) {
190+
if (!Array.isArray(response.webSearches)) {
191+
results.push({
192+
severity: "error",
193+
fixtureIndex,
194+
message: "webSearches must be an array of strings",
195+
});
196+
} else if (response.webSearches.length === 0) {
197+
results.push({
198+
severity: "warning",
199+
fixtureIndex,
200+
message: "webSearches is empty array — no web search events will be emitted",
201+
});
202+
} else {
203+
for (let j = 0; j < response.webSearches.length; j++) {
204+
if (typeof response.webSearches[j] !== "string") {
205+
results.push({
206+
severity: "error",
207+
fixtureIndex,
208+
message: `webSearches[${j}] is not a string`,
209+
});
210+
break;
211+
}
212+
if (response.webSearches[j] === "") {
213+
results.push({
214+
severity: "warning",
215+
fixtureIndex,
216+
message: `webSearches[${j}] is empty string`,
217+
});
218+
}
219+
}
220+
}
221+
}
222+
}
223+
123224
export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
124225
const results: ValidationResult[] = [];
125226

@@ -132,17 +233,24 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
132233
// --- Error checks ---
133234

134235
// Response type recognition
236+
// Note: isContentWithToolCallsResponse must be checked before isTextResponse
237+
// and isToolCallResponse since it is a structural superset of both.
135238
if (
239+
!isContentWithToolCallsResponse(response) &&
136240
!isTextResponse(response) &&
137241
!isToolCallResponse(response) &&
138242
!isErrorResponse(response) &&
139-
!isEmbeddingResponse(response)
243+
!isEmbeddingResponse(response) &&
244+
!isImageResponse(response) &&
245+
!isAudioResponse(response) &&
246+
!isTranscriptionResponse(response) &&
247+
!isVideoResponse(response)
140248
) {
141249
results.push({
142250
severity: "error",
143251
fixtureIndex: i,
144252
message:
145-
"response is not a recognized type (must have content, toolCalls, error, or embedding)",
253+
"response is not a recognized type (must have content, toolCalls, error, embedding, image, audio, transcription, or video)",
146254
});
147255
}
148256

@@ -155,54 +263,47 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
155263
message: "content is empty string",
156264
});
157265
}
158-
if (response.reasoning !== undefined) {
159-
if (typeof response.reasoning !== "string") {
266+
validateReasoning(response, i, results);
267+
validateWebSearches(response, i, results);
268+
}
269+
270+
// ContentWithToolCalls response checks
271+
if (isContentWithToolCallsResponse(response)) {
272+
if (response.content === "") {
273+
results.push({
274+
severity: "error",
275+
fixtureIndex: i,
276+
message: "content is empty string",
277+
});
278+
}
279+
if (response.toolCalls.length === 0) {
280+
results.push({
281+
severity: "warning",
282+
fixtureIndex: i,
283+
message: "toolCalls array is empty — fixture will never produce tool calls",
284+
});
285+
}
286+
for (let j = 0; j < response.toolCalls.length; j++) {
287+
const tc = response.toolCalls[j];
288+
if (!tc.name) {
160289
results.push({
161290
severity: "error",
162291
fixtureIndex: i,
163-
message: "reasoning must be a string",
164-
});
165-
} else if (response.reasoning === "") {
166-
results.push({
167-
severity: "warning",
168-
fixtureIndex: i,
169-
message: "reasoning is empty string — no reasoning events will be emitted",
292+
message: `toolCalls[${j}].name is empty`,
170293
});
171294
}
172-
}
173-
if (response.webSearches !== undefined) {
174-
if (!Array.isArray(response.webSearches)) {
295+
try {
296+
JSON.parse(tc.arguments);
297+
} catch {
175298
results.push({
176299
severity: "error",
177300
fixtureIndex: i,
178-
message: "webSearches must be an array of strings",
179-
});
180-
} else if (response.webSearches.length === 0) {
181-
results.push({
182-
severity: "warning",
183-
fixtureIndex: i,
184-
message: "webSearches is empty array — no web search events will be emitted",
301+
message: `toolCalls[${j}].arguments is not valid JSON: ${tc.arguments}`,
185302
});
186-
} else {
187-
for (let j = 0; j < response.webSearches.length; j++) {
188-
if (typeof response.webSearches[j] !== "string") {
189-
results.push({
190-
severity: "error",
191-
fixtureIndex: i,
192-
message: `webSearches[${j}] is not a string`,
193-
});
194-
break;
195-
}
196-
if (response.webSearches[j] === "") {
197-
results.push({
198-
severity: "warning",
199-
fixtureIndex: i,
200-
message: `webSearches[${j}] is empty string`,
201-
});
202-
}
203-
}
204303
}
205304
}
305+
validateReasoning(response, i, results);
306+
validateWebSearches(response, i, results);
206307
}
207308

208309
// Tool call response checks
@@ -274,6 +375,78 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
274375
}
275376
}
276377

378+
// Validate ResponseOverrides fields
379+
if (
380+
isTextResponse(response) ||
381+
isToolCallResponse(response) ||
382+
isContentWithToolCallsResponse(response)
383+
) {
384+
const r = response as ResponseOverrides;
385+
if (r.id !== undefined && typeof r.id !== "string") {
386+
results.push({
387+
severity: "error",
388+
fixtureIndex: i,
389+
message: `override "id" must be a string, got ${typeof r.id}`,
390+
});
391+
}
392+
if (r.created !== undefined && (typeof r.created !== "number" || r.created < 0)) {
393+
results.push({
394+
severity: "error",
395+
fixtureIndex: i,
396+
message: `override "created" must be a non-negative number`,
397+
});
398+
}
399+
if (r.model !== undefined && typeof r.model !== "string") {
400+
results.push({
401+
severity: "error",
402+
fixtureIndex: i,
403+
message: `override "model" must be a string, got ${typeof r.model}`,
404+
});
405+
}
406+
if (r.finishReason !== undefined && typeof r.finishReason !== "string") {
407+
results.push({
408+
severity: "error",
409+
fixtureIndex: i,
410+
message: `override "finishReason" must be a string, got ${typeof r.finishReason}`,
411+
});
412+
}
413+
if (r.role !== undefined && typeof r.role !== "string") {
414+
results.push({
415+
severity: "error",
416+
fixtureIndex: i,
417+
message: `override "role" must be a string, got ${typeof r.role}`,
418+
});
419+
}
420+
if (r.systemFingerprint !== undefined && typeof r.systemFingerprint !== "string") {
421+
results.push({
422+
severity: "error",
423+
fixtureIndex: i,
424+
message: `override "systemFingerprint" must be a string, got ${typeof r.systemFingerprint}`,
425+
});
426+
}
427+
if (r.usage !== undefined) {
428+
if (typeof r.usage !== "object" || r.usage === null || Array.isArray(r.usage)) {
429+
results.push({
430+
severity: "error",
431+
fixtureIndex: i,
432+
message: `override "usage" must be an object`,
433+
});
434+
} else {
435+
// Check all known usage fields are numbers if present
436+
for (const key of Object.keys(r.usage)) {
437+
const val = (r.usage as Record<string, unknown>)[key];
438+
if (val !== undefined && typeof val !== "number") {
439+
results.push({
440+
severity: "error",
441+
fixtureIndex: i,
442+
message: `override "usage.${key}" must be a number, got ${typeof val}`,
443+
});
444+
}
445+
}
446+
}
447+
}
448+
}
449+
277450
// Numeric sanity checks
278451
if (f.latency !== undefined && f.latency < 0) {
279452
results.push({

0 commit comments

Comments
 (0)