ai-stream-auditor 1.0.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [1.0.0] - 2026-10-03
6
+
7
+ ### Added
8
+
9
+ - Bounded incremental audit state machine for OpenAI Responses and Anthropic Messages events.
10
+ - Separate protocol-completion, semantic-success, and outcome reporting.
11
+ - Lifecycle checks for terminals, content units, deltas, provider status, sequence numbers, and stop reasons.
12
+ - Forward-compatible unknown-event warnings without raw event retention.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Huzaifa Asif
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # ai-stream-auditor
2
+
3
+ Audit decoded OpenAI Responses and Anthropic Messages event lifecycles for truncated, contradictory, or silently failed streams.
4
+
5
+ `ai-stream-auditor` is a zero-dependency diagnostic state machine. Feed it the event objects your provider SDK already decoded. It reports whether the provider protocol reached a terminal event, whether that terminal represents a successful result, and exactly where lifecycle invariants broke.
6
+
7
+ It does **not** open network connections, parse SSE bytes, retry requests, accumulate generated text, or replace a provider SDK.
8
+
9
+ ## Why this exists
10
+
11
+ An async iterator ending is not proof that a model response completed. A transport can close, a consumer can cancel, an SDK can mishandle a mid-stream error, or a proxy can synthesize an end marker. Provider protocols also distinguish a clean terminal event from outcomes such as token exhaustion, refusal, and failure.
12
+
13
+ The package's checks are based on the providers' documented event flows and motivated by concrete integration failures:
14
+
15
+ - [OpenAI streaming Responses guide](https://developers.openai.com/api/docs/guides/streaming-responses)
16
+ - [OpenAI Node issue #2046: mid-stream errors silently swallowed during iteration](https://github.com/openai/openai-node/issues/2046)
17
+ - [Anthropic streaming Messages documentation](https://platform.claude.com/docs/en/build-with-claude/streaming)
18
+ - [AxonHub issue #2092: synthetic end marker hides incomplete Responses stream](https://github.com/looplj/axonhub/issues/2092)
19
+
20
+ This project is independent and is not an official OpenAI or Anthropic package.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ npm install ai-stream-auditor
26
+ ```
27
+
28
+ Requires Node.js 18 or newer. ESM, CommonJS, and TypeScript declarations are included.
29
+
30
+ ## Quick start
31
+
32
+ ```js
33
+ import { createStreamAuditor } from 'ai-stream-auditor';
34
+
35
+ const auditor = createStreamAuditor('openai-responses');
36
+
37
+ try {
38
+ for await (const event of stream) {
39
+ auditor.push(event);
40
+ // Process the event normally; the auditor does not retain its payload.
41
+ }
42
+ const report = auditor.finish({ cause: 'iterator_end' });
43
+ if (!report.successful) console.error(report);
44
+ } catch (error) {
45
+ const report = auditor.finish({ cause: 'transport_error' });
46
+ console.error(report, error);
47
+ }
48
+ ```
49
+
50
+ For Anthropic:
51
+
52
+ ```js
53
+ const auditor = createStreamAuditor('anthropic-messages');
54
+ for await (const event of stream) auditor.push(event);
55
+ const report = auditor.finish();
56
+ ```
57
+
58
+ ## The three questions in every report
59
+
60
+ ```ts
61
+ {
62
+ protocolComplete: boolean; // Was a provider terminal event observed?
63
+ successful: boolean; // Was it structurally valid and semantically successful?
64
+ outcome: 'completed' | 'tool_use' | 'paused' | 'incomplete' |
65
+ 'refusal' | 'error' | 'cancelled' | 'timeout' |
66
+ 'transport_closed' | 'invalid';
67
+ }
68
+ ```
69
+
70
+ Examples:
71
+
72
+ | Situation | `protocolComplete` | `successful` | `outcome` |
73
+ | --- | --- | --- | --- |
74
+ | OpenAI `response.completed`, valid lifecycle | `true` | `true` | `completed` |
75
+ | Anthropic `message_stop`, `stop_reason: max_tokens` | `true` | `false` | `incomplete` |
76
+ | Anthropic `message_stop`, `stop_reason: tool_use` | `true` | `true` | `tool_use` |
77
+ | Provider `error` event | `true` | `false` | `error` |
78
+ | Iterator ends without terminal | `false` | `false` | `transport_closed` |
79
+ | Caller cancellation without terminal | `false` | `false` | `cancelled` |
80
+ | Completion event with an open content unit | `true` | `false` | `invalid` |
81
+
82
+ Protocol completion and application success are intentionally separate. A cleanly encoded incomplete or refused response is complete at the transport protocol level but is not reported as successful.
83
+
84
+ ## API
85
+
86
+ ### `createStreamAuditor(dialect, options?)`
87
+
88
+ Creates a bounded state machine. Supported dialects:
89
+
90
+ - `openai-responses`
91
+ - `anthropic-messages`
92
+
93
+ Options:
94
+
95
+ | Option | Default | Purpose |
96
+ | --- | ---: | --- |
97
+ | `maxEvents` | 100,000 | Bounds event processing |
98
+ | `maxOpenUnits` | 10,000 | Bounds simultaneously open output items/content parts/blocks |
99
+ | `maxDiagnostics` | 1,000 | Bounds retained diagnostic records |
100
+
101
+ ### `auditor.push(event)`
102
+
103
+ Push one already-decoded provider event. Events are inspected synchronously and are not retained. Only type counts, bounded lifecycle keys, terminal metadata, and bounded diagnostics remain.
104
+
105
+ Calling `push` after `finish` throws `StreamAuditError`.
106
+
107
+ ### `auditor.finish({ cause? })`
108
+
109
+ Finalizes and returns a frozen report. `cause` defaults to `iterator_end` and can be:
110
+
111
+ - `iterator_end`
112
+ - `cancelled`
113
+ - `timeout`
114
+ - `transport_error`
115
+
116
+ Use the actual consumer/transport cause; the auditor cannot infer why an iterator stopped.
117
+
118
+ The report also includes `stopReason`, `terminalEvent`, `eventCount`, event-type counts, diagnostics, the number of dropped diagnostics, and the finish cause.
119
+
120
+ ### `auditStream(events, dialect, options?)`
121
+
122
+ Audits a synchronous iterable, useful for fixtures, stored diagnostic logs, and mutation tests:
123
+
124
+ ```js
125
+ const report = auditStream(recordedEvents, 'anthropic-messages');
126
+ ```
127
+
128
+ This function deliberately accepts only synchronous iterables. Use `createStreamAuditor` for live async streams so your application controls transport errors and cancellation.
129
+
130
+ ## Checks performed
131
+
132
+ ### Both dialects
133
+
134
+ - Missing, duplicate, and post-terminal events
135
+ - Provider events before the documented start event
136
+ - Open content units at terminal
137
+ - Duplicate starts/stops and deltas without an open unit
138
+ - Hard event, open-unit, and diagnostic bounds
139
+ - Unknown event types, reported as warnings for forward compatibility
140
+
141
+ ### OpenAI Responses
142
+
143
+ - `response.created` lifecycle start
144
+ - Output-item and content-part added/done pairing
145
+ - Delta events targeting an open item or content part
146
+ - Non-increasing `sequence_number` values when present
147
+ - `response.completed`, `response.failed`, `response.incomplete`, `response.cancelled`, and `error` terminals
148
+ - Terminal event name versus `response.status` contradictions
149
+
150
+ ### Anthropic Messages
151
+
152
+ - `message_start` / `message_stop`
153
+ - `content_block_start` / `content_block_delta` / `content_block_stop` by block index
154
+ - Stop reason captured from `message_delta`
155
+ - `max_tokens` and `model_context_window_exceeded` as incomplete
156
+ - `tool_use`, `pause_turn`, and `refusal` as distinct outcomes
157
+ - `error` as a terminal failure
158
+ - `ping` events accepted anywhere before terminal
159
+
160
+ ## Diagnostics
161
+
162
+ Each diagnostic includes:
163
+
164
+ ```ts
165
+ {
166
+ severity: 'error' | 'warning';
167
+ code: string;
168
+ index: number; // zero-based event position
169
+ eventType?: string;
170
+ // code-specific details such as unit, expected, or actual
171
+ }
172
+ ```
173
+
174
+ Warnings do not make an otherwise valid stream unsuccessful. Today, unknown future event types are warnings. Structural errors make a nominal completion `invalid`.
175
+
176
+ ## Testing truncation and middleware
177
+
178
+ A useful regression test is cut-at-k: take a valid fixture and audit every prefix before its terminal event. No prefix should be successful.
179
+
180
+ ```js
181
+ for (let cut = 0; cut < validEvents.length; cut += 1) {
182
+ const report = auditStream(validEvents.slice(0, cut), 'openai-responses');
183
+ assert.equal(report.successful, false);
184
+ }
185
+ ```
186
+
187
+ Also mutate fixtures by removing a terminal, moving a block stop before its delta, duplicating completion, inserting a provider error, and finishing with cancellation. These cases are included in this package's test suite.
188
+
189
+ ## Limits and non-goals
190
+
191
+ - The auditor starts at the first event you feed it. Partial logs that intentionally omit earlier lifecycle events will be diagnosed as incomplete.
192
+ - It does not validate SSE framing, `[DONE]` markers, HTTP status, headers, or byte-level decoder behavior.
193
+ - It does not retain or compare generated text, tool arguments, token usage, model IDs, or request IDs.
194
+ - Recognized event names follow the public provider documentation at release time. New names are warnings rather than hard failures; update the package to gain lifecycle-specific checks for them.
195
+ - A valid event sequence does not prove output correctness, safety, authorization, or schema conformance.
196
+ - `transport_error` records the consumer's finish cause; pass the original error separately to your logger if needed.
197
+
198
+ ## Troubleshooting
199
+
200
+ ### `missing_terminal` / `transport_closed`
201
+
202
+ The iterator ended without a provider terminal. Inspect transport and SDK error channels, proxy buffering, and cancellation paths. Do not treat accumulated text as a completed model result.
203
+
204
+ ### `open_unit_at_terminal`
205
+
206
+ An output item, content part, or Anthropic block never received its documented done/stop event. Preserve the raw event log in a secure diagnostic environment to identify where the lifecycle was truncated.
207
+
208
+ ### `terminal_status_mismatch`
209
+
210
+ The OpenAI terminal event name disagrees with `response.status`. This may indicate a transformed or malformed fixture/proxy response. The auditor reports `invalid` rather than guessing which field is authoritative.
211
+
212
+ ### `unknown_event`
213
+
214
+ Provider protocols can add event types. The report remains successful if no structural error exists. Check current provider documentation and update this package before depending on the new event's lifecycle semantics.
215
+
216
+ ### Too many diagnostics
217
+
218
+ `droppedDiagnostics` counts records beyond `maxDiagnostics`. The first bounded set is retained. Increase the limit only when your logging and memory budgets permit.
219
+
220
+ ## Security and privacy
221
+
222
+ The auditor does not retain raw event payloads or generated text by default because there is no retention option. Diagnostics contain event type, event index, lifecycle keys, and status/sequence values; those may still be sensitive in some systems. Apply your logging policy before exporting reports.
223
+
224
+ See [SECURITY.md](SECURITY.md) for vulnerability reporting.
225
+
226
+ ## Development
227
+
228
+ ```sh
229
+ npm ci
230
+ npm run check
231
+ node examples/audit-openai.mjs
232
+ ```
233
+
234
+ ## License
235
+
236
+ MIT © 2026 Huzaifa Asif
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "ai-stream-auditor",
3
+ "version": "1.0.0",
4
+ "description": "Audit decoded OpenAI Responses and Anthropic Messages event lifecycles for silent or malformed stream endings.",
5
+ "keywords": ["ai", "streaming", "openai", "anthropic", "llm", "diagnostics", "testing"],
6
+ "license": "MIT",
7
+ "author": "Huzaifa Asif",
8
+ "repository": { "type": "git", "url": "git+https://github.com/Huzaifa-Asif/ai-stream-auditor.git" },
9
+ "bugs": { "url": "https://github.com/Huzaifa-Asif/ai-stream-auditor/issues" },
10
+ "homepage": "https://github.com/Huzaifa-Asif/ai-stream-auditor#readme",
11
+ "type": "module",
12
+ "main": "./src/index.cjs",
13
+ "module": "./src/index.js",
14
+ "types": "./src/index.d.ts",
15
+ "exports": { ".": { "types": "./src/index.d.ts", "import": "./src/index.js", "require": "./src/index.cjs" } },
16
+ "files": ["src", "README.md", "LICENSE", "CHANGELOG.md"],
17
+ "engines": { "node": ">=18" },
18
+ "sideEffects": false,
19
+ "scripts": {
20
+ "lint": "node scripts/lint.mjs",
21
+ "test": "node --test",
22
+ "test:coverage": "node --experimental-test-coverage --test",
23
+ "check": "npm run lint && npm run test:coverage && npm pack --dry-run"
24
+ },
25
+ "publishConfig": { "access": "public" }
26
+ }
package/src/index.cjs ADDED
@@ -0,0 +1,288 @@
1
+ 'use strict';
2
+
3
+ const DIALECTS = Object.freeze(['openai-responses', 'anthropic-messages']);
4
+ const DEFAULT_LIMITS = Object.freeze({ maxEvents: 100_000, maxOpenUnits: 10_000, maxDiagnostics: 1_000 });
5
+ const OPENAI_EVENTS = new Set([
6
+ 'error', 'response.queued', 'response.created', 'response.in_progress', 'response.completed', 'response.failed',
7
+ 'response.incomplete', 'response.cancelled', 'response.output_item.added', 'response.output_item.done',
8
+ 'response.content_part.added', 'response.content_part.done', 'response.output_text.delta', 'response.output_text.done',
9
+ 'response.output_text.annotation.added', 'response.refusal.delta', 'response.refusal.done',
10
+ 'response.function_call_arguments.delta', 'response.function_call_arguments.done',
11
+ 'response.reasoning_summary_part.added', 'response.reasoning_summary_part.done',
12
+ 'response.reasoning_summary_text.delta', 'response.reasoning_summary_text.done',
13
+ 'response.reasoning_text.delta', 'response.reasoning_text.done',
14
+ 'response.file_search_call.in_progress', 'response.file_search_call.searching', 'response.file_search_call.completed',
15
+ 'response.web_search_call.in_progress', 'response.web_search_call.searching', 'response.web_search_call.completed',
16
+ 'response.code_interpreter_call.in_progress', 'response.code_interpreter_call.interpreting',
17
+ 'response.code_interpreter_call.completed', 'response.code_interpreter_call_code.delta',
18
+ 'response.code_interpreter_call_code.done', 'response.image_generation_call.in_progress',
19
+ 'response.image_generation_call.generating', 'response.image_generation_call.partial_image',
20
+ 'response.image_generation_call.completed', 'response.mcp_call.in_progress', 'response.mcp_call.arguments.delta',
21
+ 'response.mcp_call.arguments.done', 'response.mcp_call.completed', 'response.mcp_call.failed',
22
+ 'response.mcp_list_tools.in_progress', 'response.mcp_list_tools.completed', 'response.mcp_list_tools.failed',
23
+ 'response.audio.delta', 'response.audio.done', 'response.audio.transcript.delta', 'response.audio.transcript.done',
24
+ ]);
25
+ const ANTHROPIC_EVENTS = new Set([
26
+ 'message_start', 'content_block_start', 'content_block_delta', 'content_block_stop',
27
+ 'message_delta', 'message_stop', 'ping', 'error',
28
+ ]);
29
+
30
+ class StreamAuditError extends Error {
31
+ constructor(message, code) {
32
+ super(message);
33
+ this.name = 'StreamAuditError';
34
+ this.code = code;
35
+ }
36
+ }
37
+
38
+ function limit(value, fallback, name) {
39
+ const result = value === undefined ? fallback : value;
40
+ if (!Number.isSafeInteger(result) || result < 1) throw new StreamAuditError(`${name} must be a positive safe integer`, 'invalid_options');
41
+ return result;
42
+ }
43
+
44
+ function eventType(event) {
45
+ return event && typeof event === 'object' && typeof event.type === 'string' ? event.type : undefined;
46
+ }
47
+
48
+ class StreamAuditor {
49
+ constructor(dialect, options = {}) {
50
+ if (!DIALECTS.includes(dialect)) throw new StreamAuditError(`unsupported dialect: ${dialect}`, 'invalid_options');
51
+ this.dialect = dialect;
52
+ this.limits = {
53
+ maxEvents: limit(options.maxEvents, DEFAULT_LIMITS.maxEvents, 'maxEvents'),
54
+ maxOpenUnits: limit(options.maxOpenUnits, DEFAULT_LIMITS.maxOpenUnits, 'maxOpenUnits'),
55
+ maxDiagnostics: limit(options.maxDiagnostics, DEFAULT_LIMITS.maxDiagnostics, 'maxDiagnostics'),
56
+ };
57
+ this.eventCount = 0;
58
+ this.counts = new Map();
59
+ this.diagnostics = [];
60
+ this.droppedDiagnostics = 0;
61
+ this.terminal = undefined;
62
+ this.started = false;
63
+ this.openUnits = new Map();
64
+ this.doneUnits = new Set();
65
+ this.stopReason = undefined;
66
+ this.lastSequence = undefined;
67
+ this.boundExceeded = false;
68
+ this.finished = false;
69
+ }
70
+
71
+ _diagnostic(severity, code, index, type, details) {
72
+ if (this.diagnostics.length >= this.limits.maxDiagnostics) {
73
+ this.droppedDiagnostics += 1;
74
+ return;
75
+ }
76
+ this.diagnostics.push(Object.freeze({ severity, code, index, ...(type ? { eventType: type } : {}), ...(details || {}) }));
77
+ }
78
+
79
+ _open(key, index, type) {
80
+ if (this.openUnits.has(key) || this.doneUnits.has(key)) {
81
+ this._diagnostic('error', 'duplicate_start', index, type, { unit: key });
82
+ return;
83
+ }
84
+ if (this.openUnits.size >= this.limits.maxOpenUnits) {
85
+ this.boundExceeded = true;
86
+ this._diagnostic('error', 'max_open_units', index, type);
87
+ return;
88
+ }
89
+ this.openUnits.set(key, index);
90
+ }
91
+
92
+ _delta(key, index, type) {
93
+ if (!this.openUnits.has(key)) {
94
+ this._diagnostic('error', this.doneUnits.has(key) ? 'delta_after_done' : 'delta_without_start', index, type, { unit: key });
95
+ }
96
+ }
97
+
98
+ _done(key, index, type) {
99
+ if (!this.openUnits.has(key)) {
100
+ this._diagnostic('error', this.doneUnits.has(key) ? 'duplicate_done' : 'done_without_start', index, type, { unit: key });
101
+ return;
102
+ }
103
+ this.openUnits.delete(key);
104
+ this.doneUnits.add(key);
105
+ }
106
+
107
+ _setTerminal(kind, index, type) {
108
+ if (this.terminal) {
109
+ this._diagnostic('error', 'duplicate_terminal', index, type, { previousTerminal: this.terminal.type });
110
+ return;
111
+ }
112
+ this.terminal = { kind, index, type };
113
+ for (const unit of this.openUnits.keys()) {
114
+ this._diagnostic('error', 'open_unit_at_terminal', index, type, { unit });
115
+ }
116
+ }
117
+
118
+ push(event) {
119
+ if (this.finished) throw new StreamAuditError('cannot push after finish()', 'already_finished');
120
+ const index = this.eventCount;
121
+ this.eventCount += 1;
122
+ if (this.eventCount > this.limits.maxEvents) {
123
+ if (!this.boundExceeded) this._diagnostic('error', 'max_events', index);
124
+ this.boundExceeded = true;
125
+ return this;
126
+ }
127
+ const type = eventType(event);
128
+ if (!type) {
129
+ this._diagnostic('error', 'invalid_event', index);
130
+ return this;
131
+ }
132
+ this.counts.set(type, (this.counts.get(type) || 0) + 1);
133
+ if (this.terminal) this._diagnostic('error', 'event_after_terminal', index, type, { terminal: this.terminal.type });
134
+ if (this.dialect === 'openai-responses') this._pushOpenAI(event, index, type);
135
+ else this._pushAnthropic(event, index, type);
136
+ return this;
137
+ }
138
+
139
+ _sequence(event, index, type) {
140
+ if (!Number.isSafeInteger(event.sequence_number)) return;
141
+ if (this.lastSequence !== undefined && event.sequence_number <= this.lastSequence) {
142
+ this._diagnostic('error', 'non_increasing_sequence', index, type, { previous: this.lastSequence, current: event.sequence_number });
143
+ }
144
+ this.lastSequence = event.sequence_number;
145
+ }
146
+
147
+ _pushOpenAI(event, index, type) {
148
+ this._sequence(event, index, type);
149
+ if (!OPENAI_EVENTS.has(type)) {
150
+ this._diagnostic('warning', 'unknown_event', index, type);
151
+ return;
152
+ }
153
+ if (type === 'response.created') {
154
+ if (this.started) this._diagnostic('error', 'duplicate_start', index, type, { unit: 'response' });
155
+ this.started = true;
156
+ return;
157
+ }
158
+ if (!this.started && type !== 'response.queued') {
159
+ this._diagnostic('error', 'event_before_start', index, type);
160
+ }
161
+ if (type === 'response.output_item.added') {
162
+ this._open(`item:${event.output_index}`, index, type);
163
+ return;
164
+ }
165
+ if (type === 'response.output_item.done') {
166
+ this._done(`item:${event.output_index}`, index, type);
167
+ return;
168
+ }
169
+ if (type === 'response.content_part.added') {
170
+ this._open(`content:${event.output_index}:${event.content_index}`, index, type);
171
+ return;
172
+ }
173
+ if (type === 'response.content_part.done') {
174
+ this._done(`content:${event.output_index}:${event.content_index}`, index, type);
175
+ return;
176
+ }
177
+ if (/\.delta$/.test(type)) {
178
+ const key = Number.isSafeInteger(event.content_index)
179
+ ? `content:${event.output_index}:${event.content_index}`
180
+ : `item:${event.output_index}`;
181
+ this._delta(key, index, type);
182
+ return;
183
+ }
184
+ if (type === 'response.completed' || type === 'response.failed' || type === 'response.incomplete' || type === 'response.cancelled' || type === 'error') {
185
+ const kind = type === 'response.completed' ? 'completed'
186
+ : type === 'response.incomplete' ? 'incomplete'
187
+ : type === 'response.cancelled' ? 'cancelled' : 'error';
188
+ if (type.startsWith('response.') && event.response && typeof event.response.status === 'string') {
189
+ const expected = type.slice('response.'.length);
190
+ if (event.response.status !== expected) {
191
+ this._diagnostic('error', 'terminal_status_mismatch', index, type, { expected, actual: event.response.status });
192
+ }
193
+ }
194
+ this._setTerminal(kind, index, type);
195
+ }
196
+ }
197
+
198
+ _pushAnthropic(event, index, type) {
199
+ if (!ANTHROPIC_EVENTS.has(type)) {
200
+ this._diagnostic('warning', 'unknown_event', index, type);
201
+ return;
202
+ }
203
+ if (type === 'ping') return;
204
+ if (type === 'message_start') {
205
+ if (this.started) this._diagnostic('error', 'duplicate_start', index, type, { unit: 'message' });
206
+ this.started = true;
207
+ return;
208
+ }
209
+ if (!this.started && type !== 'ping') this._diagnostic('error', 'event_before_start', index, type);
210
+ if (type === 'content_block_start') {
211
+ this._open(`block:${event.index}`, index, type);
212
+ return;
213
+ }
214
+ if (type === 'content_block_delta') {
215
+ this._delta(`block:${event.index}`, index, type);
216
+ return;
217
+ }
218
+ if (type === 'content_block_stop') {
219
+ this._done(`block:${event.index}`, index, type);
220
+ return;
221
+ }
222
+ if (type === 'message_delta') {
223
+ if (!this.started) this._diagnostic('error', 'message_delta_without_start', index, type);
224
+ if (event.delta && typeof event.delta.stop_reason === 'string') this.stopReason = event.delta.stop_reason;
225
+ return;
226
+ }
227
+ if (type === 'message_stop') {
228
+ if (!this.stopReason) this._diagnostic('error', 'missing_stop_reason', index, type);
229
+ const incomplete = this.stopReason === 'max_tokens' || this.stopReason === 'model_context_window_exceeded';
230
+ const refused = this.stopReason === 'refusal';
231
+ const kind = incomplete ? 'incomplete' : refused ? 'refusal' : 'completed';
232
+ this._setTerminal(kind, index, type);
233
+ return;
234
+ }
235
+ if (type === 'error') this._setTerminal('error', index, type);
236
+ }
237
+
238
+ finish(options = {}) {
239
+ if (this.finished) throw new StreamAuditError('finish() may only be called once', 'already_finished');
240
+ this.finished = true;
241
+ const cause = options.cause || 'iterator_end';
242
+ if (!['iterator_end', 'cancelled', 'timeout', 'transport_error'].includes(cause)) {
243
+ throw new StreamAuditError(`unsupported finish cause: ${cause}`, 'invalid_options');
244
+ }
245
+ if (!this.started && !this.terminal && this.eventCount > 0) this._diagnostic('error', 'missing_start', this.eventCount);
246
+ if (!this.terminal && !this.boundExceeded) this._diagnostic('error', 'missing_terminal', this.eventCount);
247
+
248
+ const hasErrors = this.diagnostics.some(({ severity }) => severity === 'error');
249
+ let outcome;
250
+ if (this.boundExceeded || hasErrors && this.terminal && this.terminal.kind === 'completed') outcome = 'invalid';
251
+ else if (this.terminal) outcome = this.terminal.kind === 'completed' && this.dialect === 'anthropic-messages' && this.stopReason === 'tool_use'
252
+ ? 'tool_use'
253
+ : this.terminal.kind === 'completed' && this.dialect === 'anthropic-messages' && this.stopReason === 'pause_turn'
254
+ ? 'paused'
255
+ : this.terminal.kind;
256
+ else if (cause === 'cancelled') outcome = 'cancelled';
257
+ else if (cause === 'timeout') outcome = 'timeout';
258
+ else if (cause === 'transport_error') outcome = 'error';
259
+ else outcome = 'transport_closed';
260
+
261
+ return Object.freeze({
262
+ dialect: this.dialect,
263
+ protocolComplete: Boolean(this.terminal),
264
+ successful: ['completed', 'tool_use', 'paused'].includes(outcome),
265
+ outcome,
266
+ stopReason: this.stopReason,
267
+ terminalEvent: this.terminal && this.terminal.type,
268
+ eventCount: this.eventCount,
269
+ eventTypes: Object.freeze(Object.fromEntries(this.counts)),
270
+ diagnostics: Object.freeze([...this.diagnostics]),
271
+ droppedDiagnostics: this.droppedDiagnostics,
272
+ finishCause: cause,
273
+ });
274
+ }
275
+ }
276
+
277
+ function createStreamAuditor(dialect, options) {
278
+ return new StreamAuditor(dialect, options);
279
+ }
280
+
281
+ function auditStream(events, dialect, options = {}) {
282
+ if (!events || typeof events[Symbol.iterator] !== 'function') throw new StreamAuditError('events must be iterable', 'invalid_options');
283
+ const auditor = createStreamAuditor(dialect, options);
284
+ for (const event of events) auditor.push(event);
285
+ return auditor.finish({ cause: options.cause });
286
+ }
287
+
288
+ module.exports = { DEFAULT_LIMITS, DIALECTS, StreamAuditError, StreamAuditor, auditStream, createStreamAuditor };
package/src/index.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ export type Dialect = 'openai-responses' | 'anthropic-messages';
2
+ export type FinishCause = 'iterator_end' | 'cancelled' | 'timeout' | 'transport_error';
3
+ export type AuditOutcome = 'completed' | 'tool_use' | 'paused' | 'incomplete' | 'refusal' | 'error' | 'cancelled' | 'timeout' | 'transport_closed' | 'invalid';
4
+
5
+ export interface AuditOptions {
6
+ maxEvents?: number;
7
+ maxOpenUnits?: number;
8
+ maxDiagnostics?: number;
9
+ cause?: FinishCause;
10
+ }
11
+
12
+ export interface Diagnostic {
13
+ readonly severity: 'error' | 'warning';
14
+ readonly code: string;
15
+ readonly index: number;
16
+ readonly eventType?: string;
17
+ readonly [detail: string]: unknown;
18
+ }
19
+
20
+ export interface AuditReport {
21
+ readonly dialect: Dialect;
22
+ readonly protocolComplete: boolean;
23
+ readonly successful: boolean;
24
+ readonly outcome: AuditOutcome;
25
+ readonly stopReason?: string;
26
+ readonly terminalEvent?: string;
27
+ readonly eventCount: number;
28
+ readonly eventTypes: Readonly<Record<string, number>>;
29
+ readonly diagnostics: readonly Diagnostic[];
30
+ readonly droppedDiagnostics: number;
31
+ readonly finishCause: FinishCause;
32
+ }
33
+
34
+ export declare const DEFAULT_LIMITS: Readonly<{ maxEvents: number; maxOpenUnits: number; maxDiagnostics: number }>;
35
+ export declare const DIALECTS: readonly Dialect[];
36
+
37
+ export declare class StreamAuditError extends Error { readonly code: string }
38
+ export declare class StreamAuditor {
39
+ constructor(dialect: Dialect, options?: AuditOptions);
40
+ push(event: unknown): this;
41
+ finish(options?: { cause?: FinishCause }): AuditReport;
42
+ }
43
+ export declare function createStreamAuditor(dialect: Dialect, options?: AuditOptions): StreamAuditor;
44
+ export declare function auditStream(events: Iterable<unknown>, dialect: Dialect, options?: AuditOptions): AuditReport;
45
+
46
+ declare const api: {
47
+ DEFAULT_LIMITS: typeof DEFAULT_LIMITS;
48
+ DIALECTS: typeof DIALECTS;
49
+ StreamAuditError: typeof StreamAuditError;
50
+ StreamAuditor: typeof StreamAuditor;
51
+ auditStream: typeof auditStream;
52
+ createStreamAuditor: typeof createStreamAuditor;
53
+ };
54
+ export default api;
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import api from './index.cjs';
2
+
3
+ export const { DEFAULT_LIMITS, DIALECTS, StreamAuditError, StreamAuditor, auditStream, createStreamAuditor } = api;
4
+ export default api;