diff --git a/apps/desktop/src/settings/ai/shared/index.tsx b/apps/desktop/src/settings/ai/shared/index.tsx index 09dfac14439..0e105db9cd1 100644 --- a/apps/desktop/src/settings/ai/shared/index.tsx +++ b/apps/desktop/src/settings/ai/shared/index.tsx @@ -255,7 +255,7 @@ export function useProviderAvailability( return provider.checkAvailability(baseUrl, apiKey); try { await verifyProviderCredentials( - { provider: provider.id, baseUrl, apiKey }, + { type: providerType, provider: provider.id, baseUrl, apiKey }, providerFetch, signal, ); diff --git a/apps/desktop/src/settings/ai/shared/provider-availability.test.tsx b/apps/desktop/src/settings/ai/shared/provider-availability.test.tsx index 1c99411eca3..b23653e3796 100644 --- a/apps/desktop/src/settings/ai/shared/provider-availability.test.tsx +++ b/apps/desktop/src/settings/ai/shared/provider-availability.test.tsx @@ -3,7 +3,12 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ fetch: vi.fn(), key: 0, baseUrl: "" })); +const mocks = vi.hoisted(() => ({ + fetch: vi.fn(), + key: 0, + baseUrl: "", + provider: "openai", +})); vi.mock("@tauri-apps/plugin-http", () => ({ fetch: mocks.fetch })); vi.mock("~/auth/billing-context", () => ({ @@ -11,7 +16,7 @@ vi.mock("~/auth/billing-context", () => ({ })); vi.mock("~/settings/providers", () => ({ useAiProviders: (type: string) => ({ - [`${type}:openai`]: { + [`${type}:${mocks.provider}`]: { api_key: `saved-key-${mocks.key}`, base_url: mocks.baseUrl, }, @@ -24,6 +29,7 @@ beforeEach(() => { mocks.fetch.mockReset(); mocks.key++; mocks.baseUrl = ""; + mocks.provider = "openai"; }); afterEach(cleanup); @@ -60,6 +66,28 @@ test.each([ }, ); +test("keeps a saved Custom STT endpoint available without model-list verification", async () => { + mocks.provider = "custom"; + mocks.baseUrl = "http://127.0.0.1:8000/v1"; + mocks.fetch.mockResolvedValue(new Response(null, { status: 404 })); + const { result, client, unmount } = setup("stt"); + await waitFor(() => expect(result.current.custom).toBe(true)); + expect(mocks.fetch).not.toHaveBeenCalled(); + unmount(); + client.clear(); +}); + +test("still probes Custom LLM credentials and rejects an unsupported model-list endpoint", async () => { + mocks.provider = "custom"; + mocks.baseUrl = "http://127.0.0.1:8000/v1"; + mocks.fetch.mockResolvedValue(new Response(null, { status: 401 })); + const { result, client, unmount } = setup("llm"); + await waitFor(() => expect(result.current.custom).toBe(false)); + expect(mocks.fetch).toHaveBeenCalled(); + unmount(); + client.clear(); +}); + function setup(type: "stt" | "llm") { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -68,7 +96,7 @@ function setup(type: "stt" | "llm") { () => useProviderAvailability(type, [ { - id: "openai", + id: mocks.provider, displayName: "OpenAI", icon: null, baseUrl: "https://api.openai.com/v1", diff --git a/apps/desktop/src/settings/providers.test.ts b/apps/desktop/src/settings/providers.test.ts index a67908a4115..c4e790bac47 100644 --- a/apps/desktop/src/settings/providers.test.ts +++ b/apps/desktop/src/settings/providers.test.ts @@ -122,6 +122,35 @@ describe("SQLite AI providers", () => { queryClient.clear(); }); + it("saves Custom STT without requiring a model-list endpoint", async () => { + const { verifyProviderCredentials } = await vi.importActual< + typeof import("@anlg/provider-validation") + >("@anlg/provider-validation"); + mocks.verify.mockImplementation(verifyProviderCredentials); + mocks.execute.mockResolvedValue([]); + mocks.fetch.mockResolvedValue(new Response(null, { status: 404 })); + const queryClient = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + const { result, unmount } = renderHook( + () => useSetAiProvider("stt", "custom", { verifyCredentials: true }), + { wrapper }, + ); + await result.current.mutateAsync({ + base_url: "http://127.0.0.1:8000/v1", + api_key: "local-key", + }); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.setSecret).toHaveBeenCalledWith( + "ai-provider-api-keys", + "stt:custom", + "local-key", + ); + expect(mocks.executeTransaction).toHaveBeenCalledOnce(); + unmount(); + queryClient.clear(); + }); + it("saves a valid local provider and rejects an invalid replacement when the server restricts origins", async () => { const { verifyProviderCredentials } = await vi.importActual< typeof import("@anlg/provider-validation") @@ -201,7 +230,12 @@ describe("SQLite AI providers", () => { ); await result.current.mutateAsync({ api_key: "working", base_url: draft }); expect(mocks.verify).toHaveBeenCalledWith( - { provider: "openai", baseUrl: expected, apiKey: "working" }, + { + type: "llm", + provider: "openai", + baseUrl: expected, + apiKey: "working", + }, expect.any(Function), ); const persisted = mocks.executeTransaction.mock.calls[0][0][0].params[0]; diff --git a/apps/desktop/src/settings/providers.ts b/apps/desktop/src/settings/providers.ts index 3300519ee46..d6a519828ef 100644 --- a/apps/desktop/src/settings/providers.ts +++ b/apps/desktop/src/settings/providers.ts @@ -223,6 +223,7 @@ export function useSetAiProvider( defaultBaseUrl.trim(); await verifyProviderCredentials( { + type, provider: providerId, baseUrl, apiKey, diff --git a/packages/provider-validation/src/index.test.mjs b/packages/provider-validation/src/index.test.mjs index b568705a379..ca219894827 100644 --- a/packages/provider-validation/src/index.test.mjs +++ b/packages/provider-validation/src/index.test.mjs @@ -78,6 +78,56 @@ test("a public model catalog cannot validate a key", async () => { ); }); +test("custom STT validates configuration without probing an OpenAI model catalog", async () => { + const fetcher = async () => { + assert.fail("Custom STT must not send model-list or control-key requests"); + }; + for (const baseUrl of [ + "http://127.0.0.1:8000/v1", + "https://stt.example/v1", + ]) { + await verifyProviderCredentials( + { ...credential, type: "stt", provider: "custom", baseUrl }, + fetcher, + ); + } +}); + +test("custom STT still rejects unsafe URLs and malformed keys before saving", async () => { + for (const update of [ + { baseUrl: "not-a-url" }, + { baseUrl: "http://stt.example/v1" }, + { baseUrl: "https://user:password@stt.example/v1" }, + { baseUrl: "https://stt.example/v1?key=secret" }, + { apiKey: "" }, + { apiKey: "bad\nkey" }, + ]) { + await assert.rejects( + verifyProviderCredentials( + { ...credential, type: "stt", provider: "custom", ...update }, + async () => assert.fail("Invalid configuration must not send requests"), + ), + ProviderCredentialError, + ); + } +}); + +test("Custom LLM still requires key proof after accepting the same STT configuration", async () => { + const custom = { ...credential, provider: "custom" }; + let calls = 0; + const fetcher = async () => { + calls++; + return Response.json({ data: [] }); + }; + await verifyProviderCredentials({ ...custom, type: "stt" }, fetcher); + assert.equal(calls, 0); + await assert.rejects( + verifyProviderCredentials({ ...custom, type: "llm" }, fetcher), + /doesn’t support API key verification/, + ); + assert.equal(calls, 2); +}); + test("a gateway must accept the candidate key and reject the control key", async () => { const keys = []; await verifyProviderCredentials( diff --git a/packages/provider-validation/src/index.ts b/packages/provider-validation/src/index.ts index 58d33bba4b4..9ca1379c033 100644 --- a/packages/provider-validation/src/index.ts +++ b/packages/provider-validation/src/index.ts @@ -1,6 +1,7 @@ import { sha256 } from "js-sha256"; export type ProviderCredential = { + type?: "stt" | "llm"; provider: string; baseUrl: string; apiKey: string; @@ -50,6 +51,10 @@ export async function verifyProviderCredentials( throw new ProviderCredentialError("Use HTTPS for provider credentials."); signal?.throwIfAborted(); + // Deepgram-compatible listen servers need not expose a model catalog or a + // credential-probe endpoint. Their credentials are checked when transcribing. + if (credential.type === "stt" && credential.provider === "custom") return; + const identity = providerCredentialIdentity({ ...credential, apiKey }); const recent = verified.get(fetcher) ?? new Map(); verified.set(fetcher, recent);