@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.
- package/dist/index.d.mts +22 -5
- package/dist/index.d.ts +22 -5
- package/dist/index.js +106 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +106 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/dropshipper.test.ts +4 -4
- package/src/dropshipper.ts +128 -14
package/src/dropshipper.test.ts
CHANGED
|
@@ -1481,13 +1481,13 @@ describe('DropshipperClient.chatAssistantStream', () => {
|
|
|
1481
1481
|
])
|
|
1482
1482
|
})
|
|
1483
1483
|
|
|
1484
|
-
it('synthesizes a one-shot stream from a plain JSON
|
|
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: '
|
|
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: '
|
|
1510
|
+
{ type: 'start', intent: 'default' },
|
|
1511
1511
|
{ type: 'text-delta', delta: '¡Hola! ¿En qué puedo ayudarte?' },
|
|
1512
|
-
{ type: 'finish', intent: '
|
|
1512
|
+
{ type: 'finish', intent: 'default', sources: [] },
|
|
1513
1513
|
])
|
|
1514
1514
|
})
|
|
1515
1515
|
})
|
package/src/dropshipper.ts
CHANGED
|
@@ -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
|
|
192
|
-
*
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? '
|
|
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() {
|