@vellumai/assistant 0.11.10 → 0.11.11-staging.1

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 (199) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  12. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  13. package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  14. package/openapi.yaml +152 -55
  15. package/package.json +1 -1
  16. package/src/__tests__/anthropic-provider.test.ts +60 -0
  17. package/src/__tests__/config-get-vision-flag.test.ts +36 -0
  18. package/src/__tests__/conversation-error.test.ts +38 -0
  19. package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
  20. package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
  21. package/src/__tests__/headless-browser-mode.test.ts +63 -13
  22. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  23. package/src/__tests__/heartbeat-service.test.ts +118 -27
  24. package/src/__tests__/host-app-control-proxy.test.ts +2 -2
  25. package/src/__tests__/host-app-control-routes.test.ts +41 -0
  26. package/src/__tests__/host-bash-routes.test.ts +30 -1
  27. package/src/__tests__/host-browser-routes.test.ts +34 -0
  28. package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
  29. package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
  30. package/src/__tests__/host-proxy-base.test.ts +1 -0
  31. package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
  32. package/src/__tests__/llm-schema.test.ts +32 -0
  33. package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
  34. package/src/__tests__/memory-config-file-parity.test.ts +18 -0
  35. package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
  36. package/src/__tests__/messaging-send-tool.test.ts +159 -13
  37. package/src/__tests__/messaging-shared.test.ts +19 -0
  38. package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
  39. package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
  40. package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
  41. package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
  42. package/src/__tests__/schedule-routes.test.ts +30 -1
  43. package/src/__tests__/scheduler-result-notification.test.ts +209 -0
  44. package/src/__tests__/skills-install-extract.test.ts +26 -0
  45. package/src/__tests__/skills-install-staging.test.ts +11 -2
  46. package/src/api/constants/personality-sliders.test.ts +21 -0
  47. package/src/api/constants/personality-sliders.ts +64 -0
  48. package/src/api/index.ts +8 -0
  49. package/src/archive/ustar-size.test.ts +32 -0
  50. package/src/archive/ustar-size.ts +23 -0
  51. package/src/background-wake/next-wake.test.ts +13 -0
  52. package/src/background-wake/next-wake.ts +4 -2
  53. package/src/calls/voice-session-bridge.ts +26 -0
  54. package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
  55. package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
  56. package/src/cli/commands/config.help.ts +3 -1
  57. package/src/cli/commands/memory/worker.ts +1 -1
  58. package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
  59. package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
  60. package/src/cli/commands/platform/credits-format.ts +57 -0
  61. package/src/cli/commands/platform/index.help.ts +19 -3
  62. package/src/cli/commands/plugins.help.ts +4 -1
  63. package/src/cli/commands/plugins.ts +2 -1
  64. package/src/cli/lib/bundled-marketplace.json +1 -1
  65. package/src/cli/lib/install-from-platform.ts +8 -10
  66. package/src/config/bundled-skills/messaging/SKILL.md +2 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
  68. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
  69. package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
  70. package/src/config/bundled-skills/schedule/SKILL.md +9 -1
  71. package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
  72. package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
  73. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
  74. package/src/config/feature-flag-registry.json +17 -1
  75. package/src/config/input-modalities.test.ts +71 -0
  76. package/src/config/input-modalities.ts +95 -0
  77. package/src/config/schemas/heartbeat.ts +3 -3
  78. package/src/config/schemas/llm.ts +13 -3
  79. package/src/config/schemas/memory-storage.test.ts +39 -0
  80. package/src/config/schemas/memory-storage.ts +21 -0
  81. package/src/context/token-estimator.ts +2 -2
  82. package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
  83. package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
  84. package/src/daemon/conversation-error.ts +20 -7
  85. package/src/daemon/embedding-reconcile.ts +64 -1
  86. package/src/daemon/handlers/config-embeddings.ts +31 -0
  87. package/src/daemon/host-app-control-proxy.ts +2 -0
  88. package/src/daemon/host-bash-proxy.ts +1 -0
  89. package/src/daemon/host-cu-proxy.ts +6 -6
  90. package/src/daemon/host-file-proxy.ts +6 -6
  91. package/src/daemon/host-proxy-base.ts +10 -9
  92. package/src/daemon/host-transfer-proxy.ts +21 -24
  93. package/src/daemon/mcp-reload-service.ts +6 -0
  94. package/src/daemon/providers-setup.ts +5 -26
  95. package/src/heartbeat/heartbeat-service.ts +72 -13
  96. package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
  97. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
  98. package/src/live-voice/live-voice-photo.ts +68 -4
  99. package/src/live-voice/live-voice-session.ts +36 -2
  100. package/src/live-voice/protocol.ts +98 -1
  101. package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
  102. package/src/mcp/__tests__/reload-signal.test.ts +57 -0
  103. package/src/mcp/__tests__/startup.test.ts +150 -0
  104. package/src/mcp/reload-signal.ts +50 -0
  105. package/src/mcp/startup.ts +101 -0
  106. package/src/messaging/providers/outlook/adapter.ts +2 -13
  107. package/src/messaging/providers/outlook/client.ts +13 -1
  108. package/src/messaging/providers/outlook/types.ts +3 -0
  109. package/src/notifications/__tests__/decision-engine.test.ts +79 -6
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
  111. package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
  112. package/src/notifications/copy-composer.ts +33 -11
  113. package/src/notifications/decision-engine.ts +48 -11
  114. package/src/notifications/events-store.ts +68 -2
  115. package/src/notifications/home-feed-side-effect.ts +15 -3
  116. package/src/notifications/schedule-result-producer.ts +275 -0
  117. package/src/notifications/signal.ts +5 -0
  118. package/src/oauth/seed-providers.ts +3 -2
  119. package/src/persistence/conversation-crud.ts +38 -0
  120. package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
  121. package/src/persistence/embeddings/embedding-backend.ts +148 -11
  122. package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
  123. package/src/persistence/embeddings/embedding-openai.ts +55 -4
  124. package/src/persistence/embeddings/embedding-types.test.ts +24 -0
  125. package/src/persistence/embeddings/embedding-types.ts +25 -1
  126. package/src/persistence/job-utils.ts +6 -2
  127. package/src/plugin-api/index.ts +2 -1
  128. package/src/plugin-api/vision-support.test.ts +81 -0
  129. package/src/plugin-api/vision-support.ts +57 -10
  130. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
  131. package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
  132. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
  133. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
  134. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
  135. package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
  136. package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
  137. package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
  138. package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
  139. package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
  140. package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
  141. package/src/providers/anthropic/client.ts +9 -4
  142. package/src/providers/inline-audio-support.test.ts +34 -0
  143. package/src/providers/inline-audio-support.ts +27 -0
  144. package/src/providers/model-catalog.ts +22 -2
  145. package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
  146. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
  147. package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
  148. package/src/providers/openai/api-error-normalization.ts +18 -3
  149. package/src/providers/openai/chat-completions-provider.ts +113 -13
  150. package/src/providers/openai/responses-provider.ts +10 -8
  151. package/src/providers/provider-secret-catalog.ts +11 -1
  152. package/src/providers/vellum/client.ts +14 -1
  153. package/src/providers/vellum/personality-directions.test.ts +163 -0
  154. package/src/providers/vellum/personality-directions.ts +120 -0
  155. package/src/providers/xml-tool-call-salvage.ts +212 -0
  156. package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
  157. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
  158. package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
  159. package/src/runtime/auth/same-actor.ts +29 -5
  160. package/src/runtime/background-job-runner.ts +38 -29
  161. package/src/runtime/guardian-action-service.ts +20 -1
  162. package/src/runtime/migrations/vbundle-validator.ts +3 -3
  163. package/src/runtime/pending-interactions.ts +4 -5
  164. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
  165. package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
  166. package/src/runtime/routes/conversation-query-routes.ts +53 -11
  167. package/src/runtime/routes/document-comments-routes.ts +8 -2
  168. package/src/runtime/routes/guardian-action-routes.ts +7 -0
  169. package/src/runtime/routes/heartbeat-routes.ts +41 -53
  170. package/src/runtime/routes/host-app-control-routes.ts +10 -36
  171. package/src/runtime/routes/host-bash-routes.ts +11 -45
  172. package/src/runtime/routes/host-browser-routes.ts +24 -0
  173. package/src/runtime/routes/host-cu-routes.ts +11 -45
  174. package/src/runtime/routes/host-file-routes.ts +11 -44
  175. package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
  176. package/src/runtime/routes/host-transfer-routes.ts +33 -25
  177. package/src/runtime/routes/index.ts +0 -2
  178. package/src/runtime/routes/integrations/a2a.ts +12 -3
  179. package/src/runtime/routes/integrations/vercel.ts +13 -3
  180. package/src/runtime/routes/platform-routes.ts +121 -24
  181. package/src/runtime/routes/schedule-routes.ts +15 -1
  182. package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
  183. package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
  184. package/src/schedule/schedule-timezone.ts +25 -0
  185. package/src/schedule/scheduler.ts +19 -0
  186. package/src/schedule/worker.ts +119 -2
  187. package/src/skills/catalog-install.ts +40 -5
  188. package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
  189. package/src/tools/browser/browser-execution.ts +117 -53
  190. package/src/tools/browser/browser-status-constants.ts +6 -6
  191. package/src/tools/capability-offer.test.ts +87 -0
  192. package/src/tools/capability-offer.ts +93 -0
  193. package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
  194. package/src/tools/terminal/safe-env.ts +3 -2
  195. package/src/util/provider-error-patterns.ts +34 -0
  196. package/src/watcher/__tests__/engine.test.ts +5 -2
  197. package/src/watcher/engine.ts +7 -4
  198. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
  199. package/src/runtime/routes/sight-frame-routes.ts +0 -136
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+
3
+ import { PROVIDER_CATALOG } from "../providers/model-catalog.js";
4
+
5
+ /**
6
+ * Input modalities a profile can declare independently of the model catalog.
7
+ * Cataloged models keep today's flags; free-text / empty-catalog models use
8
+ * these overrides to say what the serving surface actually accepts.
9
+ */
10
+ export const INPUT_MODALITIES = ["text", "image", "audio", "video"] as const;
11
+ export type InputModality = (typeof INPUT_MODALITIES)[number];
12
+
13
+ /**
14
+ * Per-modality policy + capability. Absent fields inherit catalog defaults
15
+ * (unknown models: unsupported, except text which is always supported).
16
+ *
17
+ * - `enabled`: whether this profile may use the modality.
18
+ * - `supported`: whether the model accepts the modality, overriding the catalog
19
+ * when set.
20
+ */
21
+ export const ModalityOverrideSchema = z
22
+ .object({
23
+ enabled: z.boolean().optional(),
24
+ supported: z.boolean().optional(),
25
+ })
26
+ .meta({ id: "ModalityOverride" });
27
+ export type ModalityOverride = z.infer<typeof ModalityOverrideSchema>;
28
+
29
+ export const InputModalitiesSchema = z
30
+ .object({
31
+ text: ModalityOverrideSchema.optional(),
32
+ image: ModalityOverrideSchema.optional(),
33
+ audio: ModalityOverrideSchema.optional(),
34
+ video: ModalityOverrideSchema.optional(),
35
+ })
36
+ .meta({ id: "InputModalities" });
37
+ export type InputModalities = z.infer<typeof InputModalitiesSchema>;
38
+
39
+ /**
40
+ * Catalog capability for a modality. Unknown models fail closed except text,
41
+ * which every chat profile can send.
42
+ */
43
+ export function catalogSupportsModality(
44
+ modality: InputModality,
45
+ catalog: {
46
+ supportsVision?: boolean;
47
+ supportsAudioInput?: boolean;
48
+ } | undefined,
49
+ ): boolean {
50
+ if (modality === "text") {
51
+ return true;
52
+ }
53
+ if (modality === "image") {
54
+ return catalog?.supportsVision === true;
55
+ }
56
+ if (modality === "audio") {
57
+ return catalog?.supportsAudioInput === true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ export function catalogEntryFor(
63
+ provider: string,
64
+ model: string,
65
+ ): { supportsVision?: boolean; supportsAudioInput?: boolean } | undefined {
66
+ const catalogProvider = PROVIDER_CATALOG.find((p) => p.id === provider);
67
+ return catalogProvider?.models.find((m) => m.id === model);
68
+ }
69
+
70
+ /**
71
+ * Resolve whether a modality should be sent on the wire.
72
+ *
73
+ * Untouched (no override for that row) inherits `catalogSupported`, which may
74
+ * be `undefined` when the catalog does not know the model. An authored
75
+ * override is always a boolean: both `enabled` and `supported` must be true,
76
+ * with missing leaves defaulting to enabled=true and supported=catalog.
77
+ */
78
+ export function resolveModalityOverride(
79
+ override: ModalityOverride | undefined,
80
+ catalogSupported: boolean | undefined,
81
+ ): boolean | undefined {
82
+ if (override == null) {
83
+ return catalogSupported;
84
+ }
85
+ const catalog = catalogSupported === true;
86
+ const enabled = override.enabled ?? true;
87
+ const supported = override.supported ?? catalog;
88
+ return enabled && supported;
89
+ }
90
+
91
+ export function modalitiesOf(
92
+ entry: { inputModalities?: InputModalities | null } | undefined,
93
+ ): InputModalities | undefined {
94
+ return entry?.inputModalities ?? undefined;
95
+ }
@@ -25,7 +25,7 @@ export const HeartbeatConfigSchema = z
25
25
  .nullable()
26
26
  .default(null)
27
27
  .describe(
28
- "Timezone for cron expression evaluation, e.g. 'America/New_York'. Ignored when cronExpression is null.",
28
+ "Timezone for cron expression and active-hours evaluation, e.g. 'America/New_York'. When null, uses the user's configured or detected timezone, then the host clock.",
29
29
  ),
30
30
  activeHoursStart: z
31
31
  .number({ error: "heartbeat.activeHoursStart must be a number" })
@@ -35,7 +35,7 @@ export const HeartbeatConfigSchema = z
35
35
  .nullable()
36
36
  .default(8)
37
37
  .describe(
38
- "Hour of the day (0-23) when heartbeat checks begin, or null to disable active hours restriction",
38
+ "Hour of the day (0-23) in the heartbeat timezone when heartbeat checks begin, or null to disable active hours restriction",
39
39
  ),
40
40
  activeHoursEnd: z
41
41
  .number({ error: "heartbeat.activeHoursEnd must be a number" })
@@ -45,7 +45,7 @@ export const HeartbeatConfigSchema = z
45
45
  .nullable()
46
46
  .default(22)
47
47
  .describe(
48
- "Hour of the day (0-23) when heartbeat checks stop, or null to disable active hours restriction",
48
+ "Hour of the day (0-23) in the heartbeat timezone when heartbeat checks stop, or null to disable active hours restriction",
49
49
  ),
50
50
  maxConsecutiveRuns: z
51
51
  .number({ error: "heartbeat.maxConsecutiveRuns must be a number" })
@@ -18,6 +18,7 @@ import {
18
18
  isBackupProfileKey,
19
19
  isDefaultProfileKey,
20
20
  } from "../default-profile-names.js";
21
+ import { InputModalitiesSchema } from "../input-modalities.js";
21
22
 
22
23
  /**
23
24
  * Unified LLM configuration schema.
@@ -640,6 +641,13 @@ export const ProfileEntry = LLMConfigFragment.extend({
640
641
  * a model the checks can vouch for never needs it.
641
642
  */
642
643
  allowUnlisted: z.boolean().optional(),
644
+ /**
645
+ * Per-profile input-modality policy and capability overrides. Absent
646
+ * inherits the model catalog (unknown models fail closed at send time).
647
+ * `.nullable()` matches `label` so edit-mode PATCH can send `null` to
648
+ * clear a previously stored override.
649
+ */
650
+ inputModalities: InputModalitiesSchema.nullable().optional(),
643
651
  /**
644
652
  * Absent means active. `.nullable()` matches `label` so the PUT route's
645
653
  * "send `null` to clear" sentinel works for status too — a managed
@@ -1040,13 +1048,15 @@ export const LLMSchema = z
1040
1048
  // --- Mix profile validation --------------------------------------------
1041
1049
  // Config keys a mix profile must NOT also set (a mix only references other
1042
1050
  // profiles + metadata). Derived from the fragment shape plus the
1043
- // ProfileEntry-only `provider_connection` and `fallbackProfile` (a mix
1044
- // carries no config of its own, so it has no route to fall back from) so
1045
- // it can't drift if a new config field is added to `LLMConfigFragment`.
1051
+ // ProfileEntry-only `provider_connection`, `fallbackProfile` (a mix
1052
+ // carries no config of its own, so it has no route to fall back from),
1053
+ // and `inputModalities` so it can't drift if a new config field is added
1054
+ // to `LLMConfigFragment`.
1046
1055
  const MIX_DISALLOWED_CONFIG_KEYS = [
1047
1056
  ...Object.keys(LLMConfigFragment.shape),
1048
1057
  "provider_connection",
1049
1058
  "fallbackProfile",
1059
+ "inputModalities",
1050
1060
  ];
1051
1061
  const mixProfileNames = new Set(
1052
1062
  Object.entries(config.profiles ?? {})
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ MemoryEmbeddingsConfigSchema,
5
+ VALID_MEMORY_EMBEDDING_PROVIDERS,
6
+ } from "./memory-storage.js";
7
+
8
+ describe("MemoryEmbeddingsConfigSchema", () => {
9
+ test("includes custom in the provider enum", () => {
10
+ expect(VALID_MEMORY_EMBEDDING_PROVIDERS).toContain("custom");
11
+ });
12
+
13
+ test("accepts a custom OpenAI-compatible endpoint", () => {
14
+ const parsed = MemoryEmbeddingsConfigSchema.parse({
15
+ provider: "custom",
16
+ baseUrl: "https://gateway.example.com/v1",
17
+ customModel: "text-embedding-3-small",
18
+ customDimensions: 1536,
19
+ });
20
+ expect(parsed.provider).toBe("custom");
21
+ expect(parsed.baseUrl).toBe("https://gateway.example.com/v1");
22
+ expect(parsed.customModel).toBe("text-embedding-3-small");
23
+ expect(parsed.customDimensions).toBe(1536);
24
+ });
25
+
26
+ test("defaults customModel when it is omitted", () => {
27
+ const parsed = MemoryEmbeddingsConfigSchema.parse({
28
+ provider: "custom",
29
+ baseUrl: "http://127.0.0.1:4000/v1",
30
+ });
31
+ expect(parsed.customModel).toBe("text-embedding-3-small");
32
+ });
33
+
34
+ test("rejects an unknown provider", () => {
35
+ expect(() =>
36
+ MemoryEmbeddingsConfigSchema.parse({ provider: "portkey" }),
37
+ ).toThrow(/must be one of/);
38
+ });
39
+ });
@@ -6,6 +6,7 @@ export const VALID_MEMORY_EMBEDDING_PROVIDERS = [
6
6
  "openai",
7
7
  "gemini",
8
8
  "ollama",
9
+ "custom",
9
10
  ] as const;
10
11
 
11
12
  const VALID_QDRANT_QUANTIZATION = ["scalar", "none"] as const;
@@ -67,6 +68,26 @@ export const MemoryEmbeddingsConfigSchema = z
67
68
  .string({ error: "memory.embeddings.ollamaModel must be a string" })
68
69
  .default("nomic-embed-text")
69
70
  .describe("Model name for the Ollama embedding provider"),
71
+ customModel: z
72
+ .string({ error: "memory.embeddings.customModel must be a string" })
73
+ .default("text-embedding-3-small")
74
+ .describe(
75
+ "Model name for a custom OpenAI-compatible embeddings endpoint",
76
+ ),
77
+ baseUrl: z
78
+ .string({ error: "memory.embeddings.baseUrl must be a string" })
79
+ .optional()
80
+ .describe(
81
+ "Base URL of a custom OpenAI-compatible embeddings endpoint, including the /v1 prefix (for example https://gateway.example.com/v1). Required when provider is 'custom'.",
82
+ ),
83
+ customDimensions: z
84
+ .number({ error: "memory.embeddings.customDimensions must be a number" })
85
+ .int("memory.embeddings.customDimensions must be an integer")
86
+ .positive("memory.embeddings.customDimensions must be a positive integer")
87
+ .optional()
88
+ .describe(
89
+ "Output dimensionality requested from a custom embeddings endpoint when the model supports it",
90
+ ),
70
91
  })
71
92
  .describe("Embedding generation configuration for semantic memory search");
72
93
 
@@ -2,8 +2,8 @@ import {
2
2
  estimateGeminiAudioTokens,
3
3
  normalizeGeminiAudioMime,
4
4
  } from "../providers/gemini/inline-media.js";
5
+ import { requestSupportsInlineAudio } from "../providers/inline-audio-support.js";
5
6
  import { mediaSourceByteLength } from "../providers/media-resolve.js";
6
- import { modelSupportsAudioInput } from "../providers/model-catalog.js";
7
7
  import {
8
8
  estimateOpenAICompatAudioTokens,
9
9
  isOpenAICompatInlineAudio,
@@ -159,7 +159,7 @@ function estimateFileDataTokens(
159
159
  // receive eligible audio inline as `input_audio` parts, billed by duration.
160
160
  if (
161
161
  options?.model !== undefined &&
162
- modelSupportsAudioInput(options.model) &&
162
+ requestSupportsInlineAudio(options.model) &&
163
163
  isOpenAICompatInlineAudio(block.source.media_type, byteLength)
164
164
  ) {
165
165
  return estimateOpenAICompatAudioTokens(byteLength);
@@ -97,6 +97,9 @@ function makeDeps(opts: {
97
97
  recreateCollectionsAtDim: mock(async () => {}),
98
98
  ensureCollections: mock(async () => {}),
99
99
  enqueueReembed: mock(() => {}),
100
+ customEmbeddingSpaceIdentity: mock(() => null),
101
+ readCustomEmbeddingSpace: mock(() => null),
102
+ writeCustomEmbeddingSpace: mock(() => {}),
100
103
  };
101
104
  }
102
105
 
@@ -289,6 +292,77 @@ describe("reconcileEmbeddingIdentity", () => {
289
292
  expect(deps.enqueueReembed).toHaveBeenCalledTimes(0);
290
293
  });
291
294
 
295
+ test("noop records a first-seen custom endpoint without re-embedding", async () => {
296
+ const deps = makeDeps({
297
+ decision: { kind: "noop" },
298
+ committedDim: 1536,
299
+ probeDim: 1536,
300
+ });
301
+ deps.customEmbeddingSpaceIdentity = mock(
302
+ () => "custom\0text-embedding-3-small\0url=http://127.0.0.1:4000/v1",
303
+ );
304
+
305
+ const outcome = await reconcileEmbeddingIdentity(
306
+ fakeConfig("custom"),
307
+ deps,
308
+ );
309
+
310
+ expect(outcome).toEqual({ action: "noop", dim: 1536 });
311
+ expect(deps.enqueueReembed).toHaveBeenCalledTimes(0);
312
+ expect(deps.writeCustomEmbeddingSpace).toHaveBeenCalledWith(
313
+ "custom\0text-embedding-3-small\0url=http://127.0.0.1:4000/v1",
314
+ );
315
+ expect(deps.recreateCollectionsAtDim).toHaveBeenCalledTimes(0);
316
+ });
317
+
318
+ test("noop reembeds when the custom endpoint changes at the same dimension", async () => {
319
+ const deps = makeDeps({
320
+ decision: { kind: "noop" },
321
+ committedDim: 1536,
322
+ probeDim: 1536,
323
+ });
324
+ deps.customEmbeddingSpaceIdentity = mock(
325
+ () => "custom\0text-embedding-3-small\0url=http://127.0.0.1:4001/v1",
326
+ );
327
+ deps.readCustomEmbeddingSpace = mock(
328
+ () => "custom\0text-embedding-3-small\0url=http://127.0.0.1:4000/v1",
329
+ );
330
+
331
+ const outcome = await reconcileEmbeddingIdentity(
332
+ fakeConfig("custom"),
333
+ deps,
334
+ );
335
+
336
+ expect(outcome).toEqual({ action: "reembed-space", dim: 1536 });
337
+ expect(deps.enqueueReembed).toHaveBeenCalledTimes(1);
338
+ expect(deps.writeCustomEmbeddingSpace).toHaveBeenCalledWith(
339
+ "custom\0text-embedding-3-small\0url=http://127.0.0.1:4001/v1",
340
+ );
341
+ expect(deps.recreateCollectionsAtDim).toHaveBeenCalledTimes(0);
342
+ expect(deps.persistVectorSize).toHaveBeenCalledTimes(0);
343
+ });
344
+
345
+ test("noop does not reembed when the custom endpoint is unchanged", async () => {
346
+ const identity =
347
+ "custom\0text-embedding-3-small\0url=http://127.0.0.1:4000/v1";
348
+ const deps = makeDeps({
349
+ decision: { kind: "noop" },
350
+ committedDim: 1536,
351
+ probeDim: 1536,
352
+ });
353
+ deps.customEmbeddingSpaceIdentity = mock(() => identity);
354
+ deps.readCustomEmbeddingSpace = mock(() => identity);
355
+
356
+ const outcome = await reconcileEmbeddingIdentity(
357
+ fakeConfig("custom"),
358
+ deps,
359
+ );
360
+
361
+ expect(outcome).toEqual({ action: "noop", dim: 1536 });
362
+ expect(deps.enqueueReembed).toHaveBeenCalledTimes(0);
363
+ expect(deps.writeCustomEmbeddingSpace).toHaveBeenCalledTimes(0);
364
+ });
365
+
292
366
  test("passes committed + probe dims and configured provider into the decision", async () => {
293
367
  const deps = makeDeps({
294
368
  decision: { kind: "noop" },
@@ -409,6 +483,9 @@ function driveBranch(
409
483
  })),
410
484
  readConceptPageCollectionDim: mock(async () => committedDim),
411
485
  decideEmbeddingReconcile: mock(() => decision),
486
+ customEmbeddingSpaceIdentity: mock(() => null),
487
+ readCustomEmbeddingSpace: mock(() => null),
488
+ writeCustomEmbeddingSpace: mock(() => {}),
412
489
  };
413
490
  }
414
491
 
@@ -56,6 +56,9 @@ function throwingDeps(): ReconcileDeps {
56
56
  }),
57
57
  ensureCollections: mock(async () => {}),
58
58
  enqueueReembed: mock(() => {}),
59
+ customEmbeddingSpaceIdentity: mock(() => null),
60
+ readCustomEmbeddingSpace: mock(() => null),
61
+ writeCustomEmbeddingSpace: mock(() => {}),
59
62
  };
60
63
  }
61
64
 
@@ -27,6 +27,7 @@ import {
27
27
  import {
28
28
  INSUFFICIENT_CREDITS_PATTERNS,
29
29
  isChatTemplateFailureError,
30
+ isModelNotFoundError,
30
31
  isVisionNotSupportedError,
31
32
  } from "../util/provider-error-patterns.js";
32
33
 
@@ -429,6 +430,11 @@ function classifyCore(
429
430
  return contextTooLargeClassification();
430
431
  }
431
432
  if (error.statusCode === 401 || error.statusCode === 403) {
433
+ // OpenCode (and similar OpenAI-compat endpoints) return 401 for an
434
+ // unknown model id. That must not read as a rejected key.
435
+ if (isModelNotFoundError(message)) {
436
+ return modelNotFoundClassification();
437
+ }
432
438
  // Managed routes through the assistant API key; if that credential is
433
439
  // stale, the user cannot fix it from model settings. Everything else is
434
440
  // a credential the user owns, so the copy names which one to update and
@@ -675,13 +681,7 @@ function reasonToClassification(
675
681
  errorCategory: "provider_network_error",
676
682
  };
677
683
  case "model_not_found":
678
- return {
679
- code: "PROVIDER_API",
680
- userMessage:
681
- "The selected model wasn't found by the provider. Switch models in Settings → Models & Services.",
682
- retryable: false,
683
- errorCategory: "provider_model_not_found",
684
- };
684
+ return modelNotFoundClassification();
685
685
  case "model_restricted": {
686
686
  const detail = extractProviderDetail(args.message);
687
687
  const prefix = "This model isn't available on your current provider plan";
@@ -890,6 +890,19 @@ function providerServerErrorClassification(): Omit<
890
890
  };
891
891
  }
892
892
 
893
+ function modelNotFoundClassification(): Omit<
894
+ ClassifiedConversationError,
895
+ "debugDetails"
896
+ > {
897
+ return {
898
+ code: "PROVIDER_API",
899
+ userMessage:
900
+ "The selected model wasn't found by the provider. Switch models in Settings → Models & Services.",
901
+ retryable: false,
902
+ errorCategory: "provider_model_not_found",
903
+ };
904
+ }
905
+
893
906
  function contextTooLargeClassification(): Omit<
894
907
  ClassifiedConversationError,
895
908
  "debugDetails"
@@ -25,6 +25,15 @@ import {
25
25
  isMemoryV3Live,
26
26
  } from "../config/memory-v3-gate.js";
27
27
  import type { AssistantConfig } from "../config/types.js";
28
+ import {
29
+ deleteMemoryCheckpoint,
30
+ getMemoryCheckpoint,
31
+ setMemoryCheckpoint,
32
+ } from "../persistence/checkpoints.js";
33
+ import {
34
+ CUSTOM_EMBEDDING_SPACE_CHECKPOINT,
35
+ customEmbeddingSpaceIdentity,
36
+ } from "../persistence/embeddings/embedding-backend.js";
28
37
  import {
29
38
  probeBackendDimension,
30
39
  readConceptPageCollectionDim,
@@ -50,6 +59,7 @@ export type ReconcileOutcome =
50
59
  | { action: "noop"; dim: number | null }
51
60
  | { action: "commit-fresh"; dim: number }
52
61
  | { action: "migrate"; fromDim: number; toDim: number }
62
+ | { action: "reembed-space"; dim: number }
53
63
  | { action: "defer-degraded"; reason: string };
54
64
 
55
65
  /**
@@ -72,6 +82,12 @@ export interface ReconcileDeps {
72
82
  ensureCollections: (dim: number) => Promise<void>;
73
83
  /** Enqueue the reembed jobs that repopulate the (re)created collections. */
74
84
  enqueueReembed: () => void;
85
+ /** Identity of the configured custom embeddings endpoint, or null. */
86
+ customEmbeddingSpaceIdentity: (config: AssistantConfig) => string | null;
87
+ /** Last custom embedding space recorded for the populated collections. */
88
+ readCustomEmbeddingSpace: () => string | null;
89
+ /** Persist (or clear) the custom embedding space identity. */
90
+ writeCustomEmbeddingSpace: (identity: string | null) => void;
75
91
  }
76
92
 
77
93
  /**
@@ -201,9 +217,38 @@ export function defaultDeps(config: AssistantConfig): ReconcileDeps {
201
217
  }
202
218
  },
203
219
  enqueueReembed: () => defaultEnqueueReembed(config),
220
+ customEmbeddingSpaceIdentity,
221
+ readCustomEmbeddingSpace: () =>
222
+ getMemoryCheckpoint(CUSTOM_EMBEDDING_SPACE_CHECKPOINT),
223
+ writeCustomEmbeddingSpace: (identity) => {
224
+ if (identity == null) {
225
+ deleteMemoryCheckpoint(CUSTOM_EMBEDDING_SPACE_CHECKPOINT);
226
+ } else {
227
+ setMemoryCheckpoint(CUSTOM_EMBEDDING_SPACE_CHECKPOINT, identity);
228
+ }
229
+ },
204
230
  };
205
231
  }
206
232
 
233
+ function syncCustomEmbeddingSpace(
234
+ config: AssistantConfig,
235
+ deps: ReconcileDeps,
236
+ opts: { reembedOnChange: boolean },
237
+ ): "unchanged" | "recorded" | "reembed" {
238
+ const current = deps.customEmbeddingSpaceIdentity(config);
239
+ const stored = deps.readCustomEmbeddingSpace();
240
+ if (current === stored) {
241
+ return "unchanged";
242
+ }
243
+ if (opts.reembedOnChange && current != null && stored != null) {
244
+ deps.enqueueReembed();
245
+ deps.writeCustomEmbeddingSpace(current);
246
+ return "reembed";
247
+ }
248
+ deps.writeCustomEmbeddingSpace(current);
249
+ return "recorded";
250
+ }
251
+
207
252
  /**
208
253
  * Reconcile the committed Qdrant collection dimension against a live probe of
209
254
  * the configured embedding backend, then execute the resulting action:
@@ -211,12 +256,18 @@ export function defaultDeps(config: AssistantConfig): ReconcileDeps {
211
256
  * - `noop` — committed dimension already matches (or auto-mode declines to
212
257
  * thrash); no writes, no destructive calls. Also returned immediately when
213
258
  * `memory.enabled === false`, before any probe/read/persist/enqueue.
259
+ * When the custom embeddings endpoint identity changed at the same
260
+ * dimension, this becomes `reembed-space` instead: enqueue a reembed
261
+ * without recreating collections.
214
262
  * - `commit-fresh` — fresh install adopts the reachable backend's dimension:
215
263
  * commit the dim, ensure the collections EXIST at it (create-if-absent, never
216
264
  * destroy), and enqueue a reembed.
217
265
  * - `migrate` — deliberate provider intent with a confirmed probe: commit the
218
266
  * new dim, then destroy + recreate the collections (the only destructive
219
267
  * path) and enqueue a reembed.
268
+ * - `reembed-space`: committed dimension already matches, but the custom
269
+ * embeddings endpoint (base URL / model / requested dimensions) changed.
270
+ * Enqueue a reembed so query and document vectors stay in one space.
220
271
  * - `defer-degraded` — backend unreachable, OR the committed dimension could
221
272
  * not be read (Qdrant unreachable / existing collection unreadable): no
222
273
  * write, no destructive call; warn and leave the collections untouched.
@@ -284,14 +335,25 @@ export async function reconcileEmbeddingIdentity(
284
335
  });
285
336
 
286
337
  switch (action.kind) {
287
- case "noop":
338
+ case "noop": {
339
+ const spaceOutcome = syncCustomEmbeddingSpace(config, deps, {
340
+ reembedOnChange: true,
341
+ });
342
+ if (spaceOutcome === "reembed" && committedDim != null) {
343
+ log.info(
344
+ "Custom embedding endpoint changed at the committed dimension; enqueued reembed",
345
+ );
346
+ return { action: "reembed-space", dim: committedDim };
347
+ }
288
348
  return { action: "noop", dim: committedDim };
349
+ }
289
350
 
290
351
  case "commit-fresh": {
291
352
  deps.setInMemoryVectorSize(action.dim);
292
353
  deps.persistVectorSize(action.dim);
293
354
  await deps.ensureCollections(action.dim);
294
355
  deps.enqueueReembed();
356
+ syncCustomEmbeddingSpace(config, deps, { reembedOnChange: false });
295
357
  log.info(
296
358
  { dim: action.dim },
297
359
  "Committed fresh embedding dimension and ensured collections",
@@ -324,6 +386,7 @@ export async function reconcileEmbeddingIdentity(
324
386
  throw err;
325
387
  }
326
388
  deps.enqueueReembed();
389
+ syncCustomEmbeddingSpace(config, deps, { reembedOnChange: false });
327
390
  log.warn(
328
391
  { fromDim: action.fromDim, toDim: action.toDim },
329
392
  "Migrated embedding dimension: recreated collections and enqueued reembed",
@@ -50,6 +50,12 @@ const EMBEDDING_PROVIDER_CATALOG = [
50
50
  defaultModel: "nomic-embed-text",
51
51
  requiresKey: false,
52
52
  },
53
+ {
54
+ id: "custom",
55
+ displayName: "Custom (OpenAI-compatible)",
56
+ defaultModel: "text-embedding-3-small",
57
+ requiresKey: false,
58
+ },
53
59
  ];
54
60
 
55
61
  // ---------------------------------------------------------------------------
@@ -61,6 +67,7 @@ const PROVIDER_MODEL_FIELD: Record<string, string> = {
61
67
  openai: "openaiModel",
62
68
  gemini: "geminiModel",
63
69
  ollama: "ollamaModel",
70
+ custom: "customModel",
64
71
  };
65
72
 
66
73
  // ---------------------------------------------------------------------------
@@ -70,6 +77,8 @@ const PROVIDER_MODEL_FIELD: Record<string, string> = {
70
77
  export async function getEmbeddingConfigInfo(): Promise<{
71
78
  provider: string;
72
79
  model: string | null;
80
+ baseUrl: string | null;
81
+ dimensions: number | null;
73
82
  activeProvider: string | null;
74
83
  activeModel: string | null;
75
84
  availableProviders: typeof EMBEDDING_PROVIDER_CATALOG;
@@ -88,6 +97,8 @@ export async function getEmbeddingConfigInfo(): Promise<{
88
97
  return {
89
98
  provider: embeddingConfig.provider,
90
99
  model: typeof model === "string" ? model : null,
100
+ baseUrl: embeddingConfig.baseUrl ?? null,
101
+ dimensions: embeddingConfig.customDimensions ?? null,
91
102
  activeProvider: backendStatus.provider,
92
103
  activeModel: backendStatus.model,
93
104
  availableProviders: EMBEDDING_PROVIDER_CATALOG,
@@ -107,6 +118,10 @@ export async function setEmbeddingConfig(
107
118
  provider: string,
108
119
  model: string | undefined,
109
120
  ctx: ModelSetContext,
121
+ extras?: {
122
+ baseUrl?: string;
123
+ dimensions?: number | null;
124
+ },
110
125
  ): Promise<ReturnType<typeof getEmbeddingConfigInfo>> {
111
126
  const validProviders = new Set<string>(VALID_MEMORY_EMBEDDING_PROVIDERS);
112
127
  if (!validProviders.has(provider)) {
@@ -130,6 +145,22 @@ export async function setEmbeddingConfig(
130
145
  }
131
146
  }
132
147
 
148
+ if (extras?.baseUrl !== undefined) {
149
+ if (extras.baseUrl === "") {
150
+ deleteMemoryEmbeddingField(raw, "baseUrl");
151
+ } else {
152
+ setMemoryEmbeddingField(raw, "baseUrl", extras.baseUrl);
153
+ }
154
+ }
155
+
156
+ if (extras?.dimensions !== undefined) {
157
+ if (extras.dimensions === null) {
158
+ deleteMemoryEmbeddingField(raw, "customDimensions");
159
+ } else {
160
+ setMemoryEmbeddingField(raw, "customDimensions", extras.dimensions);
161
+ }
162
+ }
163
+
133
164
  // Suppress the file watcher callback — we handle the reload ourselves.
134
165
  const wasSuppressed = ctx.suppressConfigReload;
135
166
  ctx.setSuppressConfigReload(true);
@@ -398,6 +398,8 @@ export class HostAppControlProxy extends HostProxyBase<
398
398
  signal,
399
399
  undefined,
400
400
  resolvedTargetClientId,
401
+ undefined,
402
+ sourceActorPrincipalId,
401
403
  );
402
404
  if (input.tool === "start") {
403
405
  if (payload.state === "running") {
@@ -152,6 +152,7 @@ export class HostBashProxy extends HostProxyBase<
152
152
  extraFields,
153
153
  resolvedTargetClientId,
154
154
  proxyTimeoutMs,
155
+ sourceActorPrincipalId,
155
156
  );
156
157
  return formatShellOutput(
157
158
  payload.stdout,
@@ -23,6 +23,7 @@ import {
23
23
  assistantEventHub,
24
24
  broadcastMessage,
25
25
  } from "../runtime/assistant-event-hub.js";
26
+ import { snapshotHostProxyActorPrincipalId } from "../runtime/auth/same-actor.js";
26
27
  import * as pendingInteractions from "../runtime/pending-interactions.js";
27
28
  import { POINT_AT_PROXY_TOOL } from "../tools/computer-use/skill-proxy-bridge.js";
28
29
  import type { ToolExecutionResult } from "../tools/types.js";
@@ -356,12 +357,11 @@ export class HostCuProxy {
356
357
  conversationId,
357
358
  kind: "host_cu",
358
359
  targetClientId: resolvedTargetClientId,
359
- targetActorPrincipalId:
360
- resolvedTargetClientId != null
361
- ? assistantEventHub.getActorPrincipalIdForClient(
362
- resolvedTargetClientId,
363
- )
364
- : undefined,
360
+ targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
361
+ hub: assistantEventHub,
362
+ targetClientId: resolvedTargetClientId,
363
+ sourceActorPrincipalId,
364
+ }),
365
365
  rpcResolve: resolve as (v: unknown) => void,
366
366
  rpcReject: reject,
367
367
  timer,