@ekodb/ekodb-client 0.24.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.
@@ -11,7 +11,9 @@ import {
11
11
  SerializationFormat,
12
12
  extractRecordId,
13
13
  DEFAULT_REQUEST_TIMEOUT_MS,
14
+ parseHealthStatus,
14
15
  } from "./client";
16
+ import type { ChatModels } from "./client";
15
17
  import { SearchQueryBuilder } from "./search";
16
18
 
17
19
  // Mock fetch globally
@@ -1435,6 +1437,55 @@ describe("EkoDBClient chat models", () => {
1435
1437
  expect(result.perplexity).toHaveLength(1);
1436
1438
  });
1437
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
+
1438
1489
  it("gets models for specific provider", async () => {
1439
1490
  const client = createTestClient();
1440
1491
 
@@ -2571,6 +2622,138 @@ describe("EkoDBClient chatMessageStream", () => {
2571
2622
  expect(events[2].executionTimeMs).toBe(42);
2572
2623
  });
2573
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
+
2574
2757
  it("emits error event on SSE error", async () => {
2575
2758
  const client = createTestClient();
2576
2759
  mockTokenResponse();
@@ -2596,6 +2779,43 @@ describe("EkoDBClient chatMessageStream", () => {
2596
2779
  expect(events[0]).toEqual({ type: "error", error: "LLM timeout" });
2597
2780
  });
2598
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
+
2599
2819
  it("emits error event on non-200 HTTP response", async () => {
2600
2820
  const client = createTestClient();
2601
2821
  mockTokenResponse();
@@ -3518,3 +3738,104 @@ describe("request timeout", () => {
3518
3738
  await expect(client.find("users")).rejects.toThrow(/timed out after 20ms/);
3519
3739
  });
3520
3740
  });
3741
+
3742
+ // ============================================================================
3743
+ // Health Contract Tests
3744
+ // ============================================================================
3745
+
3746
+ describe("health / healthStatus", () => {
3747
+ it("health() returns true for a reachable degraded server (bug fix)", async () => {
3748
+ const client = createTestClient();
3749
+ mockTokenResponse();
3750
+ mockJsonResponse({ status: "degraded", integrity_ok: false });
3751
+ expect(await client.health()).toBe(true);
3752
+ });
3753
+
3754
+ it("health() returns false when unreachable", async () => {
3755
+ const client = createTestClient();
3756
+ mockTokenResponse();
3757
+ mockErrorResponse(503, "unavailable");
3758
+ expect(await client.health()).toBe(false);
3759
+ });
3760
+
3761
+ it("healthStatus() surfaces degraded without erroring", async () => {
3762
+ const client = createTestClient();
3763
+ mockTokenResponse();
3764
+ mockJsonResponse({ status: "degraded", integrity_ok: false });
3765
+ const hs = await client.healthStatus();
3766
+ expect(hs.reachable).toBe(true);
3767
+ expect(hs.status).toBe("degraded");
3768
+ expect(hs.integrityOk).toBe(false);
3769
+ });
3770
+
3771
+ it("healthStatus() unreachable -> reachable=false, status=unknown", async () => {
3772
+ const client = createTestClient();
3773
+ mockTokenResponse();
3774
+ mockErrorResponse(503, "unavailable");
3775
+ const hs = await client.healthStatus();
3776
+ expect(hs.reachable).toBe(false);
3777
+ expect(hs.status).toBe("unknown");
3778
+ });
3779
+
3780
+ it("healthStatus() reads integrity from the admin nested shape", async () => {
3781
+ const client = createTestClient();
3782
+ mockTokenResponse();
3783
+ mockJsonResponse({
3784
+ status: "ok",
3785
+ integrity: { healthy: true, manifest_load_failed: [] },
3786
+ });
3787
+ const hs = await client.healthStatus();
3788
+ expect(hs.status).toBe("ok");
3789
+ expect(hs.integrityOk).toBe(true);
3790
+ });
3791
+
3792
+ it("parseHealthStatus fails safe to degraded on a missing status", () => {
3793
+ const hs = parseHealthStatus({ integrity_ok: true });
3794
+ expect(hs.reachable).toBe(true);
3795
+ expect(hs.status).toBe("degraded");
3796
+ });
3797
+
3798
+ it("parseHealthStatus yields an unknown snapshot for a non-object body", () => {
3799
+ const hs = parseHealthStatus("not json");
3800
+ expect(hs.reachable).toBe(false);
3801
+ expect(hs.status).toBe("unknown");
3802
+ });
3803
+
3804
+ it("JSON serialization is a safe summary that excludes detail", () => {
3805
+ const hs = parseHealthStatus({
3806
+ status: "degraded",
3807
+ integrity: {
3808
+ healthy: false,
3809
+ manifest_load_failed: ["secret_collection"],
3810
+ },
3811
+ });
3812
+ const json = JSON.stringify(hs);
3813
+ expect(json).not.toContain("detail");
3814
+ expect(json).not.toContain("secret_collection");
3815
+ const parsed = JSON.parse(json);
3816
+ expect(parsed).toEqual({
3817
+ reachable: true,
3818
+ status: "degraded",
3819
+ integrity_ok: false,
3820
+ });
3821
+ // detail is still readable in-process
3822
+ expect(hs.detail).toBeDefined();
3823
+ });
3824
+
3825
+ it("parseHealthStatus preserves a raw off-contract status (matches Go)", () => {
3826
+ expect(parseHealthStatus({ status: "healthy" }).status).toBe("healthy");
3827
+ expect(parseHealthStatus({ status: "ok" }).status).toBe("ok");
3828
+ expect(parseHealthStatus({ status: "degraded" }).status).toBe("degraded");
3829
+ });
3830
+
3831
+ it("parseHealthStatus fails safe to degraded on a non-string status", () => {
3832
+ expect(parseHealthStatus({ status: { nested: 1 } }).status).toBe(
3833
+ "degraded",
3834
+ );
3835
+ });
3836
+
3837
+ it("parseHealthStatus treats a non-object (array) body as unknown", () => {
3838
+ expect(parseHealthStatus([1, 2, 3]).reachable).toBe(false);
3839
+ expect(parseHealthStatus([1, 2, 3]).status).toBe("unknown");
3840
+ });
3841
+ });
package/src/client.ts CHANGED
@@ -119,6 +119,94 @@ export interface UpsertOptions {
119
119
  bypassCache?: boolean;
120
120
  }
121
121
 
122
+ /**
123
+ * A health status value. Like the Go client's `HealthState` (a string type), an
124
+ * off-contract status reported by the server is preserved verbatim.
125
+ */
126
+ export type HealthState = string;
127
+
128
+ /** Canonical {@link HealthStatus.status} values. */
129
+ export const HealthOK = "ok";
130
+ export const HealthDegraded = "degraded";
131
+ export const HealthUnknown = "unknown";
132
+
133
+ /**
134
+ * A snapshot of an ekoDB /api/health probe.
135
+ *
136
+ * It is degraded-tolerant: a reachable server that reports `degraded` is a
137
+ * successful snapshot (`reachable: true`, `status: "degraded"`), NOT an error.
138
+ * An unreachable/unparseable probe yields `{ reachable: false, status: "unknown" }`.
139
+ *
140
+ * Consumers base liveness on `reachable` and treat `degraded` as a warning,
141
+ * never as a fatal. `detail` (the full admin body, which includes internal
142
+ * metrics and collection names) is excluded from JSON via `toJSON()` so
143
+ * surfacing the snapshot cannot leak internals; read it in-process when needed.
144
+ */
145
+ export class HealthStatus {
146
+ reachable: boolean;
147
+ status: HealthState;
148
+ integrityOk: boolean;
149
+ detail?: { [key: string]: any };
150
+
151
+ constructor(init: {
152
+ reachable: boolean;
153
+ status: HealthState;
154
+ integrityOk: boolean;
155
+ detail?: { [key: string]: any };
156
+ }) {
157
+ this.reachable = init.reachable;
158
+ this.status = init.status;
159
+ this.integrityOk = init.integrityOk;
160
+ this.detail = init.detail;
161
+ }
162
+
163
+ toJSON(): { reachable: boolean; status: HealthState; integrity_ok: boolean } {
164
+ return {
165
+ reachable: this.reachable,
166
+ status: this.status,
167
+ integrity_ok: this.integrityOk,
168
+ };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Interprets a parsed /api/health body per the shared health contract. A
174
+ * missing/odd `status` on a reachable body fails safe to `degraded`; a
175
+ * non-object body yields an unreachable `unknown` snapshot. `integrity_ok` is
176
+ * read from the top-level field (public) or nested `integrity.healthy` (admin).
177
+ */
178
+ export function parseHealthStatus(body: any): HealthStatus {
179
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
180
+ return new HealthStatus({
181
+ reachable: false,
182
+ status: HealthUnknown,
183
+ integrityOk: false,
184
+ });
185
+ }
186
+ // Default to degraded; a non-empty string status is kept verbatim (matches
187
+ // the Go client). A missing/non-string status fails safe to degraded.
188
+ let status: HealthState = HealthDegraded;
189
+ if (typeof body.status === "string" && body.status !== "") {
190
+ status = body.status;
191
+ }
192
+ let integrityOk = false;
193
+ if (typeof body.integrity_ok === "boolean") {
194
+ integrityOk = body.integrity_ok;
195
+ } else if (
196
+ body.integrity &&
197
+ typeof body.integrity === "object" &&
198
+ typeof body.integrity.healthy === "boolean"
199
+ ) {
200
+ integrityOk = body.integrity.healthy;
201
+ }
202
+ return new HealthStatus({
203
+ reachable: true,
204
+ status,
205
+ integrityOk,
206
+ detail: body,
207
+ });
208
+ }
209
+
122
210
  export interface FindOptions {
123
211
  filter?: any;
124
212
  sort?: any;
@@ -328,12 +416,55 @@ export interface MergeSessionsRequest {
328
416
  }
329
417
 
330
418
  /**
331
- * 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.
332
455
  */
333
456
  export interface ChatModels {
334
457
  openai: string[];
335
458
  anthropic: string[];
336
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 };
337
468
  }
338
469
 
339
470
  /**
@@ -1827,20 +1958,39 @@ export class EkoDBClient {
1827
1958
  }
1828
1959
 
1829
1960
  /**
1830
- * Health check - verify the ekoDB server is responding
1961
+ * Health check - reports whether the ekoDB server is reachable.
1962
+ *
1963
+ * Returns `true` whenever the server responds (INCLUDING when it reports
1964
+ * `degraded`) and `false` only when it is unreachable. The ekoDB server
1965
+ * returns HTTP 200 while degraded on purpose, so gating on `status === "ok"`
1966
+ * would treat a degraded-but-serving server as down. Use {@link healthStatus}
1967
+ * for the ok/degraded distinction.
1831
1968
  */
1832
1969
  async health(): Promise<boolean> {
1970
+ return (await this.healthStatus()).reachable;
1971
+ }
1972
+
1973
+ /**
1974
+ * Structured, degraded-tolerant health. Returns a {@link HealthStatus}
1975
+ * snapshot; an unreachable server yields `{ reachable: false, status:
1976
+ * "unknown" }` rather than throwing.
1977
+ */
1978
+ async healthStatus(): Promise<HealthStatus> {
1833
1979
  try {
1834
- const result = await this.makeRequest<{ status: string }>(
1980
+ const body = await this.makeRequest<any>(
1835
1981
  "GET",
1836
1982
  "/api/health",
1837
1983
  undefined,
1838
1984
  0,
1839
1985
  true,
1840
1986
  );
1841
- return result.status === "ok";
1987
+ return parseHealthStatus(body);
1842
1988
  } catch {
1843
- return false;
1989
+ return new HealthStatus({
1990
+ reachable: false,
1991
+ status: HealthUnknown,
1992
+ integrityOk: false,
1993
+ });
1844
1994
  }
1845
1995
  }
1846
1996
 
@@ -2156,16 +2306,37 @@ export class EkoDBClient {
2156
2306
  return;
2157
2307
  }
2158
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;
2159
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
+ }
2160
2325
  if (!line.startsWith("data:")) return;
2161
2326
  const dataStr = line.slice(5).trim();
2162
2327
  if (!dataStr) return;
2163
2328
  try {
2164
2329
  const eventData = JSON.parse(dataStr);
2165
- 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;
2166
2336
  stream.emit("event", {
2167
2337
  type: "error",
2168
- error: eventData.error,
2338
+ error: streamErrorText(eventData),
2339
+ ...providerFailureFields(eventData),
2169
2340
  } as ChatStreamEvent);
2170
2341
  } else if (eventData.content && eventData.message_id) {
2171
2342
  // Done event — has full content + message_id
@@ -2198,17 +2369,26 @@ export class EkoDBClient {
2198
2369
  if (done) break;
2199
2370
  buffer += decoder.decode(value, { stream: true });
2200
2371
  let nl: number;
2201
- while ((nl = buffer.indexOf("\n")) >= 0) {
2372
+ while (!stopped && (nl = buffer.indexOf("\n")) >= 0) {
2202
2373
  emitLine(buffer.slice(0, nl));
2203
2374
  buffer = buffer.slice(nl + 1);
2204
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);
2205
2384
  }
2206
- buffer += decoder.decode();
2207
- if (buffer) emitLine(buffer);
2208
2385
  } else {
2209
2386
  // Fallback for environments/tests without a readable body stream.
2210
2387
  const body = await response.text();
2211
- 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
+ }
2212
2392
  }
2213
2393
  stream.close();
2214
2394
  } catch (err: any) {
@@ -3494,7 +3674,65 @@ export type ChatStreamEvent =
3494
3674
  toolName: string;
3495
3675
  arguments: any;
3496
3676
  }
3497
- | { 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
+ }
3498
3736
 
3499
3737
  /** Definition for a client-side tool the LLM can call. */
3500
3738
  export interface ClientToolDefinition {
@@ -4178,9 +4416,12 @@ export class WebSocketClient {
4178
4416
  const chatId = msg.payload?.chat_id || msg.payload?.chatId;
4179
4417
  const stream = this.chatStreams.get(chatId);
4180
4418
  if (stream) {
4419
+ // The text guard and the classification are the SSE route's,
4420
+ // so the two routes emit the same shape.
4181
4421
  stream.emit("event", {
4182
4422
  type: "error",
4183
- error: msg.payload.error || msg.payload.message || "Unknown error",
4423
+ error: streamErrorText(msg.payload),
4424
+ ...providerFailureFields(msg.payload),
4184
4425
  } as ChatStreamEvent);
4185
4426
  this.chatStreams.delete(chatId);
4186
4427
  stream.close();