@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
@@ -665,13 +665,14 @@ describe("config mode flip → provider reinit", () => {
665
665
  });
666
666
 
667
667
  describe("managed proxy integration — constants integrity", () => {
668
- test("anthropic, openai, gemini, fireworks, and together have metadata with managed=true and a proxyPath", () => {
668
+ test("anthropic, openai, gemini, fireworks, together, and vellum have metadata with managed=true and a proxyPath", () => {
669
669
  for (const provider of [
670
670
  "anthropic",
671
671
  "openai",
672
672
  "gemini",
673
673
  "fireworks",
674
674
  "together",
675
+ "vellum",
675
676
  ]) {
676
677
  const meta = PLATFORM_PROVIDER_META[provider];
677
678
  expect(meta).toBeDefined();
@@ -711,6 +712,12 @@ describe("managed proxy integration — constants integrity", () => {
711
712
  );
712
713
  });
713
714
 
715
+ test("vellum routes through the vellum runtime-proxy path", () => {
716
+ expect(PLATFORM_PROVIDER_META.vellum.proxyPath).toBe(
717
+ "/v1/runtime-proxy/vellum",
718
+ );
719
+ });
720
+
714
721
  test("openrouter is not managed proxy capable", () => {
715
722
  expect(PLATFORM_PROVIDER_META.openrouter.managed).toBe(false);
716
723
  expect(PLATFORM_PROVIDER_META.openrouter.proxyPath).toBeUndefined();
@@ -390,6 +390,7 @@ describe("startVoiceTurn escalation-continuation persistence", () => {
390
390
  );
391
391
  expect(fake.lastPersistOpts()?.metadata).toEqual({
392
392
  voiceSessionTurn: true,
393
+ scripted: true,
393
394
  hidden: true,
394
395
  messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
395
396
  });
@@ -404,8 +405,11 @@ describe("startVoiceTurn escalation-continuation persistence", () => {
404
405
 
405
406
  await startVoiceTurn(makeTurnOptions()); // content: CALL_OPENING_MARKER
406
407
 
408
+ // Visible AND scripted: the opener is shown, but the assistant wrote it,
409
+ // so it is not the user taking a turn.
407
410
  expect(fake.lastPersistOpts()?.metadata).toEqual({
408
411
  voiceSessionTurn: true,
412
+ scripted: true,
409
413
  });
410
414
  });
411
415
 
@@ -425,6 +429,7 @@ describe("startVoiceTurn escalation-continuation persistence", () => {
425
429
  // analytics already read.
426
430
  expect(fake.lastPersistOpts()?.metadata).toEqual({
427
431
  voiceSessionTurn: true,
432
+ scripted: true,
428
433
  client: {
429
434
  voice: true,
430
435
  voice_session_id: "session-123",
@@ -444,10 +449,41 @@ describe("startVoiceTurn escalation-continuation persistence", () => {
444
449
 
445
450
  expect(fake.lastPersistOpts()?.metadata).toEqual({
446
451
  voiceSessionTurn: true,
452
+ scripted: true,
447
453
  client: { voice: true, voice_session_id: "session-123" },
448
454
  });
449
455
  });
450
456
 
457
+ test("a turn the user really spoke is left unmarked, not marked false", async () => {
458
+ // Absent means UNKNOWN and falls through to the legacy classifier, which
459
+ // is the safe answer here. Stamping `false` would assert this turn was
460
+ // typed by the user, and a wrong `false` is trusted downstream.
461
+ const fake = makeFakeConversation({ processing: false });
462
+ fakeConversation = fake.conversation;
463
+
464
+ await startVoiceTurn({
465
+ ...makeTurnOptions(),
466
+ content: "what is on my calendar",
467
+ });
468
+
469
+ expect(fake.lastPersistOpts()?.metadata).not.toHaveProperty("scripted");
470
+ });
471
+
472
+ test("scripted is not derived from hidden", async () => {
473
+ // The two answer different questions, and the opener is the case that
474
+ // separates them: shown to the user, written by the assistant. Deriving
475
+ // one from the other lets every visible-but-scripted turn count as
476
+ // activation, which is the largest single source of funnel inflation.
477
+ const fake = makeFakeConversation({ processing: false });
478
+ fakeConversation = fake.conversation;
479
+
480
+ await startVoiceTurn(makeTurnOptions()); // content: CALL_OPENING_MARKER
481
+
482
+ const metadata = fake.lastPersistOpts()?.metadata;
483
+ expect(metadata?.scripted).toBe(true);
484
+ expect(metadata).not.toHaveProperty("hidden");
485
+ });
486
+
451
487
  test("a phone turn carries no client bag", async () => {
452
488
  const fake = makeFakeConversation({ processing: false });
453
489
  fakeConversation = fake.conversation;
@@ -501,6 +537,7 @@ describe("startVoiceTurn hiddenSyntheticPrompt", () => {
501
537
  expect(fake.lastPersistOpts()?.content).toBe(SYNTHETIC_CONTENT);
502
538
  expect(fake.lastPersistOpts()?.metadata).toEqual({
503
539
  voiceSessionTurn: true,
540
+ scripted: true,
504
541
  hidden: true,
505
542
  });
506
543
  expect(echoes).toHaveLength(0);
@@ -1031,6 +1031,19 @@ export async function startVoiceTurn(
1031
1031
  // Durable "this turn came from an open voice session" marker; see
1032
1032
  // `isVoiceSessionUserMessage` for why the channel fields cannot carry it.
1033
1033
  voiceSessionTurn: true,
1034
+ // Auto-sent on the user's behalf rather than spoken or typed by them,
1035
+ // so activation metrics exclude it (see the `scripted` contract on the
1036
+ // send route). Keyed off the synthetic set, NOT off
1037
+ // `isHiddenSyntheticPrompt`: the two answer different questions, and
1038
+ // deriving one from the other is what lets a visible-but-scripted turn
1039
+ // through. `hidden` decides whether a human sees the row; `scripted`
1040
+ // decides whether it counts as the user taking a turn.
1041
+ //
1042
+ // Only ever `true` here. A genuine voice turn is left absent rather
1043
+ // than stamped `false`, because absent means UNKNOWN and falls through
1044
+ // to the legacy classifier, while a wrong `false` is trusted
1045
+ // downstream.
1046
+ ...(isSyntheticVoicePrompt ? { scripted: true } : {}),
1034
1047
  ...(isHiddenSyntheticPrompt ? { hidden: true } : {}),
1035
1048
  ...(isEscalationContinuation
1036
1049
  ? { messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND }
@@ -404,6 +404,22 @@
404
404
  "label": "Channel Conversation Sidecar",
405
405
  "description": "Renders a conversation bound to an external channel (Slack, Telegram, Discord, phone, email, and any other channel adapter) as two lanes in web chat: Vellum stays the primary full-width lane, and the canonical external-channel transcript moves into the existing read-only right drawer, opened from a channel-thread control in the chat header. Rows the client can attribute to the external channel are drawn once, in the drawer, instead of being blended into the Vellum lane. The drawer offers an Open in <channel> secondary action and a per-message Reference in Vellum action that stages one quoted reference above the Vellum composer. The Vellum composer stays the only input; nothing about delivery, routing, or permissions changes. Off leaves the header deep-link pill and the blended transcript unchanged.",
406
406
  "defaultEnabled": false
407
+ },
408
+ {
409
+ "id": "monday-oauth",
410
+ "scope": "assistant",
411
+ "key": "monday-oauth",
412
+ "label": "monday.com Integration",
413
+ "description": "Gates the seeded monday.com OAuth provider. When off, the provider is hidden from GET /v1/oauth/providers, its get-by-id route, and the connect/update routes, so it cannot be listed or connected from the CLI, gateway, or web integrations list. The row is still seeded into oauth_providers on startup; only its visibility changes.",
414
+ "defaultEnabled": false
415
+ },
416
+ {
417
+ "id": "chatgpt-device-code-login",
418
+ "scope": "client",
419
+ "key": "chatgpt-device-code-login",
420
+ "label": "ChatGPT Device Code Login",
421
+ "description": "Gates the device-code sign-in as the primary ChatGPT subscription connect flow in the web client. Off: the redirect-and-paste flow is the only flow the connect section shows, with no device code UI and no other-sign-in-options disclosure. On: device code leads, and the redirect-and-paste flow stays reachable behind that disclosure and takes the section over when the assistant has no device-auth route.",
422
+ "defaultEnabled": false
407
423
  }
408
424
  ]
409
425
  }
@@ -58,10 +58,10 @@ export const KNOWN_LLM_PROVIDERS = [
58
58
  "opencode",
59
59
  "baseten",
60
60
  "poolside",
61
- // Routing identities rather than adapters: "vellum" = the platform-managed
62
- // route (upstream derived from the model at dispatch), "chatgpt" = the
63
- // subscription route to OpenAI. Neither has a PROVIDER_CATALOG entry;
64
- // dispatch substitutes the real upstream before any adapter lookup.
61
+ // Routing identities: "vellum" = the platform-managed route (upstream
62
+ // derived from the model at dispatch) and the catalog owner of
63
+ // Vellum-hosted GPU models; "chatgpt" = the subscription route to OpenAI.
64
+ // Dispatch substitutes a concrete upstream before adapter lookup.
65
65
  "vellum",
66
66
  "chatgpt",
67
67
  ] as const;
@@ -157,6 +157,70 @@ describe("live-voice session telemetry", () => {
157
157
  });
158
158
  });
159
159
 
160
+ /**
161
+ * Run one typed turn against a session with a real turn starter, then close.
162
+ *
163
+ * `readySession` wires none, so `handleTextTurn` returns before dispatching
164
+ * and every silence assertion against it passes for the wrong reason.
165
+ */
166
+ async function endAfterTypedTurn(frame: {
167
+ text: string;
168
+ hidden?: boolean;
169
+ }): Promise<void> {
170
+ let started = false;
171
+ const session = new LiveVoiceSession(createContext(), {
172
+ resolveTranscriber: mock(async () => new MockStreamingTranscriber()),
173
+ resolveCredentialReadiness: mock(
174
+ async (): Promise<LiveVoiceCredentialReadiness> => ({
175
+ status: "ready",
176
+ }),
177
+ ),
178
+ startVoiceTurn: mock(async () => {
179
+ started = true;
180
+ return { turnId: "bridge-turn-1", abort: mock() };
181
+ }),
182
+ emitMetrics: false,
183
+ });
184
+ await session.start();
185
+ await session.handleClientFrame({ type: "text", ...frame });
186
+ for (let attempt = 0; attempt < 40 && !started; attempt += 1) {
187
+ await new Promise((resolve) => setTimeout(resolve, 5));
188
+ }
189
+ if (!started) {
190
+ throw new Error("the typed turn never reached the bridge");
191
+ }
192
+ await session.close("client_end");
193
+ }
194
+
195
+ test("the greeting that opens a session does not spend its silence reason", async () => {
196
+ // The classification answers "did anything come from the person?". A
197
+ // session that greets and hears nothing back is silent, and counting the
198
+ // greeting would retire the taxonomy for every greeted session.
199
+ await endAfterTypedTurn({
200
+ text: "this message is sent automatically",
201
+ hidden: true,
202
+ });
203
+
204
+ expect(recordLiveVoiceSessionEnded).toHaveBeenCalledWith({
205
+ sessionId: "session-123",
206
+ screen: "ended_client_end:silent_no_audio",
207
+ outcome: "completed",
208
+ });
209
+ });
210
+
211
+ test("a turn the user really took clears the silence reason", async () => {
212
+ // The other half, and what keeps the test above honest: the same path with
213
+ // an ordinary typed turn is the person taking a turn, so the session is
214
+ // not silent and carries no classification at all.
215
+ await endAfterTypedTurn({ text: "typed by hand" });
216
+
217
+ expect(recordLiveVoiceSessionEnded).toHaveBeenCalledWith({
218
+ sessionId: "session-123",
219
+ screen: "ended_client_end",
220
+ outcome: "completed",
221
+ });
222
+ });
223
+
160
224
  test("a dropped socket ends as completed, not failed", async () => {
161
225
  const session = readySession();
162
226
  await session.start();
@@ -4953,7 +4953,17 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4953
4953
  // silent" onto a session that has turns, a contradictory row. Setting it
4954
4954
  // before can at worst leave a silent session unexplained, which is a gap
4955
4955
  // rather than a false statement.
4956
- this.dispatchedTurn = true;
4956
+ //
4957
+ // A synthetic prompt does not count. The silence classification answers
4958
+ // "did anything come from the person?", and the session opening by
4959
+ // greeting them is the session talking to itself. Counting it would
4960
+ // retire the whole taxonomy for every greeted session: `no_speech`,
4961
+ // `text_only` and `no_turn` become unreachable, and `no_audio` survives
4962
+ // only where capture failed early enough to withhold the greeting. That
4963
+ // is the one rate the funnel has for "voice didn't work for me".
4964
+ if (!activeTurn.hiddenPrompt) {
4965
+ this.dispatchedTurn = true;
4966
+ }
4957
4967
  const handle = await this.startVoiceTurn({
4958
4968
  conversationId: this.conversationId,
4959
4969
  voiceSessionId: this.context.sessionId,
@@ -1,3 +1,4 @@
1
+ import type { ChannelId } from "../channels/types.js";
1
2
  import { safeParseRecord } from "../util/json.js";
2
3
  import {
3
4
  type ProviderMessageMetadata,
@@ -49,3 +50,34 @@ export function readProviderMetadata(
49
50
  const slackMeta = readSlackMetadataFromMessageMetadata(metadata, opts);
50
51
  return slackMeta ? slackMetadataAsProviderMetadata(slackMeta) : null;
51
52
  }
53
+
54
+ /**
55
+ * Merge an edit or delete stamp into a row's serialized metadata, producing
56
+ * the neutral envelope to store back under `providerMeta`.
57
+ *
58
+ * The base is whatever the row already says about itself through
59
+ * {@link readProviderMetadata}, flat-legacy Slack rows included, so fields
60
+ * the old envelope carried (thread, actor, display name) survive the stamp
61
+ * rather than being shadowed by a minimal synthesis. A row that says
62
+ * nothing gets the envelope synthesized from the caller's lookup-derived
63
+ * facts, so the result always validates on read.
64
+ */
65
+ export function mergeProviderMessageMetadata(
66
+ rowMetadata: string | null,
67
+ seed: {
68
+ source: ChannelId;
69
+ conversationExternalId: string;
70
+ messageId: string;
71
+ threadId?: string;
72
+ },
73
+ patch: { editedAt?: number; deletedAt?: number },
74
+ ): string {
75
+ const base = readProviderMetadata(rowMetadata, { allowFlatLegacy: true }) ?? {
76
+ source: seed.source,
77
+ conversationExternalId: seed.conversationExternalId,
78
+ messageId: seed.messageId,
79
+ ...(seed.threadId ? { threadId: seed.threadId } : {}),
80
+ eventKind: "message" as const,
81
+ };
82
+ return JSON.stringify({ ...base, ...patch });
83
+ }
@@ -19,6 +19,7 @@
19
19
  * - Running identity verifiers
20
20
  */
21
21
 
22
+ import { loadConfig } from "../config/loader.js";
22
23
  import { emitPostConnectNudge } from "../home/post-connect-feed.js";
23
24
  import { invalidateAssistantSuggestedPromptsCache } from "../home/suggested-prompts-cache.js";
24
25
  import type { TokenEndpointAuthMethod } from "../security/oauth2.js";
@@ -27,6 +28,7 @@ import { getLogger } from "../util/logger.js";
27
28
  import type { OAuthConnectResult } from "./connect-types.js";
28
29
  import { verifyIdentity } from "./identity-verifier.js";
29
30
  import { getProvider } from "./oauth-store.js";
31
+ import { isProviderVisible } from "./provider-visibility.js";
30
32
  import { storeOAuth2Tokens } from "./token-persistence.js";
31
33
 
32
34
  const log = getLogger("oauth-connect-orchestrator");
@@ -130,6 +132,20 @@ export async function orchestrateOAuthConnect(
130
132
  };
131
133
  }
132
134
 
135
+ // A provider gated behind a disabled feature flag is not connectable. This
136
+ // is the choke point every connect path funnels through (runtime routes,
137
+ // gateway, CLI, credential vault tool), so enforcing here covers all of
138
+ // them rather than relying on each entry point to check. The error matches
139
+ // the not-found case above so a gated provider is indistinguishable from an
140
+ // absent one, mirroring the provider routes.
141
+ if (!isProviderVisible(providerRow, loadConfig())) {
142
+ return {
143
+ success: false,
144
+ error: `No OAuth provider registered for "${options.service}". Ensure the provider is seeded in the database.`,
145
+ safeError: true,
146
+ };
147
+ }
148
+
133
149
  // Deserialize JSON fields from the DB row
134
150
  const dbAuthorizeParams = safeJsonParse<Record<string, string> | undefined>(
135
151
  providerRow.authorizeParams,
@@ -807,6 +807,7 @@ export const PROVIDER_SEED_DATA: Record<
807
807
  identityHeaders: { "Content-Type": "application/json" },
808
808
  identityBody: { query: "{ me { id name email } }" },
809
809
  identityResponsePaths: ["data.me.name", "data.me.email"],
810
+ featureFlag: "monday-oauth",
810
811
  },
811
812
 
812
813
  eventbrite: {
@@ -3999,7 +3999,9 @@ export function updateMessageMetadata(
3999
3999
  .from(messages)
4000
4000
  .where(eq(messages.id, messageId))
4001
4001
  .get();
4002
- const existing = row?.metadata ? JSON.parse(row.metadata) : {};
4002
+ // Sanitized like the transactional sibling above: a malformed stored
4003
+ // envelope must not fail the update that is trying to stamp the row.
4004
+ const existing = row?.metadata ? safeParseRecord(row.metadata) : {};
4003
4005
  db.update(messages)
4004
4006
  .set({ metadata: JSON.stringify({ ...existing, ...updates }) })
4005
4007
  .where(eq(messages.id, messageId))
@@ -38,6 +38,7 @@ describe("API_KEY_PROVIDERS", () => {
38
38
  expect(API_KEY_PROVIDERS).toContain("anthropic");
39
39
  expect(API_KEY_PROVIDERS).toContain("openai");
40
40
  expect(API_KEY_PROVIDERS).toContain("gemini");
41
+ expect(API_KEY_PROVIDERS).not.toContain("vellum");
41
42
  });
42
43
 
43
44
  test("includes search providers", () => {
@@ -52,9 +52,10 @@ describe("vellum connection routing", () => {
52
52
  expect(isVellumManagedConnection({ provider: "vellum" })).toBe(true);
53
53
  });
54
54
 
55
- test("the vellum sentinel is not a real provider without an override", () => {
56
- // No `provider` override → effective provider is the `vellum` sentinel,
57
- // which has no catalog entry / adapter → null.
55
+ test("the vellum sentinel does not dispatch without an override", () => {
56
+ // No `provider` override → the connection column is the routing
57
+ // sentinel and must not build an adapter, even though Vellum-hosted
58
+ // GPU models share this catalog id.
58
59
  const adapter = createAdapterFromConnection(
59
60
  vellumConnection,
60
61
  resolvedAuth,
@@ -76,6 +77,23 @@ describe("vellum connection routing", () => {
76
77
  );
77
78
  expect(adapter).not.toBeNull();
78
79
  });
80
+
81
+ test("provider override vellum routes GPU models through VellumProvider", () => {
82
+ const adapter = createAdapterFromConnection(
83
+ vellumConnection,
84
+ {
85
+ kind: "header",
86
+ headers: { Authorization: "Bearer test-key" },
87
+ baseUrl: "https://platform.example/v1/runtime-proxy/vellum",
88
+ },
89
+ {
90
+ model: "qwen/qwen3-8b",
91
+ provider: "vellum",
92
+ },
93
+ );
94
+ expect(adapter).not.toBeNull();
95
+ expect(adapter?.name).toBe("vellum");
96
+ });
79
97
  });
80
98
 
81
99
  describe("effectiveConnectionAuth", () => {
@@ -48,6 +48,7 @@ import { RetryProvider } from "../retry.js";
48
48
  import { TogetherProvider } from "../together/client.js";
49
49
  import type { Provider, SendMessageOptions } from "../types.js";
50
50
  import { UsageTrackingProvider } from "../usage-tracking.js";
51
+ import { VellumProvider } from "../vellum/client.js";
51
52
  import {
52
53
  getManagedUpstream,
53
54
  isVellumManagedConnection,
@@ -204,6 +205,11 @@ const ADAPTER_FACTORIES: Record<string, AdapterFactory> = {
204
205
  streamTimeoutMs,
205
206
  ...(baseURL ? { baseURL } : {}),
206
207
  }),
208
+ vellum: ({ apiKey, model, streamTimeoutMs, baseURL }) =>
209
+ new VellumProvider(apiKey, model, {
210
+ streamTimeoutMs,
211
+ ...(baseURL ? { baseURL } : {}),
212
+ }),
207
213
  };
208
214
 
209
215
  /**
@@ -551,6 +557,15 @@ function buildConnectionAdapter(
551
557
  },
552
558
  ): Provider | null {
553
559
  const provider = opts.provider ?? connection.provider;
560
+ // The connection's own `vellum` column is a routing sentinel. Dispatch
561
+ // only when the caller names an upstream, including Vellum-hosted GPU
562
+ // models whose catalog id is also `vellum`.
563
+ if (
564
+ connection.provider === VELLUM_MANAGED_PROVIDER &&
565
+ opts.provider === undefined
566
+ ) {
567
+ return null;
568
+ }
554
569
  const entry = PROVIDER_CATALOG.find((e) => e.id === provider);
555
570
  if (!entry) {
556
571
  return null;
@@ -118,20 +118,20 @@ export type ResolvedAuth =
118
118
  // providers at runtime.
119
119
 
120
120
  export const VALID_CONNECTION_PROVIDERS: readonly string[] = [
121
- ...PROVIDER_CATALOG.map((p) => p.id),
122
- // The provider-agnostic Vellum-managed connection stores this sentinel in its
123
- // `provider` column. It is intentionally not a PROVIDER_CATALOG entry (it
124
- // names no single upstream), so it must be allowlisted explicitly or the DB
125
- // loaders (getConnection/listConnections) and the create route would reject
126
- // persisted `vellum` rows — the routing threaded in via `providerOverride`
127
- // never runs on a row that fails to load.
128
- VELLUM_MANAGED_PROVIDER,
129
- // The ChatGPT-subscription row stores the "chatgpt" routing identity in its
130
- // `provider` column for the same reason: the row IS the subscription route
131
- // (auth modality = provider identity), and dispatch derives the openai
132
- // upstream per-request. Allowlisted explicitly or the DB loaders would drop
133
- // the persisted row.
134
- "chatgpt",
121
+ ...new Set([
122
+ ...PROVIDER_CATALOG.map((p) => p.id),
123
+ // The provider-agnostic Vellum-managed connection stores this sentinel in
124
+ // its `provider` column. The same id owns Vellum-hosted GPU models in the
125
+ // catalog. Keep it allowlisted explicitly so a future catalog rename
126
+ // cannot drop persisted `vellum` rows from the DB loaders.
127
+ VELLUM_MANAGED_PROVIDER,
128
+ // The ChatGPT-subscription row stores the "chatgpt" routing identity in
129
+ // its `provider` column: the row IS the subscription route (auth
130
+ // modality = provider identity), and dispatch derives the openai
131
+ // upstream per-request. Allowlisted explicitly or the DB loaders would
132
+ // drop the persisted row.
133
+ "chatgpt",
134
+ ]),
135
135
  ];
136
136
 
137
137
  export type ConnectionProvider = string;
@@ -2271,6 +2271,31 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
2271
2271
  apiKeyUrl: "https://poolside.ai",
2272
2272
  apiKeyPlaceholder: "Your Poolside API key",
2273
2273
  },
2274
+ {
2275
+ id: "vellum",
2276
+ displayName: "Vellum",
2277
+ subtitle:
2278
+ "Models served on Vellum GPU nodes through the managed connection.",
2279
+ setupMode: "api-key",
2280
+ setupHint:
2281
+ "Uses the assistant API key through the Vellum managed connection. These models cannot use a bring-your-own key.",
2282
+ featureFlag: "settings-developer-nav",
2283
+ models: [
2284
+ {
2285
+ id: "qwen/qwen3-8b",
2286
+ displayName: "Qwen3 8B",
2287
+ contextWindowTokens: 32768,
2288
+ maxOutputTokens: 32768,
2289
+ supportsThinking: false,
2290
+ supportsCaching: false,
2291
+ supportsVision: false,
2292
+ supportsToolUse: true,
2293
+ pricing: { inputPer1mTokens: 0.3, outputPer1mTokens: 0.3 },
2294
+ featureFlag: "settings-developer-nav",
2295
+ },
2296
+ ],
2297
+ defaultModel: "qwen/qwen3-8b",
2298
+ },
2274
2299
  ];
2275
2300
 
2276
2301
  export const PROVIDER_CATALOG: ProviderCatalogEntry[] =
@@ -51,6 +51,11 @@ export const PLATFORM_PROVIDER_META: Record<string, ManagedProviderMeta> = {
51
51
  managed: true,
52
52
  proxyPath: "/v1/runtime-proxy/together",
53
53
  },
54
+ vellum: {
55
+ name: "vellum",
56
+ managed: true,
57
+ proxyPath: "/v1/runtime-proxy/vellum",
58
+ },
54
59
  poolside: {
55
60
  name: "poolside",
56
61
  managed: false,
@@ -39,14 +39,15 @@ import { listCredentialProviderNames as listSttCredentialProviderNames } from ".
39
39
  * `ollama` is intentionally included here even though it is `setupMode:
40
40
  * "keyless"` — the keyless mode is enforced elsewhere; this list is purely
41
41
  * about the set of bare-name credential-store keys accepted by
42
- * `assistant keys ...`.
42
+ * `assistant keys ...`. `vellum` is excluded: those models authenticate
43
+ * with the assistant API key on the managed connection, not a user key.
43
44
  *
44
45
  * Search providers (`brave`, `perplexity`, `tavily`) have no catalog module
45
46
  * yet and remain statically declared below.
46
47
  */
47
48
  const LLM_API_KEY_PROVIDERS: readonly string[] = PROVIDER_CATALOG.map(
48
49
  (p) => p.id,
49
- );
50
+ ).filter((id) => id !== "vellum");
50
51
 
51
52
  /**
52
53
  * Search API providers, derived from `SEARCH_PROVIDER_CATALOG`. Managed
@@ -0,0 +1,29 @@
1
+ import { OpenAIChatCompletionsProvider } from "../openai/chat-completions-provider.js";
2
+
3
+ export interface VellumProviderOptions {
4
+ apiKey?: string;
5
+ baseURL?: string;
6
+ streamTimeoutMs?: number;
7
+ }
8
+
9
+ /**
10
+ * OpenAI-compatible client for Vellum-hosted GPU inference (vLLM).
11
+ * Managed requests set `baseURL` to the platform `/v1/runtime-proxy/vellum`
12
+ * path and authenticate with the assistant API key. These models have no
13
+ * bring-your-own-key path.
14
+ */
15
+ export class VellumProvider extends OpenAIChatCompletionsProvider {
16
+ constructor(
17
+ apiKey: string,
18
+ model: string,
19
+ options: VellumProviderOptions = {},
20
+ ) {
21
+ super(apiKey || "not-needed", model, {
22
+ providerName: "vellum",
23
+ providerLabel: "Vellum",
24
+ streamTimeoutMs: options.streamTimeoutMs,
25
+ omitToolChoiceWhenReasoning: true,
26
+ ...(options.baseURL ? { baseURL: options.baseURL } : {}),
27
+ });
28
+ }
29
+ }
@@ -41,6 +41,7 @@ describe("vellum-model-routing", () => {
41
41
 
42
42
  test("getManagedUpstream resolves a bare catalog id to its owner", () => {
43
43
  expect(getManagedUpstream("claude-fable-5")).toBe("anthropic");
44
+ expect(getManagedUpstream("qwen/qwen3-8b")).toBe("vellum");
44
45
  });
45
46
 
46
47
  test("getManagedUpstream resolves a routing string by its prefix", () => {
@@ -72,6 +73,7 @@ describe("vellum-model-routing", () => {
72
73
  "gemini",
73
74
  "openai",
74
75
  "together",
76
+ "vellum",
75
77
  ]);
76
78
  });
77
79
  });
@@ -36,9 +36,9 @@ export const MANAGED_ROUTABLE_PROVIDERS: ReadonlySet<string> = new Set(
36
36
  * Sentinel provider id for the single, provider-agnostic Vellum-managed
37
37
  * connection. Unlike the per-provider `*-managed` connections, this one does
38
38
  * not name an upstream provider on its DB row — the upstream is determined
39
- * per-request from the resolving profile. This id is never a real catalog
40
- * entry, so routing code must substitute the effective provider before any
41
- * catalog/adapter lookup.
39
+ * per-request from the resolving profile. The same id is the catalog owner
40
+ * of Vellum-hosted GPU models. Dispatch still substitutes a concrete
41
+ * upstream (including `vellum` for those GPU models) before adapter lookup.
42
42
  */
43
43
  export const VELLUM_MANAGED_PROVIDER = "vellum";
44
44