@crossworks/voice-client 0.232.112 → 0.232.122

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/voice-client",
3
- "version": "0.232.112",
3
+ "version": "0.232.122",
4
4
  "description": "Browser-safe surface of the voice/model layer — provider catalogue, model catalogs, audio tags, and the adapter type/metadata contract. Zero deps by design: nothing here may reach the network adapters or node builtins (the jackdaw-repo-split P0 boundary).",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -19,7 +19,7 @@
19
19
  * OpenRouter is intentionally NOT wrapped — its SDK already retries, and
20
20
  * double-wrapping would compound attempt counts. See registry.getChatAdapter.
21
21
  */
22
- import type { ChatDispatcher, ChatOptions, ChatResult } from './types';
22
+ import type { ChatDispatcher, ChatOptions, ChatResult, ChatStreamSink } from './types';
23
23
 
24
24
  /** Default attempts AFTER the first try (so 2 ⇒ up to 3 total calls). */
25
25
  export const DEFAULT_MAX_RETRIES = 2;
@@ -152,42 +152,86 @@ export interface ChatRetryConfig {
152
152
  maxDelayMs?: number;
153
153
  }
154
154
 
155
+ /** One attempt loop shared by the one-shot and streaming wrappers. `run` is
156
+ * called per attempt; `mayRetry` lets the streaming path veto a replay once
157
+ * output has already reached the user. */
158
+ async function attemptWithRetry<T>(
159
+ adapter: ChatDispatcher,
160
+ opts: ChatOptions,
161
+ config: ChatRetryConfig,
162
+ run: () => Promise<T>,
163
+ mayRetry: () => boolean,
164
+ ): Promise<T> {
165
+ const base = config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
166
+ const max = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
167
+ const maxRetries = opts.maxRetries ?? config.maxRetries ?? DEFAULT_MAX_RETRIES;
168
+ let attempt = 0;
169
+ for (;;) {
170
+ try {
171
+ return await run();
172
+ } catch (err) {
173
+ // A caller-aborted signal (user Stop) is not transient: every retry
174
+ // would abort identically after a pointless backoff sleep. Surface it
175
+ // immediately — the tool loop / run-turn recognise the stop.
176
+ if (opts.signal?.aborted) throw err;
177
+ if (!mayRetry()) throw err;
178
+ const { retry, retryAfterMs } = classifyChatError(err);
179
+ if (!retry || attempt >= maxRetries) throw err;
180
+ attempt += 1;
181
+ const delay =
182
+ retryAfterMs != null
183
+ ? Math.min(retryAfterMs, RETRY_AFTER_CAP_MS)
184
+ : Math.round(Math.random() * Math.min(max, base * 2 ** (attempt - 1)));
185
+ console.warn(
186
+ `[chat-retry] ${adapter.adapterName} ${opts.model}: ${describeError(err)} — retry ${attempt}/${maxRetries} in ${delay}ms`,
187
+ );
188
+ await new Promise((resolve) => setTimeout(resolve, delay));
189
+ }
190
+ }
191
+ }
192
+
155
193
  /**
156
- * Wrap a ChatDispatcher with retry/backoff on its `chat` call. Per-call
157
- * `opts.maxRetries` overrides the config default; 0 disables. All other
158
- * dispatcher members (providerId, adapterName, discoverModels, staticCatalog)
159
- * are preserved unchanged.
194
+ * Wrap a ChatDispatcher with retry/backoff on its `chat` AND `chatStream`
195
+ * calls. Per-call `opts.maxRetries` overrides the config default; 0 disables.
196
+ * All other dispatcher members (providerId, adapterName, discoverModels,
197
+ * staticCatalog) are preserved unchanged.
198
+ *
199
+ * Streaming is retried only while nothing has reached the user yet: a 429, a
200
+ * 5xx, a network blip or a connect timeout before the first delta re-sends the
201
+ * request exactly like the one-shot path. Once a delta has been emitted the
202
+ * error surfaces as-is — replaying would repeat text the client already
203
+ * rendered, and the adapters already return the partial on a user Stop.
204
+ * (Before 2026-09-02 only `chat` was wrapped; the tool loop prefers
205
+ * `chatStream` whenever a turn is live, so live turns had no retry at all.)
160
206
  */
161
207
  export function withChatRetry(
162
208
  adapter: ChatDispatcher,
163
209
  config: ChatRetryConfig = {},
164
210
  ): ChatDispatcher {
165
- const base = config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
166
- const max = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
167
- const chat = async (opts: ChatOptions): Promise<ChatResult> => {
168
- const maxRetries = opts.maxRetries ?? config.maxRetries ?? DEFAULT_MAX_RETRIES;
169
- let attempt = 0;
170
- for (;;) {
171
- try {
172
- return await adapter.chat(opts);
173
- } catch (err) {
174
- // A caller-aborted signal (user Stop) is not transient: every retry
175
- // would abort identically after a pointless backoff sleep. Surface it
176
- // immediately — the tool loop / run-turn recognise the stop.
177
- if (opts.signal?.aborted) throw err;
178
- const { retry, retryAfterMs } = classifyChatError(err);
179
- if (!retry || attempt >= maxRetries) throw err;
180
- attempt += 1;
181
- const delay =
182
- retryAfterMs != null
183
- ? Math.min(retryAfterMs, RETRY_AFTER_CAP_MS)
184
- : Math.round(Math.random() * Math.min(max, base * 2 ** (attempt - 1)));
185
- console.warn(
186
- `[chat-retry] ${adapter.adapterName} ${opts.model}: ${describeError(err)} — retry ${attempt}/${maxRetries} in ${delay}ms`,
211
+ const chat = (opts: ChatOptions): Promise<ChatResult> =>
212
+ attemptWithRetry(
213
+ adapter,
214
+ opts,
215
+ config,
216
+ () => adapter.chat(opts),
217
+ () => true,
218
+ );
219
+ const inner = adapter.chatStream?.bind(adapter);
220
+ const chatStream = inner
221
+ ? (opts: ChatOptions, onDelta: ChatStreamSink): Promise<ChatResult> => {
222
+ let emitted = false;
223
+ const sink: ChatStreamSink = (delta) => {
224
+ emitted = true;
225
+ onDelta(delta);
226
+ };
227
+ return attemptWithRetry(
228
+ adapter,
229
+ opts,
230
+ config,
231
+ () => inner(opts, sink),
232
+ () => !emitted,
187
233
  );
188
- await new Promise((resolve) => setTimeout(resolve, delay));
189
234
  }
190
- }
191
- };
192
- return { ...adapter, chat };
235
+ : undefined;
236
+ return { ...adapter, chat, ...(chatStream ? { chatStream } : {}) };
193
237
  }