@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.
- package/README.md +5 -2
- package/dist/client.d.ts +109 -2
- package/dist/client.js +160 -47
- package/dist/client.test.js +264 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -1
- package/dist/websocket.test.js +66 -0
- package/package.json +2 -2
- package/src/client.test.ts +321 -0
- package/src/client.ts +254 -13
- package/src/index.ts +8 -0
- package/src/websocket.test.ts +90 -0
- package/tsconfig.json +2 -2
package/src/client.test.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 -
|
|
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
|
|
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
|
|
1987
|
+
return parseHealthStatus(body);
|
|
1842
1988
|
} catch {
|
|
1843
|
-
return
|
|
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
|
-
|
|
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
|
|
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"))
|
|
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
|
-
| {
|
|
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
|
|
4423
|
+
error: streamErrorText(msg.payload),
|
|
4424
|
+
...providerFailureFields(msg.payload),
|
|
4184
4425
|
} as ChatStreamEvent);
|
|
4185
4426
|
this.chatStreams.delete(chatId);
|
|
4186
4427
|
stream.close();
|