diff --git a/CHANGELOG.md b/CHANGELOG.md index fb0b77747..947185fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ See [`RELEASE.md`](RELEASE.md) for publication and verification procedures. ## [Unreleased] +- Personal chat keeps its admitted provider and credential when marketplace + availability changes. Already-admitted direct calls skip Surplus discovery, + and protected Memory retains its approved marketplace embedding disclosure. + +- Surplus model selection and requests now use its live catalogue for confirmed + model names, capabilities, reasoning parameters, and token limits. Auto skips + unconfirmed routes, and chat and Soul share the same marketplace budget. + Responses with an adapted output ceiling are accepted when they finish + naturally and reported output stays within the original request budget. + +- Onboarding waits for an in-progress Soul before saving the final profile, + and automatic foreground chat selection supports Surplus-only servers. + +- Invite room pickers now list only public rooms and exclude external rooms. + +- Web Genie customization now loads the default shell avatar correctly instead + of showing a missing-image error and requiring a replacement. + +- Surplus-only chat can use available sellers for eligible ordinary models, + preserves streamed tool calls, and respects qualified marketplace output + limits. Missing recovery keys retain the original marketplace failure; + pre-routing error traces no longer become pending cost receipts. + +- Embeddings can use Surplus with an explicit Venice text-embedding-3-large + selection and Prefer Surplus enabled. Protected Memory shows and binds the + processing route before saving or searching, and usage receipts track + marketplace costs without replaying provider requests. + +- Soul generation supports a server Surplus key without a direct provider key, + including streamed customization previews and service cost tracking. Eligible + Surplus-only requests let the marketplace choose an available seller and can + recover from confirmed supply refusals through other eligible models. + - Operators can connect a selected Genie to mAgent through a private Unix socket, with a fresh external-context Room and a fixed sponsor and sender identity. diff --git a/apps/desktop/electron/foreground-shadow-controller.ts b/apps/desktop/electron/foreground-shadow-controller.ts index 08396ab99..1717005cb 100644 --- a/apps/desktop/electron/foreground-shadow-controller.ts +++ b/apps/desktop/electron/foreground-shadow-controller.ts @@ -1,4 +1,8 @@ -import type { NautiloApiClient, ProtectedMemoryProjectionV1 } from "@nautilo/api-client"; +import { type NautiloApiClient, type ProtectedMemoryProjectionV1 } from "@nautilo/api-client"; +import { + sameMemoryEmbeddingDisclosureV2, + type MemoryEmbeddingDisclosureV2, +} from "@nautilo/lattice-crypto/wire"; import type { TaskOperationRunResultReadV1, TaskOperationRunTranscriptReadV1, @@ -743,18 +747,27 @@ export class ElectronForegroundShadowController { type: string; content: string; importance: number; + requestedEmbedding?: MemoryEmbeddingDisclosureV2; }>) { return this.#runMemory(async () => { - const recipient = await this.#api.getMemoryProcessorRecipient(); - if (recipient.embedding === undefined) { - throw new TypeError("Memory embedding is unavailable"); + const embedding = input.requestedEmbedding === undefined + ? (await this.#api.getMemoryProcessorRecipient()).embedding + : (await this.#api.getMemoryProcessorRecipientV2()).embedding; + if (embedding === undefined) throw new TypeError("Memory embedding is unavailable"); + if (input.requestedEmbedding !== undefined + && (!("canonicalSpace" in embedding) + || !sameMemoryEmbeddingDisclosureV2(input.requestedEmbedding, embedding))) { + throw new TypeError( + "The Memory embedding disclosure changed. Reload it before trying again.", + ); } let item: unknown; const receipt = await this.#humanMemory.update(input.memoryId, { payload: { formatVersion: 1, type: input.type, content: input.content }, importance: input.importance, - requestedProvider: recipient.embedding.provider, - requestedModel: recipient.embedding.model, + ...("canonicalSpace" in embedding + ? { requestedEmbedding: input.requestedEmbedding! } + : { requestedProvider: embedding.provider, requestedModel: embedding.model }), }, (opened) => { item = protectedOpenedMemory(opened); }); diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 517d9c412..4bd09db21 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,4 +1,9 @@ import { createChatGPTDesktopListener } from "./chatgpt-plan-loopback"; +import { + assertMemoryEmbeddingDisclosureV2, + decodeMemoryEmbeddingDisclosureV2, + encodeMemoryEmbeddingDisclosureV2, +} from "@nautilo/lattice-crypto/wire"; import "./instance-argv-bootstrap.ts"; import { LOCAL_EXECUTION_MAX_IDENTITIES } from "@nautilo/relay"; import { developmentProfileScope, projectAgentAccess, selectionForAgentAccess, selectionForReadyComponents } from "./agent-access"; @@ -8601,10 +8606,21 @@ ipcMain.handle("foregroundShadow:memory:list", async (e, raw: unknown) => { }); ipcMain.handle("foregroundShadow:memory:search", async (e, raw: unknown) => { - const options = boundedForegroundShadowValue(raw, "Protected Memory search") as - Parameters[0]; + const value = boundedForegroundShadowValue(raw, "Protected Memory search") as + Parameters[0] & { + embeddingDisclosure?: unknown; + }; + let embeddingDisclosure; + if (value.embeddingDisclosure !== undefined) { + assertMemoryEmbeddingDisclosureV2(value.embeddingDisclosure); + embeddingDisclosure = decodeMemoryEmbeddingDisclosureV2( + encodeMemoryEmbeddingDisclosureV2(value.embeddingDisclosure), + ); + } + const { embeddingDisclosure: _untrustedEmbedding, ...options } = value; const { controller } = await foregroundShadowControllerForSender(e); - return controller.memorySearch(options); + return controller.memorySearch({ ...options, + ...(embeddingDisclosure === undefined ? {} : { embeddingDisclosure }) }); }); ipcMain.handle("foregroundShadow:memory:detail", async (e, raw: unknown) => { @@ -8618,12 +8634,21 @@ ipcMain.handle("foregroundShadow:memory:detail", async (e, raw: unknown) => { ipcMain.handle("foregroundShadow:memory:update", async (e, raw: unknown) => { const value = boundedForegroundShadowValue(raw, "Protected Memory update") as { memoryId?: unknown; type?: unknown; content?: unknown; importance?: unknown; + requestedEmbedding?: unknown; }; + let requestedEmbedding; + if (value.requestedEmbedding !== undefined) { + assertMemoryEmbeddingDisclosureV2(value.requestedEmbedding); + requestedEmbedding = decodeMemoryEmbeddingDisclosureV2( + encodeMemoryEmbeddingDisclosureV2(value.requestedEmbedding), + ); + } const input = Object.freeze({ memoryId: foregroundShadowString(value.memoryId, "Memory id", 200), type: foregroundShadowString(value.type, "Memory type", 4_096), content: foregroundShadowString(value.content, "Memory content", 65_536), importance: value.importance, + ...(requestedEmbedding === undefined ? {} : { requestedEmbedding }), }); if (typeof input.importance !== "number" || !Number.isFinite(input.importance) || input.importance < 0 || input.importance > 1) { @@ -15003,26 +15028,8 @@ function showOnboardingWizard( "POST /api/profile/generate-soul/stream returned no final soul file", ); } - // First-run onboarding must never hold its final save screen open for - // model latency. The renderer is allowed to close as soon as the - // ordinary profile write finishes, so Desktop durably commits the soul - // from this detached main-process request when generation completes. - // A final profile write that omits `soulFile` preserves this result no - // matter which request wins the insert/update race. - const persistResp = await fetch(`${serverUrl}/api/profile`, { - method: "PUT", - headers, - body: JSON.stringify({ soulFile: finalSoulFile }), - }); - if (!persistResp.ok) { - console.warn( - `[onboarding:soul] generated in ${Date.now() - generationStartedAt}ms but background persistence failed: ${persistResp.status}`, - ); - } else { - console.log( - `[onboarding:soul] generated and persisted in ${Date.now() - generationStartedAt}ms`, - ); - } + // Generation is a preview. The shared wizard waits for this result and + // commits it in the final profile write, just as the web adapter does. return ipcOk({ soulFile: finalSoulFile }); } catch (err) { const error = err instanceof Error ? err.message : String(err); diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index b72230c90..55423593c 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -25,6 +25,7 @@ import type { } from "@nautilo/api-client"; import type { AtomicDocumentMutationEventBatch } from "@nautilo/document-mutations"; import type { MessagePayloadV2 } from "@nautilo/lattice-bridge"; +import type { MemoryEmbeddingDisclosureV2 } from "@nautilo/lattice-crypto/wire"; import type { BrowserRoomHistoryShadowAcknowledgementInput, HumanPeerLiveShadowReceiveResult, @@ -2082,12 +2083,14 @@ const foregroundShadowAPI = { search: (options: Readonly<{ q: string; mode: "text" | "semantic"; limit?: number; includeArchive?: boolean; + embeddingDisclosure?: MemoryEmbeddingDisclosureV2; }>) => ipcRenderer.invoke("foregroundShadow:memory:search", options), detail: (memoryId: string) => ipcRenderer.invoke( "foregroundShadow:memory:detail", { memoryId }, ), update: (input: Readonly<{ memoryId: string; type: string; content: string; importance: number; + requestedEmbedding?: MemoryEmbeddingDisclosureV2; }>) => ipcRenderer.invoke("foregroundShadow:memory:update", input), retryPendingMutations: () => ipcRenderer.invoke( "foregroundShadow:memory:retryPending", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c8038329f..a6157e294 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -62,6 +62,7 @@ "@nautilo/config": "workspace:*", "@nautilo/instance-discovery": "workspace:*", "@nautilo/lattice-bridge": "workspace:*", + "@nautilo/lattice-crypto": "workspace:*", "@nautilo/logger": "workspace:*", "@nautilo/mcp-client": "workspace:*", "@nautilo/relay": "workspace:*", diff --git a/apps/desktop/tests/unit/foreground-shadow-controller.test.ts b/apps/desktop/tests/unit/foreground-shadow-controller.test.ts index a1dc636e1..c04fbe5fc 100644 --- a/apps/desktop/tests/unit/foreground-shadow-controller.test.ts +++ b/apps/desktop/tests/unit/foreground-shadow-controller.test.ts @@ -843,6 +843,89 @@ describe("ElectronForegroundShadowController", () => { await controller.dispose(); }); + test("signs Desktop Memory edits with the advertised marketplace disclosure", async () => { + let intent: unknown; + const embedding = { + contractVersion: 2 as const, + canonicalSpace: { provider: "venice" as const, + model: "text-embedding-3-large", dimensions: 1536 as const }, + processingRoute: { transport: "surplus_marketplace" as const, + marketplacePolicyVersion: "surplus-embedding-marketplace-v1" as const, + dataRecipientClasses: [ + "surplus", "marketplace_seller", "seller_upstream_processor", + ] as const }, + }; + const controller = createElectronForegroundShadowController(controllerInput({ + api: { + setToken: () => undefined, + admin: { encryptionTransition: { getPolicy: async () => ({ + policy: { mode: "shadow_encryption", shadowBehavior: "fallback", + revision: 1, updatedAt: "2026-09-01T00:00:00.000Z" }, + }) } }, + getMemoryProcessorRecipientV2: async () => ({ formatVersion: 2, + purpose: "memory.foreground_embedding", recipientId: "A".repeat(43), + publicKeyBase64url: "B".repeat(87), embedding }), + } as unknown as ElectronForegroundShadowApi, + factories: factories({ humanMemory: { + update: async (_memoryId: string, value: unknown) => { + intent = value; + return { status: "ordinary_fallback", memoryId: "memory-1", + reason: "target_encryption_not_ready" }; + }, + } }), + })); + expect(await controller.memoryUpdate({ memoryId: "memory-1", type: "fact", + content: "private", importance: 0.8, + requestedEmbedding: embedding })).toMatchObject({ + status: "ordinary_fallback", memoryId: "memory-1", + }); + expect(intent).toEqual({ + payload: { formatVersion: 1, type: "fact", content: "private" }, + importance: 0.8, + requestedEmbedding: embedding, + }); + await controller.dispose(); + }); + + test("rejects a Desktop Memory edit when the displayed route no longer matches", async () => { + const direct = { + contractVersion: 2 as const, + canonicalSpace: { provider: "venice" as const, + model: "text-embedding-3-large", dimensions: 1536 as const }, + processingRoute: { transport: "direct" as const }, + }; + const marketplace = { ...direct, processingRoute: { + transport: "surplus_marketplace" as const, + marketplacePolicyVersion: "surplus-embedding-marketplace-v1" as const, + dataRecipientClasses: [ + "surplus", "marketplace_seller", "seller_upstream_processor", + ] as const, + } }; + let updates = 0; + const controller = createElectronForegroundShadowController(controllerInput({ + api: { + setToken: () => undefined, + admin: { encryptionTransition: { getPolicy: async () => ({ + policy: { mode: "shadow_encryption", shadowBehavior: "fallback", + revision: 1, updatedAt: "2026-09-01T00:00:00.000Z" }, + }) } }, + getMemoryProcessorRecipientV2: async () => ({ formatVersion: 2, + purpose: "memory.foreground_embedding", recipientId: "A".repeat(43), + publicKeyBase64url: "B".repeat(87), embedding: marketplace }), + } as unknown as ElectronForegroundShadowApi, + factories: factories({ humanMemory: { + update: async () => { updates += 1; throw new Error("unexpected update"); }, + } }), + })); + const failure = await rejected(controller.memoryUpdate({ memoryId: "memory-1", + type: "fact", content: "private", importance: 0.8, + requestedEmbedding: direct })); + expect(failure).toBeInstanceOf(TypeError); + expect((failure as TypeError).message).toContain("embedding disclosure changed"); + expect(updates).toBe(0); + await controller.dispose(); + }); + test("withholds a Memory result when policy changes during the operation", async () => { const started = deferred(); const release = deferred(); diff --git a/apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts b/apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts index e003b19da..a182fc413 100644 --- a/apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts +++ b/apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts @@ -91,6 +91,21 @@ describe("foreground Shadow main-process contract", () => { } }); + test("strictly decodes the renderer-approved Memory disclosure before custody", () => { + const search = handler("foregroundShadow:memory:search", "foregroundShadow:memory:detail"); + const update = handler("foregroundShadow:memory:update", "foregroundShadow:memory:retryPending"); + for (const operation of [search, update]) { + expect(operation).toContain("assertMemoryEmbeddingDisclosureV2("); + expect(operation).toContain("decodeMemoryEmbeddingDisclosureV2("); + expect(operation).toContain("encodeMemoryEmbeddingDisclosureV2("); + expect(operation.indexOf("assertMemoryEmbeddingDisclosureV2(")).toBeLessThan( + operation.indexOf("foregroundShadowControllerForSender(e)"), + ); + } + expect(search).toContain("embeddingDisclosure"); + expect(update).toContain("requestedEmbedding"); + }); + test("binds one controller to the active sender/session and disposes on lifecycle exits", () => { const factory = main.slice( main.indexOf("async function foregroundShadowControllerForSender("), diff --git a/apps/desktop/tests/unit/foreground-shadow-preload-contract.test.ts b/apps/desktop/tests/unit/foreground-shadow-preload-contract.test.ts index 2231d1c4e..c69b7d415 100644 --- a/apps/desktop/tests/unit/foreground-shadow-preload-contract.test.ts +++ b/apps/desktop/tests/unit/foreground-shadow-preload-contract.test.ts @@ -80,4 +80,11 @@ describe("M300 foreground Shadow preload contract", () => { "DesktopForegroundShadowStoredMessage", ); }); + + test("carries the displayed Memory embedding disclosure across the IPC surface", () => { + expect(preload).toContain("embeddingDisclosure?: MemoryEmbeddingDisclosureV2"); + expect(preload).toContain("requestedEmbedding?: MemoryEmbeddingDisclosureV2"); + expect(workbenchDesktop).toContain("embeddingDisclosure?: MemoryEmbeddingDisclosureV2"); + expect(workbenchDesktop).toContain("requestedEmbedding?: MemoryEmbeddingDisclosureV2"); + }); }); diff --git a/apps/desktop/tests/unit/onboarding-soul-preview-contract.test.ts b/apps/desktop/tests/unit/onboarding-soul-preview-contract.test.ts new file mode 100644 index 000000000..3e2e27449 --- /dev/null +++ b/apps/desktop/tests/unit/onboarding-soul-preview-contract.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; + +test("Desktop Soul generation remains a preview until the wizard saves the profile", () => { + const source = readFileSync(join(import.meta.dir, "../../electron/main.ts"), "utf8"); + const file = ts.createSourceFile("main.ts", source, ts.ScriptTarget.Latest, true); + let handler: ts.Node | undefined; + function find(node: ts.Node): void { + if (ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && node.expression.getText(file) === "ipcMain.handle" + && node.arguments[0] && ts.isStringLiteral(node.arguments[0]) + && node.arguments[0].text === "onboarding:generate-soul") { + handler = node.arguments[1]; + } + ts.forEachChild(node, find); + } + find(file); + if (!handler) throw new Error("Missing Desktop Soul generation handler"); + const requests: string[] = []; + function inspect(node: ts.Node): void { + if (ts.isCallExpression(node) && node.expression.getText(file) === "fetch") { + requests.push(node.arguments[0]?.getText(file) ?? ""); + } + ts.forEachChild(node, inspect); + } + inspect(handler); + expect(requests).toEqual(["`${serverUrl}/api/profile/generate-soul/stream`"]); + expect(handler.getText(file)).toContain("return ipcOk({ soulFile: finalSoulFile })"); +}); diff --git a/apps/workbench/package.json b/apps/workbench/package.json index 103af6237..9f6145d44 100644 --- a/apps/workbench/package.json +++ b/apps/workbench/package.json @@ -106,6 +106,7 @@ "@nautilo/agent": "workspace:*", "@nautilo/computer-use-contracts": "workspace:*", "@nautilo/lattice-bridge": "workspace:*", + "@nautilo/lattice-crypto": "workspace:*", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "4.2.2", "@testing-library/react": "16.3.2", diff --git a/apps/workbench/src/genie-customization/workbench-onboarding-api.ts b/apps/workbench/src/genie-customization/workbench-onboarding-api.ts index b55dd88c9..a752fa767 100644 --- a/apps/workbench/src/genie-customization/workbench-onboarding-api.ts +++ b/apps/workbench/src/genie-customization/workbench-onboarding-api.ts @@ -96,7 +96,8 @@ function toSnapshot(profile: AgentProfileFull, avatarUrl: string | null): Profil } function onboardingAvatarUrl(avatar: AvatarRef): string | null { - return avatar.kind === "preset" + // The shell is rendered by the authenticated avatar route, not a preset file. + return avatar.kind === "preset" && avatar.id !== "shell" ? `/api/onboarding/images/avatars/${encodeURIComponent(avatar.id)}.webp` : null; } diff --git a/apps/workbench/src/lib/desktop.ts b/apps/workbench/src/lib/desktop.ts index 4799b949e..a127cc61a 100644 --- a/apps/workbench/src/lib/desktop.ts +++ b/apps/workbench/src/lib/desktop.ts @@ -44,6 +44,7 @@ import type { TaskContentBackfillProgressResponse, } from "@nautilo/api-client/browser"; import type { MessagePayloadV2 } from "@nautilo/lattice-bridge"; +import type { MemoryEmbeddingDisclosureV2 } from "@nautilo/lattice-crypto/wire"; import type { AuthorizedHumanMemoryClient, BrowserRoomHistoryShadowAcknowledgementInput, @@ -374,6 +375,7 @@ export interface DesktopForegroundShadowAPI { search(options: Readonly<{ q: string; mode: "text" | "semantic"; limit?: number; includeArchive?: boolean; + embeddingDisclosure?: MemoryEmbeddingDisclosureV2; }>): Promise>; update(input: Readonly<{ memoryId: string; type: string; content: string; importance: number; + requestedEmbedding?: MemoryEmbeddingDisclosureV2; }>): Promise Promise; resolveEmbedding?: () => Promise>; + }> | MemoryEmbeddingDisclosureV2>; }>): WorkbenchMemoryReadOperations { return Object.freeze({ async list(options: MemoryListOptions) { @@ -173,9 +174,17 @@ export function createWorkbenchMemoryReadOperations(input: Readonly<{ ...item, updatedAt: item.createdAt, namespaceIds: [], })); }, - protected: async () => (await protectedPort(input.protected).search({ - ...options, mode: "semantic", - })).items.map(row), + protected: async () => { + const embedding = await input.resolveEmbedding?.(); + if (embedding === undefined) throw new ClassifiedDataOperationError( + "unsupported", "Memory embedding is unavailable", + ); + return (await protectedPort(input.protected).search({ + ...options, mode: "semantic", + ...( "canonicalSpace" in embedding + ? { embeddingDisclosure: embedding } : {}), + })).items.map(row); + }, consumeOrdinary: (value) => value, consumeProtected: (value) => value, })).value; @@ -213,13 +222,17 @@ export function createWorkbenchMemoryReadOperations(input: Readonly<{ const embedding = await input.resolveEmbedding?.(); if (embedding === undefined) throw new ClassifiedDataOperationError("unsupported", "Memory embedding is unavailable"); return protectedMutationPort(input.protected).update({ memoryId, ...edit, - requestedProvider: embedding.provider, requestedModel: embedding.model }); + ...("canonicalSpace" in embedding + ? { requestedEmbedding: embedding } + : { requestedProvider: embedding.provider, requestedModel: embedding.model }) }); }, protected: async () => { const embedding = await input.resolveEmbedding?.(); if (embedding === undefined) throw new ClassifiedDataOperationError("unsupported", "Memory embedding is unavailable"); return protectedMutationPort(input.protected).update({ memoryId, ...edit, - requestedProvider: embedding.provider, requestedModel: embedding.model }); + ...("canonicalSpace" in embedding + ? { requestedEmbedding: embedding } + : { requestedProvider: embedding.provider, requestedModel: embedding.model }) }); }, }); }, diff --git a/apps/workbench/src/lib/protected-human-memory-controller.ts b/apps/workbench/src/lib/protected-human-memory-controller.ts index 135bd23f3..6b9420614 100644 --- a/apps/workbench/src/lib/protected-human-memory-controller.ts +++ b/apps/workbench/src/lib/protected-human-memory-controller.ts @@ -5,6 +5,7 @@ import type { AuthorizedHumanMemoryUnavailableReason, } from "@nautilo/lattice-bridge/client/browser"; import type { ProtectedMemoryProjectionV1 } from "@nautilo/api-client/browser"; +import type { MemoryEmbeddingDisclosureV2 } from "@nautilo/api-client/browser"; import { runWithCryptoAdmission } from "./crypto-admission-access"; export type WorkbenchProtectedMemoryContent = @@ -151,9 +152,10 @@ function createUnfencedWorkbenchProtectedHumanMemoryController( type: string; content: string; importance: number; + } & ({ requestedProvider: "openai" | "openrouter" | "venice"; requestedModel: string; - }>) { + } | { requestedEmbedding: MemoryEmbeddingDisclosureV2 })>) { let memory: WorkbenchProtectedMemory | undefined; const response = await client.update(input.memoryId, { payload: { @@ -162,8 +164,10 @@ function createUnfencedWorkbenchProtectedHumanMemoryController( content: input.content, }, importance: input.importance, - requestedProvider: input.requestedProvider, - requestedModel: input.requestedModel, + ...("requestedEmbedding" in input + ? { requestedEmbedding: input.requestedEmbedding } + : { requestedProvider: input.requestedProvider, + requestedModel: input.requestedModel }), }, (value) => { memory = protectedOpened(value); }); diff --git a/apps/workbench/src/pages/admin/sections/models-section.test.tsx b/apps/workbench/src/pages/admin/sections/models-section.test.tsx index 64ac06f9e..afa68a0a5 100644 --- a/apps/workbench/src/pages/admin/sections/models-section.test.tsx +++ b/apps/workbench/src/pages/admin/sections/models-section.test.tsx @@ -623,7 +623,7 @@ describe("ModelsSection Embedding model", () => { expect(select.selectedOptions[0]?.textContent).toBe("Server configuration"); expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ "Server configuration", - "Automatic — Venice → OpenRouter → OpenAI", + "Automatic — Venice → OpenRouter → OpenAI → Surplus fallback", "Qwen3 Embedding 8B (Venice)", "Qwen3 Embedding 8B (OpenRouter)", "Text Embedding 3 Small (Venice)", @@ -631,6 +631,8 @@ describe("ModelsSection Embedding model", () => { "Text Embedding 3 Small (OpenAI) (Unavailable)", ]); expect(select.options[6]?.disabled).toBe(true); + expect(view.getByText(/If no direct embedding credential is ready, Prefer Surplus can use/)) + .toBeTruthy(); }); test("persists automatic independently", async () => { diff --git a/apps/workbench/src/pages/admin/sections/models-section.tsx b/apps/workbench/src/pages/admin/sections/models-section.tsx index 5f7ab2913..862d19603 100644 --- a/apps/workbench/src/pages/admin/sections/models-section.tsx +++ b/apps/workbench/src/pages/admin/sections/models-section.tsx @@ -33,6 +33,7 @@ const SUPPORTED_EMBEDDING_MODEL_IDS = new Set([ "venice:text-embedding-3-small", "openrouter:openai/text-embedding-3-small", "openai:text-embedding-3-small", + "venice:text-embedding-3-large", ]); type SaveState = "idle" | "saving" | "saved" | { error: string }; @@ -665,7 +666,7 @@ export function ModelsSection() { Embedding model

- Creates vectors for semantic Memory search and Reflection. Server configuration inherits the operator setting. Automatic selects the first configured provider: Qwen3 Embedding 8B on Venice or OpenRouter, then text-embedding-3-small on OpenAI. + Creates vectors for semantic Memory search and Reflection. Server configuration inherits the operator setting. Automatic keeps the first configured direct provider: Qwen3 Embedding 8B on Venice or OpenRouter, then text-embedding-3-small on OpenAI. If no direct embedding credential is ready, Prefer Surplus can use the qualified Venice text-embedding-3-large route.