@gtrabanco/pi-nan-provider 1.0.0 → 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/README.md CHANGED
@@ -327,12 +327,14 @@ Chat models:
327
327
  | :--- | :--- | :--- | :--- | :---: |
328
328
  | `qwen3.6` | 262,144 | 65,536 | text, image | ✅ |
329
329
  | `gemma4` | 262,144 | 32,768 | text, image | ✅ |
330
- | `deepseek-v4-flash` | 1,000,000 | 384,000 | text, image | ✅ |
330
+ | `deepseek-v4-flash` | 1,000,000 | 32,768* | text, image | ✅ |
331
331
  | `mimo-v2.6-flash` | 1,048,576 | 131,072 | text, image | ✅ |
332
332
  | `glm5.3-flash` | 1,000,000 | 131,072 | text, image | ✅ |
333
333
  | `qwen3.8-flash` | 262,144 | 131,072 | text, image | ✅ |
334
334
 
335
335
  > [!NOTE]
336
+ > `*` deepseek-v4-flash max output 32,768 is the ceiling from the official NaN pi docs (https://nan.builders/docs/pi, "the same set the NaN CLI writes", issue #20, 2026-10-02); models.dev still lists 384,000 but the gateway rejects `max_tokens` above ~256k with 403.
337
+ >
336
338
  > **Cold prompt cache practical ceiling (issue #18).** The context windows above are the models' real windows, but the api.nan.builders edge (Cloudflare) aborts responses past its 120 s Proxy Read Timeout. Measured with direct cold requests (unique filler, no prompt cache; 2026-10-01): 208,036 tokens (~960 KB body) answered in 12.9 s while ~220k and ~286k tokens returned **HTTP 524 `origin_response_timeout`** at ~126 s; the same sizes with a warm prompt cache answer in ~10 s. The practical ceiling for cold requests is therefore well below the declared windows. Since 1.0.0 the provider reclassifies a 524 on an estimated over-200,000-token request (`NAN_COLD_CACHE_CEILING_TOKENS`) as a context overflow so pi compacts and retries instead of wedging; a 524 on a smaller request is left alone (genuine transient timeout).
337
339
 
338
340
  Non-chat image models (available through `models.generateImages()` /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-nan-provider",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "NaN Builders (api.nan.builders) model provider for pi - OpenAI-compatible registration with a models.dev-generated fallback, tier-aware live catalog, and MCP bridges (official web search + optional community media server)",
5
5
  "keywords": [
6
6
  "pi",
@@ -141,10 +141,12 @@ export const MANUAL_ONLY_MODELS: Record<string, ManualModelOverride> = {
141
141
  export const MANUAL_OVERRIDES: Record<string, ManualModelOverride> = {
142
142
  "deepseek-v4-flash": {
143
143
  input: ["text", "image"],
144
+ maxTokens: 32_768,
144
145
  reasoningEffortValues: ["none"],
145
146
  thinkingLevelMap: { off: "none" },
146
147
  note:
147
148
  "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. " +
149
+ "maxTokens 32768 per the official NaN pi docs (https://nan.builders/docs/pi, 'the same set the NaN CLI writes', checked 2026-10-02) closing issue #20 — the gateway now rejects max_tokens above ~256k for this model with 403 permission_error (measured 2026-10-02: 256,000 → 200; 262,143 → 403), and the previous models.dev-derived 384000 made every pi request fail since pi-ai sends max_tokens = model.maxTokens when no cap is set. models.dev still lists 384000, so this override pins the documented value. " +
148
150
  "reasoning_effort_values=[\"none\"] (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): only \"none\" deterministically disables reasoning (0 reasoning tokens, answer produced). Every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:\"length\" with ZERO answer text). The catalog declares only [\"none\"] because the remaining values are useless noise. " +
149
151
  "thinkingLevelMap { off: \"none\" } (issue #16, measured 2026-09-27): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"none\" supplies the effort so the gateway receives reasoning_effort:\"none\" and does NOT enter runaway reasoning mode.",
150
152
  },
@@ -1,7 +1,7 @@
1
1
  // This file is auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually — run `bun run generate-models` to update.
3
3
  //
4
- // Source: https://models.dev/api.json (provider "nan"), fetched 2026-10-02T00:02:21.641Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-10-02T22:53:40.291Z
5
5
  // Provenance: every contextWindow/maxTokens/input/cost value traces to
6
6
  // models.dev or to the per-entry notes below. Nothing is invented; entries
7
7
  // models.dev documents incompletely are omitted and flagged instead.
@@ -27,7 +27,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
27
27
  "cacheWrite": 0
28
28
  },
29
29
  "contextWindow": 1000000,
30
- "maxTokens": 384000,
30
+ "maxTokens": 32768,
31
31
  "reasoningEffortValues": [
32
32
  "none"
33
33
  ],
@@ -43,7 +43,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
43
43
  },
44
44
  "notes": [
45
45
  "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
46
- "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. reasoning_effort_values=[\"none\"] (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): only \"none\" deterministically disables reasoning (0 reasoning tokens, answer produced). Every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:\"length\" with ZERO answer text). The catalog declares only [\"none\"] because the remaining values are useless noise. thinkingLevelMap { off: \"none\" } (issue #16, measured 2026-09-27): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"none\" supplies the effort so the gateway receives reasoning_effort:\"none\" and does NOT enter runaway reasoning mode."
46
+ "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. maxTokens 32768 per the official NaN pi docs (https://nan.builders/docs/pi, 'the same set the NaN CLI writes', checked 2026-10-02) closing issue #20 — the gateway now rejects max_tokens above ~256k for this model with 403 permission_error (measured 2026-10-02: 256,000 → 200; 262,143 → 403), and the previous models.dev-derived 384000 made every pi request fail since pi-ai sends max_tokens = model.maxTokens when no cap is set. models.dev still lists 384000, so this override pins the documented value. reasoning_effort_values=[\"none\"] (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): only \"none\" deterministically disables reasoning (0 reasoning tokens, answer produced). Every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:\"length\" with ZERO answer text). The catalog declares only [\"none\"] because the remaining values are useless noise. thinkingLevelMap { off: \"none\" } (issue #16, measured 2026-09-27): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"none\" supplies the effort so the gateway receives reasoning_effort:\"none\" and does NOT enter runaway reasoning mode."
47
47
  ],
48
48
  "extras": {
49
49
  "id": "deepseek-v4-flash",
@@ -455,7 +455,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
455
455
  export const GENERATED_CATALOG_META = {
456
456
  source: "https://models.dev/api.json",
457
457
  modelsDevProvider: "nan",
458
- fetchedAt: "2026-10-02T00:02:21.641Z",
458
+ fetchedAt: "2026-10-02T22:53:40.291Z",
459
459
  modelCount: 6,
460
460
  models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.6-flash","qwen3.6","qwen3.8-flash"],
461
461
  notes: [
@@ -469,7 +469,7 @@ export const GENERATED_CATALOG_META = {
469
469
  "non-chat: \"whisper\" excluded from the chat catalog (speech-to-text (STT) endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model)",
470
470
  "non-chat: \"flux-2-klein\" excluded from the chat catalog (image generation endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model)",
471
471
  "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
472
- "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. reasoning_effort_values=[\"none\"] (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): only \"none\" deterministically disables reasoning (0 reasoning tokens, answer produced). Every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:\"length\" with ZERO answer text). The catalog declares only [\"none\"] because the remaining values are useless noise. thinkingLevelMap { off: \"none\" } (issue #16, measured 2026-09-27): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"none\" supplies the effort so the gateway receives reasoning_effort:\"none\" and does NOT enter runaway reasoning mode.",
472
+ "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. maxTokens 32768 per the official NaN pi docs (https://nan.builders/docs/pi, 'the same set the NaN CLI writes', checked 2026-10-02) closing issue #20 — the gateway now rejects max_tokens above ~256k for this model with 403 permission_error (measured 2026-10-02: 256,000 → 200; 262,143 → 403), and the previous models.dev-derived 384000 made every pi request fail since pi-ai sends max_tokens = model.maxTokens when no cap is set. models.dev still lists 384000, so this override pins the documented value. reasoning_effort_values=[\"none\"] (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): only \"none\" deterministically disables reasoning (0 reasoning tokens, answer produced). Every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:\"length\" with ZERO answer text). The catalog declares only [\"none\"] because the remaining values are useless noise. thinkingLevelMap { off: \"none\" } (issue #16, measured 2026-09-27): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"none\" supplies the effort so the gateway receives reasoning_effort:\"none\" and does NOT enter runaway reasoning mode.",
473
473
  "thinkingLevelMap { off: \"none\" } (issue #16): NaN docs state that with no reasoning_effort parameter gemma4 defaults to reasoning ON (16,384-token budget), while `none`/`minimal` skip reasoning entirely (https://nan.builders/docs/models#controlling-reasoning, checked 2026-10-01). pi maps reasoning:\"off\" to reasoningEffort:undefined, so without this map `thinking: off` sent no parameter and the model reasoned anyway; the map sends reasoning_effort:\"none\" and actually disables reasoning.",
474
474
  "thinkingLevelMap { off: \"minimal\" } (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"minimal\" supplies the effort so the gateway receives reasoning_effort:\"minimal\" (38 reasoning tokens + answer). Note: \"none\" does NOT suppress reasoning on glm5.3-flash (13,382 tokens) — only \"minimal\" was measured to be effective. reasoning_effort_values includes \"minimal\" (not documented by NaN docs but accepted and effective by the gateway, issue #16).",
475
475
  "manual-only: \"mimo-v2.6-flash\" skipped: models.dev provider \"nan\" now lists it, so the manual-only entry is not emitted and the models.dev values are used (omnimodal model (text, image, audio input) served by NaN; limits and modalities from https://nan.builders/docs/models#mimo-v2-6-flash (checked 2026-09-25, quotas re-checked 2026-09-29): 1,048,576 context / 131,072 max output / 1.0B monthly quota per member. Reasoning: accepted, depth not adjustable (docs table, checked 2026-09-29). Tool calling: yes. Streaming: yes. Input modalities (pi-representable): text, image (audio not representable in pi's Model type). Fallback entry: emitted only while models.dev provider nan does not list the model — models.dev started listing it on 2026-09-29, so the generator then keeps the models.dev values and attaches this note to that entry instead of emitting a second one.)",
@@ -37,6 +37,7 @@ import type {
37
37
  RefreshModelsContext,
38
38
  } from "@earendil-works/pi-ai";
39
39
  import { withContextOverflowClassification } from "./context-overflow-classifier.ts";
40
+ import { withReasoningOnlyStreamGuard } from "./reasoning-only-stream-guard.ts";
40
41
  import { NAN_IMAGE_API, baselineImageModels, createNanImagesApi } from "./images.ts";
41
42
  import {
42
43
  baselineModels,
@@ -248,13 +249,19 @@ export async function createNanCompatibleProvider(
248
249
  const current = liveIds;
249
250
  return current ? models.filter((model) => current.has(model.id)) : models;
250
251
  },
251
- // Sanitize the payload for NaN's strict schema, then classify an opaque
252
- // generic 400 as a context overflow when the request we just sent was over
253
- // the model's window. The second layer keeps a replayed cross-model
254
- // reasoning trace (or any other over-window request the context-hook guard
255
- // cannot reach, e.g. NAN_THINKING_GUARD=0) recoverable instead of wedging
256
- // the session — see src/context-overflow-classifier.ts.
257
- api: withContextOverflowClassification(wrapApiForStrictSanitization(apiFactory())),
252
+ // Sanitize the payload for NaN's strict schema, then wrap a guard that
253
+ // detects NaN's reasoning-only stream closure marker and rewrites the
254
+ // terminal message so pi-ai retries it as a retryable error (issue #21).
255
+ // Finally, classify an opaque generic 400 as a context overflow when
256
+ // the request we just sent was over the model's window (issue #3).
257
+ //
258
+ // Composition order: outermost → classifier (error rewrites),
259
+ // middle → guard (marker detection), innermost → sanitizer (payload).
260
+ // The guard's onProviderStreamEvent hook sees the raw response chunks
261
+ // (NOT onPayload, which only receives the outgoing request params).
262
+ api: withContextOverflowClassification(
263
+ withReasoningOnlyStreamGuard(wrapApiForStrictSanitization(apiFactory())),
264
+ ),
258
265
  }),
259
266
  overlayMap,
260
267
  );
@@ -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
+ }