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
2 changes: 1 addition & 1 deletion apps/desktop/src/settings/ai/shared/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
34 changes: 31 additions & 3 deletions apps/desktop/src/settings/ai/shared/provider-availability.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ 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", () => ({
useBillingAccess: () => ({ isPaid: true }),
}));
vi.mock("~/settings/providers", () => ({
useAiProviders: (type: string) => ({
[`${type}:openai`]: {
[`${type}:${mocks.provider}`]: {
api_key: `saved-key-${mocks.key}`,
base_url: mocks.baseUrl,
},
Expand All @@ -24,6 +29,7 @@ beforeEach(() => {
mocks.fetch.mockReset();
mocks.key++;
mocks.baseUrl = "";
mocks.provider = "openai";
});
afterEach(cleanup);

Expand Down Expand Up @@ -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 } },
Expand All @@ -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",
Expand Down
36 changes: 35 additions & 1 deletion apps/desktop/src/settings/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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];
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export function useSetAiProvider(
defaultBaseUrl.trim();
await verifyProviderCredentials(
{
type,
provider: providerId,
baseUrl,
apiKey,
Expand Down
50 changes: 50 additions & 0 deletions packages/provider-validation/src/index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions packages/provider-validation/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { sha256 } from "js-sha256";

export type ProviderCredential = {
type?: "stt" | "llm";
provider: string;
baseUrl: string;
apiKey: string;
Expand Down Expand Up @@ -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<string, number>();
verified.set(fetcher, recent);
Expand Down
Loading