@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
|
@@ -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
|
|
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
|
|
62
|
-
//
|
|
63
|
-
// subscription route to OpenAI.
|
|
64
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
// No `provider` override →
|
|
57
|
-
//
|
|
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
|
-
...
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|