@vellumai/assistant 0.12.3-dev.202609221821.7bf89b6 → 0.12.3-dev.202609222016.8d633dd

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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/conversation-pairing.test.ts +10 -0
  3. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  4. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  5. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  6. package/src/__tests__/pricing.test.ts +33 -0
  7. package/src/calls/voice-escalation-judge.ts +1 -1
  8. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +60 -0
  9. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  10. package/src/cli/lib/plugin-catalog-cache.ts +17 -2
  11. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  12. package/src/config/__tests__/default-profile-catalog.test.ts +28 -0
  13. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  14. package/src/config/call-site-defaults.ts +6 -0
  15. package/src/config/default-profile-catalog.ts +30 -1
  16. package/src/config/default-profile-names.ts +36 -2
  17. package/src/config/feature-flag-registry.json +8 -0
  18. package/src/config/schemas/call-site-catalog.ts +7 -0
  19. package/src/config/schemas/llm.ts +21 -5
  20. package/src/config/sync-gated-profiles.ts +96 -44
  21. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  22. package/src/daemon/auto-profile-router.ts +220 -0
  23. package/src/daemon/conversation-agent-loop.ts +77 -4
  24. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  25. package/src/daemon/pkb-reminder-builder.ts +1 -1
  26. package/src/home/feed-types.ts +2 -0
  27. package/src/live-voice/continuation-judge.ts +1 -1
  28. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  29. package/src/notifications/conversation-pairing.ts +23 -8
  30. package/src/notifications/home-feed-mirror.ts +39 -0
  31. package/src/notifications/home-feed-side-effect.ts +46 -19
  32. package/src/persistence/jobs-store.ts +229 -0
  33. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  34. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  35. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +561 -0
  36. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  37. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  38. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  39. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  40. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  41. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  42. package/src/providers/anthropic/client.ts +2 -2
  43. package/src/{calls/__tests__/typesafe-noul.test.ts → providers/jev/ask.test.ts} +4 -4
  44. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +67 -34
  45. package/src/providers/model-catalog.ts +234 -0
  46. package/src/providers/openai/codex-models.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.3-dev.202609221821.7bf89b6",
3
+ "version": "0.12.3-dev.202609222016.8d633dd",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -996,6 +996,12 @@ describe("pairDeliveryWithConversation", () => {
996
996
  expect(createConversationMock).not.toHaveBeenCalled();
997
997
  expect(addMessageMock).toHaveBeenCalledTimes(1);
998
998
  expect(messagesInvalidated).toContain("conv-foreground");
999
+ // The transcript is this notification's only home, so a chat marked Done
1000
+ // comes back for it.
1001
+ const options = addMessageMock.mock.calls[0]![3] as {
1002
+ skipResurface?: boolean;
1003
+ };
1004
+ expect(options.skipResurface).toBe(false);
999
1005
  });
1000
1006
 
1001
1007
  test("appended notification body skips indexing", async () => {
@@ -1017,8 +1023,12 @@ describe("pairDeliveryWithConversation", () => {
1017
1023
 
1018
1024
  const options = addMessageMock.mock.calls[0]![3] as {
1019
1025
  skipIndexing?: boolean;
1026
+ skipResurface?: boolean;
1020
1027
  };
1021
1028
  expect(options.skipIndexing).toBe(true);
1029
+ // The bell carries this one (async-background), so the append is
1030
+ // bookkeeping and never resurfaces a chat the user marked Done.
1031
+ expect(options.skipResurface).toBe(true);
1022
1032
  });
1023
1033
 
1024
1034
  test("passive vellum signal appends regardless of the producing conversation's source", async () => {
@@ -35,6 +35,7 @@ const MEMORY_JOB_TYPES = [
35
35
  "memory_retrospective",
36
36
  "memory_retrospective_sweep",
37
37
  "skill_card_insert",
38
+ "skill_update_receipt",
38
39
  "pkb_filing",
39
40
  "pkb_compaction",
40
41
  ].sort();
@@ -446,7 +446,7 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
446
446
  expect(JSON.stringify(messages)).not.toContain("prompt_cache_breakpoint");
447
447
  });
448
448
 
449
- test("catalog flags GPT-5.6 and GPT-6 Astra direct-openai rows", () => {
449
+ test("catalog flags GPT-5.6 and GPT-6 direct-openai rows", () => {
450
450
  const openai = PROVIDER_CATALOG.find((p) => p.id === "openai");
451
451
  const flagged = (openai?.models ?? [])
452
452
  .filter((m) => m.supportsPromptCacheBreakpoints)
@@ -457,6 +457,8 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
457
457
  "gpt-5.6-sol",
458
458
  "gpt-5.6-terra",
459
459
  "gpt-6-astra",
460
+ "gpt-6-luna",
461
+ "gpt-6-sol",
460
462
  ]);
461
463
  });
462
464
  });
@@ -196,6 +196,9 @@ const BASELINE: Record<string, readonly string[]> = {
196
196
  "../../../daemon/trust-context.js",
197
197
  "../../../home/job-handlers/conversation-starters.js",
198
198
  "../../../media/job-handlers/media-processing.js",
199
+ // The skill-update receipt job announces a burst through the
200
+ // notification pipeline's single entry point. No plugin-api equivalent.
201
+ "../../../notifications/emit-signal.js",
199
202
  "../../../permissions/types.js",
200
203
  "../../../persistence/auto-analysis-constants.js",
201
204
  "../../../persistence/checkpoints.js",
@@ -135,6 +135,39 @@ describe("resolvePricing", () => {
135
135
  expect(result.estimatedCostUsd).toBeCloseTo(0.5 + 0.625 + 0.05, 10);
136
136
  });
137
137
 
138
+ test("bills GPT-6 Sol at published short-context rates", () => {
139
+ const result = resolvePricingForUsage("openai", "gpt-6-sol", {
140
+ directInputTokens: 50_000,
141
+ outputTokens: 50_000,
142
+ cacheCreationInputTokens: 50_000,
143
+ cacheReadInputTokens: 50_000,
144
+ anthropicCacheCreation: null,
145
+ });
146
+
147
+ expect(result.pricingStatus).toBe("priced");
148
+ // 0.05M x $2 direct + 0.05M x $2.5 write + 0.05M x $0.2 read
149
+ // + 0.05M x $10 output.
150
+ expect(result.estimatedCostUsd).toBeCloseTo(0.1 + 0.125 + 0.01 + 0.5, 10);
151
+ });
152
+
153
+ test("bills GPT-6 Luna at the long-context tier above 272k", () => {
154
+ const result = resolvePricingForUsage("openai", "gpt-6-luna", {
155
+ directInputTokens: 500_000,
156
+ outputTokens: 1_000_000,
157
+ cacheCreationInputTokens: 500_000,
158
+ cacheReadInputTokens: 1_000_000,
159
+ anthropicCacheCreation: null,
160
+ });
161
+
162
+ expect(result.pricingStatus).toBe("priced");
163
+ // Tier rates: 0.5M x $0.2 direct + 0.5M x $0.25 write + 1M x $0.02 read
164
+ // + 1M x $0.75 output.
165
+ expect(result.estimatedCostUsd).toBeCloseTo(
166
+ 0.1 + 0.125 + 0.02 + 0.75,
167
+ 10,
168
+ );
169
+ });
170
+
138
171
  test("bills GPT-6 Astra at the long-context tier above 272k", () => {
139
172
  const result = resolvePricingForUsage("openai", "gpt-6-astra", {
140
173
  directInputTokens: 500_000,
@@ -16,9 +16,9 @@
16
16
  * decision stands exactly as it would without the judge.
17
17
  */
18
18
 
19
+ import { askTypesafeNoul } from "../providers/jev/ask.js";
19
20
  import type { Message, Provider } from "../providers/types.js";
20
21
  import { safeStringSlice } from "../util/unicode.js";
21
- import { askTypesafeNoul } from "./typesafe-noul.js";
22
22
 
23
23
  export const VOICE_ESCALATION_JUDGE_CALL_SITE = "voiceEscalationJudge";
24
24
 
@@ -228,4 +228,64 @@ describe("mergePlatformCatalogWithBundledLocals", () => {
228
228
  expect(merged.matches[1]?.source.kind).toBe("local");
229
229
  expect(merged.ref).toBe("main");
230
230
  });
231
+ test("keeps the bundled integration when a platform row without one shadows it", () => {
232
+ const integration = {
233
+ kind: "mcp",
234
+ displayName: "Fathom",
235
+ documentationUrl: "https://example.com/docs",
236
+ verifiedAt: "2026-09-10",
237
+ verification: "documentation-only",
238
+ setup: { mode: "oauth", instructions: "Sign in." },
239
+ logo: "fathom.png",
240
+ } as const;
241
+ const platformIntegration = { ...integration, displayName: "Platform" };
242
+ const platform: PluginCatalog = {
243
+ ref: "main",
244
+ matches: [
245
+ {
246
+ name: "fathom",
247
+ path: "github:provider/fathom@pin",
248
+ category: null,
249
+ source: {
250
+ kind: "github",
251
+ repo: "provider/fathom",
252
+ ref: "0".repeat(40),
253
+ },
254
+ },
255
+ {
256
+ name: "linear",
257
+ path: "github:provider/linear@pin",
258
+ category: null,
259
+ integration: platformIntegration,
260
+ source: {
261
+ kind: "github",
262
+ repo: "provider/linear",
263
+ ref: "0".repeat(40),
264
+ },
265
+ },
266
+ ],
267
+ };
268
+ const bundled: PluginCatalog = {
269
+ ref: "bundled",
270
+ matches: ["fathom", "linear"].map((name) => ({
271
+ name,
272
+ path: `local:plugins/mcp-catalog/${name}@1.0.0`,
273
+ category: null,
274
+ integration,
275
+ source: {
276
+ kind: "local",
277
+ path: `plugins/mcp-catalog/${name}`,
278
+ version: "1.0.0",
279
+ },
280
+ })),
281
+ };
282
+
283
+ const merged = mergePlatformCatalogWithBundledLocals(platform, bundled);
284
+
285
+ const [fathom, linear] = merged.matches;
286
+ expect(fathom?.source.kind).toBe("github");
287
+ expect(fathom?.integration).toEqual(integration);
288
+ expect(linear?.source.kind).toBe("github");
289
+ expect(linear?.integration).toEqual(platformIntegration);
290
+ });
231
291
  });
@@ -161,14 +161,78 @@ describe("fetchPluginCatalogFromPlatform", () => {
161
161
  await expect(promise).rejects.toMatchObject({ status: 502 });
162
162
  });
163
163
 
164
- test("throws on a schema violation", async () => {
164
+ test.each([
165
+ ["a missing plugins array", {}],
166
+ ["a non-array plugins field", { plugins: "nope" }],
167
+ ["a non-object body", []],
168
+ ])("throws on an invalid envelope (%s)", async (_label, body) => {
165
169
  const promise = fetchPluginCatalogFromPlatform({
166
- fetch: platformFetch({ plugins: [{ repo: "acme/x", ref: SHA_A }] }),
170
+ fetch: platformFetch(body),
167
171
  });
168
172
  await expect(promise).rejects.toBeInstanceOf(PluginCatalogUnavailableError);
169
173
  await expect(promise).rejects.toMatchObject({ status: 502 });
170
174
  });
171
175
 
176
+ test("skips rows that fail the base row schema and keeps the rest", async () => {
177
+ const catalog = await fetchPluginCatalogFromPlatform({
178
+ fetch: platformFetch({
179
+ plugins: [
180
+ { repo: "acme/nameless", ref: SHA_A },
181
+ { name: 42, repo: "acme/numeric", ref: SHA_A },
182
+ "not-a-row",
183
+ { name: "good", repo: "acme/good", ref: SHA_B },
184
+ ],
185
+ }),
186
+ });
187
+ expect(catalog.matches.map((m) => m.name)).toEqual(["good"]);
188
+ });
189
+
190
+ test("keeps a row whose integration is invalid, without the integration", async () => {
191
+ const catalog = await fetchPluginCatalogFromPlatform({
192
+ fetch: platformFetch({
193
+ plugins: [
194
+ {
195
+ name: "snake-case",
196
+ repo: "acme/snake-case",
197
+ ref: SHA_A,
198
+ description: "kept",
199
+ integration: {
200
+ kind: "mcp",
201
+ display_name: "Snake Case",
202
+ documentation_url: "https://example.com/docs",
203
+ verified_at: "2026-09-10",
204
+ verification: "documentation-only",
205
+ setup: { mode: "oauth", instructions: "Sign in." },
206
+ logo: "snake-case.png",
207
+ },
208
+ },
209
+ {
210
+ name: "valid",
211
+ repo: "acme/valid",
212
+ ref: SHA_B,
213
+ integration: {
214
+ kind: "mcp",
215
+ displayName: "Valid",
216
+ documentationUrl: "https://example.com/docs",
217
+ verifiedAt: "2026-09-10",
218
+ verification: "documentation-only",
219
+ setup: { mode: "manual", instructions: "Paste a key." },
220
+ logo: "valid.png",
221
+ },
222
+ },
223
+ ],
224
+ }),
225
+ });
226
+
227
+ expect(catalog.matches.map((m) => m.name)).toEqual(["snake-case", "valid"]);
228
+ expect(catalog.matches[0]).toMatchObject({
229
+ name: "snake-case",
230
+ description: "kept",
231
+ });
232
+ expect(catalog.matches[0]?.integration).toBeUndefined();
233
+ expect(catalog.matches[1]?.integration?.displayName).toBe("Valid");
234
+ });
235
+
172
236
  test("throws when fetch rejects (network error / abort)", async () => {
173
237
  const promise = fetchPluginCatalogFromPlatform({
174
238
  fetch: (async () => {
@@ -26,16 +26,31 @@ interface CacheEntry {
26
26
 
27
27
  const cache = new Map<string, CacheEntry>();
28
28
 
29
- /** Add bundled local packages without overriding platform-authoritative rows. */
29
+ /**
30
+ * Add bundled local packages without overriding platform-authoritative rows.
31
+ * A platform row without an integration borrows the bundled row's
32
+ * integration of the same name, so an integration the platform omits or
33
+ * serves malformed never disappears from the catalog.
34
+ */
30
35
  export function mergePlatformCatalogWithBundledLocals(
31
36
  platform: PluginCatalog,
32
37
  bundledLocal: PluginCatalog = readBundledLocalPluginCatalog(),
33
38
  ): PluginCatalog {
39
+ const bundledByName = new Map(
40
+ bundledLocal.matches.map((match) => [match.name, match]),
41
+ );
42
+ const platformMatches = platform.matches.map((match) => {
43
+ const bundledIntegration = bundledByName.get(match.name)?.integration;
44
+ if (match.integration || !bundledIntegration) {
45
+ return match;
46
+ }
47
+ return { ...match, integration: bundledIntegration };
48
+ });
34
49
  const seen = new Set(platform.matches.map((match) => match.name));
35
50
  return {
36
51
  ref: platform.ref,
37
52
  matches: [
38
- ...platform.matches,
53
+ ...platformMatches,
39
54
  ...bundledLocal.matches.filter((match) => !seen.has(match.name)),
40
55
  ].sort((a, b) => a.name.localeCompare(b.name)),
41
56
  };
@@ -7,15 +7,17 @@
7
7
  * catalog sourced from the platform is indistinguishable from one read off
8
8
  * GitHub.
9
9
  *
10
- * Every failure mode (non-2xx, unreachable/aborted, malformed body) throws
11
- * {@link PluginCatalogUnavailableError} — the fetcher never returns a partial
12
- * or silently-empty catalog, so a caller can safely fall back to a bundled
13
- * offline copy instead of mistaking an outage for "no plugins".
10
+ * Every failure mode (non-2xx, unreachable/aborted, malformed envelope)
11
+ * throws {@link PluginCatalogUnavailableError}, so a caller never mistakes an
12
+ * outage for "no plugins". Rows are validated one at a time: a row with an
13
+ * invalid `integration` is kept without it, and a row that fails the base
14
+ * row schema is skipped, so one bad row never blanks the whole catalog.
14
15
  */
15
16
 
16
17
  import { z } from "zod";
17
18
 
18
19
  import { getPlatformBaseUrl } from "../../config/env.js";
20
+ import { getLogger } from "../../util/logger.js";
19
21
  import {
20
22
  type MarketplaceEntry,
21
23
  mcpIntegrationSchema,
@@ -27,9 +29,12 @@ import {
27
29
  type SearchPluginsDeps,
28
30
  } from "./search-plugins.js";
29
31
 
32
+ const log = getLogger("plugin-catalog");
33
+
30
34
  /**
31
35
  * One flattened row from the platform catalog. Unknown keys (`id`,
32
- * `display_name`) are accepted and dropped — zod strips them by default.
36
+ * `display_name`) are accepted and dropped (zod strips them by default).
37
+ * `integration` is validated separately by {@link integrationSchema}.
33
38
  */
34
39
  const platformPluginRowSchema = z.object({
35
40
  name: z.string(),
@@ -41,11 +46,13 @@ const platformPluginRowSchema = z.object({
41
46
  category: z.string().nullable().optional(),
42
47
  homepage: z.string().nullable().optional(),
43
48
  license: z.string().nullable().optional(),
44
- integration: mcpIntegrationSchema.nullable().optional(),
49
+ integration: z.unknown(),
45
50
  });
46
51
 
52
+ const integrationSchema = mcpIntegrationSchema.nullable().optional();
53
+
47
54
  const platformCatalogSchema = z.object({
48
- plugins: z.array(platformPluginRowSchema),
55
+ plugins: z.array(z.unknown()),
49
56
  });
50
57
 
51
58
  /**
@@ -99,10 +106,22 @@ export async function fetchPluginCatalogFromPlatform(
99
106
  }
100
107
 
101
108
  const entries: MarketplaceEntry[] = [];
102
- for (const row of parsed.data.plugins) {
109
+ const skippedRows: string[] = [];
110
+ const droppedIntegrations: string[] = [];
111
+ for (const [index, rawRow] of parsed.data.plugins.entries()) {
112
+ const rowResult = platformPluginRowSchema.safeParse(rawRow);
113
+ if (!rowResult.success) {
114
+ skippedRows.push(describeInvalidRow(rawRow, index));
115
+ continue;
116
+ }
117
+ const row = rowResult.data;
103
118
  if (!row.repo || !row.ref) {
104
119
  continue;
105
120
  }
121
+ const integrationResult = integrationSchema.safeParse(row.integration);
122
+ if (!integrationResult.success) {
123
+ droppedIntegrations.push(row.name);
124
+ }
106
125
  entries.push({
107
126
  name: row.name,
108
127
  source: {
@@ -116,12 +135,32 @@ export async function fetchPluginCatalogFromPlatform(
116
135
  category: row.category ?? undefined,
117
136
  homepage: row.homepage ?? undefined,
118
137
  license: row.license ?? undefined,
119
- integration: row.integration ?? undefined,
138
+ integration: integrationResult.success
139
+ ? (integrationResult.data ?? undefined)
140
+ : undefined,
120
141
  });
121
142
  }
122
143
 
144
+ if (skippedRows.length > 0 || droppedIntegrations.length > 0) {
145
+ log.warn(
146
+ { skippedRows, droppedIntegrations },
147
+ "platform plugin catalog contained invalid rows; skipped rows and dropped integrations",
148
+ );
149
+ }
150
+
123
151
  return {
124
152
  ref: opts?.ref ?? "platform",
125
153
  matches: projectMarketplaceEntries(entries),
126
154
  };
127
155
  }
156
+
157
+ /** Name of an invalid row for logging, or its position when it has none. */
158
+ function describeInvalidRow(row: unknown, index: number): string {
159
+ if (row && typeof row === "object" && "name" in row) {
160
+ const name = (row as { name: unknown }).name;
161
+ if (typeof name === "string" && name.length > 0) {
162
+ return name;
163
+ }
164
+ }
165
+ return `#${index}`;
166
+ }
@@ -10,6 +10,7 @@ import {
10
10
  getEffectiveProfiles,
11
11
  getEffectiveProfilesForProvider,
12
12
  getUserSelectableProfilesForProvider,
13
+ MANAGED_PROFILE_TEMPLATES,
13
14
  PROFILE_IMPLS,
14
15
  resolveDefaultProfileForProvider,
15
16
  } from "../default-profile-catalog.js";
@@ -79,6 +80,33 @@ describe("getEffectiveProfiles", () => {
79
80
  ).toBeUndefined();
80
81
  });
81
82
 
83
+ test("the Auto profile is the managed Balanced body relabeled, managed-only, and flag-gated", () => {
84
+ const auto = CODE_DEFAULT_PROFILE_ENTRIES.auto;
85
+ const balanced = CODE_DEFAULT_PROFILE_ENTRIES.balanced;
86
+ expect(auto.label).toBe("Auto");
87
+ expect(auto.model).toBe(balanced.model);
88
+ expect(auto.provider).toBe(balanced.provider);
89
+ expect(auto.fallbackProfile).toBeUndefined();
90
+ // Not seeded unconditionally: the flag reconcile writes the stub.
91
+ expect(Object.keys(MANAGED_PROFILE_TEMPLATES)).not.toContain("auto");
92
+ expect(
93
+ resolveDefaultProfileForProvider(undefined, "auto", null),
94
+ ).toBeUndefined();
95
+ const stub: Record<string, ProfileEntry> = { auto: { source: "managed" } };
96
+ expect(resolveDefaultProfileForProvider(stub, "auto", null)?.model).toBe(
97
+ balanced.model,
98
+ );
99
+ expect(
100
+ resolveDefaultProfileForProvider(stub, "auto", {
101
+ provider: "anthropic",
102
+ connectionName: "anthropic-personal",
103
+ }),
104
+ ).toBeUndefined();
105
+ expect(
106
+ Object.keys(getConversationProfilesForProvider(stub, null)),
107
+ ).toContain("auto");
108
+ });
109
+
82
110
  test("the conversation view drops the managed Jev profile but keeps it selectable for call sites", () => {
83
111
  expect(
84
112
  Object.keys(getConversationProfilesForProvider(undefined, null)),
@@ -368,4 +368,75 @@ describe("reconcileFlagGatedProfiles", () => {
368
368
  expect(reconcileFlagGatedProfiles()).toBe(false);
369
369
  expect(readFileSync(CONFIG_PATH, "utf-8")).toBe(before);
370
370
  });
371
+
372
+ test("auto-profile flag on materializes the managed auto profile at the head of the order", () => {
373
+ process.env.IS_PLATFORM = "true";
374
+ seedBalancedConfig();
375
+ setOverridesForTesting({ "auto-profile": true });
376
+
377
+ expect(reconcileFlagGatedProfiles()).toBe(true);
378
+
379
+ const raw = readConfig();
380
+ expect(raw.llm.profiles.auto).toEqual({ source: "managed" });
381
+ expect(raw.llm.profileOrder[0]).toBe("auto");
382
+ const effective = getEffectiveProfile(raw.llm.profiles, "auto")!;
383
+ expect(effective.label).toBe("Auto");
384
+ expect(effective.model).toBe(
385
+ getEffectiveProfile(raw.llm.profiles, "balanced")!.model,
386
+ );
387
+ expect(LLMSchema.safeParse(raw.llm).success).toBe(true);
388
+ });
389
+
390
+ test("auto-profile flag on in BYOK mode writes a plain stub that the effective view hides", () => {
391
+ seedBalancedConfig();
392
+ setOverridesForTesting({ "auto-profile": true });
393
+
394
+ expect(reconcileFlagGatedProfiles()).toBe(true);
395
+
396
+ const raw = readConfig();
397
+ expect(raw.llm.profiles.auto).toEqual({ source: "managed" });
398
+ expect(getEffectiveProfile(raw.llm.profiles, "auto")).toBeDefined();
399
+ });
400
+
401
+ test("auto-profile flag off removes the managed auto profile and repoints its references", () => {
402
+ process.env.IS_PLATFORM = "true";
403
+ seedBalancedConfig();
404
+ setOverridesForTesting({ "auto-profile": true });
405
+ reconcileFlagGatedProfiles();
406
+
407
+ const raw = readConfig();
408
+ raw.llm.activeProfile = "auto";
409
+ (raw.llm as Record<string, unknown>).callSites = {
410
+ subagentSpawn: { profile: "auto" },
411
+ };
412
+ writeConfig(raw);
413
+ invalidateConfigCache();
414
+
415
+ setOverridesForTesting({ "auto-profile": false });
416
+ expect(reconcileFlagGatedProfiles()).toBe(true);
417
+
418
+ const after = readConfig();
419
+ expect(after.llm.profiles.auto).toBeUndefined();
420
+ expect(after.llm.profileOrder.includes("auto")).toBe(false);
421
+ expect(after.llm.activeProfile).toBe("balanced");
422
+ expect((after.llm as Record<string, unknown>).callSites).toEqual({
423
+ subagentSpawn: {},
424
+ });
425
+ });
426
+
427
+ test("os-beta and auto-profile reconcile independently in one pass", () => {
428
+ process.env.IS_PLATFORM = "true";
429
+ seedBalancedConfig();
430
+ setOverridesForTesting({ "os-beta": true, "auto-profile": true });
431
+ expect(reconcileFlagGatedProfiles()).toBe(true);
432
+ let order = readConfig().llm.profileOrder;
433
+ expect(order[0]).toBe("auto");
434
+ expect(order.indexOf("os-beta")).toBe(order.indexOf("balanced") + 1);
435
+
436
+ setOverridesForTesting({ "os-beta": false, "auto-profile": true });
437
+ expect(reconcileFlagGatedProfiles()).toBe(true);
438
+ order = readConfig().llm.profileOrder;
439
+ expect(order[0]).toBe("auto");
440
+ expect(order.includes("os-beta")).toBe(false);
441
+ });
371
442
  });
@@ -159,6 +159,12 @@ export const CALL_SITE_DEFAULTS: Record<LLMCallSite, CallSiteDefaultConfig> = {
159
159
  voiceEscalationJudge: {
160
160
  profile: JEV_MANAGED_PROFILE_KEY,
161
161
  },
162
+ // Picks the default profile for a turn pinned to the Auto profile. On a
163
+ // BYOK install the managed-only Jev profile has no body, so the router is
164
+ // unavailable and the turn runs on Balanced.
165
+ autoProfileRouter: {
166
+ profile: JEV_MANAGED_PROFILE_KEY,
167
+ },
162
168
  // The barge-in continuation judge remains off until a TypeSafe profile is
163
169
  // pinned here (see live-voice/continuation-judge.ts).
164
170
  voiceContinuationJudge: { profile: "cost-optimized" },
@@ -8,6 +8,7 @@ import { isCodexSubscriptionModel } from "../providers/openai/codex-models.js";
8
8
  import type { ModelIntent } from "../providers/types.js";
9
9
  import { getManagedUpstream } from "../providers/vellum-model-routing.js";
10
10
  import {
11
+ AUTO_PROFILE_KEY,
11
12
  BACKUP_PROFILE_KEYS,
12
13
  type BackupProfileKey,
13
14
  DEFAULT_PROFILE_KEYS,
@@ -17,6 +18,7 @@ import {
17
18
  FALLBACK_PROFILE_BY_KEY,
18
19
  isDefaultProfileKey,
19
20
  isDefaultProfileProvider,
21
+ isFlagGatedProfileKey,
20
22
  isManagedOnlyProfileKey,
21
23
  JEV_MANAGED_PROFILE_KEY,
22
24
  OS_BETA_PROFILE_KEY,
@@ -407,6 +409,27 @@ export const JEV_MANAGED_PROFILE_TEMPLATE: DefaultProfileTemplate = {
407
409
  contextWindow: { maxInputTokens: 32_000 },
408
410
  };
409
411
 
412
+ /**
413
+ * The Auto profile: the managed Balanced body under its own label, so that
414
+ * anything dispatching the name directly runs Balanced. The per-message
415
+ * choice among the defaults happens in the agent loop, not here (see
416
+ * `AUTO_PROFILE_KEY`). No `fallbackProfile`: the code-owned fallback mapping
417
+ * is keyed by default profile, and a direct dispatch of this name is already
418
+ * the fallback path. Flag-gated like `os-beta`: NOT in
419
+ * `MANAGED_PROFILE_TEMPLATES`, so the unconditional boot seed never creates
420
+ * it; the flag-gated profile reconcile materializes it while the
421
+ * `auto-profile` feature flag is on.
422
+ */
423
+ export const AUTO_PROFILE_TEMPLATE: DefaultProfileTemplate = (() => {
424
+ const { fallbackProfile: _fallbackProfile, ...balanced } =
425
+ VELLUM_PROFILE_IMPLS.balanced;
426
+ return {
427
+ ...balanced,
428
+ label: "Auto",
429
+ description: "Picks the profile that fits each message, in beta",
430
+ };
431
+ })();
432
+
410
433
  /**
411
434
  * Managed profiles, i.e. the `vellum` column keyed by profile name, plus the
412
435
  * managed backup profiles and the managed Jev profile. Backups come after the
@@ -521,6 +544,7 @@ export const INVARIANT_PROFILE_NAMES = new Set<string>([
521
544
  ...DEFAULT_PROFILE_KEYS,
522
545
  ...BACKUP_PROFILE_KEYS,
523
546
  JEV_MANAGED_PROFILE_KEY,
547
+ AUTO_PROFILE_KEY,
524
548
  OS_BETA_PROFILE_KEY,
525
549
  ]);
526
550
 
@@ -535,6 +559,7 @@ export const MANAGED_PROFILE_NAMES = new Set<string>([
535
559
  ...DEFAULT_PROFILE_KEYS,
536
560
  ...BACKUP_PROFILE_KEYS,
537
561
  JEV_MANAGED_PROFILE_KEY,
562
+ AUTO_PROFILE_KEY,
538
563
  OS_BETA_PROFILE_KEY,
539
564
  ]);
540
565
 
@@ -666,6 +691,10 @@ function buildDefaultProfileEntries(): Record<string, ProfileEntry> {
666
691
  JEV_MANAGED_PROFILE_TEMPLATE,
667
692
  JEV_MANAGED_PROFILE_TEMPLATE.provider,
668
693
  );
694
+ entries[AUTO_PROFILE_KEY] = materializeProfile(
695
+ AUTO_PROFILE_TEMPLATE,
696
+ AUTO_PROFILE_TEMPLATE.provider,
697
+ );
669
698
  entries[OS_BETA_PROFILE_KEY] = materializeProfile(
670
699
  OS_BETA_PROFILE_TEMPLATE,
671
700
  OS_BETA_PROFILE_TEMPLATE.provider,
@@ -757,7 +786,7 @@ function resolveAgainstBody(
757
786
  return { ...body };
758
787
  }
759
788
  if (workspace == null) {
760
- return name === OS_BETA_PROFILE_KEY ? undefined : { ...body };
789
+ return isFlagGatedProfileKey(name) ? undefined : { ...body };
761
790
  }
762
791
  if (workspace.source !== "managed") {
763
792
  return workspace;