@vellumai/assistant 0.11.7-staging.1 → 0.11.7
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/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/node_modules/@vellumai/ces-client/src/http-credentials.ts +161 -0
- package/node_modules/@vellumai/ces-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/openapi.yaml +1 -1
- package/package.json +1 -1
- package/scripts/sync-llm-catalog.ts +6 -0
- package/src/__tests__/credential-record-write-through.test.ts +78 -0
- package/src/__tests__/delete-propagation.test.ts +92 -2
- package/src/__tests__/edit-propagation.test.ts +42 -2
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/oauth-connect-orchestrator.test.ts +95 -0
- package/src/__tests__/provider-catalog-visibility.test.ts +17 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +8 -1
- package/src/calls/__tests__/voice-session-bridge.test.ts +37 -0
- package/src/calls/voice-session-bridge.ts +13 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/schemas/llm.ts +4 -4
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +64 -0
- package/src/live-voice/live-voice-session.ts +11 -1
- package/src/messaging/read-provider-metadata.ts +32 -0
- package/src/oauth/connect-orchestrator.ts +16 -0
- package/src/oauth/seed-providers.ts +1 -0
- package/src/persistence/conversation-crud.ts +3 -1
- package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
- package/src/providers/__tests__/vellum-connection-routing.test.ts +21 -3
- package/src/providers/inference/adapter-factory.ts +15 -0
- package/src/providers/inference/auth.ts +14 -14
- package/src/providers/model-catalog.ts +25 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/provider-secret-catalog.ts +3 -2
- package/src/providers/vellum/client.ts +29 -0
- package/src/providers/vellum-model-routing.test.ts +2 -0
- package/src/providers/vellum-model-routing.ts +3 -3
- package/src/runtime/routes/inbound-message-handler.ts +26 -39
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +20 -2
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/security/ces-rpc-record-backend.ts +123 -0
- package/src/security/secure-keys.ts +15 -0
- package/src/tools/credentials/metadata-store.ts +65 -16
- package/src/tools/credentials/store.ts +2 -0
- package/src/watch/__tests__/watch-retro.test.ts +48 -1
- package/src/watch/watch-retro.ts +46 -4
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* - `delete_credential` — Delete a credential by account name
|
|
17
17
|
* - `list_credentials` — List all credential account names
|
|
18
18
|
* - `bulk_set_credentials` — Store multiple credentials at once
|
|
19
|
+
*
|
|
20
|
+
* **Credential records** (non-secret identity + policy)
|
|
21
|
+
* - `get_credential_record` — Retrieve a credential record by account name
|
|
22
|
+
* - `set_credential_record` — Store or update a credential record
|
|
23
|
+
* - `delete_credential_record` — Delete a credential record by account name
|
|
24
|
+
* - `list_credential_records` — List all credential records
|
|
25
|
+
* - `bulk_set_credential_records` — Store multiple credential records at once
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
import { z } from "zod";
|
|
@@ -38,6 +45,16 @@ export const CesRpcMethod = {
|
|
|
38
45
|
ListCredentials: "list_credentials",
|
|
39
46
|
/** Bulk-import credentials (set multiple at once). */
|
|
40
47
|
BulkSetCredentials: "bulk_set_credentials",
|
|
48
|
+
/** Retrieve a single credential record (identity + policy) by account name. */
|
|
49
|
+
GetCredentialRecord: "get_credential_record",
|
|
50
|
+
/** Store or update a credential record by account name. */
|
|
51
|
+
SetCredentialRecord: "set_credential_record",
|
|
52
|
+
/** Delete a credential record by account name. */
|
|
53
|
+
DeleteCredentialRecord: "delete_credential_record",
|
|
54
|
+
/** List all credential records. */
|
|
55
|
+
ListCredentialRecords: "list_credential_records",
|
|
56
|
+
/** Bulk-import credential records. */
|
|
57
|
+
BulkSetCredentialRecords: "bulk_set_credential_records",
|
|
41
58
|
} as const;
|
|
42
59
|
|
|
43
60
|
export type CesRpcMethod = (typeof CesRpcMethod)[keyof typeof CesRpcMethod];
|
|
@@ -168,6 +185,123 @@ export type BulkSetCredentialsResponse = z.infer<
|
|
|
168
185
|
typeof BulkSetCredentialsResponseSchema
|
|
169
186
|
>;
|
|
170
187
|
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Credential records (identity + policy, never secret values)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
export const CredentialInjectionTemplateSchema = z.object({
|
|
193
|
+
hostPattern: z.string(),
|
|
194
|
+
injectionType: z.enum(["header", "query"]),
|
|
195
|
+
headerName: z.string().optional(),
|
|
196
|
+
valuePrefix: z.string().optional(),
|
|
197
|
+
queryParamName: z.string().optional(),
|
|
198
|
+
composeWith: z
|
|
199
|
+
.object({
|
|
200
|
+
service: z.string(),
|
|
201
|
+
field: z.string(),
|
|
202
|
+
separator: z.string(),
|
|
203
|
+
})
|
|
204
|
+
.optional(),
|
|
205
|
+
valueTransform: z.enum(["base64"]).optional(),
|
|
206
|
+
});
|
|
207
|
+
export type CredentialInjectionTemplate = z.infer<
|
|
208
|
+
typeof CredentialInjectionTemplateSchema
|
|
209
|
+
>;
|
|
210
|
+
|
|
211
|
+
export const CredentialRecordSchema = z.object({
|
|
212
|
+
credentialId: z.string(),
|
|
213
|
+
service: z.string(),
|
|
214
|
+
field: z.string(),
|
|
215
|
+
allowedTools: z.array(z.string()),
|
|
216
|
+
allowedDomains: z.array(z.string()),
|
|
217
|
+
usageDescription: z.string().optional(),
|
|
218
|
+
alias: z.string().optional(),
|
|
219
|
+
injectionTemplates: z.array(CredentialInjectionTemplateSchema).optional(),
|
|
220
|
+
createdAt: z.number(),
|
|
221
|
+
updatedAt: z.number(),
|
|
222
|
+
});
|
|
223
|
+
export type CredentialRecord = z.infer<typeof CredentialRecordSchema>;
|
|
224
|
+
|
|
225
|
+
export const GetCredentialRecordSchema = z.object({
|
|
226
|
+
/** The account name to look up (`credential/{service}/{field}`). */
|
|
227
|
+
account: z.string(),
|
|
228
|
+
});
|
|
229
|
+
export type GetCredentialRecord = z.infer<typeof GetCredentialRecordSchema>;
|
|
230
|
+
|
|
231
|
+
export const GetCredentialRecordResponseSchema = z.object({
|
|
232
|
+
found: z.boolean(),
|
|
233
|
+
record: CredentialRecordSchema.optional(),
|
|
234
|
+
});
|
|
235
|
+
export type GetCredentialRecordResponse = z.infer<
|
|
236
|
+
typeof GetCredentialRecordResponseSchema
|
|
237
|
+
>;
|
|
238
|
+
|
|
239
|
+
export const SetCredentialRecordSchema = z.object({
|
|
240
|
+
account: z.string(),
|
|
241
|
+
record: CredentialRecordSchema,
|
|
242
|
+
});
|
|
243
|
+
export type SetCredentialRecord = z.infer<typeof SetCredentialRecordSchema>;
|
|
244
|
+
|
|
245
|
+
export const SetCredentialRecordResponseSchema = z.object({
|
|
246
|
+
ok: z.boolean(),
|
|
247
|
+
});
|
|
248
|
+
export type SetCredentialRecordResponse = z.infer<
|
|
249
|
+
typeof SetCredentialRecordResponseSchema
|
|
250
|
+
>;
|
|
251
|
+
|
|
252
|
+
export const DeleteCredentialRecordSchema = z.object({
|
|
253
|
+
account: z.string(),
|
|
254
|
+
});
|
|
255
|
+
export type DeleteCredentialRecord = z.infer<
|
|
256
|
+
typeof DeleteCredentialRecordSchema
|
|
257
|
+
>;
|
|
258
|
+
|
|
259
|
+
export const DeleteCredentialRecordResponseSchema = z.object({
|
|
260
|
+
result: z.enum(["deleted", "not-found", "error"]),
|
|
261
|
+
});
|
|
262
|
+
export type DeleteCredentialRecordResponse = z.infer<
|
|
263
|
+
typeof DeleteCredentialRecordResponseSchema
|
|
264
|
+
>;
|
|
265
|
+
|
|
266
|
+
export const ListCredentialRecordsSchema = z.object({});
|
|
267
|
+
export type ListCredentialRecords = z.infer<typeof ListCredentialRecordsSchema>;
|
|
268
|
+
|
|
269
|
+
export const ListCredentialRecordsResponseSchema = z.object({
|
|
270
|
+
records: z.array(
|
|
271
|
+
z.object({
|
|
272
|
+
account: z.string(),
|
|
273
|
+
record: CredentialRecordSchema,
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
});
|
|
277
|
+
export type ListCredentialRecordsResponse = z.infer<
|
|
278
|
+
typeof ListCredentialRecordsResponseSchema
|
|
279
|
+
>;
|
|
280
|
+
|
|
281
|
+
export const BulkSetCredentialRecordsSchema = z.object({
|
|
282
|
+
records: z.array(
|
|
283
|
+
z.object({
|
|
284
|
+
account: z.string(),
|
|
285
|
+
record: CredentialRecordSchema,
|
|
286
|
+
}),
|
|
287
|
+
),
|
|
288
|
+
});
|
|
289
|
+
export type BulkSetCredentialRecords = z.infer<
|
|
290
|
+
typeof BulkSetCredentialRecordsSchema
|
|
291
|
+
>;
|
|
292
|
+
|
|
293
|
+
export const BulkSetCredentialRecordsResponseSchema = z.object({
|
|
294
|
+
results: z.array(
|
|
295
|
+
z.object({
|
|
296
|
+
account: z.string(),
|
|
297
|
+
ok: z.boolean(),
|
|
298
|
+
}),
|
|
299
|
+
),
|
|
300
|
+
});
|
|
301
|
+
export type BulkSetCredentialRecordsResponse = z.infer<
|
|
302
|
+
typeof BulkSetCredentialRecordsResponseSchema
|
|
303
|
+
>;
|
|
304
|
+
|
|
171
305
|
// ---------------------------------------------------------------------------
|
|
172
306
|
// Full RPC contract type map
|
|
173
307
|
// ---------------------------------------------------------------------------
|
|
@@ -201,6 +335,26 @@ export interface CesRpcContract {
|
|
|
201
335
|
request: BulkSetCredentials;
|
|
202
336
|
response: BulkSetCredentialsResponse;
|
|
203
337
|
};
|
|
338
|
+
[CesRpcMethod.GetCredentialRecord]: {
|
|
339
|
+
request: GetCredentialRecord;
|
|
340
|
+
response: GetCredentialRecordResponse;
|
|
341
|
+
};
|
|
342
|
+
[CesRpcMethod.SetCredentialRecord]: {
|
|
343
|
+
request: SetCredentialRecord;
|
|
344
|
+
response: SetCredentialRecordResponse;
|
|
345
|
+
};
|
|
346
|
+
[CesRpcMethod.DeleteCredentialRecord]: {
|
|
347
|
+
request: DeleteCredentialRecord;
|
|
348
|
+
response: DeleteCredentialRecordResponse;
|
|
349
|
+
};
|
|
350
|
+
[CesRpcMethod.ListCredentialRecords]: {
|
|
351
|
+
request: ListCredentialRecords;
|
|
352
|
+
response: ListCredentialRecordsResponse;
|
|
353
|
+
};
|
|
354
|
+
[CesRpcMethod.BulkSetCredentialRecords]: {
|
|
355
|
+
request: BulkSetCredentialRecords;
|
|
356
|
+
response: BulkSetCredentialRecordsResponse;
|
|
357
|
+
};
|
|
204
358
|
}
|
|
205
359
|
|
|
206
360
|
/**
|
|
@@ -231,4 +385,24 @@ export const CesRpcSchemas = {
|
|
|
231
385
|
request: BulkSetCredentialsSchema,
|
|
232
386
|
response: BulkSetCredentialsResponseSchema,
|
|
233
387
|
},
|
|
388
|
+
[CesRpcMethod.GetCredentialRecord]: {
|
|
389
|
+
request: GetCredentialRecordSchema,
|
|
390
|
+
response: GetCredentialRecordResponseSchema,
|
|
391
|
+
},
|
|
392
|
+
[CesRpcMethod.SetCredentialRecord]: {
|
|
393
|
+
request: SetCredentialRecordSchema,
|
|
394
|
+
response: SetCredentialRecordResponseSchema,
|
|
395
|
+
},
|
|
396
|
+
[CesRpcMethod.DeleteCredentialRecord]: {
|
|
397
|
+
request: DeleteCredentialRecordSchema,
|
|
398
|
+
response: DeleteCredentialRecordResponseSchema,
|
|
399
|
+
},
|
|
400
|
+
[CesRpcMethod.ListCredentialRecords]: {
|
|
401
|
+
request: ListCredentialRecordsSchema,
|
|
402
|
+
response: ListCredentialRecordsResponseSchema,
|
|
403
|
+
},
|
|
404
|
+
[CesRpcMethod.BulkSetCredentialRecords]: {
|
|
405
|
+
request: BulkSetCredentialRecordsSchema,
|
|
406
|
+
response: BulkSetCredentialRecordsResponseSchema,
|
|
407
|
+
},
|
|
234
408
|
} as const;
|
package/openapi.yaml
CHANGED
|
@@ -15046,7 +15046,7 @@ paths:
|
|
|
15046
15046
|
type: string
|
|
15047
15047
|
description:
|
|
15048
15048
|
"Filter by provider id. One of: anthropic, openai, gemini, ollama, fireworks, together, openrouter,
|
|
15049
|
-
vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten, poolside"
|
|
15049
|
+
vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten, poolside, vellum"
|
|
15050
15050
|
responses:
|
|
15051
15051
|
"200":
|
|
15052
15052
|
description: Successful response
|
package/package.json
CHANGED
|
@@ -86,6 +86,9 @@ function projectModel(model: CatalogModel): Record<string, unknown> {
|
|
|
86
86
|
if (model.pricing !== undefined) {
|
|
87
87
|
projected.pricing = model.pricing;
|
|
88
88
|
}
|
|
89
|
+
if (model.featureFlag !== undefined) {
|
|
90
|
+
projected.featureFlag = model.featureFlag;
|
|
91
|
+
}
|
|
89
92
|
return projected;
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -115,6 +118,9 @@ function projectProvider(entry: ProviderCatalogEntry): Record<string, unknown> {
|
|
|
115
118
|
if (entry.supportsPlatformAuth !== undefined) {
|
|
116
119
|
projected.supportsPlatformAuth = entry.supportsPlatformAuth;
|
|
117
120
|
}
|
|
121
|
+
if (entry.featureFlag !== undefined) {
|
|
122
|
+
projected.featureFlag = entry.featureFlag;
|
|
123
|
+
}
|
|
118
124
|
projected.defaultModel = entry.defaultModel;
|
|
119
125
|
projected.models = entry.models.map(projectModel);
|
|
120
126
|
// NOTE: `apiKeyUrl` intentionally omitted — clients use
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { credentialKey } from "@vellumai/credential-storage";
|
|
4
|
+
import type { CredentialRecord } from "@vellumai/service-contracts/credential-rpc";
|
|
5
|
+
|
|
6
|
+
import type { CredentialRecordBackend } from "../security/ces-rpc-record-backend.js";
|
|
7
|
+
import {
|
|
8
|
+
_setMetadataPath,
|
|
9
|
+
persistCredentialMetadata,
|
|
10
|
+
setCredentialRecordBackend,
|
|
11
|
+
upsertCredentialMetadata,
|
|
12
|
+
} from "../tools/credentials/metadata-store.js";
|
|
13
|
+
|
|
14
|
+
function makeBackend(): CredentialRecordBackend & {
|
|
15
|
+
store: Map<string, CredentialRecord>;
|
|
16
|
+
} {
|
|
17
|
+
const store = new Map<string, CredentialRecord>();
|
|
18
|
+
return {
|
|
19
|
+
store,
|
|
20
|
+
isAvailable: () => true,
|
|
21
|
+
get: async (account) => store.get(account),
|
|
22
|
+
set: async (account, record) => {
|
|
23
|
+
store.set(account, record);
|
|
24
|
+
return true;
|
|
25
|
+
},
|
|
26
|
+
delete: async (account) => {
|
|
27
|
+
if (!store.has(account)) {
|
|
28
|
+
return "not-found";
|
|
29
|
+
}
|
|
30
|
+
store.delete(account);
|
|
31
|
+
return "deleted";
|
|
32
|
+
},
|
|
33
|
+
list: async () =>
|
|
34
|
+
[...store.entries()].map(([account, record]) => ({ account, record })),
|
|
35
|
+
bulkSet: async (records) => {
|
|
36
|
+
for (const { account, record } of records) {
|
|
37
|
+
store.set(account, record);
|
|
38
|
+
}
|
|
39
|
+
return records.map((entry) => ({ account: entry.account, ok: true }));
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("CES credential record write-through", () => {
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
setCredentialRecordBackend(undefined);
|
|
47
|
+
_setMetadataPath(null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("upsert write-through updates the CES backend", async () => {
|
|
51
|
+
const backend = makeBackend();
|
|
52
|
+
setCredentialRecordBackend(backend);
|
|
53
|
+
|
|
54
|
+
const created = upsertCredentialMetadata("slack_channel", "bot_token", {
|
|
55
|
+
allowedTools: ["bash"],
|
|
56
|
+
});
|
|
57
|
+
await persistCredentialMetadata(created);
|
|
58
|
+
|
|
59
|
+
const stored = backend.store.get(
|
|
60
|
+
credentialKey("slack_channel", "bot_token"),
|
|
61
|
+
);
|
|
62
|
+
expect(stored?.allowedTools).toEqual(["bash"]);
|
|
63
|
+
expect(stored?.credentialId).toBe(created.credentialId);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("test metadata path override skips CES write-through", async () => {
|
|
67
|
+
const backend = makeBackend();
|
|
68
|
+
setCredentialRecordBackend(backend);
|
|
69
|
+
_setMetadataPath("/tmp/does-not-write-through-metadata.json");
|
|
70
|
+
|
|
71
|
+
const created = upsertCredentialMetadata("github", "token", {
|
|
72
|
+
allowedTools: ["bash"],
|
|
73
|
+
});
|
|
74
|
+
await persistCredentialMetadata(created);
|
|
75
|
+
|
|
76
|
+
expect(backend.store.size).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
11
11
|
|
|
12
|
+
import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
|
|
13
|
+
|
|
12
14
|
mock.module("../config/env.js", () => ({
|
|
13
15
|
isHttpAuthDisabled: () => true,
|
|
14
16
|
getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
|
|
@@ -249,7 +251,10 @@ describe("Slack delete propagation", () => {
|
|
|
249
251
|
expect(slackMeta!.deletedAt).toBeUndefined();
|
|
250
252
|
});
|
|
251
253
|
|
|
252
|
-
test("delete for row without slackMeta
|
|
254
|
+
test("delete for row without slackMeta stamps the neutral metadata", async () => {
|
|
255
|
+
// A legacy pre-enrichment row still gets its delete marked: the neutral
|
|
256
|
+
// envelope is synthesized so readProviderMetadata serves the stamp to
|
|
257
|
+
// every channel-agnostic reader, and content stays for audit.
|
|
253
258
|
const seeded = seedSlackMessage({
|
|
254
259
|
externalChatId: "C0123CHANNEL",
|
|
255
260
|
originalTs: "2222.2222",
|
|
@@ -257,6 +262,7 @@ describe("Slack delete propagation", () => {
|
|
|
257
262
|
withSlackMeta: false,
|
|
258
263
|
});
|
|
259
264
|
|
|
265
|
+
const before = Date.now();
|
|
260
266
|
const req = buildSlackDeleteRequest({
|
|
261
267
|
externalChatId: seeded.externalChatId,
|
|
262
268
|
deletedTs: seeded.originalTs,
|
|
@@ -265,7 +271,7 @@ describe("Slack delete propagation", () => {
|
|
|
265
271
|
const json = (await resp.json()) as Record<string, unknown>;
|
|
266
272
|
|
|
267
273
|
expect(json.accepted).toBe(true);
|
|
268
|
-
expect(json.deleted).toBe(
|
|
274
|
+
expect(json.deleted).toBe(true);
|
|
269
275
|
|
|
270
276
|
const db = getDb();
|
|
271
277
|
const row = db
|
|
@@ -277,6 +283,90 @@ describe("Slack delete propagation", () => {
|
|
|
277
283
|
expect(row!.content).toBe("Legacy pre-upgrade text");
|
|
278
284
|
const parsed = JSON.parse(row!.metadata!) as Record<string, unknown>;
|
|
279
285
|
expect(parsed.slackMeta).toBeUndefined();
|
|
286
|
+
const neutral = readProviderMetadata(row!.metadata);
|
|
287
|
+
expect(neutral).not.toBeNull();
|
|
288
|
+
expect(neutral!.source).toBe("slack");
|
|
289
|
+
expect(neutral!.messageId).toBe(seeded.originalTs);
|
|
290
|
+
expect(neutral!.deletedAt).toBeDefined();
|
|
291
|
+
expect(neutral!.deletedAt!).toBeGreaterThanOrEqual(before);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a flat-legacy row's fields survive the delete stamp", async () => {
|
|
295
|
+
// Rows written before slackMeta nesting carry the Slack envelope flat in
|
|
296
|
+
// messages.metadata. The stamp bases on the mapped envelope, so thread
|
|
297
|
+
// and display identity remain readable beside deletedAt instead of
|
|
298
|
+
// being shadowed by a minimal synthesis.
|
|
299
|
+
const seeded = seedSlackMessage({
|
|
300
|
+
externalChatId: "C0123CHANNEL",
|
|
301
|
+
originalTs: "3333.3333",
|
|
302
|
+
content: "Flat legacy text",
|
|
303
|
+
withSlackMeta: false,
|
|
304
|
+
});
|
|
305
|
+
const db = getDb();
|
|
306
|
+
db.update(messages)
|
|
307
|
+
.set({
|
|
308
|
+
metadata: JSON.stringify({
|
|
309
|
+
source: "slack",
|
|
310
|
+
channelId: seeded.externalChatId,
|
|
311
|
+
channelTs: seeded.originalTs,
|
|
312
|
+
threadTs: "3000.0001",
|
|
313
|
+
eventKind: "message",
|
|
314
|
+
displayName: "Flat User",
|
|
315
|
+
}),
|
|
316
|
+
})
|
|
317
|
+
.where(eq(messages.id, seeded.messageId))
|
|
318
|
+
.run();
|
|
319
|
+
|
|
320
|
+
const req = buildSlackDeleteRequest({
|
|
321
|
+
externalChatId: seeded.externalChatId,
|
|
322
|
+
deletedTs: seeded.originalTs,
|
|
323
|
+
});
|
|
324
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
325
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
326
|
+
expect(json.deleted).toBe(true);
|
|
327
|
+
|
|
328
|
+
const row = db
|
|
329
|
+
.select()
|
|
330
|
+
.from(messages)
|
|
331
|
+
.where(eq(messages.id, seeded.messageId))
|
|
332
|
+
.get();
|
|
333
|
+
const neutral = readProviderMetadata(row!.metadata);
|
|
334
|
+
expect(neutral).not.toBeNull();
|
|
335
|
+
expect(neutral!.deletedAt).toBeDefined();
|
|
336
|
+
expect(neutral!.threadId).toBe("3000.0001");
|
|
337
|
+
expect(neutral!.displayName).toBe("Flat User");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("a row with malformed metadata still records its delete", async () => {
|
|
341
|
+
const seeded = seedSlackMessage({
|
|
342
|
+
externalChatId: "C0123CHANNEL",
|
|
343
|
+
originalTs: "4444.4444",
|
|
344
|
+
content: "Row with broken envelope",
|
|
345
|
+
withSlackMeta: false,
|
|
346
|
+
});
|
|
347
|
+
const db = getDb();
|
|
348
|
+
db.update(messages)
|
|
349
|
+
.set({ metadata: "{not json" })
|
|
350
|
+
.where(eq(messages.id, seeded.messageId))
|
|
351
|
+
.run();
|
|
352
|
+
|
|
353
|
+
const req = buildSlackDeleteRequest({
|
|
354
|
+
externalChatId: seeded.externalChatId,
|
|
355
|
+
deletedTs: seeded.originalTs,
|
|
356
|
+
});
|
|
357
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
358
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
359
|
+
expect(json.accepted).toBe(true);
|
|
360
|
+
expect(json.deleted).toBe(true);
|
|
361
|
+
|
|
362
|
+
const row = db
|
|
363
|
+
.select()
|
|
364
|
+
.from(messages)
|
|
365
|
+
.where(eq(messages.id, seeded.messageId))
|
|
366
|
+
.get();
|
|
367
|
+
const neutral = readProviderMetadata(row!.metadata);
|
|
368
|
+
expect(neutral).not.toBeNull();
|
|
369
|
+
expect(neutral!.deletedAt).toBeDefined();
|
|
280
370
|
});
|
|
281
371
|
|
|
282
372
|
test("delete missing sourceMetadata.messageId is a no-op", async () => {
|
|
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, test } from "bun:test";
|
|
|
12
12
|
import { eq } from "drizzle-orm";
|
|
13
13
|
|
|
14
14
|
import { readSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
|
|
15
|
+
import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
|
|
15
16
|
import { addMessage } from "../persistence/conversation-crud.js";
|
|
16
17
|
import { getConversationByKey } from "../persistence/conversation-key-store.js";
|
|
17
18
|
import { getDb, getMemoryDb } from "../persistence/db-connection.js";
|
|
@@ -72,11 +73,13 @@ async function seedSlackMessage(opts: {
|
|
|
72
73
|
conversationExternalId: string;
|
|
73
74
|
channelTs: string;
|
|
74
75
|
initialContent: string;
|
|
76
|
+
sourceChannel?: "slack" | "telegram";
|
|
75
77
|
}): Promise<SeededFixture> {
|
|
76
78
|
const { conversationExternalId, channelTs, initialContent } = opts;
|
|
79
|
+
const sourceChannel = opts.sourceChannel ?? "slack";
|
|
77
80
|
|
|
78
81
|
const inboundResult = recordInbound(
|
|
79
|
-
|
|
82
|
+
sourceChannel,
|
|
80
83
|
conversationExternalId,
|
|
81
84
|
channelTs,
|
|
82
85
|
{
|
|
@@ -88,7 +91,7 @@ async function seedSlackMessage(opts: {
|
|
|
88
91
|
inboundResult.conversationId,
|
|
89
92
|
"user",
|
|
90
93
|
initialContent,
|
|
91
|
-
{ metadata: { userMessageChannel:
|
|
94
|
+
{ metadata: { userMessageChannel: sourceChannel }, skipIndexing: true },
|
|
92
95
|
);
|
|
93
96
|
|
|
94
97
|
linkMessage(inboundResult.eventId, inserted.id);
|
|
@@ -171,6 +174,43 @@ describe("Slack edit propagation", () => {
|
|
|
171
174
|
expect(slackMeta!.editedAt!).toBeGreaterThanOrEqual(t0);
|
|
172
175
|
});
|
|
173
176
|
|
|
177
|
+
test("a Telegram edit stamps the neutral editedAt every reader serves", async () => {
|
|
178
|
+
// The neutral stamp is what makes an edit visible on channels without a
|
|
179
|
+
// provider envelope of their own: content rewrites in place and
|
|
180
|
+
// readProviderMetadata reports when.
|
|
181
|
+
const seeded = await seedSlackMessage({
|
|
182
|
+
conversationExternalId: "555010042",
|
|
183
|
+
channelTs: "60",
|
|
184
|
+
initialContent: "original text",
|
|
185
|
+
sourceChannel: "telegram",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const t0 = Date.now();
|
|
189
|
+
const resp = await handleEditIntercept({
|
|
190
|
+
sourceChannel: "telegram",
|
|
191
|
+
conversationExternalId: seeded.conversationExternalId,
|
|
192
|
+
externalMessageId: nextEditEventId(),
|
|
193
|
+
sourceMessageId: seeded.channelTs,
|
|
194
|
+
assistantId: "self",
|
|
195
|
+
content: "corrected text",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const respJson = resp as Record<string, unknown>;
|
|
199
|
+
expect(respJson.accepted).toBe(true);
|
|
200
|
+
expect(respJson.duplicate).toBe(false);
|
|
201
|
+
|
|
202
|
+
const after = readMessageRow(seeded.messageId);
|
|
203
|
+
expect(after.content).toBe("corrected text");
|
|
204
|
+
|
|
205
|
+
const neutral = readProviderMetadata(after.metadata);
|
|
206
|
+
expect(neutral).not.toBeNull();
|
|
207
|
+
expect(neutral!.source).toBe("telegram");
|
|
208
|
+
expect(neutral!.conversationExternalId).toBe(seeded.conversationExternalId);
|
|
209
|
+
expect(neutral!.messageId).toBe(seeded.channelTs);
|
|
210
|
+
expect(typeof neutral!.editedAt).toBe("number");
|
|
211
|
+
expect(neutral!.editedAt!).toBeGreaterThanOrEqual(t0);
|
|
212
|
+
});
|
|
213
|
+
|
|
174
214
|
// Times out on the default budget: an unresolvable target pays the full
|
|
175
215
|
// `EDIT_LOOKUP_RETRIES` window before it is dropped.
|
|
176
216
|
test("an edit of a message the assistant never stored creates no conversation", async () => {
|
|
@@ -67,6 +67,7 @@ interface ClientCatalogModel {
|
|
|
67
67
|
cacheWritePer1mTokens?: number;
|
|
68
68
|
}>;
|
|
69
69
|
};
|
|
70
|
+
featureFlag?: string;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
interface ClientCatalogEntry {
|
|
@@ -79,6 +80,7 @@ interface ClientCatalogEntry {
|
|
|
79
80
|
apiKeyPlaceholder?: string;
|
|
80
81
|
credentialsGuide?: ClientCatalogCredentialsGuide;
|
|
81
82
|
supportsPlatformAuth?: boolean;
|
|
83
|
+
featureFlag?: string;
|
|
82
84
|
defaultModel: string;
|
|
83
85
|
models: ClientCatalogModel[];
|
|
84
86
|
}
|
|
@@ -133,6 +135,7 @@ describe("LLM catalog parity: daemon vs client", () => {
|
|
|
133
135
|
expect(clientEntry.supportsPlatformAuth).toBe(
|
|
134
136
|
daemonEntry.supportsPlatformAuth,
|
|
135
137
|
);
|
|
138
|
+
expect(clientEntry.featureFlag).toBe(daemonEntry.featureFlag);
|
|
136
139
|
expect(clientEntry.credentialsGuide).toEqual(
|
|
137
140
|
daemonEntry.credentialsGuide,
|
|
138
141
|
);
|
|
@@ -199,6 +202,7 @@ describe("LLM catalog parity: daemon vs client", () => {
|
|
|
199
202
|
expect(clientModel.supportsVision).toBe(daemonModel.supportsVision);
|
|
200
203
|
expect(clientModel.supportsToolUse).toBe(daemonModel.supportsToolUse);
|
|
201
204
|
expect(clientModel.pricing).toEqual(daemonModel.pricing);
|
|
205
|
+
expect(clientModel.featureFlag).toBe(daemonModel.featureFlag);
|
|
202
206
|
}
|
|
203
207
|
}
|
|
204
208
|
});
|
|
@@ -133,6 +133,7 @@ mock.module("../inbound/public-ingress-urls.js", () => ({
|
|
|
133
133
|
// ---------------------------------------------------------------------------
|
|
134
134
|
|
|
135
135
|
import { orchestrateOAuthConnect } from "../oauth/connect-orchestrator.js";
|
|
136
|
+
import { setOverridesForTesting } from "./feature-flag-test-helpers.js";
|
|
136
137
|
import { setConfig } from "./helpers/set-config.js";
|
|
137
138
|
|
|
138
139
|
/** Seed `ingress.publicBaseUrl` in the real workspace config. */
|
|
@@ -219,6 +220,7 @@ beforeEach(() => {
|
|
|
219
220
|
setPublicBaseUrl("");
|
|
220
221
|
mockIdentityResult = "user@example.com";
|
|
221
222
|
mockProviderStore = {};
|
|
223
|
+
setOverridesForTesting({});
|
|
222
224
|
|
|
223
225
|
mockPrepareResult = {
|
|
224
226
|
authorizeUrl: "https://provider.example.com/authorize?prepared",
|
|
@@ -805,3 +807,96 @@ describe("orchestrateOAuthConnect — transport selection", () => {
|
|
|
805
807
|
});
|
|
806
808
|
});
|
|
807
809
|
});
|
|
810
|
+
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
// Feature-flag gating
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* A provider whose seed entry declares a `featureFlag` is only connectable
|
|
817
|
+
* while that flag is enabled. The orchestrator is the shared choke point for
|
|
818
|
+
* every connect path (runtime routes, gateway, CLI, credential vault tool),
|
|
819
|
+
* so the gate lives here rather than in each entry point.
|
|
820
|
+
*/
|
|
821
|
+
describe("orchestrateOAuthConnect — feature-flag gating", () => {
|
|
822
|
+
const GATED_PROVIDER = makeProviderRow({
|
|
823
|
+
provider: "gated",
|
|
824
|
+
displayLabel: "Gated",
|
|
825
|
+
loopbackPort: 17399,
|
|
826
|
+
featureFlag: "gated-provider-flag",
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("refuses to connect a gated provider when its flag is disabled", async () => {
|
|
830
|
+
mockProviderStore["gated"] = GATED_PROVIDER;
|
|
831
|
+
setOverridesForTesting({ "gated-provider-flag": false });
|
|
832
|
+
|
|
833
|
+
const result = await orchestrateOAuthConnect({
|
|
834
|
+
service: "gated",
|
|
835
|
+
clientId: "client-id",
|
|
836
|
+
isInteractive: false,
|
|
837
|
+
callbackTransport: "loopback",
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
expect(result.success).toBe(false);
|
|
841
|
+
// No authorization flow may be started for a gated provider.
|
|
842
|
+
expect(lastPrepareArgs).toBeNull();
|
|
843
|
+
expect(lastStartArgs).toBeNull();
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test("gated provider is indistinguishable from an unregistered one", async () => {
|
|
847
|
+
mockProviderStore["gated"] = GATED_PROVIDER;
|
|
848
|
+
setOverridesForTesting({ "gated-provider-flag": false });
|
|
849
|
+
|
|
850
|
+
const gated = await orchestrateOAuthConnect({
|
|
851
|
+
service: "gated",
|
|
852
|
+
clientId: "client-id",
|
|
853
|
+
isInteractive: false,
|
|
854
|
+
callbackTransport: "loopback",
|
|
855
|
+
});
|
|
856
|
+
const absent = await orchestrateOAuthConnect({
|
|
857
|
+
service: "not-a-provider",
|
|
858
|
+
clientId: "client-id",
|
|
859
|
+
isInteractive: false,
|
|
860
|
+
callbackTransport: "loopback",
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
expect(gated.success).toBe(false);
|
|
864
|
+
expect(absent.success).toBe(false);
|
|
865
|
+
if (gated.success || absent.success) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
// Identical wording so the gate does not leak that the provider exists.
|
|
869
|
+
expect(gated.error).toBe(absent.error.replace("not-a-provider", "gated"));
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("connects a gated provider once its flag is enabled", async () => {
|
|
873
|
+
mockProviderStore["gated"] = GATED_PROVIDER;
|
|
874
|
+
setOverridesForTesting({ "gated-provider-flag": true });
|
|
875
|
+
|
|
876
|
+
const result = await orchestrateOAuthConnect({
|
|
877
|
+
service: "gated",
|
|
878
|
+
clientId: "client-id",
|
|
879
|
+
isInteractive: false,
|
|
880
|
+
callbackTransport: "loopback",
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
expect(result.success).toBe(true);
|
|
884
|
+
expect(lastPrepareArgs).not.toBeNull();
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test("leaves ungated providers connectable", async () => {
|
|
888
|
+
mockProviderStore["google"] = GOOGLE_PROVIDER;
|
|
889
|
+
setOverridesForTesting({});
|
|
890
|
+
|
|
891
|
+
const result = await orchestrateOAuthConnect({
|
|
892
|
+
service: "google",
|
|
893
|
+
clientId: "client-id",
|
|
894
|
+
isInteractive: false,
|
|
895
|
+
callbackTransport: "loopback",
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
expect(GOOGLE_PROVIDER.featureFlag).toBeNull();
|
|
899
|
+
expect(result.success).toBe(true);
|
|
900
|
+
expect(lastPrepareArgs).not.toBeNull();
|
|
901
|
+
});
|
|
902
|
+
});
|
|
@@ -19,6 +19,23 @@ function makeConfig(): AssistantConfig {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
describe("getVisibleProviderCatalog", () => {
|
|
22
|
+
test("hides Vellum-hosted GPU models unless developer mode is on", () => {
|
|
23
|
+
setOverridesForTesting({});
|
|
24
|
+
const hidden = getVisibleProviderCatalog(makeConfig());
|
|
25
|
+
expect(hidden.find((p) => p.id === "vellum")).toBeUndefined();
|
|
26
|
+
expect(
|
|
27
|
+
hidden
|
|
28
|
+
.flatMap((p) => p.models)
|
|
29
|
+
.some((m) => m.id === "qwen/qwen3-8b"),
|
|
30
|
+
).toBe(false);
|
|
31
|
+
|
|
32
|
+
setOverridesForTesting({ "settings-developer-nav": true });
|
|
33
|
+
const visible = getVisibleProviderCatalog(makeConfig());
|
|
34
|
+
const vellum = visible.find((p) => p.id === "vellum");
|
|
35
|
+
expect(vellum).toBeDefined();
|
|
36
|
+
expect(vellum!.models.map((m) => m.id)).toEqual(["qwen/qwen3-8b"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
22
39
|
test("shows openai-compatible endpoints unconditionally (GA'ed)", () => {
|
|
23
40
|
setOverridesForTesting({});
|
|
24
41
|
|