@thorprovider/medusa-extended 1.11.3 → 1.11.6

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.
@@ -1481,13 +1481,13 @@ describe('DropshipperClient.chatAssistantStream', () => {
1481
1481
  ])
1482
1482
  })
1483
1483
 
1484
- it('synthesizes a one-shot stream from a plain JSON greeting/other response', async () => {
1484
+ it('synthesizes a one-shot stream from a plain JSON default response', async () => {
1485
1485
  server.use(
1486
1486
  http.post(`${BASE}/admin/thor/dropshipper/ai/chat`, ({ request }) => {
1487
1487
  requireBearerToken(request)
1488
1488
  return HttpResponse.json({
1489
1489
  answer: '¡Hola! ¿En qué puedo ayudarte?',
1490
- intent: 'greeting',
1490
+ intent: 'default',
1491
1491
  model_used: 'gpt-4o',
1492
1492
  sources: [],
1493
1493
  })
@@ -1507,9 +1507,9 @@ describe('DropshipperClient.chatAssistantStream', () => {
1507
1507
  }
1508
1508
 
1509
1509
  expect(events).toEqual([
1510
- { type: 'start', intent: 'greeting' },
1510
+ { type: 'start', intent: 'default' },
1511
1511
  { type: 'text-delta', delta: '¡Hola! ¿En qué puedo ayudarte?' },
1512
- { type: 'finish', intent: 'greeting', sources: [] },
1512
+ { type: 'finish', intent: 'default', sources: [] },
1513
1513
  ])
1514
1514
  })
1515
1515
  })
@@ -160,6 +160,7 @@ import type {
160
160
  ChatResponse,
161
161
  ChatIntent,
162
162
  ChatSource,
163
+ ChatAssistantHealthResponse,
163
164
  // Profile
164
165
  GetMyProfileResponse,
165
166
  // Notifications
@@ -188,8 +189,8 @@ type FetchMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
188
189
  * One event from the streamed AI assistant chat (`chatAssistantStream`).
189
190
  *
190
191
  * The backend returns a UI-message stream (SSE over `text/event-stream`) for
191
- * catalog searches, and a plain JSON `ChatResponse` for greetings/other
192
- * intents. `chatAssistantStream` normalizes both into this single event type so
192
+ * catalog searches, and a plain JSON `ChatResponse` for `default` intents.
193
+ * `chatAssistantStream` normalizes both into this single event type so
193
194
  * consumers can render incremental text and update the "thinking" label as the
194
195
  * model progresses without knowing the transport details.
195
196
  */
@@ -207,7 +208,7 @@ export type ChatStreamEvent =
207
208
  | {
208
209
  type: 'finish';
209
210
  intent: ChatIntent;
210
- /** Products referenced by the answer (empty for greetings/other). */
211
+ /** Products referenced by the answer (empty for `default` intents). */
211
212
  sources: ChatSource[];
212
213
  }
213
214
  | {
@@ -372,7 +373,7 @@ export class DropshipperClient {
372
373
  *
373
374
  * Unlike `chatAssistant`, this surfaces the model's progress: the backend
374
375
  * resolves the intent first and then streams the answer token by token
375
- * (catalog searches) or returns the full answer in one shot (greetings/other).
376
+ * (catalog searches) or returns the full answer in one shot (`default`).
376
377
  * The response is normalized into a single `ReadableStream<ChatStreamEvent>`
377
378
  * so callers can show a live "thinking" label and render text incrementally.
378
379
  *
@@ -394,7 +395,16 @@ export class DropshipperClient {
394
395
 
395
396
  let response: Response;
396
397
  try {
397
- response = await fetchBackendWithResilience(url, init);
398
+ // This endpoint performs two LLM round-trips plus channel-scoped retrieval
399
+ // BEFORE it starts streaming (headers only flush when Stage 3 begins), so
400
+ // the default 10s timeout aborts legitimate catalog searches. Use a larger
401
+ // per-attempt timeout; once headers arrive the timer is cleared and the
402
+ // streamed body is unaffected. POST is a mutation, so on timeout/network it
403
+ // won't be retried — maxRetries:1 only guards against redundant 5xx/red work.
404
+ response = await fetchBackendWithResilience(url, init, {
405
+ timeoutMs: 60_000,
406
+ maxRetries: 1,
407
+ });
398
408
  } catch (networkError: any) {
399
409
  throw new ProviderAPIError(
400
410
  `[dropshipper] Network error on POST /admin/thor/dropshipper/ai/chat: ${networkError.message}`,
@@ -405,25 +415,44 @@ export class DropshipperClient {
405
415
 
406
416
  if (!response.ok) {
407
417
  let message = `HTTP ${response.status}`;
418
+ let errorCode: string | undefined;
419
+ let requestId: string | undefined;
420
+ let details: string | undefined;
408
421
  try {
409
- const json = await response.json();
422
+ const json: any = await response.json();
410
423
  message = json?.message ?? message;
424
+ errorCode = json?.error_code ?? json?.type;
425
+ requestId = json?.request_id;
426
+ details = json?.details;
411
427
  } catch {
412
428
  // ignore parse errors
413
429
  }
430
+ this.logger.error(
431
+ `[dropshipper] POST /admin/thor/dropshipper/ai/chat failed status=${response.status} message=${message}` +
432
+ (errorCode ? ` error_code=${errorCode}` : '') +
433
+ (requestId ? ` request_id=${requestId}` : '') +
434
+ (details ? ` details=${details}` : '')
435
+ );
414
436
  throw new ProviderAPIError(
415
437
  `[dropshipper] POST /admin/thor/dropshipper/ai/chat failed: ${message}`,
416
438
  `DROPSHIPPER_API_${response.status}`,
417
439
  response.status,
440
+ {
441
+ message,
442
+ status: response.status,
443
+ errorCode,
444
+ requestId,
445
+ details,
446
+ },
418
447
  );
419
448
  }
420
449
 
421
450
  const contentType = response.headers.get('content-type') ?? '';
422
451
  if (contentType.includes('text/event-stream') && response.body) {
423
- return mapUiMessageStreamToEvents(response.body);
452
+ return mapUiMessageStreamToEvents(response.body, this.logger);
424
453
  }
425
454
 
426
- // Non-streamed reply (greeting/other): synthesize a one-shot event stream.
455
+ // Non-streamed reply (`default`): synthesize a one-shot event stream.
427
456
  const data: ChatResponse = await response.json();
428
457
  return new ReadableStream<ChatStreamEvent>({
429
458
  start(controller) {
@@ -439,6 +468,69 @@ export class DropshipperClient {
439
468
  });
440
469
  }
441
470
 
471
+ /**
472
+ * Cheap liveness probe for the AI assistant.
473
+ *
474
+ * The backend performs no LLM or retrieval work, so this is safe to poll
475
+ * (e.g. from a dashboard availability indicator). Returns 200 with
476
+ * `available: true` when the assistant is configured; the backend answers
477
+ * HTTP 503 with `available: false` when `AI_GATEWAY_API_KEY` is missing.
478
+ *
479
+ * Deliberately does NOT go through `fetchBackendWithResilience`: that layer
480
+ * funnels every call through the SHARED `backendCircuitBreaker`, and the
481
+ * definitive 503 "not configured" answer would count as failures and could
482
+ * open the circuit, taking down all dropshipper API traffic. A health probe
483
+ * must stay isolated, with no retries and a short timeout.
484
+ *
485
+ * `GET /admin/thor/dropshipper/ai/health`
486
+ */
487
+ async chatAssistantHealth(): Promise<ChatAssistantHealthResponse> {
488
+ const url = `${this.baseUrl}/admin/thor/dropshipper/ai/health`;
489
+ const controller = new AbortController();
490
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
491
+
492
+ let response: Response;
493
+ try {
494
+ response = await fetch(url, {
495
+ method: 'GET',
496
+ headers: { Authorization: `Bearer ${this.token}` },
497
+ signal: controller.signal,
498
+ cache: 'no-store',
499
+ });
500
+ } catch (error: any) {
501
+ clearTimeout(timeoutId);
502
+ throw new ProviderAPIError(
503
+ `[dropshipper] Network error on GET /admin/thor/dropshipper/ai/health: ${error.message}`,
504
+ 'NETWORK_ERROR',
505
+ 0,
506
+ );
507
+ }
508
+ clearTimeout(timeoutId);
509
+
510
+ // 503 is the backend's definitive "not configured" answer, not a transient
511
+ // failure: parse it as a valid (unavailable) health response.
512
+ if (response.status === 503) {
513
+ try {
514
+ const body = await response.json();
515
+ if (body && typeof body.available === 'boolean') {
516
+ return body as ChatAssistantHealthResponse;
517
+ }
518
+ } catch {
519
+ // fall through to the generic failure below
520
+ }
521
+ }
522
+
523
+ if (!response.ok) {
524
+ throw new ProviderAPIError(
525
+ `[dropshipper] GET /admin/thor/dropshipper/ai/health failed: HTTP ${response.status}`,
526
+ `DROPSHIPPER_API_${response.status}`,
527
+ response.status,
528
+ );
529
+ }
530
+
531
+ return response.json() as Promise<ChatAssistantHealthResponse>;
532
+ }
533
+
442
534
  // -------------------------------------------------------------------------
443
535
  // Notifications
444
536
  // -------------------------------------------------------------------------
@@ -1887,10 +1979,12 @@ export type { DropshipperCategory, DropshipperPromotion, NotificationData, Price
1887
1979
  */
1888
1980
  async function* parseSse(
1889
1981
  stream: ReadableStream<Uint8Array>,
1982
+ logger?: ReturnType<typeof createLogger>,
1890
1983
  ): AsyncGenerator<unknown> {
1891
1984
  const reader = stream.getReader();
1892
1985
  const decoder = new TextDecoder();
1893
1986
  let buffer = '';
1987
+ let frameIndex = 0;
1894
1988
  try {
1895
1989
  while (true) {
1896
1990
  const { done, value } = await reader.read();
@@ -1903,10 +1997,13 @@ async function* parseSse(
1903
1997
  if (!trimmed.startsWith('data:')) continue;
1904
1998
  const payload = trimmed.slice(5).trim();
1905
1999
  if (!payload || payload === '[DONE]') continue;
2000
+ frameIndex += 1;
1906
2001
  try {
1907
2002
  yield JSON.parse(payload);
1908
- } catch {
1909
- // Skip malformed frames.
2003
+ } catch (err: any) {
2004
+ logger?.warn(
2005
+ `[dropshipper] sse-malformed-frame frame=${frameIndex} payload=${payload.slice(0, 200)}`
2006
+ );
1910
2007
  }
1911
2008
  }
1912
2009
  }
@@ -1914,10 +2011,13 @@ async function* parseSse(
1914
2011
  if (tail.startsWith('data:')) {
1915
2012
  const payload = tail.slice(5).trim();
1916
2013
  if (payload && payload !== '[DONE]') {
2014
+ frameIndex += 1;
1917
2015
  try {
1918
2016
  yield JSON.parse(payload);
1919
- } catch {
1920
- // Ignore trailing partial frame.
2017
+ } catch (err: any) {
2018
+ logger?.warn(
2019
+ `[dropshipper] sse-malformed-frame frame=${frameIndex} payload=${payload.slice(0, 200)}`
2020
+ );
1921
2021
  }
1922
2022
  }
1923
2023
  }
@@ -1932,10 +2032,12 @@ async function* parseSse(
1932
2032
  */
1933
2033
  function mapUiMessageStreamToEvents(
1934
2034
  stream: ReadableStream<Uint8Array>,
2035
+ logger?: ReturnType<typeof createLogger>,
1935
2036
  ): ReadableStream<ChatStreamEvent> {
1936
2037
  return new ReadableStream<ChatStreamEvent>({
1937
2038
  async start(controller) {
1938
- for await (const raw of parseSse(stream)) {
2039
+ let eventCount = 0;
2040
+ for await (const raw of parseSse(stream, logger)) {
1939
2041
  const chunk = raw as {
1940
2042
  type?: string;
1941
2043
  messageId?: string;
@@ -1949,27 +2051,39 @@ function mapUiMessageStreamToEvents(
1949
2051
  type: 'start',
1950
2052
  intent: chunk.messageMetadata?.intent,
1951
2053
  });
2054
+ eventCount += 1;
1952
2055
  break;
1953
2056
  case 'text-delta':
1954
2057
  if (chunk.delta !== undefined) {
1955
2058
  controller.enqueue({ type: 'text-delta', delta: chunk.delta });
2059
+ eventCount += 1;
1956
2060
  }
1957
2061
  break;
1958
2062
  case 'finish':
1959
2063
  controller.enqueue({
1960
2064
  type: 'finish',
1961
- intent: chunk.messageMetadata?.intent ?? 'other',
2065
+ intent: chunk.messageMetadata?.intent ?? 'default',
1962
2066
  sources: chunk.messageMetadata?.sources ?? [],
1963
2067
  });
2068
+ eventCount += 1;
1964
2069
  break;
1965
2070
  case 'error':
2071
+ logger?.warn(
2072
+ `[dropshipper] chat-stream-error errorText=${chunk.errorText}`
2073
+ );
1966
2074
  controller.enqueue({
1967
2075
  type: 'error',
1968
2076
  errorText: chunk.errorText ?? 'Error inesperado del asistente.',
1969
2077
  });
2078
+ eventCount += 1;
1970
2079
  break;
2080
+ default:
2081
+ logger?.debug(
2082
+ `[dropshipper] chat-stream-unknown-frame type=${chunk.type}`
2083
+ );
1971
2084
  }
1972
2085
  }
2086
+ logger?.debug(`[dropshipper] chat-stream-completed events=${eventCount}`);
1973
2087
  controller.close();
1974
2088
  },
1975
2089
  cancel() {