@cogitator-ai/test-utils 0.2.20 → 0.3.0

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.
@@ -0,0 +1,345 @@
1
+ import { CONFORMANCE_FIXTURE as F, } from '../scenarios.js';
2
+ import { sse } from './encoding.js';
3
+ const ARGUMENTS = JSON.stringify(F.toolCall.arguments);
4
+ /** The error bodies of the OpenAI API (developers.openai.com/api/docs/guides/error-codes). */
5
+ const OPENAI_ERRORS = {
6
+ 'rate-limit': {
7
+ type: 'json',
8
+ status: 429,
9
+ headers: { 'retry-after': String(F.retryAfterSeconds) },
10
+ body: {
11
+ error: {
12
+ message: 'Rate limit reached for requests',
13
+ type: 'requests',
14
+ code: 'rate_limit_exceeded',
15
+ },
16
+ },
17
+ },
18
+ quota: {
19
+ type: 'json',
20
+ status: 429,
21
+ body: {
22
+ error: {
23
+ message: 'You exceeded your current quota, please check your plan and billing details.',
24
+ type: 'insufficient_quota',
25
+ code: 'insufficient_quota',
26
+ },
27
+ },
28
+ },
29
+ auth: {
30
+ type: 'json',
31
+ status: 401,
32
+ body: {
33
+ error: {
34
+ message: 'Incorrect API key provided',
35
+ type: 'invalid_request_error',
36
+ code: 'invalid_api_key',
37
+ },
38
+ },
39
+ },
40
+ overloaded: {
41
+ type: 'json',
42
+ status: 503,
43
+ body: {
44
+ error: {
45
+ message: 'The server is overloaded',
46
+ type: 'service_unavailable_error',
47
+ code: 'server_is_overloaded',
48
+ },
49
+ },
50
+ },
51
+ 'server-error': {
52
+ type: 'json',
53
+ status: 500,
54
+ body: { error: { message: 'The server had an error', type: 'server_error', code: null } },
55
+ },
56
+ 'context-overflow': {
57
+ type: 'json',
58
+ status: 400,
59
+ body: {
60
+ error: {
61
+ message: "This model's maximum context length is 128000 tokens.",
62
+ type: 'invalid_request_error',
63
+ code: 'context_length_exceeded',
64
+ },
65
+ },
66
+ },
67
+ };
68
+ const OVERLOADED = {
69
+ message: 'The server is overloaded',
70
+ type: 'service_unavailable_error',
71
+ code: 'server_is_overloaded',
72
+ };
73
+ const CHAT_USAGE = {
74
+ prompt_tokens: F.usage.inputTokens,
75
+ completion_tokens: F.usage.outputTokens,
76
+ total_tokens: F.usage.inputTokens + F.usage.outputTokens,
77
+ };
78
+ const CHAT_TURNS = {
79
+ text: { content: F.text, finishReason: 'stop' },
80
+ 'tool-call': { content: null, toolArguments: ARGUMENTS, finishReason: 'tool_calls' },
81
+ 'tool-call-with-stop': { content: null, toolArguments: ARGUMENTS, finishReason: 'stop' },
82
+ 'truncated-tool-call': {
83
+ content: null,
84
+ toolArguments: F.truncatedArguments,
85
+ finishReason: 'length',
86
+ },
87
+ 'content-filter': { content: null, finishReason: 'content_filter' },
88
+ };
89
+ function chatChunk(delta, finishReason = null) {
90
+ return {
91
+ id: 'chatcmpl-conformance',
92
+ object: 'chat.completion.chunk',
93
+ created: 0,
94
+ model: 'conformance',
95
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
96
+ };
97
+ }
98
+ function chatStream(turn) {
99
+ const events = [sse(chatChunk({ role: 'assistant', content: '' }))];
100
+ if (turn.content) {
101
+ for (const part of F.textChunks)
102
+ events.push(sse(chatChunk({ content: part })));
103
+ }
104
+ if (turn.toolArguments !== undefined) {
105
+ const half = Math.ceil(turn.toolArguments.length / 2);
106
+ events.push(sse(chatChunk({
107
+ tool_calls: [
108
+ {
109
+ index: 0,
110
+ id: F.toolCall.id,
111
+ type: 'function',
112
+ function: { name: F.toolCall.name, arguments: '' },
113
+ },
114
+ ],
115
+ })), sse(chatChunk({
116
+ tool_calls: [{ index: 0, function: { arguments: turn.toolArguments.slice(0, half) } }],
117
+ })), sse(chatChunk({
118
+ tool_calls: [{ index: 0, function: { arguments: turn.toolArguments.slice(half) } }],
119
+ })));
120
+ }
121
+ events.push(sse(chatChunk({}, turn.finishReason)));
122
+ events.push(sse({
123
+ id: 'chatcmpl-conformance',
124
+ object: 'chat.completion.chunk',
125
+ created: 0,
126
+ model: 'conformance',
127
+ choices: [],
128
+ usage: CHAT_USAGE,
129
+ }));
130
+ events.push(sse('[DONE]'));
131
+ return events;
132
+ }
133
+ function chatCompletion(turn) {
134
+ return {
135
+ id: 'chatcmpl-conformance',
136
+ object: 'chat.completion',
137
+ created: 0,
138
+ model: 'conformance',
139
+ choices: [
140
+ {
141
+ index: 0,
142
+ message: {
143
+ role: 'assistant',
144
+ content: turn.content,
145
+ refusal: null,
146
+ ...(turn.toolArguments !== undefined && {
147
+ tool_calls: [
148
+ {
149
+ id: F.toolCall.id,
150
+ type: 'function',
151
+ function: { name: F.toolCall.name, arguments: turn.toolArguments },
152
+ },
153
+ ],
154
+ }),
155
+ },
156
+ finish_reason: turn.finishReason,
157
+ },
158
+ ],
159
+ usage: CHAT_USAGE,
160
+ };
161
+ }
162
+ /**
163
+ * The Chat Completions API of OpenAI and of the servers compatible with it, at any path ending
164
+ * in `/chat/completions`. Errors inside a stream come as a `data: {"error": ...}` chunk.
165
+ */
166
+ export const openAIChatWire = {
167
+ name: 'OpenAI Chat Completions',
168
+ path: () => /\/chat\/completions(\?|$)/,
169
+ reply(scenario, mode) {
170
+ const error = OPENAI_ERRORS[scenario];
171
+ if (error)
172
+ return error;
173
+ const stream = (chunks, hang = false) => ({
174
+ type: 'stream',
175
+ contentType: 'text/event-stream',
176
+ chunks,
177
+ hang,
178
+ });
179
+ switch (scenario) {
180
+ case 'stream-error':
181
+ return stream([
182
+ sse(chatChunk({ role: 'assistant', content: F.partialText })),
183
+ sse({ error: OVERLOADED }),
184
+ ]);
185
+ case 'abort':
186
+ return mode === 'chat'
187
+ ? { type: 'hang' }
188
+ : stream([sse(chatChunk({ role: 'assistant', content: F.partialText }))], true);
189
+ default: {
190
+ const turn = CHAT_TURNS[scenario];
191
+ if (!turn)
192
+ return undefined;
193
+ return mode === 'chat'
194
+ ? { type: 'json', body: chatCompletion(turn) }
195
+ : stream(chatStream(turn));
196
+ }
197
+ }
198
+ },
199
+ };
200
+ const RESPONSES_TURNS = {
201
+ text: { status: 'completed', text: F.text },
202
+ 'tool-call': { status: 'completed', toolArguments: ARGUMENTS },
203
+ 'tool-call-with-stop': { status: 'completed', toolArguments: ARGUMENTS },
204
+ 'truncated-tool-call': {
205
+ status: 'incomplete',
206
+ incompleteReason: 'max_output_tokens',
207
+ toolArguments: F.truncatedArguments,
208
+ },
209
+ 'content-filter': { status: 'incomplete', incompleteReason: 'content_filter' },
210
+ };
211
+ function responsesOutput(turn) {
212
+ const output = [];
213
+ if (turn.text) {
214
+ output.push({
215
+ type: 'message',
216
+ id: 'msg_conformance',
217
+ role: 'assistant',
218
+ status: 'completed',
219
+ content: [{ type: 'output_text', text: turn.text, annotations: [] }],
220
+ });
221
+ }
222
+ if (turn.toolArguments !== undefined) {
223
+ output.push({
224
+ type: 'function_call',
225
+ id: 'fc_conformance',
226
+ call_id: F.toolCall.id,
227
+ name: F.toolCall.name,
228
+ arguments: turn.toolArguments,
229
+ status: turn.status === 'completed' ? 'completed' : 'incomplete',
230
+ });
231
+ }
232
+ return output;
233
+ }
234
+ function response(turn, output = responsesOutput(turn)) {
235
+ return {
236
+ id: 'resp_conformance',
237
+ object: 'response',
238
+ created_at: 0,
239
+ model: 'conformance',
240
+ status: turn.status,
241
+ incomplete_details: turn.incompleteReason ? { reason: turn.incompleteReason } : null,
242
+ error: null,
243
+ output,
244
+ usage: {
245
+ input_tokens: F.usage.inputTokens,
246
+ input_tokens_details: { cached_tokens: 0 },
247
+ output_tokens: F.usage.outputTokens,
248
+ output_tokens_details: { reasoning_tokens: 0 },
249
+ total_tokens: F.usage.inputTokens + F.usage.outputTokens,
250
+ },
251
+ };
252
+ }
253
+ function responsesStream(turn) {
254
+ const inProgress = { ...response(turn, []), status: 'in_progress', incomplete_details: null };
255
+ let sequence = 0;
256
+ const event = (type, data) => sse({ type, sequence_number: sequence++, ...data }, type);
257
+ const events = [event('response.created', { response: inProgress })];
258
+ responsesOutput(turn).forEach((item, outputIndex) => {
259
+ if (item.type === 'message') {
260
+ events.push(event('response.output_item.added', {
261
+ output_index: outputIndex,
262
+ item: { ...item, status: 'in_progress', content: [] },
263
+ }));
264
+ for (const delta of F.textChunks) {
265
+ events.push(event('response.output_text.delta', {
266
+ item_id: item.id,
267
+ output_index: outputIndex,
268
+ content_index: 0,
269
+ delta,
270
+ }));
271
+ }
272
+ }
273
+ else {
274
+ const args = String(item.arguments);
275
+ events.push(event('response.output_item.added', {
276
+ output_index: outputIndex,
277
+ item: { ...item, arguments: '', status: 'in_progress' },
278
+ }), event('response.function_call_arguments.delta', {
279
+ item_id: item.id,
280
+ output_index: outputIndex,
281
+ delta: args,
282
+ }));
283
+ }
284
+ events.push(event('response.output_item.done', { output_index: outputIndex, item }));
285
+ });
286
+ const terminal = turn.status === 'completed' ? 'response.completed' : 'response.incomplete';
287
+ events.push(event(terminal, { response: response(turn) }));
288
+ return events;
289
+ }
290
+ /**
291
+ * The OpenAI Responses API at `/responses`. An error inside a stream is an `error` event.
292
+ */
293
+ export const openAIResponsesWire = {
294
+ name: 'OpenAI Responses',
295
+ path: () => /\/responses$/,
296
+ reply(scenario, mode) {
297
+ const error = OPENAI_ERRORS[scenario];
298
+ if (error)
299
+ return error;
300
+ const stream = (chunks, hang = false) => ({
301
+ type: 'stream',
302
+ contentType: 'text/event-stream',
303
+ chunks,
304
+ hang,
305
+ });
306
+ const started = [
307
+ sse({
308
+ type: 'response.created',
309
+ sequence_number: 0,
310
+ response: { ...response({ status: 'completed' }, []), status: 'in_progress' },
311
+ }, 'response.created'),
312
+ sse({
313
+ type: 'response.output_text.delta',
314
+ sequence_number: 1,
315
+ item_id: 'msg_conformance',
316
+ output_index: 0,
317
+ content_index: 0,
318
+ delta: F.partialText,
319
+ }, 'response.output_text.delta'),
320
+ ];
321
+ switch (scenario) {
322
+ case 'stream-error':
323
+ return stream([
324
+ ...started,
325
+ sse({
326
+ type: 'error',
327
+ sequence_number: 2,
328
+ code: OVERLOADED.code,
329
+ message: OVERLOADED.message,
330
+ param: null,
331
+ }, 'error'),
332
+ ]);
333
+ case 'abort':
334
+ return mode === 'chat' ? { type: 'hang' } : stream(started, true);
335
+ default: {
336
+ const turn = RESPONSES_TURNS[scenario];
337
+ if (!turn)
338
+ return undefined;
339
+ return mode === 'chat'
340
+ ? { type: 'json', body: response(turn) }
341
+ : stream(responsesStream(turn));
342
+ }
343
+ }
344
+ },
345
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './mocks/index.js';
2
2
  export * from './fixtures/index.js';
3
3
  export * from './helpers/index.js';
4
+ export * from './conformance/index.js';
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './mocks/index.js';
2
2
  export * from './fixtures/index.js';
3
3
  export * from './helpers/index.js';
4
+ export * from './conformance/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cogitator-ai/test-utils",
3
- "version": "0.2.20",
3
+ "version": "0.3.0",
4
4
  "description": "Shared testing utilities for Cogitator packages",
5
5
  "keywords": [
6
6
  "cogitator",
@@ -28,7 +28,7 @@
28
28
  "node": ">=22.12.0"
29
29
  },
30
30
  "dependencies": {
31
- "@cogitator-ai/types": "0.41.1",
31
+ "@cogitator-ai/types": "0.42.0",
32
32
  "nanoid": "^6.0.1"
33
33
  },
34
34
  "devDependencies": {