@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.
- package/AGENTS.md +68 -9
- package/README.es.md +57 -9
- package/README.md +60 -10
- package/package.json +5 -5
- package/scripts/manual-overrides.ts +2 -0
- package/scripts/models.generated.ts +5 -5
- package/src/commands.ts +6 -4
- package/src/context-overflow-classifier.ts +114 -23
- package/src/fetch-models.ts +147 -2
- package/src/images.ts +408 -0
- package/src/index.ts +5 -2
- package/src/provider-factory.ts +109 -30
- package/src/reasoning-only-stream-guard.ts +201 -0
|
@@ -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
|
+
}
|