Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions App/frontend/desktop/src/pages/api-key-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
assignCatalogPreset,
createModelWorkspace,
modelConfigInput,
setModelAssignment,
upsertByokPreset
} from "../state/model-workspace.js";
import {
Expand All @@ -38,7 +39,7 @@ import {
testModelConnection
} from "./model-config.js";

type EmbeddingMode = "custom";
type EmbeddingMode = "local" | "custom";

interface EmbeddingCustomConfig {
model: string;
Expand Down Expand Up @@ -97,7 +98,10 @@ export function ApiKeyPage() {
hasExistingApiKey: Boolean(apiKeyMasked)
};
const [llmValidation, setLlmValidation] = useState<ModelConfigValidationState>(initialModelForm.llmValidation);
const initialEmbeddingMode: EmbeddingMode = "custom";
const initialWorkspace = createModelWorkspace(state.modelConfig);
const initialEmbeddingMode: EmbeddingMode = initialWorkspace.catalog.modelAssignments.byok.embedding
? "custom"
: "local";
const [embeddingMode, setEmbeddingMode] = useState<EmbeddingMode>(initialEmbeddingMode);
const [embeddingConfig, setEmbeddingConfig] = useState<EmbeddingCustomConfig>({
model: initialModelForm.embModelId,
Expand All @@ -116,7 +120,7 @@ export function ApiKeyPage() {
};
const [embeddingValidation, setEmbeddingValidation] = useState<ModelConfigValidationState>(initialModelForm.embValidation);
const canSave = canSaveModelConfig(modelFormValues, llmValidation)
&& canSaveOptionalModelConfig(true, embeddingFormValues, embeddingValidation);
&& canSaveOptionalModelConfig(embeddingMode === "custom", embeddingFormValues, embeddingValidation);
const [savePending, setSavePending] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const testedKey = createModelConfigValidationKey(modelFormValues);
Expand All @@ -137,9 +141,8 @@ export function ApiKeyPage() {
embeddingConfig.apiKey,
embeddingConfig.apiKeyMasked
);
const saveSignature = `${testedKey}\n${embeddingTestKey}`;
const saveSignature = `${embeddingMode}\n${testedKey}\n${embeddingTestKey}`;
const savedCatalogSignatureRef = useRef<string | null>(null);
const initialWorkspace = createModelWorkspace(state.modelConfig);
const initialAgentEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "agent");
const initialEmbeddingEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "embedding");
const savedEndpointIdentitiesRef = useRef<Partial<Record<"agent" | "embedding", SavedEndpointIdentity>>>({
Expand Down Expand Up @@ -211,20 +214,24 @@ export function ApiKeyPage() {
});
workspace = assignCatalogPreset(agent.workspace, "byok", "agent", agent.presetId);
const savedEmbeddingIdentity = savedEndpointIdentitiesRef.current.embedding;
const embeddingEndpointId = savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature
? savedEmbeddingIdentity.endpointId
: undefined;
const embedding = upsertByokPreset(workspace, {
provider: "openai",
...(embeddingEndpointId ? { endpointId: embeddingEndpointId } : {}),
endpoint: embeddingConfig.endpoint,
protocol: "openai-embeddings",
...(embeddingConfig.apiKey.trim() ? { apiKey: embeddingConfig.apiKey.trim() } : {}),
...(embeddingConfig.apiKeyMasked ? { apiKeyMasked: embeddingConfig.apiKeyMasked } : {}),
model: embeddingConfig.model,
capabilities: ["embedding"]
});
workspace = assignCatalogPreset(embedding.workspace, "byok", "embedding", embedding.presetId);
if (embeddingMode === "custom") {
const embeddingEndpointId = savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature
? savedEmbeddingIdentity.endpointId
: undefined;
const embedding = upsertByokPreset(workspace, {
provider: "openai",
...(embeddingEndpointId ? { endpointId: embeddingEndpointId } : {}),
endpoint: embeddingConfig.endpoint,
protocol: "openai-embeddings",
...(embeddingConfig.apiKey.trim() ? { apiKey: embeddingConfig.apiKey.trim() } : {}),
...(embeddingConfig.apiKeyMasked ? { apiKeyMasked: embeddingConfig.apiKeyMasked } : {}),
model: embeddingConfig.model,
capabilities: ["embedding"]
});
workspace = assignCatalogPreset(embedding.workspace, "byok", "embedding", embedding.presetId);
} else {
workspace = setModelAssignment(workspace, "byok", "embedding", null);
}
const saved = await clients.config.saveModelCatalog(modelConfigInput(workspace));
if (!saved.catalog?.modelAssignments.byok.agent.candidates.length) {
throw new Error("persisted BYOK Agent assignment is empty");
Expand All @@ -240,6 +247,8 @@ export function ApiKeyPage() {
: {}),
...(savedEmbeddingEndpointId
? { embedding: { endpointId: savedEmbeddingEndpointId, credentialSignature: embeddingCredentialSignature } }
: embeddingMode === "local" && savedEmbeddingIdentity
? { embedding: savedEmbeddingIdentity }
: {})
};
savedCatalogSignatureRef.current = saveSignature;
Expand Down Expand Up @@ -330,10 +339,11 @@ export function ApiKeyPage() {
onValueChange={(value) => setEmbeddingMode(value as EmbeddingMode)}
className="select-control--subtle"
options={[
{ value: "local", label: t("apiKey.localEmbedding") },
{ value: "custom", label: t("apiKey.customEmbedding") }
]}
/>
{(
{embeddingMode === "custom" ? (
<>
<ConfigField
label={t("apiKey.embeddingModel")}
Expand Down Expand Up @@ -361,7 +371,7 @@ export function ApiKeyPage() {
<TestButton status={embeddingValidation.status} onClick={testEmbeddingConnection} label={t("apiKey.test")} />
</div>
</>
)}
) : null}
</div>
</div>

Expand Down
24 changes: 21 additions & 3 deletions App/frontend/desktop/src/pages/model-workspace-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type ModelKind = "text" | "embedding" | "asr" | "image";

const DEFAULT_TEXT_CAPABILITIES: ModelCapability[] = ["chat", "memorySummary", "memoryEvolution"];
const MODEL_KIND_OPTIONS = ["text", "embedding", "asr", "image"] as const;
const LOCAL_EMBEDDING_OPTION_VALUE = "builtin:local-embedding";

export function modelCapabilitiesForKind(kind: ModelKind): ModelCapability[] {
if (kind === "text") return [...DEFAULT_TEXT_CAPABILITIES];
Expand Down Expand Up @@ -585,7 +586,12 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) {
}

function updateAssignment(kind: ModelAssignmentKind, candidateId: string) {
commitWorkspace(setModelAssignment(workspace, props.mode, kind, candidateId));
const assignment = kind === "embedding"
&& props.mode === "byok"
&& candidateId === LOCAL_EMBEDDING_OPTION_VALUE
? null
: candidateId;
commitWorkspace(setModelAssignment(workspace, props.mode, kind, assignment));
}

function toggleTaskCandidate(candidateId: string) {
Expand Down Expand Up @@ -654,7 +660,19 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) {
candidate.source,
candidate.provider
));
const embeddingOptions: SelectOption[] = embeddingModelOptions;
const embeddingOptions: SelectOption[] = props.mode === "byok"
? [
{
value: LOCAL_EMBEDDING_OPTION_VALUE,
label: t("settings.modelWorkspace.localEmbedding"),
selectedLabel: t("settings.modelWorkspace.localEmbeddingShort"),
groupLabel: t("settings.modelWorkspace.specialBuiltins")
},
...embeddingModelOptions
]
: embeddingModelOptions;
const embeddingAssignment = space.assignments.embedding
?? (props.mode === "byok" ? LOCAL_EMBEDDING_OPTION_VALUE : undefined);
const editorExistingConnection = editor?.connectionId
? space.connections.find((connection) => connection.id === editor.connectionId)
: undefined;
Expand Down Expand Up @@ -973,7 +991,7 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) {
kind="embedding"
label={t("settings.model.embeddingSearch")}
description={t("settings.model.embeddingDesc")}
value={space.assignments.embedding}
value={embeddingAssignment}
options={embeddingOptions}
onChange={updateAssignment}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ describe("ApiKeyPage source", () => {
expect(fieldsSource).toContain("auth-code-form-input");
expect(source).toContain("testEmbeddingConnection");
expect(source).toContain('"embedding"');
expect(source).toContain('type EmbeddingMode = "local" | "custom"');
expect(source).toContain('{ value: "local", label: t("apiKey.localEmbedding") }');
expect(source).toContain('canSaveOptionalModelConfig(embeddingMode === "custom"');
expect(source).toContain('const saveSignature = `${embeddingMode}\\n${testedKey}\\n${embeddingTestKey}`');
expect(source).toContain('workspace = setModelAssignment(workspace, "byok", "embedding", null)');
expect(source).not.toContain("testAsrConnection");
expect(source).not.toContain("testImageGenConnection");
expect(source).not.toContain("optionalModelMissingWarning");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,94 @@ describe("BYOK setup save feedback", () => {
expect(container.querySelector('[role="alert"]')).toBeNull();
});

it("defaults a genuinely absent BYOK embedding assignment to the local model", async () => {
const catalog = configuredCatalog(false);
catalog.modelAssignments.byok.embedding = null;
mocks.state = {
...createInitialAppState(),
modelConfig: { ...savedModelConfig(catalog), embedding: null }
};
mocks.clients = createClients(vi.fn(async () => savedModelConfig(catalog)));

await render(<ApiKeyPage />);

expect(combobox("apiKey.embeddingMode").textContent).toContain("apiKey.localEmbedding");
expect(hasField("apiKey.embeddingModel")).toBe(false);
expect(hasField("apiKey.embeddingEndpoint")).toBe(false);
expect(hasField("apiKey.embeddingKey")).toBe(false);
});

it("keeps an explicit invalid BYOK embedding assignment in custom mode", async () => {
const catalog = configuredCatalog(false);
catalog.modelAssignments.byok.embedding = "missing-embedding-preset";
mocks.state = {
...createInitialAppState(),
modelConfig: { ...savedModelConfig(catalog), embedding: null }
};
mocks.clients = createClients(vi.fn(async () => savedModelConfig(catalog)));

await render(<ApiKeyPage />);

expect(combobox("apiKey.embeddingMode").textContent).toContain("apiKey.customEmbedding");
expect(hasField("apiKey.embeddingModel")).toBe(true);
expect(button("apiKey.next").disabled).toBe(true);
});

it("saves local embedding without deleting the custom preset or account assignment", async () => {
const initialCatalog = catalogWithSharedEmbeddingAssignment();
const embeddingPresetId = initialCatalog.modelAssignments.byok.embedding;
const server = createCatalogServer(initialCatalog);
mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) };
mocks.clients = createClients(server.saveModelCatalog, { getModelConfig: server.getModelConfig });
await render(<ApiKeyPage />);

await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding");
await click(button("apiKey.next"));

const saved = server.catalog();
expect(saved.modelAssignments.byok.embedding).toBeNull();
expect(saved.modelAssignments.account.embedding).toBe(embeddingPresetId);
expect(saved.providers.flatMap((provider) => provider.models).map((model) => model.presetId))
.toContain(embeddingPresetId);
});

it("reuses masked custom embedding identity after a partial local-mode save", async () => {
const initialCatalog = catalogWithSharedEmbeddingAssignment();
const initialEmbeddingEndpointId = assignedCatalogEndpointId(
createModelWorkspace(initialCatalog),
"byok",
"embedding"
);
const initialEmbeddingPresetId = initialCatalog.modelAssignments.byok.embedding;
const initialProviderCount = initialCatalog.providers.length;
const initialPresetCount = initialCatalog.providers.flatMap((provider) => provider.models).length;
const server = createCatalogServer(initialCatalog);
const updateSettings = vi.fn(async (settings: unknown) => settings)
.mockRejectedValueOnce(new Error("settings offline"));
mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) };
mocks.clients = createClients(server.saveModelCatalog, {
getModelConfig: server.getModelConfig,
updateSettings
});
await render(<ApiKeyPage />);

await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding");
await click(button("apiKey.next"));
expect(server.catalog().modelAssignments.byok.embedding).toBeNull();

await selectOption("apiKey.embeddingMode", "apiKey.customEmbedding");
await click(button("apiKey.next"));

const restored = server.catalog();
expect(server.saveModelCatalog).toHaveBeenCalledTimes(2);
expect(assignedCatalogEndpointId(createModelWorkspace(restored), "byok", "embedding"))
.toBe(initialEmbeddingEndpointId);
expect(restored.modelAssignments.byok.embedding).toBe(initialEmbeddingPresetId);
expect(restored.modelAssignments.account.embedding).toBe(initialEmbeddingPresetId);
expect(restored.providers).toHaveLength(initialProviderCount);
expect(restored.providers.flatMap((provider) => provider.models)).toHaveLength(initialPresetCount);
});

it("shows a first-step conflict, stays put, and allows a successful retry", async () => {
const firstSave = deferred<ModelProviderConfig>();
const saveModelCatalog = vi.fn()
Expand Down Expand Up @@ -119,8 +207,33 @@ describe("BYOK setup save feedback", () => {
expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-models"));
});

it("saves the catalog again when a partial custom save is retried as local", async () => {
const initialCatalog = catalogWithSharedEmbeddingAssignment();
const server = createCatalogServer(initialCatalog);
const updateSettings = vi.fn(async (settings: unknown) => settings)
.mockRejectedValueOnce(new Error("settings offline"));
mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) };
mocks.clients = createClients(server.saveModelCatalog, {
getModelConfig: server.getModelConfig,
updateSettings
});
await render(<ApiKeyPage />);

await click(button("apiKey.next"));
expect(server.saveModelCatalog).toHaveBeenCalledTimes(1);
expect(server.catalog().modelAssignments.byok.embedding).not.toBeNull();

await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding");
await click(button("apiKey.next"));

expect(server.saveModelCatalog).toHaveBeenCalledTimes(2);
expect(server.catalog().modelAssignments.byok.embedding).toBeNull();
expect(server.catalog().modelAssignments.account.embedding)
.toBe(initialCatalog.modelAssignments.account.embedding);
});

it("reuses first-step endpoint identities when only the model changes after partial success", async () => {
const server = createCatalogServer();
const server = createCatalogServer(configuredCatalog(false));
const updateSettings = vi.fn(async (settings: unknown) => settings)
.mockRejectedValueOnce(new Error("settings offline"));
mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) };
Expand Down Expand Up @@ -176,7 +289,7 @@ describe("BYOK setup save feedback", () => {
});

it("invalidates only the changed first-step credential identity", async () => {
const server = createCatalogServer();
const server = createCatalogServer(configuredCatalog(false));
const updateSettings = vi.fn(async (settings: unknown) => settings)
.mockRejectedValueOnce(new Error("settings offline"));
mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) };
Expand Down Expand Up @@ -373,6 +486,31 @@ describe("BYOK setup save feedback", () => {
return target;
}

function combobox(labelText: string): HTMLButtonElement {
const label = [...container.querySelectorAll(".select-control__label")]
.find((candidate) => candidate.textContent === labelText);
const target = label?.parentElement?.querySelector('button[role="combobox"]');
if (!(target instanceof HTMLButtonElement)) {
throw new Error(`combobox not found: ${labelText}`);
}
return target;
}

async function selectOption(labelText: string, optionText: string) {
await click(combobox(labelText));
const target = [...container.querySelectorAll('button[role="option"]')]
.find((candidate) => candidate.textContent?.includes(optionText));
if (!(target instanceof HTMLButtonElement)) {
throw new Error(`option not found: ${optionText}`);
}
await click(target);
}

function hasField(labelText: string): boolean {
return [...container.querySelectorAll("label")]
.some((candidate) => candidate.textContent === labelText);
}

async function changeField(labelText: string, value: string) {
const label = [...container.querySelectorAll("label")]
.find((candidate) => candidate.textContent === labelText);
Expand Down Expand Up @@ -517,6 +655,12 @@ function configuredCatalog(includeOptional = true): ModelConfigView {
return catalogFromInput(modelConfigInput(workspace), empty, 1);
}

function catalogWithSharedEmbeddingAssignment(): ModelConfigView {
const catalog = configuredCatalog(false);
catalog.modelAssignments.account.embedding = catalog.modelAssignments.byok.embedding;
return catalog;
}

function endpointCount(catalog: ModelConfigView): number {
return catalog.providers.reduce((total, provider) => total + provider.endpoints.length, 0);
}
Expand Down
Loading
Loading