@gtrabanco/pi-nan-provider 0.10.3 → 1.0.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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * NaN reasoning-only stream guard (issue #21).
3
+ *
4
+ * Problem: NaN's gateway closes a streaming turn that produced ONLY reasoning
5
+ * (no content, no tool calls) at 60,000 reasoning chars or 420 seconds:
6
+ * `finish_reason: "length"` on an empty delta, followed by a final usage chunk
7
+ * (estimated, unbilled) carrying `nan_truncation: { reason: "reasoning_only_stream" }`.
8
+ * Through pi-ai this maps to `stopReason: "length"` with no text — indistinguishable
9
+ * from a real output-limit cut, never retried, and the turn is silently wasted.
10
+ *
11
+ * Solution: intercept the raw stream chunks via the `onProviderStreamEvent`
12
+ * hook (pi-ai's openai-completions calls `options?.onProviderStreamEvent?.(chunk,
13
+ * model)` for every raw chunk — including the final usage-only chunk, which is
14
+ * where NaN attaches `nan_truncation`) and detect the
15
+ * `nan_truncation.reason === "reasoning_only_stream"` marker. When seen,
16
+ * rewrite the terminal message (both the async-iterator `done`/`error` events and
17
+ * `result()`) to `stopReason: "error"` with an errorMessage containing the word
18
+ * "timeout", so pi-ai's `RETRYABLE_PROVIDER_ERROR_PATTERN` treats it as retryable.
19
+ *
20
+ * The rewrite is **never** applied when the marker was not observed on that stream —
21
+ * a legitimate `length` (real output cut with content) and the issue-#16 runaway
22
+ * `length` (no marker) must pass through untouched.
23
+ *
24
+ * Composition: `withReasoningOnlyStreamGuard` is the **innermost** of the three
25
+ * wrapper layers in `src/provider-factory.ts` (inside both the sanitizer and the
26
+ * overflow classifier) so that:
27
+ * 1. The sanitizer still runs first (payload goes out clean).
28
+ * 2. The classifier's error rewrites still apply on top of our rewrite.
29
+ * 3. The guard's marker detection runs on the *raw* chunk.
30
+ *
31
+ * NOTE: the observation hook MUST be `onProviderStreamEvent` (response chunks),
32
+ * NOT `onPayload` — `onPayload` receives the OUTGOING request params
33
+ * (openai-completions.js:188) and would never carry `nan_truncation`.
34
+ *
35
+ * In practice, the composition line is:
36
+ * ```
37
+ * withContextOverflowClassification(
38
+ * withReasoningOnlyStreamGuard(
39
+ * wrapApiForStrictSanitization(apiFactory())
40
+ * )
41
+ * )
42
+ * ```
43
+ */
44
+
45
+ import type {
46
+ AssistantMessage,
47
+ AssistantMessageEventStream,
48
+ ProviderStreams,
49
+ } from "@earendil-works/pi-ai";
50
+
51
+ /** The JSON key under which NaN attaches the truncation metadata. */
52
+ export const NAN_TRUNCATION_MARKER = "nan_truncation";
53
+
54
+ /** The value of `reason` when NaN closes a reasoning-only stream. */
55
+ export const REASONING_ONLY_STREAM_REASON = "reasoning_only_stream";
56
+
57
+ /**
58
+ * Check whether a raw stream chunk carries the reasoning-only stream marker.
59
+ *
60
+ * Pure function — useful for unit tests and for reasoning about chunk shapes
61
+ * without needing the full guard wrapper.
62
+ */
63
+ export function isReasoningOnlyStreamChunk(chunk: unknown): boolean {
64
+ if (typeof chunk !== "object" || chunk === null) return false;
65
+ const obj = chunk as Record<string, unknown>;
66
+ const truncation = obj[NAN_TRUNCATION_MARKER];
67
+ if (typeof truncation !== "object" || truncation === null) return false;
68
+ return (truncation as Record<string, unknown>)?.reason === REASONING_ONLY_STREAM_REASON;
69
+ }
70
+
71
+ /** The rewrite applied to a terminal message when the marker was observed. */
72
+ const REASONING_ONLY_REWRITE: Partial<AssistantMessage> = {
73
+ stopReason: "error" as const,
74
+ errorMessage:
75
+ "NaN timed out the stream before any answer content (reasoning-only stream guard: 420 s / 60,000-character limit; the turn was not billed). Original finish_reason: length.",
76
+ };
77
+
78
+ /**
79
+ * Rewrite the terminal assistant message when the reasoning-only stream marker
80
+ * was observed during this stream. Only rewrites when `stopReason === "length"`
81
+ * AND the marker was set — everything else passes through untouched.
82
+ *
83
+ * Mutates and returns the same message object so that both the async-iterator
84
+ * consumers and `result()`-only consumers see the rewrite (pi-ai yields the
85
+ * same object through the `"done"` event's `message` field and through `result()`).
86
+ */
87
+ function rewriteReasoningOnlyMessage(
88
+ message: AssistantMessage | undefined,
89
+ observedMarker: boolean,
90
+ ): AssistantMessage | undefined {
91
+ if (!observedMarker || !message) return message;
92
+ if (message.stopReason !== "length") return message;
93
+
94
+ // Only rewrite a terminal "length" that came with the nan_truncation marker.
95
+ // Preserve all existing fields: reasoning/thinking blocks, usage, responseId.
96
+ message.stopReason = "error";
97
+ message.errorMessage = REASONING_ONLY_REWRITE.errorMessage!;
98
+ return message;
99
+ }
100
+
101
+ /**
102
+ * Wrap a stream so its terminal message is rewritten when the reasoning-only
103
+ * stream marker was observed. Uses a Proxy to intercept both the async-iterator
104
+ * `"error"`/`"done"` events and the `result()` call, mirroring the
105
+ * `classifyStreamContextOverflow` technique.
106
+ */
107
+ function rewriteStream(
108
+ stream: AssistantMessageEventStream,
109
+ observedMarker: () => boolean,
110
+ ): AssistantMessageEventStream {
111
+ const rewrite = (message: AssistantMessage | undefined): AssistantMessage | undefined =>
112
+ rewriteReasoningOnlyMessage(message, observedMarker());
113
+ const resultPromise = stream.result().then(rewrite);
114
+
115
+ return new Proxy(stream, {
116
+ get(target, property, receiver) {
117
+ if (property === "result") return () => resultPromise;
118
+ if (property === Symbol.asyncIterator) {
119
+ return () => {
120
+ const iterator = target[Symbol.asyncIterator]();
121
+ return {
122
+ async next() {
123
+ const step = await iterator.next();
124
+ if (!step.done && step.value?.type === "error") rewrite(step.value.error);
125
+ return step;
126
+ },
127
+ async return(value?: unknown) {
128
+ return iterator.return ? iterator.return(value) : { done: true, value };
129
+ },
130
+ async throw(error?: unknown) {
131
+ if (iterator.throw) return iterator.throw(error);
132
+ throw error;
133
+ },
134
+ [Symbol.asyncIterator]() {
135
+ return this;
136
+ },
137
+ };
138
+ };
139
+ }
140
+ const value = Reflect.get(target, property, receiver);
141
+ return typeof value === "function" ? value.bind(target) : value;
142
+ },
143
+ }) as AssistantMessageEventStream;
144
+ }
145
+
146
+ /**
147
+ * Wrap every `stream`/`streamSimple` call so a NaN reasoning-only stream that
148
+ * terminates with `stopReason: "length"` (but carries the
149
+ * `nan_truncation.reason: "reasoning_only_stream"` marker) is surfaced as a
150
+ * retryable error instead of a silent waste.
151
+ *
152
+ * The guard chains an `onProviderStreamEvent` hook that detects the raw chunk
153
+ * marker and sets a closure-local flag. The `done` event's `message` and the
154
+ * `result()` promise both receive the rewritten terminal message.
155
+ *
156
+ * Any caller-supplied `onProviderStreamEvent` is preserved and chained **after**
157
+ * the guard's marker detection, so the caller always sees the same chunks it
158
+ * would have without the guard — the guard only reads, never mutates the chunk.
159
+ *
160
+ * @see src/provider-factory.ts for composition
161
+ * @see Issue #21
162
+ */
163
+ export function withReasoningOnlyStreamGuard(api: ProviderStreams): ProviderStreams {
164
+ return {
165
+ ...api,
166
+ stream: (model, context, options) => {
167
+ let observedMarker = false;
168
+ const withGuardedOptions = { ...((options ?? {}) as Record<string, unknown>) };
169
+
170
+ const userOnStreamEvent = withGuardedOptions.onProviderStreamEvent;
171
+ withGuardedOptions.onProviderStreamEvent = async (chunk: unknown, modelHint: unknown) => {
172
+ if (isReasoningOnlyStreamChunk(chunk)) {
173
+ observedMarker = true;
174
+ }
175
+ if (typeof userOnStreamEvent === "function") {
176
+ await userOnStreamEvent(chunk, modelHint);
177
+ }
178
+ };
179
+
180
+ const stream = api.stream(model, context, withGuardedOptions as typeof options);
181
+ return rewriteStream(stream, () => observedMarker);
182
+ },
183
+ streamSimple: (model, context, options) => {
184
+ let observedMarker = false;
185
+ const withGuardedOptions = { ...((options ?? {}) as Record<string, unknown>) };
186
+
187
+ const userOnStreamEvent = withGuardedOptions.onProviderStreamEvent;
188
+ withGuardedOptions.onProviderStreamEvent = async (chunk: unknown, modelHint: unknown) => {
189
+ if (isReasoningOnlyStreamChunk(chunk)) {
190
+ observedMarker = true;
191
+ }
192
+ if (typeof userOnStreamEvent === "function") {
193
+ await userOnStreamEvent(chunk, modelHint);
194
+ }
195
+ };
196
+
197
+ const stream = api.streamSimple(model, context, withGuardedOptions as typeof options);
198
+ return rewriteStream(stream, () => observedMarker);
199
+ },
200
+ };
201
+ }