@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
@@ -3,15 +3,18 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
3
3
  import type { AssistantConfig } from "../../config/types.js";
4
4
  import {
5
5
  clearEmbeddingBackendCache,
6
+ customEmbeddingSpaceIdentity,
6
7
  embedWithBackend,
7
8
  isEmbeddingDimensionAvailable,
8
9
  resetLocalEmbeddingFailureState,
10
+ resolveBackendDimension,
9
11
  selectEmbeddingBackend,
10
12
  } from "./embedding-backend.js";
11
13
  import {
12
14
  _resetEmbeddingBillingBreaker,
13
15
  recordBillingBlock,
14
16
  } from "./embedding-billing-breaker.js";
17
+ import { OpenAIEmbeddingBackend } from "./embedding-openai.js";
15
18
 
16
19
  const getProviderKeyAsyncMock = mock(
17
20
  async (_provider: string): Promise<string | undefined> => undefined,
@@ -361,3 +364,162 @@ describe("managed-proxy Gemini fallback to direct key", () => {
361
364
  expect(directUrl).toContain("key=direct-key");
362
365
  });
363
366
  });
367
+
368
+ function customConfig(overrides: {
369
+ baseUrl?: string;
370
+ customModel?: string;
371
+ customDimensions?: number;
372
+ }): AssistantConfig {
373
+ return {
374
+ memory: {
375
+ embeddings: {
376
+ provider: "custom",
377
+ customModel: overrides.customModel ?? "text-embedding-3-small",
378
+ baseUrl: overrides.baseUrl,
379
+ customDimensions: overrides.customDimensions,
380
+ },
381
+ },
382
+ } as unknown as AssistantConfig;
383
+ }
384
+
385
+ describe("custom OpenAI-compatible embedding backend selection", () => {
386
+ afterEach(() => {
387
+ clearEmbeddingBackendCache();
388
+ getProviderKeyAsyncMock.mockReset();
389
+ getProviderKeyAsyncMock.mockImplementation(async () => undefined);
390
+ });
391
+
392
+ test("selects a custom backend with a trimmed baseUrl", async () => {
393
+ getProviderKeyAsyncMock.mockImplementation(async (provider) =>
394
+ provider === "custom" ? "gw-key" : undefined,
395
+ );
396
+ const { backend, reason } = await selectEmbeddingBackend(
397
+ customConfig({
398
+ baseUrl: "http://127.0.0.1:4000/v1/",
399
+ customModel: "embed-mistral",
400
+ customDimensions: 1024,
401
+ }),
402
+ );
403
+ expect(reason).toBeNull();
404
+ expect(backend).toBeInstanceOf(OpenAIEmbeddingBackend);
405
+ expect(backend?.provider).toBe("custom");
406
+ expect(backend?.model).toBe("embed-mistral");
407
+ expect((backend as OpenAIEmbeddingBackend).baseURL).toBe(
408
+ "http://127.0.0.1:4000/v1",
409
+ );
410
+ expect((backend as OpenAIEmbeddingBackend).dimensions).toBe(1024);
411
+ });
412
+
413
+ test("selects a custom backend without an API key", async () => {
414
+ const { backend, reason } = await selectEmbeddingBackend(
415
+ customConfig({ baseUrl: "http://127.0.0.1:1234/v1" }),
416
+ );
417
+ expect(reason).toBeNull();
418
+ expect(backend?.provider).toBe("custom");
419
+ });
420
+
421
+ test("is not configured when baseUrl is missing", async () => {
422
+ const { backend, reason } = await selectEmbeddingBackend(customConfig({}));
423
+ expect(backend).toBeNull();
424
+ expect(reason).toMatch(/baseUrl/);
425
+ });
426
+
427
+ test("is not configured when baseUrl is not http(s)", async () => {
428
+ const { backend, reason } = await selectEmbeddingBackend(
429
+ customConfig({ baseUrl: "not-a-url" }),
430
+ );
431
+ expect(backend).toBeNull();
432
+ expect(reason).toMatch(/baseUrl/);
433
+ });
434
+
435
+ test("is not selected by auto even when baseUrl is set", async () => {
436
+ const { backend } = await selectEmbeddingBackend({
437
+ memory: {
438
+ embeddings: {
439
+ provider: "auto",
440
+ localModel: "BAAI/bge-small-en-v1.5",
441
+ baseUrl: "http://127.0.0.1:4000/v1",
442
+ customModel: "embed-mistral",
443
+ },
444
+ },
445
+ } as unknown as AssistantConfig);
446
+ expect(backend?.provider).toBe("local");
447
+ });
448
+
449
+ test("caches custom backends by baseUrl", async () => {
450
+ const first = await selectEmbeddingBackend(
451
+ customConfig({ baseUrl: "http://127.0.0.1:4000/v1" }),
452
+ );
453
+ const second = await selectEmbeddingBackend(
454
+ customConfig({ baseUrl: "http://127.0.0.1:4001/v1" }),
455
+ );
456
+ const firstAgain = await selectEmbeddingBackend(
457
+ customConfig({ baseUrl: "http://127.0.0.1:4000/v1" }),
458
+ );
459
+ expect(first.backend).not.toBe(second.backend);
460
+ expect(firstAgain.backend).toBe(first.backend);
461
+ });
462
+
463
+ test("caches custom backends by requested dimensions", async () => {
464
+ const first = await selectEmbeddingBackend(
465
+ customConfig({
466
+ baseUrl: "http://127.0.0.1:4000/v1",
467
+ customDimensions: 1024,
468
+ }),
469
+ );
470
+ const second = await selectEmbeddingBackend(
471
+ customConfig({
472
+ baseUrl: "http://127.0.0.1:4000/v1",
473
+ customDimensions: 1536,
474
+ }),
475
+ );
476
+ expect(first.backend).not.toBe(second.backend);
477
+ });
478
+
479
+ test("dimension probes for custom backends are keyed by baseUrl", async () => {
480
+ const { backend: first } = await selectEmbeddingBackend(
481
+ customConfig({ baseUrl: "http://127.0.0.1:4000/v1" }),
482
+ );
483
+ const { backend: second } = await selectEmbeddingBackend(
484
+ customConfig({ baseUrl: "http://127.0.0.1:4001/v1" }),
485
+ );
486
+ if (!first || !second) {
487
+ throw new Error("expected custom backends");
488
+ }
489
+ (first as { embed: typeof first.embed }).embed = mock(async () => [
490
+ new Array(1536).fill(0),
491
+ ]) as unknown as typeof first.embed;
492
+ (second as { embed: typeof second.embed }).embed = mock(async () => [
493
+ new Array(1024).fill(0),
494
+ ]) as unknown as typeof second.embed;
495
+
496
+ expect(await resolveBackendDimension(first)).toBe(1536);
497
+ expect(await resolveBackendDimension(second)).toBe(1024);
498
+ });
499
+
500
+ test("customEmbeddingSpaceIdentity includes model, url, and dimensions", () => {
501
+ expect(
502
+ customEmbeddingSpaceIdentity(
503
+ customConfig({
504
+ baseUrl: "http://127.0.0.1:4000/v1/",
505
+ customModel: "embed-mistral",
506
+ customDimensions: 1024,
507
+ }),
508
+ ),
509
+ ).toBe("custom\0embed-mistral\0url=http://127.0.0.1:4000/v1\0dim=1024");
510
+ });
511
+
512
+ test("customEmbeddingSpaceIdentity is null when the provider is not custom", () => {
513
+ expect(
514
+ customEmbeddingSpaceIdentity({
515
+ memory: {
516
+ embeddings: {
517
+ provider: "openai",
518
+ openaiModel: "text-embedding-3-small",
519
+ baseUrl: "http://127.0.0.1:4000/v1",
520
+ },
521
+ },
522
+ } as unknown as AssistantConfig),
523
+ ).toBeNull();
524
+ });
525
+ });
@@ -17,7 +17,10 @@ import {
17
17
  } from "./embedding-billing-breaker.js";
18
18
  import { GeminiEmbeddingBackend } from "./embedding-gemini.js";
19
19
  import { OllamaEmbeddingBackend } from "./embedding-ollama.js";
20
- import { OpenAIEmbeddingBackend } from "./embedding-openai.js";
20
+ import {
21
+ OpenAIEmbeddingBackend,
22
+ resolveOpenAICompatibleBaseUrl,
23
+ } from "./embedding-openai.js";
21
24
  import { EmbeddingRuntimeManager } from "./embedding-runtime-manager.js";
22
25
  import {
23
26
  EMBEDDING_DIMENSION_PROBE_TEXT,
@@ -428,6 +431,70 @@ export function geminiCacheExtras(config: AssistantConfig): string[] {
428
431
  return extras;
429
432
  }
430
433
 
434
+ /**
435
+ * Cache-key fragments for a custom OpenAI-compatible embeddings endpoint.
436
+ * Base URL and requested output dimensionality both change the vectors a
437
+ * given model returns, so they belong in backend, in-memory, and durable
438
+ * cache identity.
439
+ */
440
+ export function customCacheExtras(config: AssistantConfig): string[] {
441
+ const extras: string[] = [];
442
+ const baseUrl = resolveOpenAICompatibleBaseUrl(
443
+ config.memory.embeddings.baseUrl,
444
+ );
445
+ if (baseUrl) {
446
+ extras.push(`url=${baseUrl}`);
447
+ }
448
+ if (config.memory.embeddings.customDimensions != null) {
449
+ extras.push(`dim=${config.memory.embeddings.customDimensions}`);
450
+ }
451
+ return extras;
452
+ }
453
+
454
+ /**
455
+ * Provider extras that belong in durable `memory_embeddings` identity.
456
+ * Gemini task type / dimensions and custom endpoint URL / dimensions both
457
+ * change the vector for identical text, so cache readers must fold them in.
458
+ */
459
+ export function durableEmbeddingCacheExtras(
460
+ config: AssistantConfig,
461
+ provider: string | null,
462
+ ): string[] {
463
+ if (provider === "gemini") {
464
+ return geminiCacheExtras(config);
465
+ }
466
+ if (provider === "custom") {
467
+ return customCacheExtras(config);
468
+ }
469
+ return [];
470
+ }
471
+
472
+ /**
473
+ * Checkpoint key for the custom embedding space last used to populate
474
+ * Qdrant. Compared at reconcile time so a same-dimension endpoint change
475
+ * still enqueues a reembed instead of mixing vector spaces.
476
+ */
477
+ export const CUSTOM_EMBEDDING_SPACE_CHECKPOINT =
478
+ "memory:custom_embedding_space";
479
+
480
+ /**
481
+ * Stable identity of the configured custom embeddings endpoint. Null when
482
+ * the provider is not `custom`. Includes model, normalized base URL, and
483
+ * requested dimensions.
484
+ */
485
+ export function customEmbeddingSpaceIdentity(
486
+ config: AssistantConfig,
487
+ ): string | null {
488
+ if (config.memory.embeddings.provider !== "custom") {
489
+ return null;
490
+ }
491
+ return [
492
+ "custom",
493
+ config.memory.embeddings.customModel,
494
+ ...customCacheExtras(config),
495
+ ].join("\0");
496
+ }
497
+
431
498
  /** Build (or reuse) the direct-API Gemini backend for the given key. */
432
499
  function getDirectGeminiBackend(
433
500
  config: AssistantConfig,
@@ -449,6 +516,29 @@ function getDirectGeminiBackend(
449
516
  );
450
517
  }
451
518
 
519
+ /** Build (or reuse) a custom OpenAI-compatible embeddings backend. */
520
+ function getCustomEmbeddingBackend(
521
+ config: AssistantConfig,
522
+ baseUrl: string,
523
+ apiKey: string | undefined,
524
+ ): EmbeddingBackend {
525
+ return getCachedOrCreate(
526
+ "custom",
527
+ config.memory.embeddings.customModel,
528
+ () =>
529
+ new OpenAIEmbeddingBackend(
530
+ apiKey ?? "",
531
+ config.memory.embeddings.customModel,
532
+ {
533
+ provider: "custom",
534
+ baseURL: baseUrl,
535
+ dimensions: config.memory.embeddings.customDimensions,
536
+ },
537
+ ),
538
+ customCacheExtras(config),
539
+ );
540
+ }
541
+
452
542
  /**
453
543
  * Build (or reuse) the managed-proxy Gemini backend, or return undefined
454
544
  * when the managed proxy prerequisites (platform URL + assistant API key)
@@ -517,6 +607,23 @@ export async function selectEmbeddingBackend(
517
607
  reason: null,
518
608
  };
519
609
  }
610
+ if (requested === "custom") {
611
+ const baseUrl = resolveOpenAICompatibleBaseUrl(
612
+ config.memory.embeddings.baseUrl,
613
+ );
614
+ if (!baseUrl) {
615
+ return {
616
+ backend: null,
617
+ reason:
618
+ 'Embedding backend "custom" requires memory.embeddings.baseUrl to be an http(s) URL',
619
+ };
620
+ }
621
+ const customKey = (await getProviderKeyAsync("custom")) ?? undefined;
622
+ return {
623
+ backend: getCustomEmbeddingBackend(config, baseUrl, customKey),
624
+ reason: null,
625
+ };
626
+ }
520
627
 
521
628
  // When managed proxy prerequisites are satisfied, insert managed-proxy Gemini
522
629
  // at the front of the auto chain so platform assistants use Vellum-managed
@@ -620,6 +727,9 @@ export async function selectEmbeddingBackend(
620
727
  reason: null,
621
728
  };
622
729
  }
730
+ case "custom":
731
+ // Custom is explicit-only and is handled before the auto chain.
732
+ continue;
623
733
  }
624
734
  }
625
735
 
@@ -666,14 +776,30 @@ export async function getMemoryBackendStatus(config: AssistantConfig): Promise<{
666
776
  }
667
777
 
668
778
  /**
669
- * Memoized output dimension per "provider:model". A backend's vector dimension
670
- * is fixed for the life of a (provider, model) pair, so a single probe answers
671
- * every subsequent {@link isEmbeddingDimensionAvailable} call without another
672
- * backend round-trip. Cleared alongside the backend cache so a credential
673
- * change or explicit reset re-probes the (possibly different) backend.
779
+ * Memoized output dimension per backend identity. A backend's vector
780
+ * dimension is fixed for the life of that identity (provider, model, and for
781
+ * custom OpenAI-compatible endpoints the base URL and requested dimensions),
782
+ * so a single probe answers every subsequent
783
+ * {@link isEmbeddingDimensionAvailable} call without another backend
784
+ * round-trip. Cleared alongside the backend cache so a credential change or
785
+ * explicit reset re-probes the (possibly different) backend.
674
786
  */
675
787
  const backendDimCache = new Map<string, number>();
676
788
 
789
+ function backendDimCacheKey(backend: EmbeddingBackend): string {
790
+ if (backend instanceof OpenAIEmbeddingBackend) {
791
+ const extras: string[] = [];
792
+ if (backend.baseURL) {
793
+ extras.push(`url=${backend.baseURL}`);
794
+ }
795
+ if (backend.dimensions != null) {
796
+ extras.push(`dim=${backend.dimensions}`);
797
+ }
798
+ return cacheKey(backend.provider, backend.model, extras);
799
+ }
800
+ return `${backend.provider}:${backend.model}`;
801
+ }
802
+
677
803
  /**
678
804
  * Whether the currently-reachable embedding backend can produce vectors of the
679
805
  * committed Qdrant collection dimension (`config.memory.qdrant.vectorSize`).
@@ -692,7 +818,7 @@ const backendDimCache = new Map<string, number>();
692
818
  *
693
819
  * The selected backend's output dimension is not statically known from
694
820
  * provider/model alone, so it is measured with a fixed-string probe and
695
- * memoized per (provider, model) — steady-state calls resolve from
821
+ * memoized per backend identity — steady-state calls resolve from
696
822
  * {@link backendDimCache} without a backend round-trip.
697
823
  */
698
824
  export async function isEmbeddingDimensionAvailable(
@@ -713,7 +839,7 @@ export async function isEmbeddingDimensionAvailable(
713
839
  // assembler, so this preflight can never be more pessimistic than the real
714
840
  // embed path. Dense recall stays available if ANY backend in the chain
715
841
  // produces the committed dimension. `resolveBackendDimension` memoizes per
716
- // provider:model, so each backend is probed at most once.
842
+ // backend identity, so each backend is probed at most once.
717
843
  const expected = config.memory.qdrant.vectorSize;
718
844
  for (const candidate of await assembleEmbeddingBackends(config, backend)) {
719
845
  if ((await resolveBackendDimension(candidate)) === expected) {
@@ -736,7 +862,7 @@ export async function isEmbeddingDimensionAvailable(
736
862
  export async function resolveBackendDimension(
737
863
  backend: EmbeddingBackend,
738
864
  ): Promise<number | null> {
739
- const key = `${backend.provider}:${backend.model}`;
865
+ const key = backendDimCacheKey(backend);
740
866
  const cached = backendDimCache.get(key);
741
867
  if (cached != null) {
742
868
  return cached;
@@ -849,7 +975,11 @@ export async function embedWithBackend(
849
975
 
850
976
  // ── Compute provider-specific vector cache extras ───────────────
851
977
  const vectorExtras =
852
- primaryProvider === "gemini" ? geminiCacheExtras(config) : undefined;
978
+ primaryProvider === "gemini"
979
+ ? geminiCacheExtras(config)
980
+ : primaryProvider === "custom"
981
+ ? customCacheExtras(config)
982
+ : undefined;
853
983
 
854
984
  // ── In-memory cache check (primary provider only) ──────────────
855
985
  const cached: (number[] | null)[] = inputs.map((input) => {
@@ -918,7 +1048,11 @@ export async function embedWithBackend(
918
1048
 
919
1049
  // Populate cache with freshly embedded vectors
920
1050
  const backendExtras =
921
- backend.provider === "gemini" ? geminiCacheExtras(config) : undefined;
1051
+ backend.provider === "gemini"
1052
+ ? geminiCacheExtras(config)
1053
+ : backend.provider === "custom"
1054
+ ? customCacheExtras(config)
1055
+ : undefined;
922
1056
  for (let i = 0; i < inputsToEmbed.length; i++) {
923
1057
  putInVectorCache(
924
1058
  backend.provider,
@@ -1060,6 +1194,9 @@ async function selectFallbackBackends(
1060
1194
  }
1061
1195
  break;
1062
1196
  }
1197
+ case "custom":
1198
+ case "local":
1199
+ break;
1063
1200
  }
1064
1201
  }
1065
1202
  return backends;
@@ -0,0 +1,113 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ const createMock = mock(async (_body: unknown, _opts?: unknown) => ({
4
+ data: [{ embedding: [0.1, 0.2, 0.3] }],
5
+ }));
6
+
7
+ let lastCtorArgs: { apiKey?: string; baseURL?: string } | undefined;
8
+
9
+ mock.module("openai", () => ({
10
+ default: class OpenAI {
11
+ embeddings = { create: createMock };
12
+ constructor(opts: { apiKey: string; baseURL?: string }) {
13
+ lastCtorArgs = opts;
14
+ }
15
+ },
16
+ }));
17
+
18
+ import {
19
+ OpenAIEmbeddingBackend,
20
+ resolveOpenAICompatibleBaseUrl,
21
+ } from "./embedding-openai.js";
22
+
23
+ describe("resolveOpenAICompatibleBaseUrl", () => {
24
+ test("trims and strips a trailing slash", () => {
25
+ expect(
26
+ resolveOpenAICompatibleBaseUrl(" https://gateway.example.com/v1/ "),
27
+ ).toBe("https://gateway.example.com/v1");
28
+ });
29
+
30
+ test("accepts http localhost URLs", () => {
31
+ expect(resolveOpenAICompatibleBaseUrl("http://127.0.0.1:4000/v1")).toBe(
32
+ "http://127.0.0.1:4000/v1",
33
+ );
34
+ });
35
+
36
+ test("rejects empty, non-http, and invalid values", () => {
37
+ expect(resolveOpenAICompatibleBaseUrl(undefined)).toBeNull();
38
+ expect(resolveOpenAICompatibleBaseUrl("")).toBeNull();
39
+ expect(resolveOpenAICompatibleBaseUrl(" ")).toBeNull();
40
+ expect(resolveOpenAICompatibleBaseUrl("not-a-url")).toBeNull();
41
+ expect(resolveOpenAICompatibleBaseUrl("ftp://gateway.example.com/v1")).toBe(
42
+ null,
43
+ );
44
+ });
45
+ });
46
+
47
+ describe("OpenAIEmbeddingBackend", () => {
48
+ beforeEach(() => {
49
+ createMock.mockClear();
50
+ lastCtorArgs = undefined;
51
+ });
52
+
53
+ afterEach(() => {
54
+ createMock.mockClear();
55
+ });
56
+
57
+ test("constructs the official OpenAI client without a baseURL", () => {
58
+ const backend = new OpenAIEmbeddingBackend(
59
+ "sk-test",
60
+ "text-embedding-3-small",
61
+ );
62
+ expect(backend.provider).toBe("openai");
63
+ expect(lastCtorArgs).toEqual({ apiKey: "sk-test" });
64
+ });
65
+
66
+ test("routes custom embeddings through the supplied baseURL", async () => {
67
+ const backend = new OpenAIEmbeddingBackend("gw-key", "embed-mistral", {
68
+ provider: "custom",
69
+ baseURL: "http://127.0.0.1:4000/v1",
70
+ dimensions: 1024,
71
+ });
72
+ expect(backend.provider).toBe("custom");
73
+ expect(backend.baseURL).toBe("http://127.0.0.1:4000/v1");
74
+ expect(lastCtorArgs).toEqual({
75
+ apiKey: "gw-key",
76
+ baseURL: "http://127.0.0.1:4000/v1",
77
+ });
78
+
79
+ const vectors = await backend.embed(["hello"]);
80
+ expect(vectors).toEqual([[0.1, 0.2, 0.3]]);
81
+ expect(createMock).toHaveBeenCalledTimes(1);
82
+ const [body] = createMock.mock.calls[0] as [
83
+ {
84
+ model: string;
85
+ input: string[];
86
+ encoding_format: string;
87
+ dimensions?: number;
88
+ },
89
+ ];
90
+ expect(body.model).toBe("embed-mistral");
91
+ expect(body.input).toEqual(["hello"]);
92
+ expect(body.encoding_format).toBe("float");
93
+ expect(body.dimensions).toBe(1024);
94
+ });
95
+
96
+ test("uses a placeholder API key when the custom endpoint is keyless", () => {
97
+ new OpenAIEmbeddingBackend("", "text-embedding-3-small", {
98
+ provider: "custom",
99
+ baseURL: "http://127.0.0.1:1234/v1",
100
+ });
101
+ expect(lastCtorArgs?.apiKey).toBe("not-needed");
102
+ });
103
+
104
+ test("omits dimensions when they are not configured", async () => {
105
+ const backend = new OpenAIEmbeddingBackend(
106
+ "sk-test",
107
+ "text-embedding-3-small",
108
+ );
109
+ await backend.embed(["hello"]);
110
+ const [body] = createMock.mock.calls[0] as [{ dimensions?: number }];
111
+ expect(body.dimensions).toBeUndefined();
112
+ });
113
+ });
@@ -3,18 +3,40 @@ import OpenAI from "openai";
3
3
  import {
4
4
  type EmbeddingBackend,
5
5
  type EmbeddingInput,
6
+ type EmbeddingProviderName,
6
7
  type EmbeddingRequestOptions,
7
8
  normalizeEmbeddingInput,
8
9
  } from "./embedding-types.js";
9
10
 
11
+ export type OpenAICompatibleEmbeddingProvider = Extract<
12
+ EmbeddingProviderName,
13
+ "openai" | "custom"
14
+ >;
15
+
16
+ export interface OpenAIEmbeddingOptions {
17
+ baseURL?: string;
18
+ dimensions?: number;
19
+ provider?: OpenAICompatibleEmbeddingProvider;
20
+ }
21
+
22
+ const PLACEHOLDER_API_KEY = "not-needed";
23
+
10
24
  export class OpenAIEmbeddingBackend implements EmbeddingBackend {
11
- readonly provider = "openai" as const;
25
+ readonly provider: OpenAICompatibleEmbeddingProvider;
12
26
  readonly model: string;
27
+ readonly baseURL?: string;
28
+ readonly dimensions?: number;
13
29
  private readonly client: OpenAI;
14
30
 
15
- constructor(apiKey: string, model: string) {
31
+ constructor(apiKey: string, model: string, options?: OpenAIEmbeddingOptions) {
16
32
  this.model = model;
17
- this.client = new OpenAI({ apiKey });
33
+ this.provider = options?.provider ?? "openai";
34
+ this.baseURL = options?.baseURL;
35
+ this.dimensions = options?.dimensions;
36
+ this.client = new OpenAI({
37
+ apiKey: apiKey || PLACEHOLDER_API_KEY,
38
+ ...(options?.baseURL ? { baseURL: options.baseURL } : {}),
39
+ });
18
40
  }
19
41
 
20
42
  async embed(
@@ -28,7 +50,9 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
28
50
  const texts = inputs.map((i) => {
29
51
  const n = normalizeEmbeddingInput(i);
30
52
  if (n.type !== "text") {
31
- throw new Error("OpenAI embedding backend only supports text inputs");
53
+ throw new Error(
54
+ `${this.provider === "custom" ? "Custom" : "OpenAI"} embedding backend only supports text inputs`,
55
+ );
32
56
  }
33
57
  return n.text;
34
58
  });
@@ -38,6 +62,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
38
62
  model: this.model,
39
63
  input: texts,
40
64
  encoding_format: "float",
65
+ ...(this.dimensions != null ? { dimensions: this.dimensions } : {}),
41
66
  },
42
67
  {
43
68
  signal: options?.signal,
@@ -46,3 +71,29 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
46
71
  return response.data.map((item) => item.embedding);
47
72
  }
48
73
  }
74
+
75
+ /**
76
+ * Trim and strip a trailing slash from a user-supplied OpenAI-compatible
77
+ * embeddings base URL. Returns null when the value is empty, not http(s),
78
+ * or not a valid absolute URL.
79
+ */
80
+ export function resolveOpenAICompatibleBaseUrl(
81
+ value: string | undefined,
82
+ ): string | null {
83
+ if (value == null) {
84
+ return null;
85
+ }
86
+ const trimmed = value.trim().replace(/\/+$/, "");
87
+ if (!trimmed) {
88
+ return null;
89
+ }
90
+ try {
91
+ const parsed = new URL(trimmed);
92
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
93
+ return null;
94
+ }
95
+ } catch {
96
+ return null;
97
+ }
98
+ return trimmed;
99
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ embeddingContentHashWithExtras,
4
5
  embeddingInputContentHash,
5
6
  type MultimodalEmbeddingInput,
6
7
  normalizeEmbeddingInput,
@@ -114,3 +115,26 @@ describe("embeddingInputContentHash", () => {
114
115
  expect(hash).toMatch(/^[0-9a-f]{64}$/);
115
116
  });
116
117
  });
118
+
119
+ describe("embeddingContentHashWithExtras", () => {
120
+ test("matches the bare content hash when extras are empty", () => {
121
+ expect(embeddingContentHashWithExtras("hello")).toBe(
122
+ embeddingInputContentHash("hello"),
123
+ );
124
+ expect(embeddingContentHashWithExtras("hello", [])).toBe(
125
+ embeddingInputContentHash("hello"),
126
+ );
127
+ });
128
+
129
+ test("changes when extras change for identical text", () => {
130
+ const text = "hello";
131
+ const a = embeddingContentHashWithExtras(text, [
132
+ "url=http://127.0.0.1:4000/v1",
133
+ ]);
134
+ const b = embeddingContentHashWithExtras(text, [
135
+ "url=http://127.0.0.1:4001/v1",
136
+ ]);
137
+ expect(a).not.toBe(b);
138
+ expect(a).not.toBe(embeddingInputContentHash(text));
139
+ });
140
+ });