@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.
Files changed (44) hide show
  1. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  2. package/node_modules/@vellumai/ces-client/src/http-credentials.ts +161 -0
  3. package/node_modules/@vellumai/ces-client/src/index.ts +1 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  5. package/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  6. package/openapi.yaml +1 -1
  7. package/package.json +1 -1
  8. package/scripts/sync-llm-catalog.ts +6 -0
  9. package/src/__tests__/credential-record-write-through.test.ts +78 -0
  10. package/src/__tests__/delete-propagation.test.ts +92 -2
  11. package/src/__tests__/edit-propagation.test.ts +42 -2
  12. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  13. package/src/__tests__/oauth-connect-orchestrator.test.ts +95 -0
  14. package/src/__tests__/provider-catalog-visibility.test.ts +17 -0
  15. package/src/__tests__/provider-platform-proxy-integration.test.ts +8 -1
  16. package/src/calls/__tests__/voice-session-bridge.test.ts +37 -0
  17. package/src/calls/voice-session-bridge.ts +13 -0
  18. package/src/config/feature-flag-registry.json +16 -0
  19. package/src/config/schemas/llm.ts +4 -4
  20. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +64 -0
  21. package/src/live-voice/live-voice-session.ts +11 -1
  22. package/src/messaging/read-provider-metadata.ts +32 -0
  23. package/src/oauth/connect-orchestrator.ts +16 -0
  24. package/src/oauth/seed-providers.ts +1 -0
  25. package/src/persistence/conversation-crud.ts +3 -1
  26. package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
  27. package/src/providers/__tests__/vellum-connection-routing.test.ts +21 -3
  28. package/src/providers/inference/adapter-factory.ts +15 -0
  29. package/src/providers/inference/auth.ts +14 -14
  30. package/src/providers/model-catalog.ts +25 -0
  31. package/src/providers/platform-proxy/constants.ts +5 -0
  32. package/src/providers/provider-secret-catalog.ts +3 -2
  33. package/src/providers/vellum/client.ts +29 -0
  34. package/src/providers/vellum-model-routing.test.ts +2 -0
  35. package/src/providers/vellum-model-routing.ts +3 -3
  36. package/src/runtime/routes/inbound-message-handler.ts +26 -39
  37. package/src/runtime/routes/inbound-stages/edit-intercept.ts +20 -2
  38. package/src/runtime/routes/migration-routes.ts +3 -1
  39. package/src/security/ces-rpc-record-backend.ts +123 -0
  40. package/src/security/secure-keys.ts +15 -0
  41. package/src/tools/credentials/metadata-store.ts +65 -16
  42. package/src/tools/credentials/store.ts +2 -0
  43. package/src/watch/__tests__/watch-retro.test.ts +48 -1
  44. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.7-staging.1",
3
+ "version": "0.11.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 is a no-op (legacy row)", async () => {
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(false);
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
- "slack",
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: "slack" }, skipIndexing: true },
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