@ynhcj/xiaoyi-channel 0.0.94-beta → 0.0.96-beta

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 +154 -39
  2. package/package.json +1 -1
@@ -23,8 +23,29 @@ function isRetryableProviderError(message) {
23
23
  return true;
24
24
  return false;
25
25
  }
26
+ /** Check if the request is triggered by a cron job by inspecting the first user message. */
27
+ function isCronTriggered(messages) {
28
+ if (!messages)
29
+ return false;
30
+ const firstUser = messages.find(m => m.role === "user");
31
+ if (!firstUser)
32
+ return false;
33
+ let text = "";
34
+ if (typeof firstUser.content === "string") {
35
+ text = firstUser.content;
36
+ }
37
+ else if (Array.isArray(firstUser.content)) {
38
+ const block = firstUser.content.find(b => b.type === "text" && typeof b.text === "string");
39
+ if (block)
40
+ text = block.text;
41
+ }
42
+ return /^\[cron:/i.test(text.trim());
43
+ }
26
44
  /** Compute retry delay in ms for the given 1-based attempt, with up to 10s jitter. */
27
- function getRetryDelayMs(attempt) {
45
+ function getRetryDelayMs(attempt, isCron = false) {
46
+ if (isCron) {
47
+ return 60_000 + Math.floor(Math.random() * 10_000);
48
+ }
28
49
  const base = attempt <= RETRY_DELAYS_MS.length
29
50
  ? RETRY_DELAYS_MS[attempt - 1]
30
51
  : RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1];
@@ -64,6 +85,128 @@ function buildReplayStream(result) {
64
85
  },
65
86
  };
66
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
+ console.log(`[xiaoyiprovider] stream completed (no content), usage: input=${event.message?.usage?.input} output=${event.message?.usage?.output}`);
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
+ console.log("[xiaoyiprovider] first content event received, switching to streaming mode");
133
+ hasContent = true;
134
+ for (const b of buffer)
135
+ yield b;
136
+ }
137
+ // IMPORTANT: resolve result() BEFORE yielding terminal events to avoid deadlock.
138
+ // The SDK calls result() when it sees done/error — if we yield first, the generator
139
+ // suspends and can never reach resolve, causing a permanent deadlock.
140
+ if (event.type === "done") {
141
+ console.log(`[xiaoyiprovider] stream completed, usage: input=${event.message?.usage?.input} output=${event.message?.usage?.output}`);
142
+ resultResolve(event.message);
143
+ yield event;
144
+ return;
145
+ }
146
+ if (event.type === "error") {
147
+ console.log(`[xiaoyiprovider] stream error after content: ${event.error?.errorMessage}`);
148
+ resultResolve(event.error);
149
+ yield event;
150
+ return;
151
+ }
152
+ yield event;
153
+ }
154
+ }
155
+ // Stream ended during buffer phase — decide whether to retry
156
+ if (errorResult?.stopReason === "error" && isRetryableProviderError(errorResult.errorMessage)) {
157
+ if (attempt < MAX_RETRY_ATTEMPTS - 1) {
158
+ const delayMs = getRetryDelayMs(attempt + 1, cronJob);
159
+ console.log(`[xiaoyiprovider] retryable error (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}): ` +
160
+ `${errorResult.errorMessage} — retrying in ${delayMs}ms`);
161
+ await sleep(delayMs);
162
+ continue; // discard buffer, retry with a new stream
163
+ }
164
+ console.log(`[xiaoyiprovider] all ${MAX_RETRY_ATTEMPTS} retries exhausted, surfacing last error`);
165
+ }
166
+ else if (errorResult) {
167
+ console.log(`[xiaoyiprovider] non-retryable error: ${errorResult.errorMessage}`);
168
+ }
169
+ // Non-retryable or retries exhausted — yield buffered events.
170
+ // Resolve before yielding the terminal event to avoid the same deadlock.
171
+ for (const b of buffer) {
172
+ if (b.type === "done") {
173
+ resultResolve(b.message);
174
+ }
175
+ else if (b.type === "error") {
176
+ resultResolve(b.error);
177
+ }
178
+ yield b;
179
+ }
180
+ if (errorResult && buffer.every(b => b.type !== "done" && b.type !== "error")) {
181
+ resultResolve(errorResult);
182
+ }
183
+ return;
184
+ }
185
+ // Safety: final fallback attempt
186
+ console.log("[xiaoyiprovider] entering final fallback attempt");
187
+ const lastStream = await createStream();
188
+ for await (const event of lastStream) {
189
+ if (event.type === "done") {
190
+ resultResolve(event.message);
191
+ yield event;
192
+ return;
193
+ }
194
+ if (event.type === "error") {
195
+ resultResolve(event.error);
196
+ yield event;
197
+ return;
198
+ }
199
+ yield event;
200
+ }
201
+ }
202
+ const gen = retryGenerator();
203
+ return {
204
+ result: () => resultPromise,
205
+ push: () => { },
206
+ end: () => { },
207
+ [Symbol.asyncIterator]: () => gen,
208
+ };
209
+ }
67
210
  /**
68
211
  * Dynamic header keys injected via extraParams and forwarded to the HTTP request.
69
212
  * Correspond to the three fields written to .xiaoyiruntime:
@@ -146,8 +289,10 @@ export const xiaoyiProvider = {
146
289
  const traceId = ctx.extraParams[HEADER_TRACE_ID];
147
290
  const sessionId = ctx.extraParams[HEADER_SESSION_ID];
148
291
  const interactionId = ctx.extraParams[HEADER_INTERACTION_ID];
149
- if (typeof traceId === "string")
150
- dynamicHeaders[HEADER_TRACE_ID] = traceId;
292
+ if (typeof traceId === "string") {
293
+ const isCron = isCronTriggered(context.messages);
294
+ dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}` : traceId;
295
+ }
151
296
  if (typeof sessionId === "string")
152
297
  dynamicHeaders[HEADER_SESSION_ID] = sessionId;
153
298
  if (typeof interactionId === "string")
@@ -195,48 +340,18 @@ export const xiaoyiProvider = {
195
340
  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"`;
196
341
  context.systemPrompt = (context.systemPrompt ?? "") + deviceSection;
197
342
  }
198
- // ── Retry loop ─────────────────────────────────────────
199
- for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
200
- const stream = await underlying(model, context, {
201
- ...options,
202
- headers: {
203
- ...options?.headers,
204
- ...dynamicHeaders,
205
- },
206
- });
207
- // Wait for the stream to settle (done or error) to inspect the result.
208
- // stream.result() resolves to the final AssistantMessage (even on error).
209
- const result = await stream.result();
210
- // Check if this is a retryable error
211
- if (result.stopReason === "error" && isRetryableProviderError(result.errorMessage)) {
212
- const delayMs = getRetryDelayMs(attempt);
213
- console.log(`[xiaoyiprovider] retryable error (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}): ` +
214
- `${result.errorMessage} — retrying in ${delayMs}ms`);
215
- await sleep(delayMs);
216
- continue;
217
- }
218
- // Success or non-retryable error — log and return
219
- if (result.stopReason === "error") {
220
- console.log(`[xiaoyiprovider] non-retryable error: ${result.errorMessage}`);
221
- }
222
- else {
223
- console.log(`[xiaoyiprovider] stream completed, usage: input=${result.usage?.input} output=${result.usage?.output}`);
224
- }
225
- // The original stream has already been consumed by result().
226
- // Build a replay stream that delivers the final result.
227
- return buildReplayStream(result);
228
- }
229
- // All retries exhausted — return the last attempt's real error via a new stream
230
- console.log(`[xiaoyiprovider] all ${MAX_RETRY_ATTEMPTS} retries exhausted, surfacing last error`);
231
- const lastStream = await underlying(model, context, {
343
+ // ── Retry-capable streaming ──────────────────────────────
344
+ const cronJob = isCronTriggered(context.messages);
345
+ if (cronJob)
346
+ console.log("[xiaoyiprovider] detected cron-triggered request, using extended retry delays");
347
+ const makeStream = () => underlying(model, context, {
232
348
  ...options,
233
349
  headers: {
234
350
  ...options?.headers,
235
351
  ...dynamicHeaders,
236
352
  },
237
353
  });
238
- const lastResult = await lastStream.result();
239
- return buildReplayStream(lastResult);
354
+ return createRetryingStream(makeStream, cronJob);
240
355
  };
241
356
  },
242
357
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.94-beta",
3
+ "version": "0.0.96-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",