@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,59 @@
1
+ /** A request the fake provider received. */
2
+ export interface FakeProviderRequest {
3
+ method: string;
4
+ /** Path with the query string, e.g. `/v1/chat/completions` */
5
+ path: string;
6
+ /** Header names in lower case */
7
+ headers: Record<string, string>;
8
+ /** The body parsed as JSON, or the raw text when it is not JSON */
9
+ body: unknown;
10
+ }
11
+ /** How the fake provider answers one request. */
12
+ export type FakeProviderReply = {
13
+ type: 'json';
14
+ status?: number;
15
+ body: unknown;
16
+ headers?: Record<string, string>;
17
+ } | {
18
+ type: 'stream';
19
+ status?: number;
20
+ contentType: string;
21
+ /** Written one by one, each flushed on its own */
22
+ chunks: readonly (string | Uint8Array)[];
23
+ headers?: Record<string, string>;
24
+ /** Keep the response open after the chunks, until the client goes away */
25
+ hang?: boolean;
26
+ }
27
+ /** Never answer: the request stays open until the client goes away */
28
+ | {
29
+ type: 'hang';
30
+ };
31
+ export type FakeProviderHandler = (request: FakeProviderRequest) => FakeProviderReply;
32
+ /**
33
+ * An HTTP server standing in for a model provider. Each request is answered by the handler set
34
+ * with `reply`, so a backend pointed at `url` talks to it through its real SDK and wire format.
35
+ */
36
+ export interface FakeProvider {
37
+ /** The base URL, e.g. `http://127.0.0.1:53111` */
38
+ readonly url: string;
39
+ /** Every request received so far, oldest first */
40
+ readonly requests: readonly FakeProviderRequest[];
41
+ /** Sets how the next requests are answered */
42
+ reply(handler: FakeProviderHandler): void;
43
+ /** Resolves once a request has arrived, with that request */
44
+ nextRequest(): Promise<FakeProviderRequest>;
45
+ /** Resolves once the connection of the latest request has closed */
46
+ latestClosed(): Promise<void>;
47
+ /** Stops the server and drops open connections */
48
+ close(): Promise<void>;
49
+ }
50
+ export interface FakeProviderOptions {
51
+ /**
52
+ * Speak HTTP/2 without TLS (h2c with prior knowledge) instead of HTTP/1.1, for clients that
53
+ * only speak HTTP/2, such as the AWS SDK's Bedrock Runtime client.
54
+ */
55
+ http2?: boolean;
56
+ }
57
+ /** Starts a fake provider on a free local port. */
58
+ export declare function startFakeProvider(options?: FakeProviderOptions): Promise<FakeProvider>;
59
+ //# sourceMappingURL=fake-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake-provider.d.ts","sourceRoot":"","sources":["../../src/conformance/fake-provider.ts"],"names":[],"mappings":"AAQA,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,mEAAmE;IACnE,IAAI,EAAE,OAAO,CAAC;CACf;AAED,iDAAiD;AACjD,MAAM,MAAM,iBAAiB,GACzB;IACE,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,MAAM,EAAE,SAAS,CAAC,MAAM,GAAG,UAAU,CAAC,EAAE,CAAC;IACzC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,0EAA0E;IAC1E,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AACH,sEAAsE;GACpE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAErB,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,mBAAmB,KAAK,iBAAiB,CAAC;AAEtF;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,kDAAkD;IAClD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,kDAAkD;IAClD,QAAQ,CAAC,QAAQ,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAClD,8CAA8C;IAC9C,KAAK,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAC1C,6DAA6D;IAC7D,WAAW,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAC5C,oEAAoE;IACpE,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,kDAAkD;IAClD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAeD,mDAAmD;AACnD,wBAAsB,iBAAiB,CAAC,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,YAAY,CAAC,CAwEhG"}
@@ -0,0 +1,134 @@
1
+ import { createServer } from 'node:http';
2
+ import { createServer as createHttp2Server, } from 'node:http2';
3
+ /** Starts a fake provider on a free local port. */
4
+ export async function startFakeProvider(options = {}) {
5
+ const requests = [];
6
+ const waiting = [];
7
+ const sessions = new Set();
8
+ let latestClosed = Promise.resolve();
9
+ let handler = () => ({
10
+ type: 'json',
11
+ status: 500,
12
+ body: { error: { message: 'The fake provider has no reply set' } },
13
+ });
14
+ const onRequest = (req, res) => {
15
+ const closed = new Promise((resolve) => res.once('close', () => resolve()));
16
+ latestClosed = closed;
17
+ void readBody(req).then((text) => {
18
+ const request = {
19
+ method: req.method ?? 'GET',
20
+ path: req.url ?? '/',
21
+ headers: flatHeaders(req),
22
+ body: parseBody(text),
23
+ };
24
+ requests.push(request);
25
+ waiting.splice(0).forEach((resolve) => resolve(request));
26
+ let reply;
27
+ try {
28
+ reply = handler(request);
29
+ }
30
+ catch (error) {
31
+ reply = {
32
+ type: 'json',
33
+ status: 500,
34
+ body: {
35
+ error: {
36
+ message: `The fake provider's handler failed: ${error instanceof Error ? error.message : String(error)}`,
37
+ },
38
+ },
39
+ };
40
+ }
41
+ answer(res, closed, reply);
42
+ });
43
+ };
44
+ const server = options.http2 ? createHttp2Server(onRequest) : createServer(onRequest);
45
+ if (options.http2) {
46
+ server.on('session', (session) => {
47
+ sessions.add(session);
48
+ session.once('close', () => sessions.delete(session));
49
+ });
50
+ }
51
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
52
+ const { port } = server.address();
53
+ return {
54
+ url: `http://127.0.0.1:${port}`,
55
+ requests,
56
+ reply(next) {
57
+ handler = next;
58
+ },
59
+ nextRequest() {
60
+ return new Promise((resolve) => waiting.push(resolve));
61
+ },
62
+ latestClosed() {
63
+ return latestClosed;
64
+ },
65
+ close() {
66
+ if ('closeAllConnections' in server)
67
+ server.closeAllConnections();
68
+ for (const session of sessions)
69
+ session.destroy();
70
+ return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
71
+ },
72
+ };
73
+ }
74
+ function answer(res, closed, reply) {
75
+ switch (reply.type) {
76
+ case 'hang':
77
+ return;
78
+ case 'json':
79
+ res.writeHead(reply.status ?? 200, { 'content-type': 'application/json', ...reply.headers });
80
+ res.end(JSON.stringify(reply.body));
81
+ return;
82
+ case 'stream': {
83
+ res.writeHead(reply.status ?? 200, {
84
+ 'content-type': reply.contentType,
85
+ 'cache-control': 'no-cache',
86
+ ...reply.headers,
87
+ });
88
+ res.flushHeaders?.();
89
+ let gone = false;
90
+ void closed.then(() => {
91
+ gone = true;
92
+ });
93
+ void writeChunks(res, reply.chunks, () => gone).then(() => {
94
+ if (!reply.hang && !gone)
95
+ res.end();
96
+ });
97
+ }
98
+ }
99
+ }
100
+ async function writeChunks(res, chunks, gone) {
101
+ for (const chunk of chunks) {
102
+ if (gone())
103
+ return;
104
+ res.write(chunk);
105
+ await new Promise((resolve) => setImmediate(resolve));
106
+ }
107
+ }
108
+ function readBody(req) {
109
+ return new Promise((resolve) => {
110
+ const parts = [];
111
+ req.on('data', (part) => parts.push(part));
112
+ req.on('end', () => resolve(Buffer.concat(parts).toString('utf8')));
113
+ req.on('error', () => resolve(''));
114
+ });
115
+ }
116
+ function parseBody(text) {
117
+ if (!text)
118
+ return undefined;
119
+ try {
120
+ return JSON.parse(text);
121
+ }
122
+ catch {
123
+ return text;
124
+ }
125
+ }
126
+ function flatHeaders(req) {
127
+ const headers = {};
128
+ for (const [name, value] of Object.entries(req.headers)) {
129
+ if (value !== undefined && !name.startsWith(':')) {
130
+ headers[name] = Array.isArray(value) ? value.join(', ') : value;
131
+ }
132
+ }
133
+ return headers;
134
+ }
@@ -0,0 +1,9 @@
1
+ export { startFakeProvider, type FakeProvider, type FakeProviderHandler, type FakeProviderOptions, type FakeProviderReply, type FakeProviderRequest, } from './fake-provider.js';
2
+ export { backendConformanceCases, conformanceRequest, CONFORMANCE_FIXTURE, type BackendConformanceCase, type BackendConformanceTarget, type ConformanceMode, type ConformanceScenario, type ProviderWire, } from './scenarios.js';
3
+ export { openAIChatWire, openAIResponsesWire } from './wires/openai.js';
4
+ export { anthropicWire } from './wires/anthropic.js';
5
+ export { geminiWire } from './wires/gemini.js';
6
+ export { ollamaWire } from './wires/ollama.js';
7
+ export { bedrockConverseWire } from './wires/bedrock.js';
8
+ export { sse, ndjson, awsEventStreamMessage } from './wires/encoding.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/conformance/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,GACzB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,uBAAuB,EACvB,kBAAkB,EAClB,mBAAmB,EACnB,KAAK,sBAAsB,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACxB,KAAK,YAAY,GAClB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,8 @@
1
+ export { startFakeProvider, } from './fake-provider.js';
2
+ export { backendConformanceCases, conformanceRequest, CONFORMANCE_FIXTURE, } from './scenarios.js';
3
+ export { openAIChatWire, openAIResponsesWire } from './wires/openai.js';
4
+ export { anthropicWire } from './wires/anthropic.js';
5
+ export { geminiWire } from './wires/gemini.js';
6
+ export { ollamaWire } from './wires/ollama.js';
7
+ export { bedrockConverseWire } from './wires/bedrock.js';
8
+ export { sse, ndjson, awsEventStreamMessage } from './wires/encoding.js';
@@ -0,0 +1,95 @@
1
+ import { type ChatRequest, type LLMBackend } from '@cogitator-ai/types';
2
+ import { type FakeProviderReply, type FakeProviderRequest } from './fake-provider.js';
3
+ /** Whether a case calls `chat` or reads `chatStream`. */
4
+ export type ConformanceMode = 'chat' | 'stream';
5
+ /**
6
+ * What the provider does in a case. Each {@link ProviderWire} answers it the way its provider
7
+ * would, and every backend must turn that answer into the same result.
8
+ *
9
+ * - `text`: answers {@link CONFORMANCE_FIXTURE}`.text`
10
+ * - `tool-call`: calls `get_weather` and reports a tool stop
11
+ * - `tool-call-with-stop`: calls the tool but reports a plain stop, as OpenAI-compatible servers
12
+ * do for a forced tool
13
+ * - `truncated-tool-call`: runs out of output tokens halfway through the call's JSON
14
+ * - `content-filter`: stops the answer for its safety policy
15
+ * - `stream-error`: streams the start of the answer, then reports that it is overloaded
16
+ * - `abort`: never finishes, until the caller aborts
17
+ * - `rate-limit`, `quota`, `auth`, `overloaded`, `server-error`, `context-overflow`: fails the
18
+ * request with that error, the way its API reports it
19
+ */
20
+ export type ConformanceScenario = 'text' | 'tool-call' | 'tool-call-with-stop' | 'truncated-tool-call' | 'content-filter' | 'stream-error' | 'abort' | 'rate-limit' | 'quota' | 'auth' | 'overloaded' | 'server-error' | 'context-overflow';
21
+ /** The data every wire answers with, so each backend can be held to the same result. */
22
+ export declare const CONFORMANCE_FIXTURE: {
23
+ readonly text: "Hello there";
24
+ /** The text in the chunks a stream sends it in */
25
+ readonly textChunks: readonly ["Hello", " there"];
26
+ /** The text a stream sends before it fails, in `stream-error` */
27
+ readonly partialText: "Hel";
28
+ readonly toolCall: {
29
+ readonly id: "call_weather_1";
30
+ readonly name: "get_weather";
31
+ readonly arguments: {
32
+ readonly city: "Paris";
33
+ };
34
+ };
35
+ /** The JSON of the call's arguments where the output tokens ran out, in `truncated-tool-call` */
36
+ readonly truncatedArguments: "{\"city\": \"Par";
37
+ readonly usage: {
38
+ readonly inputTokens: 12;
39
+ readonly outputTokens: 5;
40
+ };
41
+ /** Seconds the provider asks to wait, in `rate-limit` */
42
+ readonly retryAfterSeconds: 7;
43
+ /** What the provider says in its errors */
44
+ readonly errorMessage: "Conformance check error";
45
+ };
46
+ /**
47
+ * A provider's wire format: how it answers each scenario over HTTP. `undefined` means the
48
+ * provider has no such case (Ollama has no content filter), and the case is skipped.
49
+ */
50
+ export interface ProviderWire {
51
+ readonly name: string;
52
+ /** The path every request of `mode` must go to, when the wire checks it */
53
+ path?(mode: ConformanceMode): RegExp;
54
+ reply(scenario: ConformanceScenario, mode: ConformanceMode, request: FakeProviderRequest): FakeProviderReply | undefined;
55
+ }
56
+ /** A backend to hold to the conformance contract. */
57
+ export interface BackendConformanceTarget {
58
+ /** Name in the case titles */
59
+ name: string;
60
+ wire: ProviderWire;
61
+ /** The backend under test, sending its requests to `providerUrl` */
62
+ createBackend(providerUrl: string): LLMBackend;
63
+ /** The model the requests name */
64
+ model: string;
65
+ /** Headers `createBackend` sets the backend to send; every request must carry them */
66
+ headers?: Record<string, string>;
67
+ /** Scenarios to leave out, with the reason */
68
+ skip?: Partial<Record<ConformanceScenario, string>>;
69
+ /** The fake provider speaks HTTP/2, for clients that only speak it (Bedrock Runtime) */
70
+ http2?: boolean;
71
+ }
72
+ export interface BackendConformanceCase {
73
+ /** `scenario (mode)` */
74
+ name: string;
75
+ scenario: ConformanceScenario;
76
+ mode: ConformanceMode;
77
+ /** Why the case does not apply, when it does not */
78
+ skip?: string;
79
+ /** Runs the case against its own fake provider; throws an `AssertionError` when the backend breaks the contract */
80
+ run(): Promise<void>;
81
+ }
82
+ /**
83
+ * The conformance cases of `target`: every scenario in both modes (`stream-error` in streams
84
+ * only), each against a fresh fake provider. Framework-agnostic, so any test runner can run them:
85
+ *
86
+ * ```ts
87
+ * for (const c of backendConformanceCases(target)) {
88
+ * (c.skip ? it.skip : it)(c.name, () => c.run());
89
+ * }
90
+ * ```
91
+ */
92
+ export declare function backendConformanceCases(target: BackendConformanceTarget): BackendConformanceCase[];
93
+ /** The request every case sends: a question and the one tool the model may call. */
94
+ export declare function conformanceRequest(model: string, signal?: AbortSignal): ChatRequest;
95
+ //# sourceMappingURL=scenarios.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scenarios.d.ts","sourceRoot":"","sources":["../../src/conformance/scenarios.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,WAAW,EAGhB,KAAK,UAAU,EAGhB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACzB,MAAM,iBAAiB,CAAC;AAEzB,yDAAyD;AACzD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEhD;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,mBAAmB,GAC3B,MAAM,GACN,WAAW,GACX,qBAAqB,GACrB,qBAAqB,GACrB,gBAAgB,GAChB,cAAc,GACd,OAAO,GACP,YAAY,GACZ,OAAO,GACP,MAAM,GACN,YAAY,GACZ,cAAc,GACd,kBAAkB,CAAC;AAEvB,wFAAwF;AACxF,eAAO,MAAM,mBAAmB;;IAE9B,kDAAkD;;IAElD,iEAAiE;;;;;;;;;IAGjE,iGAAiG;;;;;;IAGjG,yDAAyD;;IAEzD,2CAA2C;;CAEnC,CAAC;AAEX;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,IAAI,CAAC,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM,CAAC;IACrC,KAAK,CACH,QAAQ,EAAE,mBAAmB,EAC7B,IAAI,EAAE,eAAe,EACrB,OAAO,EAAE,mBAAmB,GAC3B,iBAAiB,GAAG,SAAS,CAAC;CAClC;AAED,qDAAqD;AACrD,MAAM,WAAW,wBAAwB;IACvC,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,CAAC;IACnB,oEAAoE;IACpE,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC;IAC/C,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,8CAA8C;IAC9C,IAAI,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,wFAAwF;IACxF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,IAAI,EAAE,eAAe,CAAC;IACtB,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mHAAmH;IACnH,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACtB;AAgDD;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,wBAAwB,GAC/B,sBAAsB,EAAE,CAmB1B;AAOD,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,WAAW,CAqBnF"}
@@ -0,0 +1,287 @@
1
+ import assert from 'node:assert/strict';
2
+ import { CogitatorError, ErrorCode, } from '@cogitator-ai/types';
3
+ import { startFakeProvider, } from './fake-provider.js';
4
+ /** The data every wire answers with, so each backend can be held to the same result. */
5
+ export const CONFORMANCE_FIXTURE = {
6
+ text: 'Hello there',
7
+ /** The text in the chunks a stream sends it in */
8
+ textChunks: ['Hello', ' there'],
9
+ /** The text a stream sends before it fails, in `stream-error` */
10
+ partialText: 'Hel',
11
+ toolCall: { id: 'call_weather_1', name: 'get_weather', arguments: { city: 'Paris' } },
12
+ /** The JSON of the call's arguments where the output tokens ran out, in `truncated-tool-call` */
13
+ truncatedArguments: '{"city": "Par',
14
+ usage: { inputTokens: 12, outputTokens: 5 },
15
+ /** Seconds the provider asks to wait, in `rate-limit` */
16
+ retryAfterSeconds: 7,
17
+ /** What the provider says in its errors */
18
+ errorMessage: 'Conformance check error',
19
+ };
20
+ const SCENARIOS = [
21
+ 'text',
22
+ 'tool-call',
23
+ 'tool-call-with-stop',
24
+ 'truncated-tool-call',
25
+ 'content-filter',
26
+ 'stream-error',
27
+ 'abort',
28
+ 'rate-limit',
29
+ 'quota',
30
+ 'auth',
31
+ 'overloaded',
32
+ 'server-error',
33
+ 'context-overflow',
34
+ ];
35
+ const MODES = ['chat', 'stream'];
36
+ const ERROR_EXPECTATIONS = {
37
+ 'rate-limit': {
38
+ code: ErrorCode.LLM_RATE_LIMITED,
39
+ kind: 'rate_limit',
40
+ retryable: true,
41
+ retryAfter: CONFORMANCE_FIXTURE.retryAfterSeconds * 1000,
42
+ },
43
+ quota: { code: ErrorCode.LLM_QUOTA_EXCEEDED, kind: 'quota', retryable: false },
44
+ auth: { code: ErrorCode.LLM_UNAVAILABLE, kind: 'authentication', retryable: false },
45
+ overloaded: { code: ErrorCode.LLM_UNAVAILABLE, kind: 'overloaded', retryable: true },
46
+ 'server-error': { code: ErrorCode.LLM_UNAVAILABLE, kind: 'server_error', retryable: true },
47
+ 'context-overflow': {
48
+ code: ErrorCode.LLM_CONTEXT_LENGTH_EXCEEDED,
49
+ kind: 'context_overflow',
50
+ retryable: false,
51
+ },
52
+ };
53
+ /** How long an aborted call may take to give up, and its connection to close. */
54
+ const ABORT_DEADLINE_MS = 2000;
55
+ /**
56
+ * The conformance cases of `target`: every scenario in both modes (`stream-error` in streams
57
+ * only), each against a fresh fake provider. Framework-agnostic, so any test runner can run them:
58
+ *
59
+ * ```ts
60
+ * for (const c of backendConformanceCases(target)) {
61
+ * (c.skip ? it.skip : it)(c.name, () => c.run());
62
+ * }
63
+ * ```
64
+ */
65
+ export function backendConformanceCases(target) {
66
+ return SCENARIOS.flatMap((scenario) => MODES.filter((mode) => scenario !== 'stream-error' || mode === 'stream').map((mode) => {
67
+ const skip = target.skip?.[scenario] ??
68
+ (applies(target.wire, scenario, mode)
69
+ ? undefined
70
+ : `${target.wire.name} has no such case`);
71
+ return {
72
+ name: `${scenario} (${mode})`,
73
+ scenario,
74
+ mode,
75
+ ...(skip && { skip }),
76
+ run: () => runCase(target, scenario, mode),
77
+ };
78
+ }));
79
+ }
80
+ function applies(wire, scenario, mode) {
81
+ const probe = { method: 'POST', path: '/', headers: {}, body: undefined };
82
+ return wire.reply(scenario, mode, probe) !== undefined;
83
+ }
84
+ /** The request every case sends: a question and the one tool the model may call. */
85
+ export function conformanceRequest(model, signal) {
86
+ return {
87
+ model,
88
+ messages: [
89
+ { role: 'system', content: 'You report the weather.' },
90
+ { role: 'user', content: 'What is the weather in Paris?' },
91
+ ],
92
+ tools: [
93
+ {
94
+ name: CONFORMANCE_FIXTURE.toolCall.name,
95
+ description: 'Current weather in a city',
96
+ parameters: {
97
+ type: 'object',
98
+ properties: { city: { type: 'string' } },
99
+ required: ['city'],
100
+ },
101
+ },
102
+ ],
103
+ maxTokens: 64,
104
+ ...(signal && { signal }),
105
+ };
106
+ }
107
+ async function runCase(target, scenario, mode) {
108
+ const provider = await startFakeProvider({ http2: target.http2 });
109
+ provider.reply((request) => {
110
+ const reply = target.wire.reply(scenario, mode, request);
111
+ if (!reply)
112
+ throw new Error(`${target.wire.name} has no reply for ${scenario} (${mode})`);
113
+ return reply;
114
+ });
115
+ const label = `${target.name}: ${scenario} (${mode})`;
116
+ try {
117
+ const backend = target.createBackend(provider.url);
118
+ if (scenario === 'abort') {
119
+ await checkAbort(backend, target, provider, mode, label);
120
+ }
121
+ else {
122
+ const outcome = await callBackend(backend, conformanceRequest(target.model), mode);
123
+ checkOutcome(scenario, mode, outcome, label);
124
+ }
125
+ checkRequests(target, provider, scenario, mode, label);
126
+ }
127
+ finally {
128
+ await provider.close();
129
+ }
130
+ }
131
+ async function callBackend(backend, request, mode) {
132
+ if (mode === 'chat') {
133
+ try {
134
+ const response = await backend.chat(request);
135
+ return {
136
+ content: response.content,
137
+ toolCalls: response.toolCalls ?? [],
138
+ finishReason: response.finishReason,
139
+ usage: response.usage,
140
+ };
141
+ }
142
+ catch (error) {
143
+ return { content: '', toolCalls: [], error };
144
+ }
145
+ }
146
+ const outcome = { content: '', toolCalls: [] };
147
+ try {
148
+ for await (const chunk of backend.chatStream(request))
149
+ addChunk(outcome, chunk);
150
+ }
151
+ catch (error) {
152
+ outcome.error = error;
153
+ }
154
+ return outcome;
155
+ }
156
+ function addChunk(outcome, chunk) {
157
+ outcome.content += chunk.delta.content ?? '';
158
+ outcome.toolCalls.push(...(chunk.delta.toolCalls ?? []));
159
+ if (chunk.finishReason)
160
+ outcome.finishReason = chunk.finishReason;
161
+ if (chunk.usage)
162
+ outcome.usage = chunk.usage;
163
+ }
164
+ function checkOutcome(scenario, mode, outcome, label) {
165
+ const expected = ERROR_EXPECTATIONS[scenario];
166
+ if (expected) {
167
+ checkError(outcome.error, expected, label);
168
+ return;
169
+ }
170
+ if (scenario === 'stream-error') {
171
+ assert.equal(outcome.content, CONFORMANCE_FIXTURE.partialText, `${label}: the text streamed before the error is passed on`);
172
+ checkError(outcome.error, { code: ErrorCode.LLM_UNAVAILABLE, kind: 'overloaded', retryable: true }, label);
173
+ return;
174
+ }
175
+ if (outcome.error !== undefined) {
176
+ assert.fail(`${label}: the call failed: ${describe(outcome.error)}`);
177
+ }
178
+ switch (scenario) {
179
+ case 'text':
180
+ assert.equal(outcome.content, CONFORMANCE_FIXTURE.text, `${label}: content`);
181
+ assert.equal(outcome.finishReason, 'stop', `${label}: finishReason`);
182
+ assert.equal(outcome.toolCalls.length, 0, `${label}: no tool calls`);
183
+ assert.deepEqual(outcome.usage && {
184
+ inputTokens: outcome.usage.inputTokens,
185
+ outputTokens: outcome.usage.outputTokens,
186
+ }, CONFORMANCE_FIXTURE.usage, `${label}: usage${mode === 'stream' ? ' of the last chunk that reports it' : ''}`);
187
+ return;
188
+ case 'tool-call':
189
+ case 'tool-call-with-stop': {
190
+ assert.equal(outcome.finishReason, 'tool_calls', `${label}: a tool turn`);
191
+ assert.equal(outcome.toolCalls.length, 1, `${label}: one tool call`);
192
+ const [call] = outcome.toolCalls;
193
+ assert.equal(call.name, CONFORMANCE_FIXTURE.toolCall.name, `${label}: tool name`);
194
+ assert.deepEqual(call.arguments, CONFORMANCE_FIXTURE.toolCall.arguments, `${label}: tool arguments`);
195
+ assert.equal(call.argumentsError, undefined, `${label}: arguments read without error`);
196
+ assert.ok(call.id, `${label}: the call has an id`);
197
+ return;
198
+ }
199
+ case 'truncated-tool-call':
200
+ assert.equal(outcome.finishReason, 'length', `${label}: cut at the token limit`);
201
+ assert.equal(outcome.toolCalls.length, 0, `${label}: a cut-off call never runs`);
202
+ return;
203
+ case 'content-filter':
204
+ assert.ok(outcome.finishReason === 'content_filter' || outcome.finishReason === 'refusal', `${label}: finishReason is content_filter or refusal, got ${String(outcome.finishReason)}`);
205
+ assert.equal(outcome.toolCalls.length, 0, `${label}: a filtered turn runs no tools`);
206
+ return;
207
+ default:
208
+ assert.fail(`${label}: no check for this scenario`);
209
+ }
210
+ }
211
+ function checkError(error, expected, label) {
212
+ assert.ok(error !== undefined, `${label}: the call fails`);
213
+ assert.ok(error instanceof CogitatorError, `${label}: fails with a CogitatorError, got ${describe(error)}`);
214
+ assert.equal(error.code, expected.code, `${label}: code (${error.message})`);
215
+ assert.equal(error.details?.kind, expected.kind, `${label}: details.kind (${error.message})`);
216
+ assert.equal(error.retryable, expected.retryable, `${label}: retryable (${error.message})`);
217
+ if (expected.retryAfter !== undefined) {
218
+ assert.equal(error.retryAfter, expected.retryAfter, `${label}: retryAfter in milliseconds`);
219
+ }
220
+ }
221
+ /**
222
+ * An abort while the provider is still answering: the call gives up at once with the reason the
223
+ * caller aborted with, not a retryable provider error a retry or fallback layer would act on,
224
+ * and the connection to the provider closes, so it stops generating (and billing) the answer.
225
+ */
226
+ async function checkAbort(backend, target, provider, mode, label) {
227
+ const controller = new AbortController();
228
+ const reason = new Error('Caller aborted the conformance call');
229
+ const request = conformanceRequest(target.model, controller.signal);
230
+ const arrived = provider.nextRequest();
231
+ const settled = (async () => {
232
+ try {
233
+ if (mode === 'chat') {
234
+ await backend.chat(request);
235
+ }
236
+ else {
237
+ for await (const chunk of backend.chatStream(request)) {
238
+ if (chunk.delta.content)
239
+ controller.abort(reason);
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+ catch (error) {
245
+ return error;
246
+ }
247
+ })();
248
+ await within(arrived, `${label}: the request reaches the provider`);
249
+ if (mode === 'chat')
250
+ controller.abort(reason);
251
+ const error = await within(settled, `${label}: the call gives up after the abort`);
252
+ assert.ok(error !== undefined, `${label}: the aborted call fails`);
253
+ assert.equal(error, reason, `${label}: fails with the abort reason, got ${describe(error)}`);
254
+ await within(provider.latestClosed(), `${label}: the connection to the provider closes`);
255
+ }
256
+ function checkRequests(target, provider, scenario, mode, label) {
257
+ assert.ok(provider.requests.length > 0, `${label}: the backend called the provider`);
258
+ const expected = ERROR_EXPECTATIONS[scenario];
259
+ if (expected && !expected.retryable) {
260
+ assert.equal(provider.requests.length, 1, `${label}: an error that is not retryable is not retried, by the backend or its SDK`);
261
+ }
262
+ const path = target.wire.path?.(mode);
263
+ if (path) {
264
+ for (const request of provider.requests) {
265
+ assert.match(request.path, path, `${label}: the request goes to the ${mode} endpoint`);
266
+ }
267
+ }
268
+ for (const [name, value] of Object.entries(target.headers ?? {})) {
269
+ for (const request of provider.requests) {
270
+ assert.equal(request.headers[name.toLowerCase()], value, `${label}: request ${request.path} carries the ${name} header`);
271
+ }
272
+ }
273
+ }
274
+ function within(promise, what) {
275
+ let timer;
276
+ const deadline = new Promise((_, reject) => {
277
+ timer = setTimeout(() => reject(new assert.AssertionError({ message: `${what} within ${ABORT_DEADLINE_MS}ms` })), ABORT_DEADLINE_MS);
278
+ });
279
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
280
+ }
281
+ function describe(error) {
282
+ if (error instanceof CogitatorError)
283
+ return `${error.name} ${error.code}: ${error.message}`;
284
+ if (error instanceof Error)
285
+ return `${error.name}: ${error.message}`;
286
+ return String(error);
287
+ }
@@ -0,0 +1,7 @@
1
+ import { type ProviderWire } from '../scenarios.js';
2
+ /**
3
+ * The Anthropic Messages API at `/v1/messages`. An error inside a stream that began is an
4
+ * `error` event, as the API sends an overload after the response started.
5
+ */
6
+ export declare const anthropicWire: ProviderWire;
7
+ //# sourceMappingURL=anthropic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anthropic.d.ts","sourceRoot":"","sources":["../../../src/conformance/wires/anthropic.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AAqJtB;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,YA8B3B,CAAC"}