@ynhcj/xiaoyi-channel 0.0.61-next → 0.0.62-next

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.
Files changed (2) hide show
  1. package/dist/src/provider.js +105 -36
  2. package/package.json +1 -1
@@ -85,6 +85,108 @@ function buildReplayStream(result) {
85
85
  },
86
86
  };
87
87
  }
88
+ /**
89
+ * Wrap the underlying stream with retry logic while preserving real-time streaming.
90
+ *
91
+ * Strategy:
92
+ * 1. Buffer events until the first content-bearing event is seen.
93
+ * 2. If the stream errors before any content, the buffer is tiny (start + error)
94
+ * and we can safely retry with a fresh API call.
95
+ * 3. Once content events appear, flush the buffer and switch to pass-through mode
96
+ * — the consumer sees every text_delta in real time.
97
+ */
98
+ function createRetryingStream(createStream, cronJob) {
99
+ let resultResolve;
100
+ const resultPromise = new Promise(resolve => { resultResolve = resolve; });
101
+ const CONTENT_EVENT_TYPES = new Set([
102
+ "text_start", "text_delta", "text_end",
103
+ "thinking_start", "thinking_delta", "thinking_end",
104
+ "toolcall_start", "toolcall_delta", "toolcall_end",
105
+ ]);
106
+ async function* retryGenerator() {
107
+ for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
108
+ const stream = await createStream();
109
+ let hasContent = false;
110
+ const buffer = [];
111
+ let errorResult = null;
112
+ for await (const event of stream) {
113
+ const isContent = CONTENT_EVENT_TYPES.has(event.type);
114
+ if (!hasContent && !isContent) {
115
+ // ── Buffer phase (no content yet) ──
116
+ if (event.type === "done") {
117
+ // Success without content — flush buffer and finish
118
+ for (const b of buffer)
119
+ yield b;
120
+ resultResolve(event.message);
121
+ yield event;
122
+ return;
123
+ }
124
+ if (event.type === "error") {
125
+ errorResult = event.error;
126
+ }
127
+ buffer.push(event);
128
+ }
129
+ else {
130
+ // ── Streaming phase ──
131
+ if (!hasContent) {
132
+ // First content event — flush buffer then yield
133
+ hasContent = true;
134
+ for (const b of buffer)
135
+ yield b;
136
+ }
137
+ yield event;
138
+ if (event.type === "done") {
139
+ resultResolve(event.message);
140
+ return;
141
+ }
142
+ if (event.type === "error") {
143
+ resultResolve(event.error);
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ // Stream ended during buffer phase — decide whether to retry
149
+ if (errorResult?.stopReason === "error" && isRetryableProviderError(errorResult.errorMessage)) {
150
+ if (attempt < MAX_RETRY_ATTEMPTS - 1) {
151
+ const delayMs = getRetryDelayMs(attempt + 1, cronJob);
152
+ console.log(`[xiaoyiprovider] retryable error (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}): ` +
153
+ `${errorResult.errorMessage} — retrying in ${delayMs}ms`);
154
+ await sleep(delayMs);
155
+ continue; // discard buffer, retry with a new stream
156
+ }
157
+ console.log(`[xiaoyiprovider] all ${MAX_RETRY_ATTEMPTS} retries exhausted, surfacing last error`);
158
+ }
159
+ else if (errorResult) {
160
+ console.log(`[xiaoyiprovider] non-retryable error: ${errorResult.errorMessage}`);
161
+ }
162
+ // Non-retryable or retries exhausted — yield buffered events
163
+ for (const b of buffer)
164
+ yield b;
165
+ resultResolve(errorResult);
166
+ return;
167
+ }
168
+ // Safety: final fallback attempt
169
+ const lastStream = await createStream();
170
+ for await (const event of lastStream) {
171
+ yield event;
172
+ if (event.type === "done") {
173
+ resultResolve(event.message);
174
+ return;
175
+ }
176
+ if (event.type === "error") {
177
+ resultResolve(event.error);
178
+ return;
179
+ }
180
+ }
181
+ }
182
+ const gen = retryGenerator();
183
+ return {
184
+ result: () => resultPromise,
185
+ push: () => { },
186
+ end: () => { },
187
+ [Symbol.asyncIterator]: () => gen,
188
+ };
189
+ }
88
190
  /**
89
191
  * Dynamic header keys injected via extraParams and forwarded to the HTTP request.
90
192
  * Correspond to the three fields written to .xiaoyiruntime:
@@ -216,51 +318,18 @@ export const xiaoyiProvider = {
216
318
  const deviceSection = `\n\n## Current User Device Context\nThe current user is using the following device: ${displayDevice}\nYou need to be aware of the user's current device and provide guidance accordingly. If the response involves device-related tools or actions, you must tailor the reply based on the user's current device, using device-specific references such as "saved to the Notes/Calendar on your {deviceType}.\n"`;
217
319
  context.systemPrompt = (context.systemPrompt ?? "") + deviceSection;
218
320
  }
219
- // ── Retry loop ─────────────────────────────────────────
321
+ // ── Retry-capable streaming ──────────────────────────────
220
322
  const cronJob = isCronTriggered(context.messages);
221
323
  if (cronJob)
222
324
  console.log("[xiaoyiprovider] detected cron-triggered request, using extended retry delays");
223
- for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
224
- const stream = await underlying(model, context, {
225
- ...options,
226
- headers: {
227
- ...options?.headers,
228
- ...dynamicHeaders,
229
- },
230
- });
231
- // Wait for the stream to settle (done or error) to inspect the result.
232
- // stream.result() resolves to the final AssistantMessage (even on error).
233
- const result = await stream.result();
234
- // Check if this is a retryable error
235
- if (result.stopReason === "error" && isRetryableProviderError(result.errorMessage)) {
236
- const delayMs = getRetryDelayMs(attempt + 1, cronJob);
237
- console.log(`[xiaoyiprovider] retryable error (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}): ` +
238
- `${result.errorMessage} — retrying in ${delayMs}ms`);
239
- await sleep(delayMs);
240
- continue;
241
- }
242
- // Success or non-retryable error — log and return
243
- if (result.stopReason === "error") {
244
- console.log(`[xiaoyiprovider] non-retryable error: ${result.errorMessage}`);
245
- }
246
- else {
247
- console.log(`[xiaoyiprovider] stream completed, usage: input=${result.usage?.input} output=${result.usage?.output}`);
248
- }
249
- // The original stream has already been consumed by result().
250
- // Build a replay stream that delivers the final result.
251
- return buildReplayStream(result);
252
- }
253
- // All retries exhausted — return the last attempt's real error via a new stream
254
- console.log(`[xiaoyiprovider] all ${MAX_RETRY_ATTEMPTS} retries exhausted, surfacing last error`);
255
- const lastStream = await underlying(model, context, {
325
+ const makeStream = () => underlying(model, context, {
256
326
  ...options,
257
327
  headers: {
258
328
  ...options?.headers,
259
329
  ...dynamicHeaders,
260
330
  },
261
331
  });
262
- const lastResult = await lastStream.result();
263
- return buildReplayStream(lastResult);
332
+ return createRetryingStream(makeStream, cronJob);
264
333
  };
265
334
  },
266
335
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.61-next",
3
+ "version": "0.0.62-next",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",