@vellumai/assistant 0.5.4 → 0.5.6
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/Dockerfile +17 -27
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +113 -0
- package/src/__tests__/config-schema.test.ts +2 -2
- package/src/__tests__/context-window-manager.test.ts +78 -0
- package/src/__tests__/conversation-title-service.test.ts +30 -1
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
- package/src/__tests__/memory-regressions.test.ts +8 -30
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/require-fresh-approval.test.ts +4 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +4 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/cli/commands/conversations.ts +0 -18
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
- package/src/config/env-registry.ts +9 -0
- package/src/config/env.ts +8 -2
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/schema.ts +0 -12
- package/src/config/schemas/memory.ts +0 -4
- package/src/config/schemas/platform.ts +1 -1
- package/src/config/schemas/security.ts +4 -0
- package/src/context/window-manager.ts +53 -2
- package/src/credential-execution/managed-catalog.ts +5 -15
- package/src/daemon/conversation-agent-loop.ts +0 -60
- package/src/daemon/conversation-memory.ts +0 -117
- package/src/daemon/conversation-runtime-assembly.ts +0 -2
- package/src/daemon/daemon-control.ts +7 -0
- package/src/daemon/handlers/conversations.ts +0 -11
- package/src/daemon/lifecycle.ts +10 -47
- package/src/daemon/providers-setup.ts +2 -1
- package/src/followups/followup-store.ts +5 -2
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- package/src/memory/conversation-crud.ts +0 -236
- package/src/memory/conversation-title-service.ts +26 -10
- package/src/memory/db-init.ts +5 -13
- package/src/memory/embedding-local.ts +11 -5
- package/src/memory/indexer.ts +15 -106
- package/src/memory/job-handlers/conversation-starters.ts +24 -36
- package/src/memory/job-handlers/embedding.ts +0 -79
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +0 -8
- package/src/memory/jobs-worker.ts +0 -20
- package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
- package/src/memory/migrations/index.ts +1 -3
- package/src/memory/qdrant-client.ts +4 -6
- package/src/memory/schema/conversations.ts +0 -3
- package/src/memory/schema/index.ts +0 -2
- package/src/messaging/draft-store.ts +2 -2
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/slack/adapter.ts +29 -2
- package/src/oauth/connection-resolver.test.ts +22 -18
- package/src/oauth/connection-resolver.ts +92 -7
- package/src/oauth/platform-connection.test.ts +78 -69
- package/src/oauth/platform-connection.ts +12 -19
- package/src/permissions/defaults.ts +3 -3
- package/src/permissions/trust-client.ts +332 -0
- package/src/permissions/trust-store-interface.ts +105 -0
- package/src/permissions/trust-store.ts +531 -39
- package/src/platform/client.test.ts +148 -0
- package/src/platform/client.ts +71 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
- package/src/providers/speech-to-text/openai-whisper.ts +68 -0
- package/src/providers/speech-to-text/resolve.ts +9 -0
- package/src/providers/speech-to-text/types.ts +17 -0
- package/src/runtime/auth/route-policy.ts +14 -0
- package/src/runtime/auth/token-service.ts +133 -0
- package/src/runtime/http-server.ts +4 -2
- package/src/runtime/routes/conversation-management-routes.ts +0 -36
- package/src/runtime/routes/conversation-query-routes.ts +44 -2
- package/src/runtime/routes/conversation-routes.ts +2 -1
- package/src/runtime/routes/inbound-message-handler.ts +27 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
- package/src/runtime/routes/log-export-routes.ts +1 -0
- package/src/runtime/routes/memory-item-routes.test.ts +221 -3
- package/src/runtime/routes/memory-item-routes.ts +124 -2
- package/src/runtime/routes/secret-routes.ts +4 -1
- package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
- package/src/schedule/schedule-store.ts +0 -21
- package/src/security/ces-credential-client.ts +173 -0
- package/src/security/secure-keys.ts +65 -22
- package/src/signals/bash.ts +3 -0
- package/src/signals/cancel.ts +3 -0
- package/src/signals/confirm.ts +3 -0
- package/src/signals/conversation-undo.ts +3 -0
- package/src/signals/event-stream.ts +7 -0
- package/src/signals/shotgun.ts +3 -0
- package/src/signals/trust-rule.ts +3 -0
- package/src/skills/inline-command-render.ts +5 -1
- package/src/skills/inline-command-runner.ts +30 -2
- package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
- package/src/telemetry/usage-telemetry-reporter.ts +21 -19
- package/src/tools/memory/handlers.ts +1 -129
- package/src/tools/permission-checker.ts +18 -0
- package/src/tools/skills/load.ts +9 -2
- package/src/util/device-id.ts +70 -7
- package/src/util/logger.ts +35 -9
- package/src/util/platform.ts +29 -5
- package/src/util/xml.ts +8 -0
- package/src/workspace/heartbeat-service.ts +5 -24
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/__tests__/archive-recall.test.ts +0 -560
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
- package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
- package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
- package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
- package/src/__tests__/memory-brief-time.test.ts +0 -285
- package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
- package/src/__tests__/memory-chunk-archive.test.ts +0 -400
- package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
- package/src/__tests__/memory-episode-archive.test.ts +0 -370
- package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
- package/src/__tests__/memory-observation-archive.test.ts +0 -375
- package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
- package/src/__tests__/memory-reducer-job.test.ts +0 -538
- package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
- package/src/__tests__/memory-reducer-store.test.ts +0 -728
- package/src/__tests__/memory-reducer-types.test.ts +0 -707
- package/src/__tests__/memory-reducer.test.ts +0 -704
- package/src/__tests__/memory-simplified-config.test.ts +0 -281
- package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
- package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
- package/src/config/schemas/memory-simplified.ts +0 -101
- package/src/memory/archive-recall.ts +0 -516
- package/src/memory/archive-store.ts +0 -400
- package/src/memory/brief-formatting.ts +0 -33
- package/src/memory/brief-open-loops.ts +0 -266
- package/src/memory/brief-time.ts +0 -162
- package/src/memory/brief.ts +0 -75
- package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
- package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
- package/src/memory/migrations/185-memory-brief-state.ts +0 -52
- package/src/memory/migrations/186-memory-archive.ts +0 -109
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
- package/src/memory/reducer-scheduler.ts +0 -242
- package/src/memory/reducer-store.ts +0 -271
- package/src/memory/reducer-types.ts +0 -106
- package/src/memory/reducer.ts +0 -467
- package/src/memory/schema/memory-archive.ts +0 -121
- package/src/memory/schema/memory-brief.ts +0 -55
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mutable mock state
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
let mockManagedProxyCtx = {
|
|
8
|
+
enabled: false,
|
|
9
|
+
platformBaseUrl: "",
|
|
10
|
+
assistantApiKey: "",
|
|
11
|
+
};
|
|
12
|
+
let mockAssistantId = "";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Module mocks
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
mock.module("../providers/managed-proxy/context.js", () => ({
|
|
19
|
+
resolveManagedProxyContext: async () => mockManagedProxyCtx,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
mock.module("../config/env.js", () => ({
|
|
23
|
+
getPlatformAssistantId: () => mockAssistantId,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Import under test (after mocks)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
import { VellumPlatformClient } from "./client.js";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Tests
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe("VellumPlatformClient", () => {
|
|
37
|
+
let originalFetch: typeof globalThis.fetch;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
originalFetch = globalThis.fetch;
|
|
41
|
+
mockManagedProxyCtx = {
|
|
42
|
+
enabled: true,
|
|
43
|
+
platformBaseUrl: "https://platform.example.com",
|
|
44
|
+
assistantApiKey: "sk-test-key",
|
|
45
|
+
};
|
|
46
|
+
mockAssistantId = "asst-123";
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
globalThis.fetch = originalFetch;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("create()", () => {
|
|
54
|
+
test("returns a client when all prerequisites are met", async () => {
|
|
55
|
+
const client = await VellumPlatformClient.create();
|
|
56
|
+
expect(client).not.toBeNull();
|
|
57
|
+
expect(client!.baseUrl).toBe("https://platform.example.com");
|
|
58
|
+
expect(client!.assistantApiKey).toBe("sk-test-key");
|
|
59
|
+
expect(client!.platformAssistantId).toBe("asst-123");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns null when managed proxy is not enabled", async () => {
|
|
63
|
+
mockManagedProxyCtx = {
|
|
64
|
+
enabled: false,
|
|
65
|
+
platformBaseUrl: "",
|
|
66
|
+
assistantApiKey: "",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const client = await VellumPlatformClient.create();
|
|
70
|
+
expect(client).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns client with empty assistantId when assistant ID is missing", async () => {
|
|
74
|
+
mockAssistantId = "";
|
|
75
|
+
|
|
76
|
+
const client = await VellumPlatformClient.create();
|
|
77
|
+
expect(client).not.toBeNull();
|
|
78
|
+
expect(client!.platformAssistantId).toBe("");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("strips trailing slash from platform base URL", async () => {
|
|
82
|
+
mockManagedProxyCtx.platformBaseUrl = "https://platform.example.com///";
|
|
83
|
+
|
|
84
|
+
const client = await VellumPlatformClient.create();
|
|
85
|
+
expect(client!.baseUrl).toBe("https://platform.example.com");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("fetch()", () => {
|
|
90
|
+
test("prepends base URL to path", async () => {
|
|
91
|
+
globalThis.fetch = mock(async (url: string | URL | Request) => {
|
|
92
|
+
expect(String(url)).toBe(
|
|
93
|
+
"https://platform.example.com/v1/some/endpoint/",
|
|
94
|
+
);
|
|
95
|
+
return new Response("ok", { status: 200 });
|
|
96
|
+
}) as unknown as typeof globalThis.fetch;
|
|
97
|
+
|
|
98
|
+
const client = await VellumPlatformClient.create();
|
|
99
|
+
await client!.fetch("/v1/some/endpoint/");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("injects Api-Key auth header", async () => {
|
|
103
|
+
globalThis.fetch = mock(
|
|
104
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
105
|
+
const headers = new Headers(init?.headers);
|
|
106
|
+
expect(headers.get("Authorization")).toBe("Api-Key sk-test-key");
|
|
107
|
+
return new Response("ok", { status: 200 });
|
|
108
|
+
},
|
|
109
|
+
) as unknown as typeof globalThis.fetch;
|
|
110
|
+
|
|
111
|
+
const client = await VellumPlatformClient.create();
|
|
112
|
+
await client!.fetch("/v1/test/");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("preserves caller-provided headers", async () => {
|
|
116
|
+
globalThis.fetch = mock(
|
|
117
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
118
|
+
const headers = new Headers(init?.headers);
|
|
119
|
+
expect(headers.get("Content-Type")).toBe("application/json");
|
|
120
|
+
expect(headers.get("Authorization")).toBe("Api-Key sk-test-key");
|
|
121
|
+
return new Response("ok", { status: 200 });
|
|
122
|
+
},
|
|
123
|
+
) as unknown as typeof globalThis.fetch;
|
|
124
|
+
|
|
125
|
+
const client = await VellumPlatformClient.create();
|
|
126
|
+
await client!.fetch("/v1/test/", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("passes through request init options", async () => {
|
|
133
|
+
globalThis.fetch = mock(
|
|
134
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
135
|
+
expect(init?.method).toBe("POST");
|
|
136
|
+
expect(init?.body).toBe('{"key":"value"}');
|
|
137
|
+
return new Response("ok", { status: 200 });
|
|
138
|
+
},
|
|
139
|
+
) as unknown as typeof globalThis.fetch;
|
|
140
|
+
|
|
141
|
+
const client = await VellumPlatformClient.create();
|
|
142
|
+
await client!.fetch("/v1/test/", {
|
|
143
|
+
method: "POST",
|
|
144
|
+
body: '{"key":"value"}',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized platform API client.
|
|
3
|
+
*
|
|
4
|
+
* Owns managed proxy context resolution, prerequisite validation, and
|
|
5
|
+
* authenticated fetch for all platform API calls that use Api-Key auth.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getPlatformAssistantId } from "../config/env.js";
|
|
9
|
+
import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
|
|
10
|
+
|
|
11
|
+
export class VellumPlatformClient {
|
|
12
|
+
private readonly platformBaseUrl: string;
|
|
13
|
+
private readonly apiKey: string;
|
|
14
|
+
private readonly assistantId: string;
|
|
15
|
+
|
|
16
|
+
private constructor(
|
|
17
|
+
platformBaseUrl: string,
|
|
18
|
+
apiKey: string,
|
|
19
|
+
assistantId: string,
|
|
20
|
+
) {
|
|
21
|
+
this.platformBaseUrl = platformBaseUrl.replace(/\/+$/, "");
|
|
22
|
+
this.apiKey = apiKey;
|
|
23
|
+
this.assistantId = assistantId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a platform client by resolving managed proxy context.
|
|
28
|
+
*
|
|
29
|
+
* Returns `null` when auth prerequisites are missing (not logged in, no API
|
|
30
|
+
* key). The assistant ID is resolved but not required — callers that need it
|
|
31
|
+
* should check `platformAssistantId` themselves.
|
|
32
|
+
*/
|
|
33
|
+
static async create(): Promise<VellumPlatformClient | null> {
|
|
34
|
+
const ctx = await resolveManagedProxyContext();
|
|
35
|
+
if (!ctx.enabled) return null;
|
|
36
|
+
|
|
37
|
+
const assistantId = getPlatformAssistantId();
|
|
38
|
+
|
|
39
|
+
return new VellumPlatformClient(
|
|
40
|
+
ctx.platformBaseUrl,
|
|
41
|
+
ctx.assistantApiKey,
|
|
42
|
+
assistantId,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Authenticated fetch against the platform API.
|
|
48
|
+
*
|
|
49
|
+
* Prepends `platformBaseUrl` to `path` and injects the `Api-Key` auth header.
|
|
50
|
+
* Callers handle response parsing and domain-specific error mapping.
|
|
51
|
+
*/
|
|
52
|
+
async fetch(path: string, init?: RequestInit): Promise<Response> {
|
|
53
|
+
const url = `${this.platformBaseUrl}${path}`;
|
|
54
|
+
const headers = new Headers(init?.headers);
|
|
55
|
+
headers.set("Authorization", `Api-Key ${this.apiKey}`);
|
|
56
|
+
|
|
57
|
+
return fetch(url, { ...init, headers });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get baseUrl(): string {
|
|
61
|
+
return this.platformBaseUrl;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get assistantApiKey(): string {
|
|
65
|
+
return this.apiKey;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get platformAssistantId(): string {
|
|
69
|
+
return this.assistantId;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Module mocks (must precede dynamic imports)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
let mockOpenAIKey: string | undefined;
|
|
8
|
+
|
|
9
|
+
mock.module("../../security/secure-keys.js", () => ({
|
|
10
|
+
getProviderKeyAsync: async (provider: string) =>
|
|
11
|
+
provider === "openai" ? mockOpenAIKey : undefined,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Dynamic import so the mock is in effect when resolve.ts loads.
|
|
15
|
+
const { resolveSpeechToTextProvider } = await import("./resolve.js");
|
|
16
|
+
|
|
17
|
+
import { OpenAIWhisperProvider } from "./openai-whisper.js";
|
|
18
|
+
|
|
19
|
+
const TEST_API_KEY = "sk-test-key-for-unit-tests";
|
|
20
|
+
|
|
21
|
+
describe("OpenAIWhisperProvider", () => {
|
|
22
|
+
let originalFetch: typeof globalThis.fetch;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
originalFetch = globalThis.fetch;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
globalThis.fetch = originalFetch;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("successful transcription returns text", async () => {
|
|
33
|
+
globalThis.fetch = (async (
|
|
34
|
+
_url: string | URL | Request,
|
|
35
|
+
_init?: RequestInit,
|
|
36
|
+
) => {
|
|
37
|
+
return new Response(JSON.stringify({ text: " Hello, world! " }), {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
});
|
|
41
|
+
}) as typeof fetch;
|
|
42
|
+
|
|
43
|
+
const provider = new OpenAIWhisperProvider(TEST_API_KEY);
|
|
44
|
+
const result = await provider.transcribe(
|
|
45
|
+
Buffer.from("fake-audio"),
|
|
46
|
+
"audio/ogg",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual({ text: "Hello, world!" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("API error throws with status and partial body", async () => {
|
|
53
|
+
const errorBody = JSON.stringify({
|
|
54
|
+
error: {
|
|
55
|
+
message: "Invalid API key provided",
|
|
56
|
+
type: "invalid_request_error",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
globalThis.fetch = (async (
|
|
61
|
+
_url: string | URL | Request,
|
|
62
|
+
_init?: RequestInit,
|
|
63
|
+
) => {
|
|
64
|
+
return new Response(errorBody, {
|
|
65
|
+
status: 401,
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
});
|
|
68
|
+
}) as typeof fetch;
|
|
69
|
+
|
|
70
|
+
const provider = new OpenAIWhisperProvider(TEST_API_KEY);
|
|
71
|
+
|
|
72
|
+
await expect(
|
|
73
|
+
provider.transcribe(Buffer.from("fake-audio"), "audio/wav"),
|
|
74
|
+
).rejects.toThrow("Whisper API error (401)");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("sends correct FormData structure (model=whisper-1, file blob with correct MIME)", async () => {
|
|
78
|
+
let capturedBody: FormData | undefined;
|
|
79
|
+
let capturedHeaders: HeadersInit | undefined;
|
|
80
|
+
|
|
81
|
+
globalThis.fetch = (async (
|
|
82
|
+
url: string | URL | Request,
|
|
83
|
+
init?: RequestInit,
|
|
84
|
+
) => {
|
|
85
|
+
capturedBody = init?.body as FormData;
|
|
86
|
+
capturedHeaders = init?.headers;
|
|
87
|
+
expect(url).toBe("https://api.openai.com/v1/audio/transcriptions");
|
|
88
|
+
expect(init?.method).toBe("POST");
|
|
89
|
+
|
|
90
|
+
return new Response(JSON.stringify({ text: "transcribed" }), {
|
|
91
|
+
status: 200,
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
});
|
|
94
|
+
}) as typeof fetch;
|
|
95
|
+
|
|
96
|
+
const provider = new OpenAIWhisperProvider(TEST_API_KEY);
|
|
97
|
+
await provider.transcribe(Buffer.from("fake-audio"), "audio/mpeg");
|
|
98
|
+
|
|
99
|
+
// Verify authorization header
|
|
100
|
+
expect(capturedHeaders).toEqual({
|
|
101
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Verify FormData contents
|
|
105
|
+
expect(capturedBody).toBeInstanceOf(FormData);
|
|
106
|
+
const model = capturedBody!.get("model");
|
|
107
|
+
expect(model).toBe("whisper-1");
|
|
108
|
+
|
|
109
|
+
const file = capturedBody!.get("file");
|
|
110
|
+
expect(file).toBeInstanceOf(Blob);
|
|
111
|
+
expect((file as Blob).type).toBe("audio/mpeg");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("returns empty text when API returns empty text field", async () => {
|
|
115
|
+
globalThis.fetch = (async () => {
|
|
116
|
+
return new Response(JSON.stringify({ text: "" }), {
|
|
117
|
+
status: 200,
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
});
|
|
120
|
+
}) as unknown as typeof fetch;
|
|
121
|
+
|
|
122
|
+
const provider = new OpenAIWhisperProvider(TEST_API_KEY);
|
|
123
|
+
const result = await provider.transcribe(
|
|
124
|
+
Buffer.from("silence"),
|
|
125
|
+
"audio/wav",
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual({ text: "" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("returns empty text when API response has no text property", async () => {
|
|
132
|
+
globalThis.fetch = (async () => {
|
|
133
|
+
return new Response(JSON.stringify({}), {
|
|
134
|
+
status: 200,
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
});
|
|
137
|
+
}) as unknown as typeof fetch;
|
|
138
|
+
|
|
139
|
+
const provider = new OpenAIWhisperProvider(TEST_API_KEY);
|
|
140
|
+
const result = await provider.transcribe(
|
|
141
|
+
Buffer.from("silence"),
|
|
142
|
+
"audio/wav",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual({ text: "" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("error body is truncated to 300 characters", async () => {
|
|
149
|
+
const longBody = "x".repeat(500);
|
|
150
|
+
|
|
151
|
+
globalThis.fetch = (async () => {
|
|
152
|
+
return new Response(longBody, { status: 500 });
|
|
153
|
+
}) as unknown as typeof fetch;
|
|
154
|
+
|
|
155
|
+
const provider = new OpenAIWhisperProvider(TEST_API_KEY);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await provider.transcribe(Buffer.from("audio"), "audio/wav");
|
|
159
|
+
expect.unreachable("should have thrown");
|
|
160
|
+
} catch (err) {
|
|
161
|
+
const msg = (err as Error).message;
|
|
162
|
+
expect(msg).toContain("Whisper API error (500)");
|
|
163
|
+
// The body portion should be at most 300 chars
|
|
164
|
+
const bodyPart = msg.replace("Whisper API error (500): ", "");
|
|
165
|
+
expect(bodyPart.length).toBeLessThanOrEqual(300);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// resolveSpeechToTextProvider
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
describe("resolveSpeechToTextProvider", () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
mockOpenAIKey = undefined;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("returns null when no OpenAI key is configured", async () => {
|
|
180
|
+
mockOpenAIKey = undefined;
|
|
181
|
+
const provider = await resolveSpeechToTextProvider();
|
|
182
|
+
expect(provider).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("returns an OpenAIWhisperProvider when key exists", async () => {
|
|
186
|
+
mockOpenAIKey = "sk-real-key";
|
|
187
|
+
const provider = await resolveSpeechToTextProvider();
|
|
188
|
+
expect(provider).toBeInstanceOf(OpenAIWhisperProvider);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { SpeechToTextProvider, SpeechToTextResult } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const WHISPER_API_URL = "https://api.openai.com/v1/audio/transcriptions";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Derive a filename extension from a MIME type so the Whisper API can detect
|
|
8
|
+
* the audio format. Falls back to "audio" when the MIME type is unrecognised.
|
|
9
|
+
*/
|
|
10
|
+
function extensionFromMime(mimeType: string): string {
|
|
11
|
+
const map: Record<string, string> = {
|
|
12
|
+
"audio/wav": "wav",
|
|
13
|
+
"audio/x-wav": "wav",
|
|
14
|
+
"audio/mpeg": "mp3",
|
|
15
|
+
"audio/mp3": "mp3",
|
|
16
|
+
"audio/ogg": "ogg",
|
|
17
|
+
"audio/opus": "opus",
|
|
18
|
+
"audio/webm": "webm",
|
|
19
|
+
"audio/mp4": "m4a",
|
|
20
|
+
"audio/x-m4a": "m4a",
|
|
21
|
+
"audio/flac": "flac",
|
|
22
|
+
};
|
|
23
|
+
const base = mimeType.split(";")[0].trim().toLowerCase();
|
|
24
|
+
return map[base] ?? "audio";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class OpenAIWhisperProvider implements SpeechToTextProvider {
|
|
28
|
+
private readonly apiKey: string;
|
|
29
|
+
|
|
30
|
+
constructor(apiKey: string) {
|
|
31
|
+
this.apiKey = apiKey;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async transcribe(
|
|
35
|
+
audio: Buffer,
|
|
36
|
+
mimeType: string,
|
|
37
|
+
signal?: AbortSignal,
|
|
38
|
+
): Promise<SpeechToTextResult> {
|
|
39
|
+
const ext = extensionFromMime(mimeType);
|
|
40
|
+
|
|
41
|
+
const formData = new FormData();
|
|
42
|
+
formData.append(
|
|
43
|
+
"file",
|
|
44
|
+
new Blob([new Uint8Array(audio)], { type: mimeType }),
|
|
45
|
+
`audio.${ext}`,
|
|
46
|
+
);
|
|
47
|
+
formData.append("model", "whisper-1");
|
|
48
|
+
|
|
49
|
+
const effectiveSignal = signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
|
|
50
|
+
|
|
51
|
+
const response = await fetch(WHISPER_API_URL, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
54
|
+
body: formData,
|
|
55
|
+
signal: effectiveSignal,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const body = await response.text().catch(() => "");
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Whisper API error (${response.status}): ${body.slice(0, 300)}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = (await response.json()) as { text?: string };
|
|
66
|
+
return { text: result.text?.trim() ?? "" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getProviderKeyAsync } from "../../security/secure-keys.js";
|
|
2
|
+
import { OpenAIWhisperProvider } from "./openai-whisper.js";
|
|
3
|
+
import type { SpeechToTextProvider } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export async function resolveSpeechToTextProvider(): Promise<SpeechToTextProvider | null> {
|
|
6
|
+
const apiKey = await getProviderKeyAsync("openai");
|
|
7
|
+
if (!apiKey) return null;
|
|
8
|
+
return new OpenAIWhisperProvider(apiKey);
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SpeechToTextResult {
|
|
2
|
+
text: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface SpeechToTextProvider {
|
|
6
|
+
/**
|
|
7
|
+
* Transcribe audio from a Buffer.
|
|
8
|
+
* @param audio - Raw audio data (WAV, OGG, MP3, etc.)
|
|
9
|
+
* @param mimeType - MIME type of the audio data
|
|
10
|
+
* @param signal - Optional abort signal for cancellation
|
|
11
|
+
*/
|
|
12
|
+
transcribe(
|
|
13
|
+
audio: Buffer,
|
|
14
|
+
mimeType: string,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
): Promise<SpeechToTextResult>;
|
|
17
|
+
}
|
|
@@ -176,6 +176,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
|
|
|
176
176
|
|
|
177
177
|
// Settings / integrations / identity
|
|
178
178
|
{ endpoint: "identity", scopes: ["settings.read"] },
|
|
179
|
+
{ endpoint: "identity/intro", scopes: ["settings.read"] },
|
|
179
180
|
{ endpoint: "brain-graph", scopes: ["settings.read"] },
|
|
180
181
|
{ endpoint: "brain-graph-ui", scopes: ["settings.read"] },
|
|
181
182
|
{ endpoint: "contacts", scopes: ["settings.read"] },
|
|
@@ -347,6 +348,10 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
|
|
|
347
348
|
{ endpoint: "config/embeddings:GET", scopes: ["settings.read"] },
|
|
348
349
|
{ endpoint: "config/embeddings:PUT", scopes: ["settings.write"] },
|
|
349
350
|
|
|
351
|
+
// Permissions config
|
|
352
|
+
{ endpoint: "config/permissions/skip:GET", scopes: ["settings.read"] },
|
|
353
|
+
{ endpoint: "config/permissions/skip:PUT", scopes: ["settings.write"] },
|
|
354
|
+
|
|
350
355
|
// Conversation management
|
|
351
356
|
{ endpoint: "conversations:DELETE", scopes: ["chat.write"] },
|
|
352
357
|
{ endpoint: "conversations/wipe", scopes: ["chat.write"] },
|
|
@@ -355,6 +360,9 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
|
|
|
355
360
|
// Conversation search
|
|
356
361
|
{ endpoint: "conversations/search", scopes: ["chat.read"] },
|
|
357
362
|
|
|
363
|
+
// Conversation starters
|
|
364
|
+
{ endpoint: "conversation-starters", scopes: ["chat.read"] },
|
|
365
|
+
|
|
358
366
|
// Message content
|
|
359
367
|
{ endpoint: "messages/content", scopes: ["chat.read"] },
|
|
360
368
|
{ endpoint: "messages/llm-context", scopes: ["chat.read"] },
|
|
@@ -498,3 +506,9 @@ for (const endpoint of INTERNAL_ENDPOINTS) {
|
|
|
498
506
|
allowedPrincipalTypes: ["svc_gateway"],
|
|
499
507
|
});
|
|
500
508
|
}
|
|
509
|
+
|
|
510
|
+
// Admin control-plane endpoints: gateway-only
|
|
511
|
+
registerPolicy("admin/upgrade-broadcast", {
|
|
512
|
+
requiredScopes: ["internal.write"],
|
|
513
|
+
allowedPrincipalTypes: ["svc_gateway"],
|
|
514
|
+
});
|
|
@@ -28,6 +28,22 @@ import type { ScopeProfile, TokenAudience, TokenClaims } from "./types.js";
|
|
|
28
28
|
|
|
29
29
|
const log = getLogger("token-service");
|
|
30
30
|
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Bootstrap sentinel error
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Thrown when the gateway's signing-key bootstrap endpoint returns 403,
|
|
37
|
+
* indicating that bootstrap has already completed (daemon restart case).
|
|
38
|
+
* The caller should fall back to loading the key from disk.
|
|
39
|
+
*/
|
|
40
|
+
export class BootstrapAlreadyCompleted extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super("Gateway signing key bootstrap already completed");
|
|
43
|
+
this.name = "BootstrapAlreadyCompleted";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
// ---------------------------------------------------------------------------
|
|
32
48
|
// Signing key management
|
|
33
49
|
// ---------------------------------------------------------------------------
|
|
@@ -78,6 +94,123 @@ export function loadOrCreateSigningKey(): Buffer {
|
|
|
78
94
|
return newKey;
|
|
79
95
|
}
|
|
80
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Fetch the shared signing key from the gateway's bootstrap endpoint.
|
|
99
|
+
*
|
|
100
|
+
* Used in Docker mode where the gateway owns the signing key and the daemon
|
|
101
|
+
* must fetch it at startup. Retries up to 30 times with 1s intervals to
|
|
102
|
+
* tolerate gateway startup delays.
|
|
103
|
+
*
|
|
104
|
+
* @returns A 32-byte Buffer containing the signing key.
|
|
105
|
+
* @throws {BootstrapAlreadyCompleted} If the gateway returns 403 (bootstrap
|
|
106
|
+
* already completed — daemon restart case). Caller should fall back to
|
|
107
|
+
* loading the key from disk.
|
|
108
|
+
* @throws {Error} If the gateway is unreachable after all retry attempts.
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchSigningKeyFromGateway(): Promise<Buffer> {
|
|
111
|
+
const gatewayUrl = process.env.GATEWAY_INTERNAL_URL;
|
|
112
|
+
if (!gatewayUrl) {
|
|
113
|
+
throw new Error("GATEWAY_INTERNAL_URL not set — cannot fetch signing key");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const maxAttempts = 30;
|
|
117
|
+
const intervalMs = 1000;
|
|
118
|
+
|
|
119
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
120
|
+
let resp: Response | undefined;
|
|
121
|
+
try {
|
|
122
|
+
resp = await fetch(`${gatewayUrl}/internal/signing-key-bootstrap`, {
|
|
123
|
+
signal: AbortSignal.timeout(5000),
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
log.warn(
|
|
127
|
+
{ err, attempt },
|
|
128
|
+
"Signing key bootstrap: connection failed, retrying",
|
|
129
|
+
);
|
|
130
|
+
await Bun.sleep(intervalMs);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (resp.ok) {
|
|
135
|
+
const body = (await resp.json()) as { key: string };
|
|
136
|
+
const keyBuf = Buffer.from(body.key, "hex");
|
|
137
|
+
if (keyBuf.length !== 32) {
|
|
138
|
+
throw new Error(`Invalid signing key length: ${keyBuf.length}`);
|
|
139
|
+
}
|
|
140
|
+
log.info("Signing key fetched from gateway bootstrap endpoint");
|
|
141
|
+
return keyBuf;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (resp.status === 403) {
|
|
145
|
+
// Bootstrap already completed — fall through to file-based load.
|
|
146
|
+
// This happens on daemon restart when the gateway lockfile persists.
|
|
147
|
+
log.info(
|
|
148
|
+
"Gateway signing key bootstrap already completed — loading from disk",
|
|
149
|
+
);
|
|
150
|
+
throw new BootstrapAlreadyCompleted();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
log.warn(
|
|
154
|
+
{ status: resp.status, attempt },
|
|
155
|
+
"Signing key bootstrap: gateway not ready, retrying",
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
await Bun.sleep(intervalMs);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw new Error("Signing key bootstrap: timed out waiting for gateway");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Persist a signing key to disk using an atomic-write pattern.
|
|
166
|
+
* Used after fetching the key from the gateway so daemon restarts can
|
|
167
|
+
* load it from disk when the gateway returns 403.
|
|
168
|
+
*/
|
|
169
|
+
function persistSigningKey(key: Buffer): void {
|
|
170
|
+
const keyPath = getSigningKeyPath();
|
|
171
|
+
const dir = dirname(keyPath);
|
|
172
|
+
if (!existsSync(dir)) {
|
|
173
|
+
mkdirSync(dir, { recursive: true });
|
|
174
|
+
}
|
|
175
|
+
const tmpPath = keyPath + ".tmp." + process.pid;
|
|
176
|
+
writeFileSync(tmpPath, key, { mode: 0o600 });
|
|
177
|
+
renameSync(tmpPath, keyPath);
|
|
178
|
+
chmodSync(keyPath, 0o600);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the signing key for the current environment.
|
|
183
|
+
*
|
|
184
|
+
* In Docker mode (IS_CONTAINERIZED=true + GATEWAY_INTERNAL_URL set), fetches
|
|
185
|
+
* the key from the gateway's bootstrap endpoint and persists it locally for
|
|
186
|
+
* restart resilience. On daemon restart (gateway returns 403), falls back to
|
|
187
|
+
* loading the key from disk.
|
|
188
|
+
*
|
|
189
|
+
* In local mode, delegates to the existing file-based loadOrCreateSigningKey().
|
|
190
|
+
*/
|
|
191
|
+
export async function resolveSigningKey(): Promise<Buffer> {
|
|
192
|
+
const isContainerized = process.env.IS_CONTAINERIZED === "true";
|
|
193
|
+
const gatewayUrl = process.env.GATEWAY_INTERNAL_URL;
|
|
194
|
+
|
|
195
|
+
if (isContainerized && gatewayUrl) {
|
|
196
|
+
try {
|
|
197
|
+
const key = await fetchSigningKeyFromGateway();
|
|
198
|
+
// Persist locally so daemon restarts (where gateway returns 403) load from disk.
|
|
199
|
+
persistSigningKey(key);
|
|
200
|
+
return key;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof BootstrapAlreadyCompleted) {
|
|
203
|
+
// Gateway already bootstrapped (daemon restart) — load from disk.
|
|
204
|
+
return loadOrCreateSigningKey();
|
|
205
|
+
}
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Local mode: use file-based load/create (unchanged behavior).
|
|
211
|
+
return loadOrCreateSigningKey();
|
|
212
|
+
}
|
|
213
|
+
|
|
81
214
|
function getSigningKey(): Buffer {
|
|
82
215
|
if (!_authSigningKey) {
|
|
83
216
|
if (process.env.NODE_ENV === "test") {
|