@ekodb/ekodb-client 0.25.0 → 0.26.0
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/README.md +5 -2
- package/dist/client.d.ts +47 -1
- package/dist/client.js +68 -42
- package/dist/client.test.js +177 -0
- package/dist/index.d.ts +1 -1
- package/dist/websocket.test.js +66 -0
- package/package.json +2 -2
- package/src/client.test.ts +219 -0
- package/src/client.ts +143 -9
- package/src/index.ts +2 -0
- package/src/websocket.test.ts +90 -0
- package/tsconfig.json +2 -2
package/README.md
CHANGED
|
@@ -308,8 +308,11 @@ const joinResults = await client.find("users", multiQuery);
|
|
|
308
308
|
|
|
309
309
|
#### Chat Models
|
|
310
310
|
|
|
311
|
-
- `getChatModels(): Promise<
|
|
312
|
-
|
|
311
|
+
- `getChatModels(): Promise<ChatModels>` - Get all available chat models by
|
|
312
|
+
provider (`openai`, `anthropic`, `perplexity`, `gemini`), plus a per-provider
|
|
313
|
+
`providers` status map (`ok`, `not_configured`, `auth_failed`,
|
|
314
|
+
`permission_denied`, `billing`, `rate_limited`, `unavailable`, `unreachable`,
|
|
315
|
+
`request_error`) so a rejected key is distinguishable from a missing one
|
|
313
316
|
- `getChatModel(provider: string): Promise<string[]>` - Get models for a
|
|
314
317
|
specific provider
|
|
315
318
|
|
package/dist/client.d.ts
CHANGED
|
@@ -326,12 +326,45 @@ export interface MergeSessionsRequest {
|
|
|
326
326
|
bypass_ripple?: boolean;
|
|
327
327
|
}
|
|
328
328
|
/**
|
|
329
|
-
*
|
|
329
|
+
* A provider's state on `GET /api/chat_models`. The union lists the states
|
|
330
|
+
* this client knows; the `string` escape keeps a newer server's status from
|
|
331
|
+
* failing to type-check.
|
|
332
|
+
*/
|
|
333
|
+
export type ChatProviderState = "ok" | "not_configured" | "auth_failed" | "permission_denied" | "billing" | "rate_limited" | "unavailable" | "unreachable" | "request_error" | (string & {});
|
|
334
|
+
/**
|
|
335
|
+
* One provider's row in `ChatModels.providers`.
|
|
336
|
+
*/
|
|
337
|
+
export interface ChatProviderStatus {
|
|
338
|
+
status: ChatProviderState;
|
|
339
|
+
/**
|
|
340
|
+
* True when the status is the provider's own answer about the configured
|
|
341
|
+
* key. A 5xx, a refused connection, or a missing key says nothing about it.
|
|
342
|
+
*/
|
|
343
|
+
verified: boolean;
|
|
344
|
+
/** The provider's own HTTP status, when it answered. */
|
|
345
|
+
http_status?: number;
|
|
346
|
+
/** The provider's own message, when it answered. */
|
|
347
|
+
message?: string;
|
|
348
|
+
/** How many models were listed, when the status is `ok`. */
|
|
349
|
+
model_count?: number;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Available chat models by provider, and why each list looks the way it does.
|
|
330
353
|
*/
|
|
331
354
|
export interface ChatModels {
|
|
332
355
|
openai: string[];
|
|
333
356
|
anthropic: string[];
|
|
334
357
|
perplexity: string[];
|
|
358
|
+
/** Google Gemini models. Absent from a server that predates the field. */
|
|
359
|
+
gemini?: string[];
|
|
360
|
+
/**
|
|
361
|
+
* Per-provider status keyed by provider name. A rejected key reports
|
|
362
|
+
* `auth_failed` where a missing one reports `not_configured`, so an empty
|
|
363
|
+
* list is never ambiguous. Absent from a server that predates the map.
|
|
364
|
+
*/
|
|
365
|
+
providers?: {
|
|
366
|
+
[provider: string]: ChatProviderStatus;
|
|
367
|
+
};
|
|
335
368
|
}
|
|
336
369
|
/**
|
|
337
370
|
* Request to compact a chat session's history on demand.
|
|
@@ -1389,6 +1422,19 @@ export type ChatStreamEvent = {
|
|
|
1389
1422
|
} | {
|
|
1390
1423
|
type: "error";
|
|
1391
1424
|
error: string;
|
|
1425
|
+
/**
|
|
1426
|
+
* The provider-failure classification (`provider_auth_failed`,
|
|
1427
|
+
* `provider_permission_denied`, `provider_billing`,
|
|
1428
|
+
* `provider_rate_limited`, `provider_unavailable`,
|
|
1429
|
+
* `provider_unreachable`, `provider_not_configured`,
|
|
1430
|
+
* `provider_request_error`), when the failure was the LLM provider's
|
|
1431
|
+
* answer. Absent for a transport failure or a plain server error.
|
|
1432
|
+
*/
|
|
1433
|
+
errorKind?: string;
|
|
1434
|
+
provider?: string;
|
|
1435
|
+
/** The provider's own HTTP status. */
|
|
1436
|
+
providerStatus?: number;
|
|
1437
|
+
retryAfterSecs?: number;
|
|
1392
1438
|
};
|
|
1393
1439
|
/** Definition for a client-side tool the LLM can call. */
|
|
1394
1440
|
export interface ClientToolDefinition {
|
package/dist/client.js
CHANGED
|
@@ -2,39 +2,6 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* ekoDB TypeScript Client
|
|
4
4
|
*/
|
|
5
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
-
if (k2 === undefined) k2 = k;
|
|
7
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
-
}
|
|
11
|
-
Object.defineProperty(o, k2, desc);
|
|
12
|
-
}) : (function(o, m, k, k2) {
|
|
13
|
-
if (k2 === undefined) k2 = k;
|
|
14
|
-
o[k2] = m[k];
|
|
15
|
-
}));
|
|
16
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
-
}) : function(o, v) {
|
|
19
|
-
o["default"] = v;
|
|
20
|
-
});
|
|
21
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
-
var ownKeys = function(o) {
|
|
23
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
-
var ar = [];
|
|
25
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
-
return ar;
|
|
27
|
-
};
|
|
28
|
-
return ownKeys(o);
|
|
29
|
-
};
|
|
30
|
-
return function (mod) {
|
|
31
|
-
if (mod && mod.__esModule) return mod;
|
|
32
|
-
var result = {};
|
|
33
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
-
__setModuleDefault(result, mod);
|
|
35
|
-
return result;
|
|
36
|
-
};
|
|
37
|
-
})();
|
|
38
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
6
|
exports.WebSocketClient = exports.SchemaCache = exports.EventStream = exports.EkoDBClient = exports.MergeStrategy = exports.HealthStatus = exports.HealthUnknown = exports.HealthDegraded = exports.HealthOK = exports.RateLimitError = exports.DEFAULT_REQUEST_TIMEOUT_MS = exports.SerializationFormat = void 0;
|
|
40
7
|
exports.parseHealthStatus = parseHealthStatus;
|
|
@@ -1454,7 +1421,22 @@ class EkoDBClient {
|
|
|
1454
1421
|
stream.close();
|
|
1455
1422
|
return;
|
|
1456
1423
|
}
|
|
1424
|
+
// The `event:` name applies to the data lines that follow it, until
|
|
1425
|
+
// the blank line that ends the frame. An error frame ends the stream:
|
|
1426
|
+
// nothing after it is surfaced and the body is not read to the end,
|
|
1427
|
+
// so a server or proxy that keeps the connection open after an error
|
|
1428
|
+
// cannot hang the caller (the Rust and Go clients stop the same way).
|
|
1429
|
+
let eventName = "";
|
|
1430
|
+
let stopped = false;
|
|
1457
1431
|
const emitLine = (line) => {
|
|
1432
|
+
if (line.startsWith("event:")) {
|
|
1433
|
+
eventName = line.slice(6).trim();
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
if (line.trim() === "") {
|
|
1437
|
+
eventName = "";
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1458
1440
|
if (!line.startsWith("data:"))
|
|
1459
1441
|
return;
|
|
1460
1442
|
const dataStr = line.slice(5).trim();
|
|
@@ -1462,10 +1444,16 @@ class EkoDBClient {
|
|
|
1462
1444
|
return;
|
|
1463
1445
|
try {
|
|
1464
1446
|
const eventData = JSON.parse(dataStr);
|
|
1465
|
-
|
|
1447
|
+
// An error frame is one the server names `error`, or whose
|
|
1448
|
+
// payload carries an `error`; a `message`-only payload is still
|
|
1449
|
+
// the error rather than a frame to skip, and the text is always a
|
|
1450
|
+
// string (`streamErrorText`).
|
|
1451
|
+
if (eventData.error != null || eventName === "error") {
|
|
1452
|
+
stopped = true;
|
|
1466
1453
|
stream.emit("event", {
|
|
1467
1454
|
type: "error",
|
|
1468
|
-
error: eventData
|
|
1455
|
+
error: streamErrorText(eventData),
|
|
1456
|
+
...providerFailureFields(eventData),
|
|
1469
1457
|
});
|
|
1470
1458
|
}
|
|
1471
1459
|
else if (eventData.content && eventData.message_id) {
|
|
@@ -1501,20 +1489,29 @@ class EkoDBClient {
|
|
|
1501
1489
|
break;
|
|
1502
1490
|
buffer += decoder.decode(value, { stream: true });
|
|
1503
1491
|
let nl;
|
|
1504
|
-
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
1492
|
+
while (!stopped && (nl = buffer.indexOf("\n")) >= 0) {
|
|
1505
1493
|
emitLine(buffer.slice(0, nl));
|
|
1506
1494
|
buffer = buffer.slice(nl + 1);
|
|
1507
1495
|
}
|
|
1496
|
+
if (stopped) {
|
|
1497
|
+
await reader.cancel?.()?.catch?.(() => { });
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
if (!stopped) {
|
|
1502
|
+
buffer += decoder.decode();
|
|
1503
|
+
if (buffer)
|
|
1504
|
+
emitLine(buffer);
|
|
1508
1505
|
}
|
|
1509
|
-
buffer += decoder.decode();
|
|
1510
|
-
if (buffer)
|
|
1511
|
-
emitLine(buffer);
|
|
1512
1506
|
}
|
|
1513
1507
|
else {
|
|
1514
1508
|
// Fallback for environments/tests without a readable body stream.
|
|
1515
1509
|
const body = await response.text();
|
|
1516
|
-
for (const line of body.split("\n"))
|
|
1510
|
+
for (const line of body.split("\n")) {
|
|
1517
1511
|
emitLine(line);
|
|
1512
|
+
if (stopped)
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1518
1515
|
}
|
|
1519
1516
|
stream.close();
|
|
1520
1517
|
}
|
|
@@ -2216,6 +2213,32 @@ class EkoDBClient {
|
|
|
2216
2213
|
}
|
|
2217
2214
|
}
|
|
2218
2215
|
exports.EkoDBClient = EkoDBClient;
|
|
2216
|
+
/**
|
|
2217
|
+
* The text of a stream error frame: the first of `error` / `message` that is
|
|
2218
|
+
* a non-empty string, else a fixed fallback — a structured `error` object is
|
|
2219
|
+
* still an error, never a non-string `error` on the event. Shared by the SSE
|
|
2220
|
+
* and WebSocket routes so the two cannot drift.
|
|
2221
|
+
*/
|
|
2222
|
+
function streamErrorText(payload) {
|
|
2223
|
+
const text = (value) => typeof value === "string" && value ? value : undefined;
|
|
2224
|
+
return text(payload.error) ?? text(payload.message) ?? "Unknown error";
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* The classification fields of a stream error frame, only those present, so
|
|
2228
|
+
* a plain error stays `{ type, error }`.
|
|
2229
|
+
*/
|
|
2230
|
+
function providerFailureFields(eventData) {
|
|
2231
|
+
const fields = {};
|
|
2232
|
+
if (typeof eventData.error_kind === "string")
|
|
2233
|
+
fields.errorKind = eventData.error_kind;
|
|
2234
|
+
if (typeof eventData.provider === "string")
|
|
2235
|
+
fields.provider = eventData.provider;
|
|
2236
|
+
if (typeof eventData.provider_status === "number")
|
|
2237
|
+
fields.providerStatus = eventData.provider_status;
|
|
2238
|
+
if (typeof eventData.retry_after_secs === "number")
|
|
2239
|
+
fields.retryAfterSecs = eventData.retry_after_secs;
|
|
2240
|
+
return fields;
|
|
2241
|
+
}
|
|
2219
2242
|
/** EventEmitter-like interface for subscriptions and chat streams. */
|
|
2220
2243
|
class EventStream {
|
|
2221
2244
|
constructor() {
|
|
@@ -2429,7 +2452,7 @@ class WebSocketClient {
|
|
|
2429
2452
|
return this.connectPromise;
|
|
2430
2453
|
}
|
|
2431
2454
|
async openSocket() {
|
|
2432
|
-
const WebSocket = (await
|
|
2455
|
+
const WebSocket = (await import("ws")).default;
|
|
2433
2456
|
let url = this.wsURL;
|
|
2434
2457
|
if (!url.endsWith("/api/ws")) {
|
|
2435
2458
|
url += "/api/ws";
|
|
@@ -2761,9 +2784,12 @@ class WebSocketClient {
|
|
|
2761
2784
|
const chatId = msg.payload?.chat_id || msg.payload?.chatId;
|
|
2762
2785
|
const stream = this.chatStreams.get(chatId);
|
|
2763
2786
|
if (stream) {
|
|
2787
|
+
// The text guard and the classification are the SSE route's,
|
|
2788
|
+
// so the two routes emit the same shape.
|
|
2764
2789
|
stream.emit("event", {
|
|
2765
2790
|
type: "error",
|
|
2766
|
-
error: msg.payload
|
|
2791
|
+
error: streamErrorText(msg.payload),
|
|
2792
|
+
...providerFailureFields(msg.payload),
|
|
2767
2793
|
});
|
|
2768
2794
|
this.chatStreams.delete(chatId);
|
|
2769
2795
|
stream.close();
|
package/dist/client.test.js
CHANGED
|
@@ -1045,6 +1045,47 @@ function mockErrorResponse(status, message) {
|
|
|
1045
1045
|
(0, vitest_1.expect)(result.anthropic).toHaveLength(2);
|
|
1046
1046
|
(0, vitest_1.expect)(result.perplexity).toHaveLength(1);
|
|
1047
1047
|
});
|
|
1048
|
+
(0, vitest_1.it)("carries gemini and the per-provider status from the server", async () => {
|
|
1049
|
+
const client = createTestClient();
|
|
1050
|
+
mockTokenResponse();
|
|
1051
|
+
mockJsonResponse({
|
|
1052
|
+
openai: [],
|
|
1053
|
+
anthropic: ["claude-sonnet-4-5"],
|
|
1054
|
+
perplexity: ["sonar"],
|
|
1055
|
+
gemini: ["gemini-2.5-flash"],
|
|
1056
|
+
providers: {
|
|
1057
|
+
anthropic: { status: "ok", verified: true, model_count: 1 },
|
|
1058
|
+
gemini: { status: "ok", verified: true, model_count: 1 },
|
|
1059
|
+
openai: {
|
|
1060
|
+
status: "auth_failed",
|
|
1061
|
+
verified: true,
|
|
1062
|
+
http_status: 401,
|
|
1063
|
+
message: "Failed to fetch OpenAI models: 401 Unauthorized",
|
|
1064
|
+
},
|
|
1065
|
+
perplexity: {
|
|
1066
|
+
status: "ok",
|
|
1067
|
+
verified: false,
|
|
1068
|
+
message: "static model list; key not verified",
|
|
1069
|
+
},
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
const result = await client.getChatModels();
|
|
1073
|
+
(0, vitest_1.expect)(result.gemini).toEqual(["gemini-2.5-flash"]);
|
|
1074
|
+
(0, vitest_1.expect)(result.providers?.openai.status).toBe("auth_failed");
|
|
1075
|
+
(0, vitest_1.expect)(result.providers?.openai.http_status).toBe(401);
|
|
1076
|
+
(0, vitest_1.expect)(result.providers?.openai.verified).toBe(true);
|
|
1077
|
+
(0, vitest_1.expect)(result.providers?.perplexity.verified).toBe(false);
|
|
1078
|
+
(0, vitest_1.expect)(result.providers?.anthropic.model_count).toBe(1);
|
|
1079
|
+
});
|
|
1080
|
+
(0, vitest_1.it)("tolerates a server that predates gemini and providers", async () => {
|
|
1081
|
+
const client = createTestClient();
|
|
1082
|
+
mockTokenResponse();
|
|
1083
|
+
mockJsonResponse({ openai: ["gpt-4o"], anthropic: [], perplexity: [] });
|
|
1084
|
+
const result = await client.getChatModels();
|
|
1085
|
+
(0, vitest_1.expect)(result.openai).toEqual(["gpt-4o"]);
|
|
1086
|
+
(0, vitest_1.expect)(result.gemini).toBeUndefined();
|
|
1087
|
+
(0, vitest_1.expect)(result.providers).toBeUndefined();
|
|
1088
|
+
});
|
|
1048
1089
|
(0, vitest_1.it)("gets models for specific provider", async () => {
|
|
1049
1090
|
const client = createTestClient();
|
|
1050
1091
|
mockTokenResponse();
|
|
@@ -1909,6 +1950,112 @@ function mockErrorResponse(status, message) {
|
|
|
1909
1950
|
(0, vitest_1.expect)(events[2].messageId).toBe("msg_1");
|
|
1910
1951
|
(0, vitest_1.expect)(events[2].executionTimeMs).toBe(42);
|
|
1911
1952
|
});
|
|
1953
|
+
(0, vitest_1.it)("treats a frame named error as an error even when its payload says message", async () => {
|
|
1954
|
+
const client = createTestClient();
|
|
1955
|
+
mockTokenResponse();
|
|
1956
|
+
const sseBody = 'event: token\ndata: {"token":"Hel"}\n\nevent: error\ndata: {"message":"boom"}\n\n';
|
|
1957
|
+
mockFetch.mockResolvedValueOnce({
|
|
1958
|
+
ok: true,
|
|
1959
|
+
status: 200,
|
|
1960
|
+
text: async () => sseBody,
|
|
1961
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
1962
|
+
});
|
|
1963
|
+
const events = [];
|
|
1964
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
1965
|
+
message: "Hello",
|
|
1966
|
+
});
|
|
1967
|
+
stream.on("event", (evt) => events.push(evt));
|
|
1968
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1969
|
+
(0, vitest_1.expect)(events).toEqual([
|
|
1970
|
+
{ type: "chunk", content: "Hel" },
|
|
1971
|
+
{ type: "error", error: "boom" },
|
|
1972
|
+
]);
|
|
1973
|
+
});
|
|
1974
|
+
(0, vitest_1.it)("stops reading after an error frame and emits nothing that follows it", async () => {
|
|
1975
|
+
const client = createTestClient();
|
|
1976
|
+
mockTokenResponse();
|
|
1977
|
+
const sseBody = 'event: error\ndata: {"message":"boom"}\n\nevent: token\ndata: {"token":"late"}\n\n';
|
|
1978
|
+
mockFetch.mockResolvedValueOnce({
|
|
1979
|
+
ok: true,
|
|
1980
|
+
status: 200,
|
|
1981
|
+
text: async () => sseBody,
|
|
1982
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
1983
|
+
});
|
|
1984
|
+
const events = [];
|
|
1985
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
1986
|
+
message: "Hello",
|
|
1987
|
+
});
|
|
1988
|
+
stream.on("event", (evt) => events.push(evt));
|
|
1989
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1990
|
+
(0, vitest_1.expect)(events).toEqual([{ type: "error", error: "boom" }]);
|
|
1991
|
+
});
|
|
1992
|
+
(0, vitest_1.it)("cancels the body reader after an error frame instead of waiting for the server to close", async () => {
|
|
1993
|
+
const client = createTestClient();
|
|
1994
|
+
mockTokenResponse();
|
|
1995
|
+
const encoder = new TextEncoder();
|
|
1996
|
+
const chunks = [
|
|
1997
|
+
encoder.encode('event: error\ndata: {"message":"boom"}\n\n'),
|
|
1998
|
+
encoder.encode('event: token\ndata: {"token":"late"}\n\n'),
|
|
1999
|
+
];
|
|
2000
|
+
const cancel = vitest_1.vi.fn(async () => { });
|
|
2001
|
+
let reads = 0;
|
|
2002
|
+
const reader = {
|
|
2003
|
+
read: vitest_1.vi.fn(async () => {
|
|
2004
|
+
// A server (or proxy) that does not close after the error frame: it
|
|
2005
|
+
// keeps sending frames. Bounded so a client that never stops fails
|
|
2006
|
+
// the assertions below instead of looping forever.
|
|
2007
|
+
if (reads >= 50)
|
|
2008
|
+
return { done: true, value: undefined };
|
|
2009
|
+
const value = chunks[Math.min(reads, chunks.length - 1)];
|
|
2010
|
+
reads += 1;
|
|
2011
|
+
return { done: false, value };
|
|
2012
|
+
}),
|
|
2013
|
+
cancel,
|
|
2014
|
+
};
|
|
2015
|
+
mockFetch.mockResolvedValueOnce({
|
|
2016
|
+
ok: true,
|
|
2017
|
+
status: 200,
|
|
2018
|
+
body: { getReader: () => reader },
|
|
2019
|
+
text: async () => "",
|
|
2020
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2021
|
+
});
|
|
2022
|
+
const events = [];
|
|
2023
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2024
|
+
message: "Hello",
|
|
2025
|
+
});
|
|
2026
|
+
stream.on("event", (evt) => events.push(evt));
|
|
2027
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2028
|
+
(0, vitest_1.expect)(events).toEqual([{ type: "error", error: "boom" }]);
|
|
2029
|
+
// One read delivered the error frame; the reader was cancelled rather
|
|
2030
|
+
// than read until the server closed.
|
|
2031
|
+
(0, vitest_1.expect)(reader.read).toHaveBeenCalledTimes(1);
|
|
2032
|
+
(0, vitest_1.expect)(cancel).toHaveBeenCalledTimes(1);
|
|
2033
|
+
});
|
|
2034
|
+
(0, vitest_1.it)("keeps the error text a string when the server sends a structured error", async () => {
|
|
2035
|
+
const client = createTestClient();
|
|
2036
|
+
mockTokenResponse();
|
|
2037
|
+
const sseBody = 'event: error\ndata: {"error":{"code":"upstream_down","status":503},"error_kind":"provider_unavailable","provider":"openai"}\n\n';
|
|
2038
|
+
mockFetch.mockResolvedValueOnce({
|
|
2039
|
+
ok: true,
|
|
2040
|
+
status: 200,
|
|
2041
|
+
text: async () => sseBody,
|
|
2042
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2043
|
+
});
|
|
2044
|
+
const events = [];
|
|
2045
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2046
|
+
message: "Hello",
|
|
2047
|
+
});
|
|
2048
|
+
stream.on("event", (evt) => events.push(evt));
|
|
2049
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2050
|
+
(0, vitest_1.expect)(events).toEqual([
|
|
2051
|
+
{
|
|
2052
|
+
type: "error",
|
|
2053
|
+
error: "Unknown error",
|
|
2054
|
+
errorKind: "provider_unavailable",
|
|
2055
|
+
provider: "openai",
|
|
2056
|
+
},
|
|
2057
|
+
]);
|
|
2058
|
+
});
|
|
1912
2059
|
(0, vitest_1.it)("emits error event on SSE error", async () => {
|
|
1913
2060
|
const client = createTestClient();
|
|
1914
2061
|
mockTokenResponse();
|
|
@@ -1928,6 +2075,36 @@ function mockErrorResponse(status, message) {
|
|
|
1928
2075
|
(0, vitest_1.expect)(events).toHaveLength(1);
|
|
1929
2076
|
(0, vitest_1.expect)(events[0]).toEqual({ type: "error", error: "LLM timeout" });
|
|
1930
2077
|
});
|
|
2078
|
+
(0, vitest_1.it)("carries the provider failure classification on an error event", async () => {
|
|
2079
|
+
// The deployment classifies a provider failure (`error_kind`, `provider`,
|
|
2080
|
+
// `provider_status`, `retry_after_secs` on the wire); the event carries
|
|
2081
|
+
// every one of them, in this shape's camelCase, so a consumer can act on
|
|
2082
|
+
// it without string-matching.
|
|
2083
|
+
const client = createTestClient();
|
|
2084
|
+
mockTokenResponse();
|
|
2085
|
+
const sseBody = 'data: {"error":"OpenAI API error: Incorrect API key provided","error_kind":"provider_auth_failed","provider":"openai","provider_status":401}\n';
|
|
2086
|
+
mockFetch.mockResolvedValueOnce({
|
|
2087
|
+
ok: true,
|
|
2088
|
+
status: 200,
|
|
2089
|
+
text: async () => sseBody,
|
|
2090
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2091
|
+
});
|
|
2092
|
+
const events = [];
|
|
2093
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2094
|
+
message: "Hello",
|
|
2095
|
+
});
|
|
2096
|
+
stream.on("event", (evt) => events.push(evt));
|
|
2097
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2098
|
+
(0, vitest_1.expect)(events).toEqual([
|
|
2099
|
+
{
|
|
2100
|
+
type: "error",
|
|
2101
|
+
error: "OpenAI API error: Incorrect API key provided",
|
|
2102
|
+
errorKind: "provider_auth_failed",
|
|
2103
|
+
provider: "openai",
|
|
2104
|
+
providerStatus: 401,
|
|
2105
|
+
},
|
|
2106
|
+
]);
|
|
2107
|
+
});
|
|
1931
2108
|
(0, vitest_1.it)("emits error event on non-200 HTTP response", async () => {
|
|
1932
2109
|
const client = createTestClient();
|
|
1933
2110
|
mockTokenResponse();
|
package/dist/index.d.ts
CHANGED
|
@@ -13,4 +13,4 @@ export type { Schema, FieldTypeSchema, IndexConfig, CollectionMetadata, } from "
|
|
|
13
13
|
export type { JoinConfig } from "./join";
|
|
14
14
|
export type { UserFunction, ParameterDefinition, FunctionStageConfig, GroupFunctionConfig, SortFieldConfig, FunctionResult, FunctionStats, StageStats, } from "./functions";
|
|
15
15
|
export type { MutationNotification, ChatStreamEvent, ClientToolDefinition, ChatSendOptions, SubscribeOptions, } from "./client";
|
|
16
|
-
export type { Record, Query, BatchOperationResult, ClientConfig, RateLimitInfo, CollectionConfig, ChatRequest, CreateChatSessionRequest, ChatMessageRequest, TokenUsage, ChatResponse, ChatSession, ChatSessionResponse, ListSessionsQuery, ListSessionsResponse, GetMessagesQuery, GetMessagesResponse, UpdateSessionRequest, MergeSessionsRequest, ChatModels, CompactChatRequest, CompactChatResponse, EmbedRequest, EmbedResponse, RawCompletionRequest, RawCompletionResponse, ToolChoice, ToolConfig, } from "./client";
|
|
16
|
+
export type { Record, Query, BatchOperationResult, ClientConfig, RateLimitInfo, CollectionConfig, ChatRequest, CreateChatSessionRequest, ChatMessageRequest, TokenUsage, ChatResponse, ChatSession, ChatSessionResponse, ListSessionsQuery, ListSessionsResponse, GetMessagesQuery, GetMessagesResponse, UpdateSessionRequest, MergeSessionsRequest, ChatModels, ChatProviderState, ChatProviderStatus, CompactChatRequest, CompactChatResponse, EmbedRequest, EmbedResponse, RawCompletionRequest, RawCompletionResponse, ToolChoice, ToolConfig, } from "./client";
|
package/dist/websocket.test.js
CHANGED
|
@@ -300,6 +300,72 @@ function waitForMessage(ws) {
|
|
|
300
300
|
(0, vitest_1.expect)(stream.closed).toBe(true);
|
|
301
301
|
client.close();
|
|
302
302
|
});
|
|
303
|
+
// The WebSocket route carries the provider-failure classification like
|
|
304
|
+
// the SSE route does, in the event's camelCase shape.
|
|
305
|
+
(0, vitest_1.it)("carries the provider failure classification on a chat stream error", async () => {
|
|
306
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
|
|
307
|
+
const streamPromise = client.chatSend("chat-4", "test");
|
|
308
|
+
await new Promise((r) => wss.once("connection", r));
|
|
309
|
+
const ws = getLastConnection();
|
|
310
|
+
await waitForMessage(ws);
|
|
311
|
+
const stream = await streamPromise;
|
|
312
|
+
const events = [];
|
|
313
|
+
stream.on("event", (e) => events.push(e));
|
|
314
|
+
ws.send(JSON.stringify({
|
|
315
|
+
type: "ChatStreamError",
|
|
316
|
+
payload: {
|
|
317
|
+
chat_id: "chat-4",
|
|
318
|
+
error: "OpenAI API error 429 Too Many Requests",
|
|
319
|
+
error_kind: "provider_rate_limited",
|
|
320
|
+
provider: "openai",
|
|
321
|
+
provider_status: 429,
|
|
322
|
+
retry_after_secs: 7,
|
|
323
|
+
},
|
|
324
|
+
}));
|
|
325
|
+
await new Promise((r) => stream.on("close", r));
|
|
326
|
+
(0, vitest_1.expect)(events).toEqual([
|
|
327
|
+
{
|
|
328
|
+
type: "error",
|
|
329
|
+
error: "OpenAI API error 429 Too Many Requests",
|
|
330
|
+
errorKind: "provider_rate_limited",
|
|
331
|
+
provider: "openai",
|
|
332
|
+
providerStatus: 429,
|
|
333
|
+
retryAfterSecs: 7,
|
|
334
|
+
},
|
|
335
|
+
]);
|
|
336
|
+
client.close();
|
|
337
|
+
});
|
|
338
|
+
// A structured `error` value is still an error, with string text — the
|
|
339
|
+
// WebSocket route guards the shape exactly as the SSE route does.
|
|
340
|
+
(0, vitest_1.it)("keeps the error text a string when the payload's error is an object", async () => {
|
|
341
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
|
|
342
|
+
const streamPromise = client.chatSend("chat-5", "test");
|
|
343
|
+
await new Promise((r) => wss.once("connection", r));
|
|
344
|
+
const ws = getLastConnection();
|
|
345
|
+
await waitForMessage(ws);
|
|
346
|
+
const stream = await streamPromise;
|
|
347
|
+
const events = [];
|
|
348
|
+
stream.on("event", (e) => events.push(e));
|
|
349
|
+
ws.send(JSON.stringify({
|
|
350
|
+
type: "ChatStreamError",
|
|
351
|
+
payload: {
|
|
352
|
+
chat_id: "chat-5",
|
|
353
|
+
error: { code: "upstream_down", status: 503 },
|
|
354
|
+
error_kind: "provider_unavailable",
|
|
355
|
+
provider: "gemini",
|
|
356
|
+
},
|
|
357
|
+
}));
|
|
358
|
+
await new Promise((r) => stream.on("close", r));
|
|
359
|
+
(0, vitest_1.expect)(events).toEqual([
|
|
360
|
+
{
|
|
361
|
+
type: "error",
|
|
362
|
+
error: "Unknown error",
|
|
363
|
+
errorKind: "provider_unavailable",
|
|
364
|
+
provider: "gemini",
|
|
365
|
+
},
|
|
366
|
+
]);
|
|
367
|
+
client.close();
|
|
368
|
+
});
|
|
303
369
|
(0, vitest_1.it)("sends options with ChatSend", async () => {
|
|
304
370
|
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
|
|
305
371
|
const streamPromise = client.chatSend("chat-3", "Hello", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ekodb/ekodb-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Official TypeScript/JavaScript client for ekoDB",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^24.13.2",
|
|
24
24
|
"@types/ws": "^8.18.1",
|
|
25
|
-
"typescript": "^
|
|
25
|
+
"typescript": "^6.0.3",
|
|
26
26
|
"vitest": "^4.0.18"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
package/src/client.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
14
14
|
parseHealthStatus,
|
|
15
15
|
} from "./client";
|
|
16
|
+
import type { ChatModels } from "./client";
|
|
16
17
|
import { SearchQueryBuilder } from "./search";
|
|
17
18
|
|
|
18
19
|
// Mock fetch globally
|
|
@@ -1436,6 +1437,55 @@ describe("EkoDBClient chat models", () => {
|
|
|
1436
1437
|
expect(result.perplexity).toHaveLength(1);
|
|
1437
1438
|
});
|
|
1438
1439
|
|
|
1440
|
+
it("carries gemini and the per-provider status from the server", async () => {
|
|
1441
|
+
const client = createTestClient();
|
|
1442
|
+
|
|
1443
|
+
mockTokenResponse();
|
|
1444
|
+
mockJsonResponse({
|
|
1445
|
+
openai: [],
|
|
1446
|
+
anthropic: ["claude-sonnet-4-5"],
|
|
1447
|
+
perplexity: ["sonar"],
|
|
1448
|
+
gemini: ["gemini-2.5-flash"],
|
|
1449
|
+
providers: {
|
|
1450
|
+
anthropic: { status: "ok", verified: true, model_count: 1 },
|
|
1451
|
+
gemini: { status: "ok", verified: true, model_count: 1 },
|
|
1452
|
+
openai: {
|
|
1453
|
+
status: "auth_failed",
|
|
1454
|
+
verified: true,
|
|
1455
|
+
http_status: 401,
|
|
1456
|
+
message: "Failed to fetch OpenAI models: 401 Unauthorized",
|
|
1457
|
+
},
|
|
1458
|
+
perplexity: {
|
|
1459
|
+
status: "ok",
|
|
1460
|
+
verified: false,
|
|
1461
|
+
message: "static model list; key not verified",
|
|
1462
|
+
},
|
|
1463
|
+
},
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
const result: ChatModels = await client.getChatModels();
|
|
1467
|
+
|
|
1468
|
+
expect(result.gemini).toEqual(["gemini-2.5-flash"]);
|
|
1469
|
+
expect(result.providers?.openai.status).toBe("auth_failed");
|
|
1470
|
+
expect(result.providers?.openai.http_status).toBe(401);
|
|
1471
|
+
expect(result.providers?.openai.verified).toBe(true);
|
|
1472
|
+
expect(result.providers?.perplexity.verified).toBe(false);
|
|
1473
|
+
expect(result.providers?.anthropic.model_count).toBe(1);
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it("tolerates a server that predates gemini and providers", async () => {
|
|
1477
|
+
const client = createTestClient();
|
|
1478
|
+
|
|
1479
|
+
mockTokenResponse();
|
|
1480
|
+
mockJsonResponse({ openai: ["gpt-4o"], anthropic: [], perplexity: [] });
|
|
1481
|
+
|
|
1482
|
+
const result: ChatModels = await client.getChatModels();
|
|
1483
|
+
|
|
1484
|
+
expect(result.openai).toEqual(["gpt-4o"]);
|
|
1485
|
+
expect(result.gemini).toBeUndefined();
|
|
1486
|
+
expect(result.providers).toBeUndefined();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1439
1489
|
it("gets models for specific provider", async () => {
|
|
1440
1490
|
const client = createTestClient();
|
|
1441
1491
|
|
|
@@ -2572,6 +2622,138 @@ describe("EkoDBClient chatMessageStream", () => {
|
|
|
2572
2622
|
expect(events[2].executionTimeMs).toBe(42);
|
|
2573
2623
|
});
|
|
2574
2624
|
|
|
2625
|
+
it("treats a frame named error as an error even when its payload says message", async () => {
|
|
2626
|
+
const client = createTestClient();
|
|
2627
|
+
mockTokenResponse();
|
|
2628
|
+
|
|
2629
|
+
const sseBody =
|
|
2630
|
+
'event: token\ndata: {"token":"Hel"}\n\nevent: error\ndata: {"message":"boom"}\n\n';
|
|
2631
|
+
|
|
2632
|
+
mockFetch.mockResolvedValueOnce({
|
|
2633
|
+
ok: true,
|
|
2634
|
+
status: 200,
|
|
2635
|
+
text: async () => sseBody,
|
|
2636
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
const events: any[] = [];
|
|
2640
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2641
|
+
message: "Hello",
|
|
2642
|
+
});
|
|
2643
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2644
|
+
|
|
2645
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2646
|
+
|
|
2647
|
+
expect(events).toEqual([
|
|
2648
|
+
{ type: "chunk", content: "Hel" },
|
|
2649
|
+
{ type: "error", error: "boom" },
|
|
2650
|
+
]);
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
it("stops reading after an error frame and emits nothing that follows it", async () => {
|
|
2654
|
+
const client = createTestClient();
|
|
2655
|
+
mockTokenResponse();
|
|
2656
|
+
|
|
2657
|
+
const sseBody =
|
|
2658
|
+
'event: error\ndata: {"message":"boom"}\n\nevent: token\ndata: {"token":"late"}\n\n';
|
|
2659
|
+
|
|
2660
|
+
mockFetch.mockResolvedValueOnce({
|
|
2661
|
+
ok: true,
|
|
2662
|
+
status: 200,
|
|
2663
|
+
text: async () => sseBody,
|
|
2664
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
const events: any[] = [];
|
|
2668
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2669
|
+
message: "Hello",
|
|
2670
|
+
});
|
|
2671
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2672
|
+
|
|
2673
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2674
|
+
|
|
2675
|
+
expect(events).toEqual([{ type: "error", error: "boom" }]);
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
it("cancels the body reader after an error frame instead of waiting for the server to close", async () => {
|
|
2679
|
+
const client = createTestClient();
|
|
2680
|
+
mockTokenResponse();
|
|
2681
|
+
|
|
2682
|
+
const encoder = new TextEncoder();
|
|
2683
|
+
const chunks = [
|
|
2684
|
+
encoder.encode('event: error\ndata: {"message":"boom"}\n\n'),
|
|
2685
|
+
encoder.encode('event: token\ndata: {"token":"late"}\n\n'),
|
|
2686
|
+
];
|
|
2687
|
+
const cancel = vi.fn(async () => {});
|
|
2688
|
+
let reads = 0;
|
|
2689
|
+
const reader = {
|
|
2690
|
+
read: vi.fn(async () => {
|
|
2691
|
+
// A server (or proxy) that does not close after the error frame: it
|
|
2692
|
+
// keeps sending frames. Bounded so a client that never stops fails
|
|
2693
|
+
// the assertions below instead of looping forever.
|
|
2694
|
+
if (reads >= 50) return { done: true, value: undefined };
|
|
2695
|
+
const value = chunks[Math.min(reads, chunks.length - 1)];
|
|
2696
|
+
reads += 1;
|
|
2697
|
+
return { done: false, value };
|
|
2698
|
+
}),
|
|
2699
|
+
cancel,
|
|
2700
|
+
};
|
|
2701
|
+
|
|
2702
|
+
mockFetch.mockResolvedValueOnce({
|
|
2703
|
+
ok: true,
|
|
2704
|
+
status: 200,
|
|
2705
|
+
body: { getReader: () => reader },
|
|
2706
|
+
text: async () => "",
|
|
2707
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
const events: any[] = [];
|
|
2711
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2712
|
+
message: "Hello",
|
|
2713
|
+
});
|
|
2714
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2715
|
+
|
|
2716
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2717
|
+
|
|
2718
|
+
expect(events).toEqual([{ type: "error", error: "boom" }]);
|
|
2719
|
+
// One read delivered the error frame; the reader was cancelled rather
|
|
2720
|
+
// than read until the server closed.
|
|
2721
|
+
expect(reader.read).toHaveBeenCalledTimes(1);
|
|
2722
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
2723
|
+
});
|
|
2724
|
+
|
|
2725
|
+
it("keeps the error text a string when the server sends a structured error", async () => {
|
|
2726
|
+
const client = createTestClient();
|
|
2727
|
+
mockTokenResponse();
|
|
2728
|
+
|
|
2729
|
+
const sseBody =
|
|
2730
|
+
'event: error\ndata: {"error":{"code":"upstream_down","status":503},"error_kind":"provider_unavailable","provider":"openai"}\n\n';
|
|
2731
|
+
|
|
2732
|
+
mockFetch.mockResolvedValueOnce({
|
|
2733
|
+
ok: true,
|
|
2734
|
+
status: 200,
|
|
2735
|
+
text: async () => sseBody,
|
|
2736
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2737
|
+
});
|
|
2738
|
+
|
|
2739
|
+
const events: any[] = [];
|
|
2740
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2741
|
+
message: "Hello",
|
|
2742
|
+
});
|
|
2743
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2744
|
+
|
|
2745
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2746
|
+
|
|
2747
|
+
expect(events).toEqual([
|
|
2748
|
+
{
|
|
2749
|
+
type: "error",
|
|
2750
|
+
error: "Unknown error",
|
|
2751
|
+
errorKind: "provider_unavailable",
|
|
2752
|
+
provider: "openai",
|
|
2753
|
+
},
|
|
2754
|
+
]);
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2575
2757
|
it("emits error event on SSE error", async () => {
|
|
2576
2758
|
const client = createTestClient();
|
|
2577
2759
|
mockTokenResponse();
|
|
@@ -2597,6 +2779,43 @@ describe("EkoDBClient chatMessageStream", () => {
|
|
|
2597
2779
|
expect(events[0]).toEqual({ type: "error", error: "LLM timeout" });
|
|
2598
2780
|
});
|
|
2599
2781
|
|
|
2782
|
+
it("carries the provider failure classification on an error event", async () => {
|
|
2783
|
+
// The deployment classifies a provider failure (`error_kind`, `provider`,
|
|
2784
|
+
// `provider_status`, `retry_after_secs` on the wire); the event carries
|
|
2785
|
+
// every one of them, in this shape's camelCase, so a consumer can act on
|
|
2786
|
+
// it without string-matching.
|
|
2787
|
+
const client = createTestClient();
|
|
2788
|
+
mockTokenResponse();
|
|
2789
|
+
|
|
2790
|
+
const sseBody =
|
|
2791
|
+
'data: {"error":"OpenAI API error: Incorrect API key provided","error_kind":"provider_auth_failed","provider":"openai","provider_status":401}\n';
|
|
2792
|
+
|
|
2793
|
+
mockFetch.mockResolvedValueOnce({
|
|
2794
|
+
ok: true,
|
|
2795
|
+
status: 200,
|
|
2796
|
+
text: async () => sseBody,
|
|
2797
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
const events: any[] = [];
|
|
2801
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2802
|
+
message: "Hello",
|
|
2803
|
+
});
|
|
2804
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2805
|
+
|
|
2806
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2807
|
+
|
|
2808
|
+
expect(events).toEqual([
|
|
2809
|
+
{
|
|
2810
|
+
type: "error",
|
|
2811
|
+
error: "OpenAI API error: Incorrect API key provided",
|
|
2812
|
+
errorKind: "provider_auth_failed",
|
|
2813
|
+
provider: "openai",
|
|
2814
|
+
providerStatus: 401,
|
|
2815
|
+
},
|
|
2816
|
+
]);
|
|
2817
|
+
});
|
|
2818
|
+
|
|
2600
2819
|
it("emits error event on non-200 HTTP response", async () => {
|
|
2601
2820
|
const client = createTestClient();
|
|
2602
2821
|
mockTokenResponse();
|
package/src/client.ts
CHANGED
|
@@ -416,12 +416,55 @@ export interface MergeSessionsRequest {
|
|
|
416
416
|
}
|
|
417
417
|
|
|
418
418
|
/**
|
|
419
|
-
*
|
|
419
|
+
* A provider's state on `GET /api/chat_models`. The union lists the states
|
|
420
|
+
* this client knows; the `string` escape keeps a newer server's status from
|
|
421
|
+
* failing to type-check.
|
|
422
|
+
*/
|
|
423
|
+
export type ChatProviderState =
|
|
424
|
+
| "ok"
|
|
425
|
+
| "not_configured"
|
|
426
|
+
| "auth_failed"
|
|
427
|
+
| "permission_denied"
|
|
428
|
+
| "billing"
|
|
429
|
+
| "rate_limited"
|
|
430
|
+
| "unavailable"
|
|
431
|
+
| "unreachable"
|
|
432
|
+
| "request_error"
|
|
433
|
+
| (string & {});
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* One provider's row in `ChatModels.providers`.
|
|
437
|
+
*/
|
|
438
|
+
export interface ChatProviderStatus {
|
|
439
|
+
status: ChatProviderState;
|
|
440
|
+
/**
|
|
441
|
+
* True when the status is the provider's own answer about the configured
|
|
442
|
+
* key. A 5xx, a refused connection, or a missing key says nothing about it.
|
|
443
|
+
*/
|
|
444
|
+
verified: boolean;
|
|
445
|
+
/** The provider's own HTTP status, when it answered. */
|
|
446
|
+
http_status?: number;
|
|
447
|
+
/** The provider's own message, when it answered. */
|
|
448
|
+
message?: string;
|
|
449
|
+
/** How many models were listed, when the status is `ok`. */
|
|
450
|
+
model_count?: number;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Available chat models by provider, and why each list looks the way it does.
|
|
420
455
|
*/
|
|
421
456
|
export interface ChatModels {
|
|
422
457
|
openai: string[];
|
|
423
458
|
anthropic: string[];
|
|
424
459
|
perplexity: string[];
|
|
460
|
+
/** Google Gemini models. Absent from a server that predates the field. */
|
|
461
|
+
gemini?: string[];
|
|
462
|
+
/**
|
|
463
|
+
* Per-provider status keyed by provider name. A rejected key reports
|
|
464
|
+
* `auth_failed` where a missing one reports `not_configured`, so an empty
|
|
465
|
+
* list is never ambiguous. Absent from a server that predates the map.
|
|
466
|
+
*/
|
|
467
|
+
providers?: { [provider: string]: ChatProviderStatus };
|
|
425
468
|
}
|
|
426
469
|
|
|
427
470
|
/**
|
|
@@ -2263,16 +2306,37 @@ export class EkoDBClient {
|
|
|
2263
2306
|
return;
|
|
2264
2307
|
}
|
|
2265
2308
|
|
|
2309
|
+
// The `event:` name applies to the data lines that follow it, until
|
|
2310
|
+
// the blank line that ends the frame. An error frame ends the stream:
|
|
2311
|
+
// nothing after it is surfaced and the body is not read to the end,
|
|
2312
|
+
// so a server or proxy that keeps the connection open after an error
|
|
2313
|
+
// cannot hang the caller (the Rust and Go clients stop the same way).
|
|
2314
|
+
let eventName = "";
|
|
2315
|
+
let stopped = false;
|
|
2266
2316
|
const emitLine = (line: string) => {
|
|
2317
|
+
if (line.startsWith("event:")) {
|
|
2318
|
+
eventName = line.slice(6).trim();
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
if (line.trim() === "") {
|
|
2322
|
+
eventName = "";
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2267
2325
|
if (!line.startsWith("data:")) return;
|
|
2268
2326
|
const dataStr = line.slice(5).trim();
|
|
2269
2327
|
if (!dataStr) return;
|
|
2270
2328
|
try {
|
|
2271
2329
|
const eventData = JSON.parse(dataStr);
|
|
2272
|
-
|
|
2330
|
+
// An error frame is one the server names `error`, or whose
|
|
2331
|
+
// payload carries an `error`; a `message`-only payload is still
|
|
2332
|
+
// the error rather than a frame to skip, and the text is always a
|
|
2333
|
+
// string (`streamErrorText`).
|
|
2334
|
+
if (eventData.error != null || eventName === "error") {
|
|
2335
|
+
stopped = true;
|
|
2273
2336
|
stream.emit("event", {
|
|
2274
2337
|
type: "error",
|
|
2275
|
-
error: eventData
|
|
2338
|
+
error: streamErrorText(eventData),
|
|
2339
|
+
...providerFailureFields(eventData),
|
|
2276
2340
|
} as ChatStreamEvent);
|
|
2277
2341
|
} else if (eventData.content && eventData.message_id) {
|
|
2278
2342
|
// Done event — has full content + message_id
|
|
@@ -2305,17 +2369,26 @@ export class EkoDBClient {
|
|
|
2305
2369
|
if (done) break;
|
|
2306
2370
|
buffer += decoder.decode(value, { stream: true });
|
|
2307
2371
|
let nl: number;
|
|
2308
|
-
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
2372
|
+
while (!stopped && (nl = buffer.indexOf("\n")) >= 0) {
|
|
2309
2373
|
emitLine(buffer.slice(0, nl));
|
|
2310
2374
|
buffer = buffer.slice(nl + 1);
|
|
2311
2375
|
}
|
|
2376
|
+
if (stopped) {
|
|
2377
|
+
await reader.cancel?.()?.catch?.(() => {});
|
|
2378
|
+
break;
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
if (!stopped) {
|
|
2382
|
+
buffer += decoder.decode();
|
|
2383
|
+
if (buffer) emitLine(buffer);
|
|
2312
2384
|
}
|
|
2313
|
-
buffer += decoder.decode();
|
|
2314
|
-
if (buffer) emitLine(buffer);
|
|
2315
2385
|
} else {
|
|
2316
2386
|
// Fallback for environments/tests without a readable body stream.
|
|
2317
2387
|
const body = await response.text();
|
|
2318
|
-
for (const line of body.split("\n"))
|
|
2388
|
+
for (const line of body.split("\n")) {
|
|
2389
|
+
emitLine(line);
|
|
2390
|
+
if (stopped) break;
|
|
2391
|
+
}
|
|
2319
2392
|
}
|
|
2320
2393
|
stream.close();
|
|
2321
2394
|
} catch (err: any) {
|
|
@@ -3601,7 +3674,65 @@ export type ChatStreamEvent =
|
|
|
3601
3674
|
toolName: string;
|
|
3602
3675
|
arguments: any;
|
|
3603
3676
|
}
|
|
3604
|
-
| {
|
|
3677
|
+
| {
|
|
3678
|
+
type: "error";
|
|
3679
|
+
error: string;
|
|
3680
|
+
/**
|
|
3681
|
+
* The provider-failure classification (`provider_auth_failed`,
|
|
3682
|
+
* `provider_permission_denied`, `provider_billing`,
|
|
3683
|
+
* `provider_rate_limited`, `provider_unavailable`,
|
|
3684
|
+
* `provider_unreachable`, `provider_not_configured`,
|
|
3685
|
+
* `provider_request_error`), when the failure was the LLM provider's
|
|
3686
|
+
* answer. Absent for a transport failure or a plain server error.
|
|
3687
|
+
*/
|
|
3688
|
+
errorKind?: string;
|
|
3689
|
+
provider?: string;
|
|
3690
|
+
/** The provider's own HTTP status. */
|
|
3691
|
+
providerStatus?: number;
|
|
3692
|
+
retryAfterSecs?: number;
|
|
3693
|
+
};
|
|
3694
|
+
|
|
3695
|
+
/**
|
|
3696
|
+
* The text of a stream error frame: the first of `error` / `message` that is
|
|
3697
|
+
* a non-empty string, else a fixed fallback — a structured `error` object is
|
|
3698
|
+
* still an error, never a non-string `error` on the event. Shared by the SSE
|
|
3699
|
+
* and WebSocket routes so the two cannot drift.
|
|
3700
|
+
*/
|
|
3701
|
+
function streamErrorText(payload: {
|
|
3702
|
+
error?: unknown;
|
|
3703
|
+
message?: unknown;
|
|
3704
|
+
}): string {
|
|
3705
|
+
const text = (value: unknown): string | undefined =>
|
|
3706
|
+
typeof value === "string" && value ? value : undefined;
|
|
3707
|
+
return text(payload.error) ?? text(payload.message) ?? "Unknown error";
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
/**
|
|
3711
|
+
* The classification fields of a stream error frame, only those present, so
|
|
3712
|
+
* a plain error stays `{ type, error }`.
|
|
3713
|
+
*/
|
|
3714
|
+
function providerFailureFields(eventData: {
|
|
3715
|
+
error_kind?: unknown;
|
|
3716
|
+
provider?: unknown;
|
|
3717
|
+
provider_status?: unknown;
|
|
3718
|
+
retry_after_secs?: unknown;
|
|
3719
|
+
}): {
|
|
3720
|
+
errorKind?: string;
|
|
3721
|
+
provider?: string;
|
|
3722
|
+
providerStatus?: number;
|
|
3723
|
+
retryAfterSecs?: number;
|
|
3724
|
+
} {
|
|
3725
|
+
const fields: ReturnType<typeof providerFailureFields> = {};
|
|
3726
|
+
if (typeof eventData.error_kind === "string")
|
|
3727
|
+
fields.errorKind = eventData.error_kind;
|
|
3728
|
+
if (typeof eventData.provider === "string")
|
|
3729
|
+
fields.provider = eventData.provider;
|
|
3730
|
+
if (typeof eventData.provider_status === "number")
|
|
3731
|
+
fields.providerStatus = eventData.provider_status;
|
|
3732
|
+
if (typeof eventData.retry_after_secs === "number")
|
|
3733
|
+
fields.retryAfterSecs = eventData.retry_after_secs;
|
|
3734
|
+
return fields;
|
|
3735
|
+
}
|
|
3605
3736
|
|
|
3606
3737
|
/** Definition for a client-side tool the LLM can call. */
|
|
3607
3738
|
export interface ClientToolDefinition {
|
|
@@ -4285,9 +4416,12 @@ export class WebSocketClient {
|
|
|
4285
4416
|
const chatId = msg.payload?.chat_id || msg.payload?.chatId;
|
|
4286
4417
|
const stream = this.chatStreams.get(chatId);
|
|
4287
4418
|
if (stream) {
|
|
4419
|
+
// The text guard and the classification are the SSE route's,
|
|
4420
|
+
// so the two routes emit the same shape.
|
|
4288
4421
|
stream.emit("event", {
|
|
4289
4422
|
type: "error",
|
|
4290
|
-
error: msg.payload
|
|
4423
|
+
error: streamErrorText(msg.payload),
|
|
4424
|
+
...providerFailureFields(msg.payload),
|
|
4291
4425
|
} as ChatStreamEvent);
|
|
4292
4426
|
this.chatStreams.delete(chatId);
|
|
4293
4427
|
stream.close();
|
package/src/index.ts
CHANGED
package/src/websocket.test.ts
CHANGED
|
@@ -432,6 +432,96 @@ describe("WebSocketClient", () => {
|
|
|
432
432
|
client.close();
|
|
433
433
|
});
|
|
434
434
|
|
|
435
|
+
// The WebSocket route carries the provider-failure classification like
|
|
436
|
+
// the SSE route does, in the event's camelCase shape.
|
|
437
|
+
it("carries the provider failure classification on a chat stream error", async () => {
|
|
438
|
+
const client = new WebSocketClient(
|
|
439
|
+
`ws://localhost:${port}/api/ws`,
|
|
440
|
+
"test-token",
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const streamPromise = client.chatSend("chat-4", "test");
|
|
444
|
+
|
|
445
|
+
await new Promise((r) => wss.once("connection", r));
|
|
446
|
+
const ws = getLastConnection();
|
|
447
|
+
await waitForMessage(ws);
|
|
448
|
+
|
|
449
|
+
const stream = await streamPromise;
|
|
450
|
+
const events: any[] = [];
|
|
451
|
+
stream.on("event", (e) => events.push(e));
|
|
452
|
+
|
|
453
|
+
ws.send(
|
|
454
|
+
JSON.stringify({
|
|
455
|
+
type: "ChatStreamError",
|
|
456
|
+
payload: {
|
|
457
|
+
chat_id: "chat-4",
|
|
458
|
+
error: "OpenAI API error 429 Too Many Requests",
|
|
459
|
+
error_kind: "provider_rate_limited",
|
|
460
|
+
provider: "openai",
|
|
461
|
+
provider_status: 429,
|
|
462
|
+
retry_after_secs: 7,
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
await new Promise((r) => stream.on("close", r));
|
|
468
|
+
expect(events).toEqual([
|
|
469
|
+
{
|
|
470
|
+
type: "error",
|
|
471
|
+
error: "OpenAI API error 429 Too Many Requests",
|
|
472
|
+
errorKind: "provider_rate_limited",
|
|
473
|
+
provider: "openai",
|
|
474
|
+
providerStatus: 429,
|
|
475
|
+
retryAfterSecs: 7,
|
|
476
|
+
},
|
|
477
|
+
]);
|
|
478
|
+
|
|
479
|
+
client.close();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// A structured `error` value is still an error, with string text — the
|
|
483
|
+
// WebSocket route guards the shape exactly as the SSE route does.
|
|
484
|
+
it("keeps the error text a string when the payload's error is an object", async () => {
|
|
485
|
+
const client = new WebSocketClient(
|
|
486
|
+
`ws://localhost:${port}/api/ws`,
|
|
487
|
+
"test-token",
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const streamPromise = client.chatSend("chat-5", "test");
|
|
491
|
+
|
|
492
|
+
await new Promise((r) => wss.once("connection", r));
|
|
493
|
+
const ws = getLastConnection();
|
|
494
|
+
await waitForMessage(ws);
|
|
495
|
+
|
|
496
|
+
const stream = await streamPromise;
|
|
497
|
+
const events: any[] = [];
|
|
498
|
+
stream.on("event", (e) => events.push(e));
|
|
499
|
+
|
|
500
|
+
ws.send(
|
|
501
|
+
JSON.stringify({
|
|
502
|
+
type: "ChatStreamError",
|
|
503
|
+
payload: {
|
|
504
|
+
chat_id: "chat-5",
|
|
505
|
+
error: { code: "upstream_down", status: 503 },
|
|
506
|
+
error_kind: "provider_unavailable",
|
|
507
|
+
provider: "gemini",
|
|
508
|
+
},
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
await new Promise((r) => stream.on("close", r));
|
|
513
|
+
expect(events).toEqual([
|
|
514
|
+
{
|
|
515
|
+
type: "error",
|
|
516
|
+
error: "Unknown error",
|
|
517
|
+
errorKind: "provider_unavailable",
|
|
518
|
+
provider: "gemini",
|
|
519
|
+
},
|
|
520
|
+
]);
|
|
521
|
+
|
|
522
|
+
client.close();
|
|
523
|
+
});
|
|
524
|
+
|
|
435
525
|
it("sends options with ChatSend", async () => {
|
|
436
526
|
const client = new WebSocketClient(
|
|
437
527
|
`ws://localhost:${port}/api/ws`,
|
package/tsconfig.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"target": "ES2020",
|
|
4
|
-
"module": "
|
|
4
|
+
"module": "nodenext",
|
|
5
5
|
"lib": ["ES2020"],
|
|
6
6
|
"declaration": true,
|
|
7
7
|
"outDir": "./dist",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"esModuleInterop": true,
|
|
11
11
|
"skipLibCheck": true,
|
|
12
12
|
"forceConsistentCasingInFileNames": true,
|
|
13
|
-
"moduleResolution": "
|
|
13
|
+
"moduleResolution": "nodenext",
|
|
14
14
|
"resolveJsonModule": true,
|
|
15
15
|
"noUnusedLocals": true,
|
|
16
16
|
"noUnusedParameters": true
|