@ekodb/ekodb-client 0.25.0 → 0.26.1

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.
@@ -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();
@@ -2802,6 +3021,16 @@ describe("EkoDBClient schedules", () => {
2802
3021
 
2803
3022
  const result = await client.pauseSchedule("sched_1");
2804
3023
  expect(result).toHaveProperty("status", "paused");
3024
+
3025
+ // Assert the REQUEST. There is no /pause route; pausing is a partial
3026
+ // update of `enabled`. A response-only assertion passed for as long as
3027
+ // this method POSTed to a route that does not exist.
3028
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3029
+ const dataCall = calls[1]; // calls[0] is the token exchange
3030
+ expect(dataCall[0]).toContain("/api/schedules/sched_1");
3031
+ expect(dataCall[0]).not.toContain("/pause");
3032
+ expect(dataCall[1]?.method).toBe("PUT");
3033
+ expect(JSON.parse(dataCall[1]?.body as string)).toEqual({ enabled: false });
2805
3034
  });
2806
3035
 
2807
3036
  it("resumes a schedule", async () => {
@@ -2811,6 +3040,13 @@ describe("EkoDBClient schedules", () => {
2811
3040
 
2812
3041
  const result = await client.resumeSchedule("sched_1");
2813
3042
  expect(result).toHaveProperty("status", "active");
3043
+
3044
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3045
+ const dataCall = calls[1];
3046
+ expect(dataCall[0]).toContain("/api/schedules/sched_1");
3047
+ expect(dataCall[0]).not.toContain("/resume");
3048
+ expect(dataCall[1]?.method).toBe("PUT");
3049
+ expect(JSON.parse(dataCall[1]?.body as string)).toEqual({ enabled: true });
2814
3050
  });
2815
3051
  });
2816
3052
 
@@ -2831,6 +3067,15 @@ describe("EkoDBClient kv links", () => {
2831
3067
 
2832
3068
  const result = await client.kvGetLinks("session:user123");
2833
3069
  expect(result).toHaveProperty("links");
3070
+
3071
+ // Assert the REQUEST, not just the mocked response. These three methods
3072
+ // shipped pointing at routes that do not exist, and every one of these
3073
+ // tests passed the whole time, because a mocked response says nothing
3074
+ // about the URL the client actually asked for.
3075
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3076
+ const dataCall = calls[1]; // calls[0] is the token exchange
3077
+ expect(dataCall[0]).toContain("/api/kv/session%3Auser123/links");
3078
+ expect(dataCall[1]?.method).toBe("GET");
2834
3079
  });
2835
3080
 
2836
3081
  it("links a document to a KV key", async () => {
@@ -2840,6 +3085,32 @@ describe("EkoDBClient kv links", () => {
2840
3085
 
2841
3086
  const result = await client.kvLink("session:user123", "users", "user_1");
2842
3087
  expect(result).toHaveProperty("status", "linked");
3088
+
3089
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3090
+ const dataCall = calls[1];
3091
+ // The identifying triple belongs in the PATH, not the body.
3092
+ expect(dataCall[0]).toContain(
3093
+ "/api/kv/session%3Auser123/links/users/user_1",
3094
+ );
3095
+ expect(dataCall[1]?.method).toBe("POST");
3096
+ });
3097
+
3098
+ it("passes optional link data in the body", async () => {
3099
+ const client = createTestClient();
3100
+ mockTokenResponse();
3101
+ mockJsonResponse({ status: "linked" });
3102
+
3103
+ await client.kvLink("session:user123", "users", "user_1", {
3104
+ field_path: "profile.avatar",
3105
+ metadata: { source: "signup" },
3106
+ });
3107
+
3108
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3109
+ const body = JSON.parse(calls[1][1]?.body as string);
3110
+ expect(body).toEqual({
3111
+ field_path: "profile.avatar",
3112
+ metadata: { source: "signup" },
3113
+ });
2843
3114
  });
2844
3115
 
2845
3116
  it("unlinks a document from a KV key", async () => {
@@ -2849,6 +3120,14 @@ describe("EkoDBClient kv links", () => {
2849
3120
 
2850
3121
  const result = await client.kvUnlink("session:user123", "users", "user_1");
2851
3122
  expect(result).toHaveProperty("status", "unlinked");
3123
+
3124
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3125
+ const dataCall = calls[1];
3126
+ expect(dataCall[0]).toContain(
3127
+ "/api/kv/session%3Auser123/links/users/user_1",
3128
+ );
3129
+ // DELETE, not POST — the previous implementation used POST and 404'd.
3130
+ expect(dataCall[1]?.method).toBe("DELETE");
2852
3131
  });
2853
3132
  });
2854
3133
 
@@ -2874,7 +3153,7 @@ describe("EkoDBClient text and hybrid search", () => {
2874
3153
  },
2875
3154
  ],
2876
3155
  total: 2,
2877
- took_ms: 12,
3156
+ execution_time_ms: 12,
2878
3157
  });
2879
3158
 
2880
3159
  const result = await client.textSearch("documents", "ownership", {
@@ -2899,7 +3178,7 @@ describe("EkoDBClient text and hybrid search", () => {
2899
3178
  },
2900
3179
  ],
2901
3180
  total: 1,
2902
- took_ms: 25,
3181
+ execution_time_ms: 25,
2903
3182
  });
2904
3183
 
2905
3184
  const queryVector = [0.1, 0.2, 0.3, 0.4, 0.5];
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
  /**
@@ -739,11 +782,11 @@ export class EkoDBClient {
739
782
  // ONLY these operations support MessagePack
740
783
  const msgpackPaths = [
741
784
  "/api/insert/",
742
- "/api/batch_insert/",
785
+ "/api/batch/insert/",
743
786
  "/api/update/",
744
- "/api/batch_update/",
787
+ "/api/batch/update/",
745
788
  "/api/delete/",
746
- "/api/batch_delete/",
789
+ "/api/batch/delete/",
747
790
  ];
748
791
 
749
792
  // Check if path starts with any MessagePack-supported operation
@@ -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) {
@@ -3158,38 +3231,49 @@ export class EkoDBClient {
3158
3231
  async kvGetLinks(key: string): Promise<Record> {
3159
3232
  return this.makeRequest<Record>(
3160
3233
  "GET",
3161
- `/api/kv/links/${encodeURIComponent(key)}`,
3234
+ `/api/kv/${encodeURIComponent(key)}/links`,
3162
3235
  undefined,
3163
3236
  0,
3164
3237
  true,
3165
3238
  );
3166
3239
  }
3167
3240
 
3168
- /** Link a document to a KV key */
3241
+ /**
3242
+ * Link a document to a KV key.
3243
+ *
3244
+ * The identifying triple goes in the path; the body carries the optional
3245
+ * link payload (`keys`, `field_path`, `metadata`), and an empty object means
3246
+ * "no extra link data".
3247
+ */
3169
3248
  async kvLink(
3170
3249
  key: string,
3171
3250
  collection: string,
3172
3251
  documentId: string,
3252
+ linkData: {
3253
+ keys?: string[];
3254
+ field_path?: string;
3255
+ metadata?: { [key: string]: string };
3256
+ } = {},
3173
3257
  ): Promise<Record> {
3174
3258
  return this.makeRequest<Record>(
3175
3259
  "POST",
3176
- `/api/kv/link`,
3177
- { key, collection, document_id: documentId },
3260
+ `/api/kv/${encodeURIComponent(key)}/links/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}`,
3261
+ linkData,
3178
3262
  0,
3179
3263
  true,
3180
3264
  );
3181
3265
  }
3182
3266
 
3183
- /** Unlink a document from a KV key */
3267
+ /** Unlink a document from a KV key. DELETE, with the triple in the path. */
3184
3268
  async kvUnlink(
3185
3269
  key: string,
3186
3270
  collection: string,
3187
3271
  documentId: string,
3188
3272
  ): Promise<Record> {
3189
3273
  return this.makeRequest<Record>(
3190
- "POST",
3191
- `/api/kv/unlink`,
3192
- { key, collection, document_id: documentId },
3274
+ "DELETE",
3275
+ `/api/kv/${encodeURIComponent(key)}/links/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}`,
3276
+ undefined,
3193
3277
  0,
3194
3278
  true,
3195
3279
  );
@@ -3248,23 +3332,38 @@ export class EkoDBClient {
3248
3332
  );
3249
3333
  }
3250
3334
 
3251
- /** Pause a schedule */
3335
+ /**
3336
+ * Pause a schedule.
3337
+ *
3338
+ * There is no `/pause` endpoint — pausing is a partial update of the
3339
+ * schedule's `enabled` flag. This previously POSTed to
3340
+ * `/api/schedules/{id}/pause`, which has never existed and always 404'd.
3341
+ */
3252
3342
  async pauseSchedule(id: string): Promise<Record> {
3253
- return this.makeRequest<Record>(
3254
- "POST",
3255
- `/api/schedules/${encodeURIComponent(id)}/pause`,
3256
- undefined,
3257
- 0,
3258
- true,
3259
- );
3343
+ return this.setScheduleEnabled(id, false);
3260
3344
  }
3261
3345
 
3262
- /** Resume a schedule */
3346
+ /**
3347
+ * Resume a paused schedule. See {@link pauseSchedule} for why this is an
3348
+ * update rather than its own endpoint.
3349
+ */
3263
3350
  async resumeSchedule(id: string): Promise<Record> {
3351
+ return this.setScheduleEnabled(id, true);
3352
+ }
3353
+
3354
+ /**
3355
+ * Shared implementation for pause/resume: a partial update carrying only
3356
+ * `enabled`. The server recomputes the next execution time when `enabled`
3357
+ * changes, so nothing else needs sending.
3358
+ */
3359
+ private async setScheduleEnabled(
3360
+ id: string,
3361
+ enabled: boolean,
3362
+ ): Promise<Record> {
3264
3363
  return this.makeRequest<Record>(
3265
- "POST",
3266
- `/api/schedules/${encodeURIComponent(id)}/resume`,
3267
- undefined,
3364
+ "PUT",
3365
+ `/api/schedules/${encodeURIComponent(id)}`,
3366
+ { enabled },
3268
3367
  0,
3269
3368
  true,
3270
3369
  );
@@ -3601,7 +3700,65 @@ export type ChatStreamEvent =
3601
3700
  toolName: string;
3602
3701
  arguments: any;
3603
3702
  }
3604
- | { type: "error"; error: string };
3703
+ | {
3704
+ type: "error";
3705
+ error: string;
3706
+ /**
3707
+ * The provider-failure classification (`provider_auth_failed`,
3708
+ * `provider_permission_denied`, `provider_billing`,
3709
+ * `provider_rate_limited`, `provider_unavailable`,
3710
+ * `provider_unreachable`, `provider_not_configured`,
3711
+ * `provider_request_error`), when the failure was the LLM provider's
3712
+ * answer. Absent for a transport failure or a plain server error.
3713
+ */
3714
+ errorKind?: string;
3715
+ provider?: string;
3716
+ /** The provider's own HTTP status. */
3717
+ providerStatus?: number;
3718
+ retryAfterSecs?: number;
3719
+ };
3720
+
3721
+ /**
3722
+ * The text of a stream error frame: the first of `error` / `message` that is
3723
+ * a non-empty string, else a fixed fallback — a structured `error` object is
3724
+ * still an error, never a non-string `error` on the event. Shared by the SSE
3725
+ * and WebSocket routes so the two cannot drift.
3726
+ */
3727
+ function streamErrorText(payload: {
3728
+ error?: unknown;
3729
+ message?: unknown;
3730
+ }): string {
3731
+ const text = (value: unknown): string | undefined =>
3732
+ typeof value === "string" && value ? value : undefined;
3733
+ return text(payload.error) ?? text(payload.message) ?? "Unknown error";
3734
+ }
3735
+
3736
+ /**
3737
+ * The classification fields of a stream error frame, only those present, so
3738
+ * a plain error stays `{ type, error }`.
3739
+ */
3740
+ function providerFailureFields(eventData: {
3741
+ error_kind?: unknown;
3742
+ provider?: unknown;
3743
+ provider_status?: unknown;
3744
+ retry_after_secs?: unknown;
3745
+ }): {
3746
+ errorKind?: string;
3747
+ provider?: string;
3748
+ providerStatus?: number;
3749
+ retryAfterSecs?: number;
3750
+ } {
3751
+ const fields: ReturnType<typeof providerFailureFields> = {};
3752
+ if (typeof eventData.error_kind === "string")
3753
+ fields.errorKind = eventData.error_kind;
3754
+ if (typeof eventData.provider === "string")
3755
+ fields.provider = eventData.provider;
3756
+ if (typeof eventData.provider_status === "number")
3757
+ fields.providerStatus = eventData.provider_status;
3758
+ if (typeof eventData.retry_after_secs === "number")
3759
+ fields.retryAfterSecs = eventData.retry_after_secs;
3760
+ return fields;
3761
+ }
3605
3762
 
3606
3763
  /** Definition for a client-side tool the LLM can call. */
3607
3764
  export interface ClientToolDefinition {
@@ -4285,9 +4442,12 @@ export class WebSocketClient {
4285
4442
  const chatId = msg.payload?.chat_id || msg.payload?.chatId;
4286
4443
  const stream = this.chatStreams.get(chatId);
4287
4444
  if (stream) {
4445
+ // The text guard and the classification are the SSE route's,
4446
+ // so the two routes emit the same shape.
4288
4447
  stream.emit("event", {
4289
4448
  type: "error",
4290
- error: msg.payload.error || msg.payload.message || "Unknown error",
4449
+ error: streamErrorText(msg.payload),
4450
+ ...providerFailureFields(msg.payload),
4291
4451
  } as ChatStreamEvent);
4292
4452
  this.chatStreams.delete(chatId);
4293
4453
  stream.close();