@vellumai/assistant 0.12.4-staging.1 → 0.12.4-staging.2

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 (36) hide show
  1. package/openapi.yaml +3 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  4. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  5. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  6. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  7. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  8. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  9. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  10. package/src/api/requests/dictation.ts +2 -2
  11. package/src/calls/__tests__/typesafe-noul.test.ts +66 -0
  12. package/src/calls/typesafe-noul.ts +8 -2
  13. package/src/calls/voice-escalation-judge.ts +5 -4
  14. package/src/config/__tests__/default-profile-catalog.test.ts +33 -1
  15. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  16. package/src/config/call-site-defaults.ts +11 -11
  17. package/src/config/default-profile-catalog.ts +88 -18
  18. package/src/config/default-profile-names.ts +35 -0
  19. package/src/config/inference-profile-validation.ts +7 -6
  20. package/src/config/llm-resolver.ts +16 -0
  21. package/src/config/profile-text-generation.test.ts +9 -0
  22. package/src/config/schemas/call-site-catalog.ts +1 -1
  23. package/src/config/schemas/llm.ts +54 -9
  24. package/src/daemon/conversation-slash.ts +2 -2
  25. package/src/daemon/dictation-text-processing.ts +4 -4
  26. package/src/plugin-api/model-profiles.ts +5 -3
  27. package/src/providers/model-catalog.ts +23 -4
  28. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  29. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  30. package/src/runtime/routes/conversation-routes.ts +4 -2
  31. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  32. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  33. package/src/tools/workflows/manage-workflows.ts +4 -2
  34. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  35. package/src/workspace/migrations/registry.ts +2 -0
  36. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -15,12 +15,14 @@ import {
15
15
  type DefaultProfileKey,
16
16
  type DefaultProfileProvider,
17
17
  FALLBACK_PROFILE_BY_KEY,
18
- isBackupProfileKey,
19
18
  isDefaultProfileKey,
20
19
  isDefaultProfileProvider,
20
+ isManagedOnlyProfileKey,
21
+ JEV_MANAGED_PROFILE_KEY,
21
22
  OS_BETA_PROFILE_KEY,
22
23
  } from "./default-profile-names.js";
23
24
  import { resolveDefaultConnectionName } from "./default-provider-resolution.js";
25
+ import { profileSupportsTextGeneration } from "./profile-text-generation.js";
24
26
  import {
25
27
  backupProfilesResolveUnderDefaultProvider,
26
28
  DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS,
@@ -385,18 +387,39 @@ export const PROFILE_IMPLS: Record<
385
387
  Record<DefaultProfileProvider, DefaultProfileTemplate>
386
388
  >;
387
389
 
390
+ /**
391
+ * The managed Jev profile: TypeSafe's System One decision model through the
392
+ * platform proxy. Managed column only, because the platform is the only
393
+ * route the catalog offers for it here, and never a conversation profile,
394
+ * because `jev-latest` returns structured answers rather than chat text
395
+ * (`supportsText: false` on the `typesafe` catalog entry). Its budget is
396
+ * TypeSafe's published request limit rather than the default context window.
397
+ */
398
+ export const JEV_MANAGED_PROFILE_TEMPLATE: DefaultProfileTemplate = {
399
+ model: "jev-latest",
400
+ provider: "vellum",
401
+ source: "managed",
402
+ label: "Jev",
403
+ description:
404
+ "TypeSafe decision model for yes/no verdicts, for the voice judges and memory selection",
405
+ maxTokens: 4096,
406
+ thinking: { enabled: false, streamThinking: false },
407
+ contextWindow: { maxInputTokens: 32_000 },
408
+ };
409
+
388
410
  /**
389
411
  * Managed profiles, i.e. the `vellum` column keyed by profile name, plus the
390
- * managed backup profiles. Backups come after the primaries, which is what
391
- * places them after the primaries in the seeded `profileOrder`: the seeder
392
- * inserts missing managed keys in this record's order. Keyed by the
393
- * user-facing defaults only: an internal profile is code-resolved and never
394
- * listed or ordered.
412
+ * managed backup profiles and the managed Jev profile. Backups come after the
413
+ * primaries, which is what places them after the primaries in the seeded
414
+ * `profileOrder`: the seeder inserts missing managed keys in this record's
415
+ * order. Keyed by the user-facing defaults only: an internal profile is
416
+ * code-resolved and never listed or ordered.
395
417
  */
396
418
  export const MANAGED_PROFILE_TEMPLATES: Record<string, DefaultProfileTemplate> =
397
419
  Object.fromEntries([
398
420
  ...DEFAULT_PROFILE_KEYS.map((key) => [key, PROFILE_IMPLS[key].vellum]),
399
421
  ...BACKUP_PROFILE_KEYS.map((key) => [key, BACKUP_PROFILE_IMPLS[key]]),
422
+ [JEV_MANAGED_PROFILE_KEY, JEV_MANAGED_PROFILE_TEMPLATE],
400
423
  ]);
401
424
 
402
425
  /**
@@ -484,6 +507,9 @@ export const OS_BETA_PROFILE_TEMPLATE: DefaultProfileTemplate = {
484
507
  export const CODE_OWNED_PROFILE_NAMES = new Set<string>([
485
508
  "latency-optimized",
486
509
  ...BACKUP_PROFILE_KEYS,
510
+ // A shadow could repoint the judges at a chat model, which answers a
511
+ // yes/no question with prose the verdict parser cannot read.
512
+ JEV_MANAGED_PROFILE_KEY,
487
513
  ]);
488
514
 
489
515
  // All managed profiles, including the flag-gated os-beta, are invariant:
@@ -494,6 +520,7 @@ export const CODE_OWNED_PROFILE_NAMES = new Set<string>([
494
520
  export const INVARIANT_PROFILE_NAMES = new Set<string>([
495
521
  ...DEFAULT_PROFILE_KEYS,
496
522
  ...BACKUP_PROFILE_KEYS,
523
+ JEV_MANAGED_PROFILE_KEY,
497
524
  OS_BETA_PROFILE_KEY,
498
525
  ]);
499
526
 
@@ -507,6 +534,7 @@ export const INVARIANT_PROFILE_NAMES = new Set<string>([
507
534
  export const MANAGED_PROFILE_NAMES = new Set<string>([
508
535
  ...DEFAULT_PROFILE_KEYS,
509
536
  ...BACKUP_PROFILE_KEYS,
537
+ JEV_MANAGED_PROFILE_KEY,
510
538
  OS_BETA_PROFILE_KEY,
511
539
  ]);
512
540
 
@@ -590,6 +618,19 @@ for (const key of BACKUP_PROFILE_KEYS) {
590
618
  }
591
619
  }
592
620
 
621
+ // The managed Jev profile pins a model the platform proxy must front, and the
622
+ // judges depend on it being the non-text TypeSafe model rather than a chat
623
+ // model that happens to share the id.
624
+ if (
625
+ getManagedUpstream(JEV_MANAGED_PROFILE_TEMPLATE.model ?? "") !== "typesafe"
626
+ ) {
627
+ throw new Error(
628
+ `JEV_MANAGED_PROFILE_TEMPLATE references model "${JEV_MANAGED_PROFILE_TEMPLATE.model ?? ""}" ` +
629
+ `which the managed route does not serve through typesafe. ` +
630
+ `Update model-catalog.ts, platform-proxy/constants.ts, or default-profile-catalog.ts.`,
631
+ );
632
+ }
633
+
593
634
  // Provider choices without a named column materialize from the shared BYOK
594
635
  // templates; verify each one's resolved model lands in the catalog.
595
636
  for (const provider of DEFAULT_PROVIDER_CHOICES) {
@@ -621,6 +662,10 @@ function buildDefaultProfileEntries(): Record<string, ProfileEntry> {
621
662
  const impl = BACKUP_PROFILE_IMPLS[key];
622
663
  entries[key] = materializeProfile(impl, impl.provider);
623
664
  }
665
+ entries[JEV_MANAGED_PROFILE_KEY] = materializeProfile(
666
+ JEV_MANAGED_PROFILE_TEMPLATE,
667
+ JEV_MANAGED_PROFILE_TEMPLATE.provider,
668
+ );
624
669
  entries[OS_BETA_PROFILE_KEY] = materializeProfile(
625
670
  OS_BETA_PROFILE_TEMPLATE,
626
671
  OS_BETA_PROFILE_TEMPLATE.provider,
@@ -697,12 +742,13 @@ function resolveAgainstBody(
697
742
  if (body == null) {
698
743
  // A managed stub is the workspace's slot for a code-owned profile, never
699
744
  // a profile of its own, so with no body behind it there is nothing to
700
- // resolve. Only a backup name reaches this branch with a stub (the
701
- // default keys always have a body), and it reaches it on the columns
702
- // where the backups do not exist. Resolving to the stub would list a
703
- // profile with no provider or model and let a reference to it look
704
- // valid, which is precisely what `LLMSchema` rejects on those columns.
705
- if (isBackupProfileKey(name) && workspace?.source === "managed") {
745
+ // resolve. Only a managed-only name (a backup, the Jev profile) reaches
746
+ // this branch with a stub (the default keys always have a body), and it
747
+ // reaches it on the columns where those profiles do not exist. Resolving
748
+ // to the stub would list a profile with no provider or model and let a
749
+ // reference to it look valid, which is precisely what `LLMSchema` rejects
750
+ // on those columns.
751
+ if (isManagedOnlyProfileKey(name) && workspace?.source === "managed") {
706
752
  return undefined;
707
753
  }
708
754
  return workspace;
@@ -787,14 +833,14 @@ function defaultProfileBodyForProvider(
787
833
  defaultProvider: DefaultProviderConfig | null,
788
834
  ): ProfileEntry | undefined {
789
835
  if (!isDefaultProfileKey(name)) {
790
- // Backup profiles are companions of the managed (`vellum`) column only:
791
- // under a BYOK or chatgpt default provider the primaries carry no
792
- // `fallbackProfile` pointers, and the backups must not materialize
793
- // either, since the install may hold no credential for the backup's
836
+ // Backup profiles and the managed Jev profile exist on the managed
837
+ // (`vellum`) column only: under a BYOK or chatgpt default provider the
838
+ // primaries carry no `fallbackProfile` pointers, and neither may
839
+ // materialize, since the install may hold no credential for their
794
840
  // provider. A null defaultProvider predates `llm.defaultProvider` and
795
- // resolves to the managed column, so it keeps its backups.
841
+ // resolves to the managed column, so it keeps them.
796
842
  if (
797
- isBackupProfileKey(name) &&
843
+ isManagedOnlyProfileKey(name) &&
798
844
  !backupProfilesResolveUnderDefaultProvider(defaultProvider)
799
845
  ) {
800
846
  return undefined;
@@ -923,3 +969,27 @@ export function getUserSelectableProfilesForProvider(
923
969
  }
924
970
  return selectable;
925
971
  }
972
+
973
+ /**
974
+ * The profiles a conversation can run on: the user-selectable view minus any
975
+ * profile whose model returns structured answers rather than chat text (the
976
+ * managed Jev profile). Every surface that picks or validates a conversation
977
+ * profile (the `/model` command, a per-conversation pin, the plugin and
978
+ * workflow listings) reads this view; the call-site override pickers keep
979
+ * the wider selectable view, since the verdict sites are what Jev is for.
980
+ */
981
+ export function getConversationProfilesForProvider(
982
+ workspaceProfiles: Record<string, ProfileEntry> | undefined,
983
+ defaultProvider: DefaultProviderConfig | null,
984
+ ): Record<string, ProfileEntry> {
985
+ const selectable = getUserSelectableProfilesForProvider(
986
+ workspaceProfiles,
987
+ defaultProvider,
988
+ );
989
+ for (const [name, entry] of Object.entries(selectable)) {
990
+ if (!profileSupportsTextGeneration(entry, selectable)) {
991
+ delete selectable[name];
992
+ }
993
+ }
994
+ return selectable;
995
+ }
@@ -67,6 +67,41 @@ export function isBackupProfileKey(value: string): value is BackupProfileKey {
67
67
  return (BACKUP_PROFILE_KEYS as readonly string[]).includes(value);
68
68
  }
69
69
 
70
+ /**
71
+ * Managed profile for the TypeSafe decision model (Jev). Deliberately NOT
72
+ * part of `DEFAULT_PROFILE_KEYS`: it has no BYOK column, because the
73
+ * platform serves Jev only through the managed route, and its model returns
74
+ * structured answers rather than chat text, so it can never be the active
75
+ * or conversation profile. It exists as a pin target for the call sites
76
+ * that consume a yes/no verdict (the voice judges, the memory selector).
77
+ */
78
+ export const JEV_MANAGED_PROFILE_KEY = "jev-managed";
79
+
80
+ /**
81
+ * The call sites that consume a structured verdict and so may be pinned to
82
+ * the Jev profile. Every other site expects chat text or a tool call, which
83
+ * the decision model never produces.
84
+ */
85
+ export const JEV_MANAGED_PROFILE_CALL_SITES = [
86
+ "voiceEscalationJudge",
87
+ "voiceContinuationJudge",
88
+ "memoryV3SelectL2",
89
+ ] as const;
90
+
91
+ /**
92
+ * Keys whose profile exists on the managed (`vellum`) column only: the
93
+ * backups and the Jev profile. Under a BYOK or ChatGPT default provider
94
+ * these names have no body to resolve to.
95
+ */
96
+ export const MANAGED_ONLY_PROFILE_KEYS = [
97
+ ...BACKUP_PROFILE_KEYS,
98
+ JEV_MANAGED_PROFILE_KEY,
99
+ ] as const;
100
+
101
+ export function isManagedOnlyProfileKey(value: string): boolean {
102
+ return (MANAGED_ONLY_PROFILE_KEYS as readonly string[]).includes(value);
103
+ }
104
+
70
105
  /**
71
106
  * Flag-gated default profile: only available while the `os-beta` feature
72
107
  * flag has reconciled it into the workspace (see `sync-gated-profiles.ts`).
@@ -1,4 +1,4 @@
1
- import { getUserSelectableProfilesForProvider } from "./default-profile-catalog.js";
1
+ import { getConversationProfilesForProvider } from "./default-profile-catalog.js";
2
2
  import { getConfigReadOnly } from "./loader.js";
3
3
 
4
4
  /**
@@ -6,17 +6,18 @@ import { getConfigReadOnly } from "./loader.js";
6
6
  * (code-defined defaults + workspace `llm.profiles`). Returns a user-facing
7
7
  * error message when the key is empty or unknown, or `null` when valid.
8
8
  *
9
- * Resolved through the `llm.defaultProvider`-aware user-selectable view so
10
- * availability matches what a direct profile pin can choose. Managed backup
11
- * routes remain available to automatic fallback resolution but are not
12
- * reported as selectable profiles.
9
+ * Resolved through the `llm.defaultProvider`-aware conversation view, since
10
+ * every caller (schedules, subagent spawns, per-conversation pins) runs chat
11
+ * turns on the result. Managed backup routes remain available to automatic
12
+ * fallback resolution, and the managed Jev profile to the verdict call
13
+ * sites, but neither is reported as a selectable profile here.
13
14
  */
14
15
  export function validateInferenceProfileKey(profile: string): string | null {
15
16
  if (!profile.trim()) {
16
17
  return "inferenceProfile must be a non-empty string";
17
18
  }
18
19
  const llm = getConfigReadOnly().llm;
19
- const profiles = getUserSelectableProfilesForProvider(
20
+ const profiles = getConversationProfilesForProvider(
20
21
  llm?.profiles,
21
22
  llm?.defaultProvider ?? null,
22
23
  );
@@ -627,6 +627,22 @@ export function resolveDefaultProfileKey(
627
627
  return selectWinningProfile(callSite, llm, {}).profileName ?? undefined;
628
628
  }
629
629
 
630
+ /**
631
+ * Returns the code-owned profile tier for catalog display when that tier is
632
+ * available through the workspace's default provider. Workspace call-site
633
+ * pins do not affect this value. A managed-only tier on a BYOK install returns
634
+ * undefined so clients do not report a profile that cannot run there.
635
+ */
636
+ export function resolveShippedDefaultProfileKey(
637
+ callSite: LLMCallSite,
638
+ llm: z.infer<typeof LLMSchema>,
639
+ ): string | undefined {
640
+ const intent = CALL_SITE_DEFAULTS[callSite]?.profile ?? "balanced";
641
+ return usableDefaultIntent(intent, llm, {}, () => undefined) == null
642
+ ? undefined
643
+ : intent;
644
+ }
645
+
630
646
  /**
631
647
  * Returns the profile key that `resolveCallSiteConfig` treats as the winning
632
648
  * (highest-precedence) profile for a turn — the profile whose fragment supplies
@@ -15,6 +15,15 @@ describe("profileSupportsTextGeneration", () => {
15
15
  ).toBe(false);
16
16
  });
17
17
 
18
+ test("false for the managed Jev profile shape", () => {
19
+ expect(
20
+ profileSupportsTextGeneration(
21
+ { provider: "vellum", model: "jev-latest" },
22
+ {},
23
+ ),
24
+ ).toBe(false);
25
+ });
26
+
18
27
  test("true for a chat model and for unlisted custom ids", () => {
19
28
  expect(
20
29
  profileSupportsTextGeneration(
@@ -307,7 +307,7 @@ const CATALOG_RECORD: CatalogRecord = {
307
307
  id: "voiceEscalationJudge",
308
308
  displayName: "Voice Escalation Judge",
309
309
  description:
310
- "Second opinion on the voice front door's escalate decision. Runs only when pinned to a TypeSafe profile; a confident 'needs tools' verdict hands the turn to the escalated leg.",
310
+ "Second opinion on the voice front door's escalate decision. Uses managed Jev when Vellum-managed inference is available, or an explicitly configured TypeSafe profile; a confident 'needs tools' verdict hands the turn to the escalated leg.",
311
311
  domain: "agentLoop",
312
312
  },
313
313
  voiceContinuationJudge: {
@@ -14,12 +14,14 @@ import {
14
14
  parseVellumModel,
15
15
  } from "../../providers/vellum-model-routing.js";
16
16
  import {
17
- BACKUP_PROFILE_KEYS,
18
17
  DEFAULT_PROFILE_KEYS,
19
18
  DEFAULT_PROFILE_PROVIDERS,
20
19
  FALLBACK_PROFILE_BY_KEY,
21
- isBackupProfileKey,
22
20
  isDefaultProfileKey,
21
+ isManagedOnlyProfileKey,
22
+ JEV_MANAGED_PROFILE_CALL_SITES,
23
+ JEV_MANAGED_PROFILE_KEY,
24
+ MANAGED_ONLY_PROFILE_KEYS,
23
25
  } from "../default-profile-names.js";
24
26
  import { InputModalitiesSchema } from "../input-modalities.js";
25
27
 
@@ -766,10 +768,12 @@ function unresolvableProfileReason(
766
768
  name: string,
767
769
  backupsResolve: boolean,
768
770
  ): string {
769
- return !backupsResolve &&
770
- (BACKUP_PROFILE_KEYS as readonly string[]).includes(name)
771
- ? "is a managed backup profile, which resolves only while llm.defaultProvider is the managed provider"
772
- : "is not defined in llm.profiles";
771
+ if (backupsResolve || !isManagedOnlyProfileKey(name)) {
772
+ return "is not defined in llm.profiles";
773
+ }
774
+ return name === JEV_MANAGED_PROFILE_KEY
775
+ ? "is the managed Jev profile, which resolves only while llm.defaultProvider is the managed provider"
776
+ : "is a managed backup profile, which resolves only while llm.defaultProvider is the managed provider";
773
777
  }
774
778
 
775
779
  /**
@@ -800,7 +804,7 @@ function referenceableProfileKeys(
800
804
  ): string[] {
801
805
  return Object.entries(profiles ?? {})
802
806
  .filter(([name, value]) => {
803
- if (backupsResolve || !isBackupProfileKey(name)) {
807
+ if (backupsResolve || !isManagedOnlyProfileKey(name)) {
804
808
  return true;
805
809
  }
806
810
  const entry =
@@ -854,7 +858,7 @@ export function collectFallbackProfileIssues(
854
858
  const profileNames = new Set([
855
859
  ...referenceableProfileKeys(profiles, backupsResolve),
856
860
  ...DEFAULT_PROFILE_KEYS,
857
- ...(backupsResolve ? BACKUP_PROFILE_KEYS : []),
861
+ ...(backupsResolve ? MANAGED_ONLY_PROFILE_KEYS : []),
858
862
  ]);
859
863
  const mixProfileNames = new Set(
860
864
  entries
@@ -1004,6 +1008,8 @@ export const LLMSchema = z
1004
1008
  // they join the set only under a managed `llm.defaultProvider`: on a BYOK
1005
1009
  // or ChatGPT default provider they have no body to resolve to, and
1006
1010
  // keeping the reference would strand a selection the picker cannot show.
1011
+ // The managed Jev profile (`JEV_MANAGED_PROFILE_KEY`) is scoped the same
1012
+ // way, as a pin target for the judge call sites.
1007
1013
  // The flag-gated `os-beta` is excluded: it resolves only while a
1008
1014
  // workspace entry exists, so a reference to it is valid only when that
1009
1015
  // entry is present in `config.profiles`. A backup name materialized as a
@@ -1018,9 +1024,23 @@ export const LLMSchema = z
1018
1024
  backupsResolve,
1019
1025
  ),
1020
1026
  ...DEFAULT_PROFILE_KEYS,
1021
- ...(backupsResolve ? BACKUP_PROFILE_KEYS : []),
1027
+ ...(backupsResolve ? MANAGED_ONLY_PROFILE_KEYS : []),
1022
1028
  ]);
1023
1029
  for (const [siteId, siteConfig] of Object.entries(config.callSites ?? {})) {
1030
+ // The Jev profile answers with structured verdicts, so only the sites
1031
+ // built to read one may pin it; a text-producing site would receive
1032
+ // output it cannot use. (Mix arms are covered by the profile-level
1033
+ // mix validation below, which bars the profile from any mix.)
1034
+ if (
1035
+ siteConfig?.profile === JEV_MANAGED_PROFILE_KEY &&
1036
+ !(JEV_MANAGED_PROFILE_CALL_SITES as readonly string[]).includes(siteId)
1037
+ ) {
1038
+ ctx.addIssue({
1039
+ code: "custom",
1040
+ path: ["callSites", siteId, "profile"],
1041
+ message: `Profile "${JEV_MANAGED_PROFILE_KEY}" returns structured answers rather than chat text, so it can only be pinned to ${JEV_MANAGED_PROFILE_CALL_SITES.join(", ")}`,
1042
+ });
1043
+ }
1024
1044
  if (siteConfig?.profile == null) {
1025
1045
  continue;
1026
1046
  }
@@ -1052,6 +1072,20 @@ export const LLMSchema = z
1052
1072
  message: `Profile "${config.advisorProfile}" referenced by llm.advisorProfile ${unresolvableProfileReason(config.advisorProfile, backupsResolve)}`,
1053
1073
  });
1054
1074
  }
1075
+ // The managed Jev profile is a valid reference target for call-site pins
1076
+ // only. Its model returns structured verdicts rather than chat text, so
1077
+ // the conversation positions reject it here as well as at the write
1078
+ // routes, which keeps a raw config write from handing the main agent or
1079
+ // the advisor a model that cannot answer in prose.
1080
+ for (const field of ["activeProfile", "advisorProfile"] as const) {
1081
+ if (config[field] === JEV_MANAGED_PROFILE_KEY) {
1082
+ ctx.addIssue({
1083
+ code: "custom",
1084
+ path: [field],
1085
+ message: `Profile "${JEV_MANAGED_PROFILE_KEY}" referenced by llm.${field} returns structured answers rather than chat text, so it cannot be the conversation or advisor profile`,
1086
+ });
1087
+ }
1088
+ }
1055
1089
 
1056
1090
  // --- Mix profile validation --------------------------------------------
1057
1091
  // Config keys a mix profile must NOT also set (a mix only references other
@@ -1105,6 +1139,17 @@ export const LLMSchema = z
1105
1139
  });
1106
1140
  continue;
1107
1141
  }
1142
+ // A mix is a conversation profile, so an arm that returns structured
1143
+ // verdicts rather than text would hand a chat turn to the decision
1144
+ // model whenever the seeded pick lands on it.
1145
+ if (arm.profile === JEV_MANAGED_PROFILE_KEY) {
1146
+ ctx.addIssue({
1147
+ code: "custom",
1148
+ path: ["profiles", name, "mix", index, "profile"],
1149
+ message: `Mix profile "${name}" references "${JEV_MANAGED_PROFILE_KEY}", which returns structured answers rather than chat text and cannot be a mix constituent.`,
1150
+ });
1151
+ continue;
1152
+ }
1108
1153
  // (b) No nesting — a mix arm must be a standard (non-mix) profile.
1109
1154
  if (mixProfileNames.has(arm.profile)) {
1110
1155
  ctx.addIssue({
@@ -1,5 +1,5 @@
1
1
  import type { InterfaceId } from "../channels/types.js";
2
- import { getUserSelectableProfilesForProvider } from "../config/default-profile-catalog.js";
2
+ import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
3
3
  import { resolveEffectiveContextWindow } from "../config/llm-context-resolution.js";
4
4
  import { resolveCallSiteConfig } from "../config/llm-resolver.js";
5
5
  import {
@@ -148,7 +148,7 @@ async function resolveModelCommand(
148
148
  parse: ModelCommandParse,
149
149
  ): Promise<SlashResolution> {
150
150
  const config = getConfig();
151
- const profiles = getUserSelectableProfilesForProvider(
151
+ const profiles = getConversationProfilesForProvider(
152
152
  config.llm.profiles,
153
153
  config.llm.defaultProvider ?? null,
154
154
  );
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Pure text-processing functions for dictation profiles.
3
3
  *
4
- * - expandSnippets: pre-LLM, dictation mode only
5
- * - applyDictionary: post-LLM, dictation + command modes (including fallback paths)
4
+ * - expandSnippets: dictation mode only
5
+ * - applyDictionary: dictation and selected-text edits (including fallback paths)
6
6
  */
7
7
 
8
8
  import type {
@@ -33,7 +33,7 @@ function wrapWordBoundary(
33
33
  }
34
34
 
35
35
  /**
36
- * Expand snippet triggers in text before sending to the LLM.
36
+ * Expand user-configured snippet triggers in dictated text.
37
37
  *
38
38
  * - Only enabled snippets are considered
39
39
  * - Sorted by trigger length descending (longest match wins)
@@ -80,7 +80,7 @@ export function expandSnippets(
80
80
  }
81
81
 
82
82
  /**
83
- * Apply dictionary normalization to text after LLM processing (or on raw fallback).
83
+ * Apply user-configured dictionary replacements to dictated or edited text.
84
84
  *
85
85
  * - Sorted by spoken length descending
86
86
  * - Respects wholeWord (default true) and caseSensitive (default false) per entry
@@ -1,4 +1,4 @@
1
- import { getUserSelectableProfilesForProvider } from "../config/default-profile-catalog.js";
1
+ import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
2
2
  import { getConfig } from "../config/loader.js";
3
3
  import { isDispatchableProfile } from "../config/profile-dispatchability.js";
4
4
  import { orderProfileKeys } from "../config/profile-order.js";
@@ -7,7 +7,9 @@ import type { ModelProfileInfo } from "./types.js";
7
7
  /**
8
8
  * List the workspace inference profiles a plugin can route to, in the order a
9
9
  * user-facing model picker presents them (`llm.profileOrder` first, then the
10
- * rest alphabetically). Managed backup routes are internal and omitted.
10
+ * rest alphabetically). Managed backup routes and the managed Jev profile
11
+ * are omitted with the rest of the conversation view: a plugin routes
12
+ * conversation turns, and a decision model cannot answer them.
11
13
  * Metadata-only entries without a provider, model, or mix are not routing
12
14
  * targets, so plugins never see them. Disabled profiles are included and flagged via
13
15
  * {@link ModelProfileInfo.isDisabled}; weighted "mix" profiles are included and
@@ -20,7 +22,7 @@ import type { ModelProfileInfo } from "./types.js";
20
22
  export function getModelProfiles(): ModelProfileInfo[] {
21
23
  const { llm } = getConfig();
22
24
  const { activeProfile } = llm;
23
- const profiles = getUserSelectableProfilesForProvider(
25
+ const profiles = getConversationProfilesForProvider(
24
26
  llm.profiles,
25
27
  llm.defaultProvider ?? null,
26
28
  );
@@ -1,7 +1,9 @@
1
1
  import { PLATFORM_PROVIDER_META } from "./platform-proxy/constants.js";
2
2
 
3
3
  export type LongContextMode =
4
- "native-model" | "provider-request-option" | "unsupported";
4
+ | "native-model"
5
+ | "provider-request-option"
6
+ | "unsupported";
5
7
 
6
8
  export interface CatalogModelPricingTier {
7
9
  /**
@@ -2602,6 +2604,16 @@ export const PROVIDER_CATALOG: ProviderCatalogEntry[] =
2602
2604
  supportsPlatformAuth: PLATFORM_PROVIDER_META[entry.id]?.managed === true,
2603
2605
  }));
2604
2606
 
2607
+ /**
2608
+ * Providers that are routing identities rather than catalog owners. Mirrors
2609
+ * `ROUTING_IDENTITY_PROVIDERS` in `inference/auth.ts`, which cannot be
2610
+ * imported here without a cycle.
2611
+ */
2612
+ const ROUTING_IDENTITY_PROVIDER_IDS: ReadonlySet<string> = new Set([
2613
+ "vellum",
2614
+ "chatgpt",
2615
+ ]);
2616
+
2605
2617
  /**
2606
2618
  * Whether a catalog model produces free-form chat text. Unlisted providers
2607
2619
  * and model ids default to true so custom endpoints and unknown snapshots
@@ -2614,9 +2626,16 @@ export function catalogModelSupportsText(
2614
2626
  if (typeof provider !== "string" || typeof modelId !== "string") {
2615
2627
  return true;
2616
2628
  }
2617
- const model = PROVIDER_CATALOG.find((p) => p.id === provider)?.models.find(
2618
- (m) => m.id === modelId,
2619
- );
2629
+ // A routing identity ("vellum", "chatgpt") owns no catalog models of the
2630
+ // upstream it dispatches to, so judge the model by its catalog owner: a
2631
+ // managed Jev profile is `vellum` + `jev-latest`, and `jev-latest` opts out
2632
+ // of text under `typesafe`.
2633
+ const catalogProvider = ROUTING_IDENTITY_PROVIDER_IDS.has(provider)
2634
+ ? (getCatalogProviderForModel(modelId) ?? provider)
2635
+ : provider;
2636
+ const model = PROVIDER_CATALOG.find(
2637
+ (p) => p.id === catalogProvider,
2638
+ )?.models.find((m) => m.id === modelId);
2620
2639
  return model?.supportsText !== false;
2621
2640
  }
2622
2641