@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 CHANGED
@@ -308,8 +308,11 @@ const joinResults = await client.find("users", multiQuery);
308
308
 
309
309
  #### Chat Models
310
310
 
311
- - `getChatModels(): Promise<Record<string, string[]>>` - Get all available chat
312
- models by provider
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
- * Available chat models by provider
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
- if (eventData.error) {
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.error,
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 Promise.resolve().then(() => __importStar(require("ws")))).default;
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.error || msg.payload.message || "Unknown error",
2791
+ error: streamErrorText(msg.payload),
2792
+ ...providerFailureFields(msg.payload),
2767
2793
  });
2768
2794
  this.chatStreams.delete(chatId);
2769
2795
  stream.close();
@@ -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";
@@ -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.25.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": "^5.9.3",
25
+ "typescript": "^6.0.3",
26
26
  "vitest": "^4.0.18"
27
27
  },
28
28
  "dependencies": {
@@ -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
- * Available chat models by provider
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
- if (eventData.error) {
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.error,
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")) emitLine(line);
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
- | { type: "error"; error: string };
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.error || msg.payload.message || "Unknown error",
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
@@ -88,6 +88,8 @@ export type {
88
88
  UpdateSessionRequest,
89
89
  MergeSessionsRequest,
90
90
  ChatModels,
91
+ ChatProviderState,
92
+ ChatProviderStatus,
91
93
  CompactChatRequest,
92
94
  CompactChatResponse,
93
95
  EmbedRequest,
@@ -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": "commonjs",
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": "node",
13
+ "moduleResolution": "nodenext",
14
14
  "resolveJsonModule": true,
15
15
  "noUnusedLocals": true,
16
16
  "noUnusedParameters": true