@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.
- package/README.md +5 -2
- package/dist/client.d.ts +79 -6
- package/dist/client.js +104 -55
- package/dist/client.test.js +227 -2
- package/dist/functions.d.ts +19 -16
- package/dist/functions.js +27 -8
- package/dist/functions.test.js +42 -0
- package/dist/index.d.ts +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/websocket.test.js +66 -0
- package/package.json +3 -3
- package/src/client.test.ts +281 -2
- package/src/client.ts +192 -32
- package/src/functions.test.ts +54 -0
- package/src/functions.ts +30 -12
- package/src/index.ts +2 -0
- package/src/search.ts +1 -1
- package/src/websocket.test.ts +90 -0
- package/tsconfig.json +2 -2
package/src/client.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
14
14
|
parseHealthStatus,
|
|
15
15
|
} from "./client";
|
|
16
|
+
import type { ChatModels } from "./client";
|
|
16
17
|
import { SearchQueryBuilder } from "./search";
|
|
17
18
|
|
|
18
19
|
// Mock fetch globally
|
|
@@ -1436,6 +1437,55 @@ describe("EkoDBClient chat models", () => {
|
|
|
1436
1437
|
expect(result.perplexity).toHaveLength(1);
|
|
1437
1438
|
});
|
|
1438
1439
|
|
|
1440
|
+
it("carries gemini and the per-provider status from the server", async () => {
|
|
1441
|
+
const client = createTestClient();
|
|
1442
|
+
|
|
1443
|
+
mockTokenResponse();
|
|
1444
|
+
mockJsonResponse({
|
|
1445
|
+
openai: [],
|
|
1446
|
+
anthropic: ["claude-sonnet-4-5"],
|
|
1447
|
+
perplexity: ["sonar"],
|
|
1448
|
+
gemini: ["gemini-2.5-flash"],
|
|
1449
|
+
providers: {
|
|
1450
|
+
anthropic: { status: "ok", verified: true, model_count: 1 },
|
|
1451
|
+
gemini: { status: "ok", verified: true, model_count: 1 },
|
|
1452
|
+
openai: {
|
|
1453
|
+
status: "auth_failed",
|
|
1454
|
+
verified: true,
|
|
1455
|
+
http_status: 401,
|
|
1456
|
+
message: "Failed to fetch OpenAI models: 401 Unauthorized",
|
|
1457
|
+
},
|
|
1458
|
+
perplexity: {
|
|
1459
|
+
status: "ok",
|
|
1460
|
+
verified: false,
|
|
1461
|
+
message: "static model list; key not verified",
|
|
1462
|
+
},
|
|
1463
|
+
},
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
const result: ChatModels = await client.getChatModels();
|
|
1467
|
+
|
|
1468
|
+
expect(result.gemini).toEqual(["gemini-2.5-flash"]);
|
|
1469
|
+
expect(result.providers?.openai.status).toBe("auth_failed");
|
|
1470
|
+
expect(result.providers?.openai.http_status).toBe(401);
|
|
1471
|
+
expect(result.providers?.openai.verified).toBe(true);
|
|
1472
|
+
expect(result.providers?.perplexity.verified).toBe(false);
|
|
1473
|
+
expect(result.providers?.anthropic.model_count).toBe(1);
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it("tolerates a server that predates gemini and providers", async () => {
|
|
1477
|
+
const client = createTestClient();
|
|
1478
|
+
|
|
1479
|
+
mockTokenResponse();
|
|
1480
|
+
mockJsonResponse({ openai: ["gpt-4o"], anthropic: [], perplexity: [] });
|
|
1481
|
+
|
|
1482
|
+
const result: ChatModels = await client.getChatModels();
|
|
1483
|
+
|
|
1484
|
+
expect(result.openai).toEqual(["gpt-4o"]);
|
|
1485
|
+
expect(result.gemini).toBeUndefined();
|
|
1486
|
+
expect(result.providers).toBeUndefined();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1439
1489
|
it("gets models for specific provider", async () => {
|
|
1440
1490
|
const client = createTestClient();
|
|
1441
1491
|
|
|
@@ -2572,6 +2622,138 @@ describe("EkoDBClient chatMessageStream", () => {
|
|
|
2572
2622
|
expect(events[2].executionTimeMs).toBe(42);
|
|
2573
2623
|
});
|
|
2574
2624
|
|
|
2625
|
+
it("treats a frame named error as an error even when its payload says message", async () => {
|
|
2626
|
+
const client = createTestClient();
|
|
2627
|
+
mockTokenResponse();
|
|
2628
|
+
|
|
2629
|
+
const sseBody =
|
|
2630
|
+
'event: token\ndata: {"token":"Hel"}\n\nevent: error\ndata: {"message":"boom"}\n\n';
|
|
2631
|
+
|
|
2632
|
+
mockFetch.mockResolvedValueOnce({
|
|
2633
|
+
ok: true,
|
|
2634
|
+
status: 200,
|
|
2635
|
+
text: async () => sseBody,
|
|
2636
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
const events: any[] = [];
|
|
2640
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2641
|
+
message: "Hello",
|
|
2642
|
+
});
|
|
2643
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2644
|
+
|
|
2645
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2646
|
+
|
|
2647
|
+
expect(events).toEqual([
|
|
2648
|
+
{ type: "chunk", content: "Hel" },
|
|
2649
|
+
{ type: "error", error: "boom" },
|
|
2650
|
+
]);
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
it("stops reading after an error frame and emits nothing that follows it", async () => {
|
|
2654
|
+
const client = createTestClient();
|
|
2655
|
+
mockTokenResponse();
|
|
2656
|
+
|
|
2657
|
+
const sseBody =
|
|
2658
|
+
'event: error\ndata: {"message":"boom"}\n\nevent: token\ndata: {"token":"late"}\n\n';
|
|
2659
|
+
|
|
2660
|
+
mockFetch.mockResolvedValueOnce({
|
|
2661
|
+
ok: true,
|
|
2662
|
+
status: 200,
|
|
2663
|
+
text: async () => sseBody,
|
|
2664
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
const events: any[] = [];
|
|
2668
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2669
|
+
message: "Hello",
|
|
2670
|
+
});
|
|
2671
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2672
|
+
|
|
2673
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2674
|
+
|
|
2675
|
+
expect(events).toEqual([{ type: "error", error: "boom" }]);
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
it("cancels the body reader after an error frame instead of waiting for the server to close", async () => {
|
|
2679
|
+
const client = createTestClient();
|
|
2680
|
+
mockTokenResponse();
|
|
2681
|
+
|
|
2682
|
+
const encoder = new TextEncoder();
|
|
2683
|
+
const chunks = [
|
|
2684
|
+
encoder.encode('event: error\ndata: {"message":"boom"}\n\n'),
|
|
2685
|
+
encoder.encode('event: token\ndata: {"token":"late"}\n\n'),
|
|
2686
|
+
];
|
|
2687
|
+
const cancel = vi.fn(async () => {});
|
|
2688
|
+
let reads = 0;
|
|
2689
|
+
const reader = {
|
|
2690
|
+
read: vi.fn(async () => {
|
|
2691
|
+
// A server (or proxy) that does not close after the error frame: it
|
|
2692
|
+
// keeps sending frames. Bounded so a client that never stops fails
|
|
2693
|
+
// the assertions below instead of looping forever.
|
|
2694
|
+
if (reads >= 50) return { done: true, value: undefined };
|
|
2695
|
+
const value = chunks[Math.min(reads, chunks.length - 1)];
|
|
2696
|
+
reads += 1;
|
|
2697
|
+
return { done: false, value };
|
|
2698
|
+
}),
|
|
2699
|
+
cancel,
|
|
2700
|
+
};
|
|
2701
|
+
|
|
2702
|
+
mockFetch.mockResolvedValueOnce({
|
|
2703
|
+
ok: true,
|
|
2704
|
+
status: 200,
|
|
2705
|
+
body: { getReader: () => reader },
|
|
2706
|
+
text: async () => "",
|
|
2707
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
const events: any[] = [];
|
|
2711
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2712
|
+
message: "Hello",
|
|
2713
|
+
});
|
|
2714
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2715
|
+
|
|
2716
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2717
|
+
|
|
2718
|
+
expect(events).toEqual([{ type: "error", error: "boom" }]);
|
|
2719
|
+
// One read delivered the error frame; the reader was cancelled rather
|
|
2720
|
+
// than read until the server closed.
|
|
2721
|
+
expect(reader.read).toHaveBeenCalledTimes(1);
|
|
2722
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
2723
|
+
});
|
|
2724
|
+
|
|
2725
|
+
it("keeps the error text a string when the server sends a structured error", async () => {
|
|
2726
|
+
const client = createTestClient();
|
|
2727
|
+
mockTokenResponse();
|
|
2728
|
+
|
|
2729
|
+
const sseBody =
|
|
2730
|
+
'event: error\ndata: {"error":{"code":"upstream_down","status":503},"error_kind":"provider_unavailable","provider":"openai"}\n\n';
|
|
2731
|
+
|
|
2732
|
+
mockFetch.mockResolvedValueOnce({
|
|
2733
|
+
ok: true,
|
|
2734
|
+
status: 200,
|
|
2735
|
+
text: async () => sseBody,
|
|
2736
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2737
|
+
});
|
|
2738
|
+
|
|
2739
|
+
const events: any[] = [];
|
|
2740
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2741
|
+
message: "Hello",
|
|
2742
|
+
});
|
|
2743
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2744
|
+
|
|
2745
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2746
|
+
|
|
2747
|
+
expect(events).toEqual([
|
|
2748
|
+
{
|
|
2749
|
+
type: "error",
|
|
2750
|
+
error: "Unknown error",
|
|
2751
|
+
errorKind: "provider_unavailable",
|
|
2752
|
+
provider: "openai",
|
|
2753
|
+
},
|
|
2754
|
+
]);
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2575
2757
|
it("emits error event on SSE error", async () => {
|
|
2576
2758
|
const client = createTestClient();
|
|
2577
2759
|
mockTokenResponse();
|
|
@@ -2597,6 +2779,43 @@ describe("EkoDBClient chatMessageStream", () => {
|
|
|
2597
2779
|
expect(events[0]).toEqual({ type: "error", error: "LLM timeout" });
|
|
2598
2780
|
});
|
|
2599
2781
|
|
|
2782
|
+
it("carries the provider failure classification on an error event", async () => {
|
|
2783
|
+
// The deployment classifies a provider failure (`error_kind`, `provider`,
|
|
2784
|
+
// `provider_status`, `retry_after_secs` on the wire); the event carries
|
|
2785
|
+
// every one of them, in this shape's camelCase, so a consumer can act on
|
|
2786
|
+
// it without string-matching.
|
|
2787
|
+
const client = createTestClient();
|
|
2788
|
+
mockTokenResponse();
|
|
2789
|
+
|
|
2790
|
+
const sseBody =
|
|
2791
|
+
'data: {"error":"OpenAI API error: Incorrect API key provided","error_kind":"provider_auth_failed","provider":"openai","provider_status":401}\n';
|
|
2792
|
+
|
|
2793
|
+
mockFetch.mockResolvedValueOnce({
|
|
2794
|
+
ok: true,
|
|
2795
|
+
status: 200,
|
|
2796
|
+
text: async () => sseBody,
|
|
2797
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
const events: any[] = [];
|
|
2801
|
+
const stream = client.chatMessageStream("chat_123", {
|
|
2802
|
+
message: "Hello",
|
|
2803
|
+
});
|
|
2804
|
+
stream.on("event", (evt: any) => events.push(evt));
|
|
2805
|
+
|
|
2806
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2807
|
+
|
|
2808
|
+
expect(events).toEqual([
|
|
2809
|
+
{
|
|
2810
|
+
type: "error",
|
|
2811
|
+
error: "OpenAI API error: Incorrect API key provided",
|
|
2812
|
+
errorKind: "provider_auth_failed",
|
|
2813
|
+
provider: "openai",
|
|
2814
|
+
providerStatus: 401,
|
|
2815
|
+
},
|
|
2816
|
+
]);
|
|
2817
|
+
});
|
|
2818
|
+
|
|
2600
2819
|
it("emits error event on non-200 HTTP response", async () => {
|
|
2601
2820
|
const client = createTestClient();
|
|
2602
2821
|
mockTokenResponse();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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/
|
|
785
|
+
"/api/batch/insert/",
|
|
743
786
|
"/api/update/",
|
|
744
|
-
"/api/
|
|
787
|
+
"/api/batch/update/",
|
|
745
788
|
"/api/delete/",
|
|
746
|
-
"/api/
|
|
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
|
-
|
|
2330
|
+
// An error frame is one the server names `error`, or whose
|
|
2331
|
+
// payload carries an `error`; a `message`-only payload is still
|
|
2332
|
+
// the error rather than a frame to skip, and the text is always a
|
|
2333
|
+
// string (`streamErrorText`).
|
|
2334
|
+
if (eventData.error != null || eventName === "error") {
|
|
2335
|
+
stopped = true;
|
|
2273
2336
|
stream.emit("event", {
|
|
2274
2337
|
type: "error",
|
|
2275
|
-
error: eventData
|
|
2338
|
+
error: streamErrorText(eventData),
|
|
2339
|
+
...providerFailureFields(eventData),
|
|
2276
2340
|
} as ChatStreamEvent);
|
|
2277
2341
|
} else if (eventData.content && eventData.message_id) {
|
|
2278
2342
|
// Done event — has full content + message_id
|
|
@@ -2305,17 +2369,26 @@ export class EkoDBClient {
|
|
|
2305
2369
|
if (done) break;
|
|
2306
2370
|
buffer += decoder.decode(value, { stream: true });
|
|
2307
2371
|
let nl: number;
|
|
2308
|
-
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
2372
|
+
while (!stopped && (nl = buffer.indexOf("\n")) >= 0) {
|
|
2309
2373
|
emitLine(buffer.slice(0, nl));
|
|
2310
2374
|
buffer = buffer.slice(nl + 1);
|
|
2311
2375
|
}
|
|
2376
|
+
if (stopped) {
|
|
2377
|
+
await reader.cancel?.()?.catch?.(() => {});
|
|
2378
|
+
break;
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
if (!stopped) {
|
|
2382
|
+
buffer += decoder.decode();
|
|
2383
|
+
if (buffer) emitLine(buffer);
|
|
2312
2384
|
}
|
|
2313
|
-
buffer += decoder.decode();
|
|
2314
|
-
if (buffer) emitLine(buffer);
|
|
2315
2385
|
} else {
|
|
2316
2386
|
// Fallback for environments/tests without a readable body stream.
|
|
2317
2387
|
const body = await response.text();
|
|
2318
|
-
for (const line of body.split("\n"))
|
|
2388
|
+
for (const line of body.split("\n")) {
|
|
2389
|
+
emitLine(line);
|
|
2390
|
+
if (stopped) break;
|
|
2391
|
+
}
|
|
2319
2392
|
}
|
|
2320
2393
|
stream.close();
|
|
2321
2394
|
} catch (err: any) {
|
|
@@ -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
|
|
3234
|
+
`/api/kv/${encodeURIComponent(key)}/links`,
|
|
3162
3235
|
undefined,
|
|
3163
3236
|
0,
|
|
3164
3237
|
true,
|
|
3165
3238
|
);
|
|
3166
3239
|
}
|
|
3167
3240
|
|
|
3168
|
-
/**
|
|
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/
|
|
3177
|
-
|
|
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
|
-
"
|
|
3191
|
-
`/api/kv/
|
|
3192
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
"
|
|
3266
|
-
`/api/schedules/${encodeURIComponent(id)}
|
|
3267
|
-
|
|
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
|
-
| {
|
|
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
|
|
4449
|
+
error: streamErrorText(msg.payload),
|
|
4450
|
+
...providerFailureFields(msg.payload),
|
|
4291
4451
|
} as ChatStreamEvent);
|
|
4292
4452
|
this.chatStreams.delete(chatId);
|
|
4293
4453
|
stream.close();
|