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
24 changes: 24 additions & 0 deletions apps/desktop/electron/ai/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,30 @@ describe("inspectProvider", () => {
expect(offline.message).toContain("ollama serve");
});

it("aborts a provider check that exceeds its timeout", async () => {
const request = vi.fn<typeof fetch>().mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => {
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
}),
);

const result = await inspectProvider(profile(), undefined, request, 5);

expect(result).toEqual({
ok: false,
message:
"Connection check timed out. Check the address and make sure LM Studio is running, then try again.",
});
expect(request.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
});

it("requires credentials before contacting a cloud provider", async () => {
const request = vi.fn<typeof fetch>();
const result = await inspectProvider(
Expand Down
19 changes: 18 additions & 1 deletion apps/desktop/electron/ai/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
} from "../ipc/types";
import { redactSensitive, secureProviderFetch } from "./security";

const INSPECTION_TIMEOUT_MS = 10_000;

const providerFor = (profile: ProviderProfile) =>
AI_PROVIDER_CATALOG.find((provider) => provider.kind === profile.kind);

Expand Down Expand Up @@ -58,15 +60,20 @@ export async function inspectProvider(
profile: ProviderProfile,
secret?: string,
doFetch: typeof fetch = fetch,
timeoutMs = INSPECTION_TIMEOUT_MS,
): Promise<ProviderConnectionResult> {
const provider = providerFor(profile);
if (!provider) return { ok: false, message: "That provider type is not supported." };
if (provider.credential === "required" && !secret)
return { ok: false, message: `Add an API key for ${provider.label}, then try again.` };
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await secureProviderFetch(
modelsUrl(profile),
{ headers: headersFor(profile, secret) },
{ headers: headersFor(profile, secret), signal: controller.signal },
doFetch,
);
if (!response.ok)
Expand All @@ -89,12 +96,22 @@ export async function inspectProvider(
models,
};
} catch (error) {
if (controller.signal.aborted)
return {
ok: false,
message:
profile.kind === "lmstudio" || profile.kind === "ollama"
? `Connection check timed out. Check the address and make sure ${provider.label} is running, then try again.`
: "Connection check timed out. Check the address and your network, then try again.",
};
return {
ok: false,
message:
profile.kind === "ollama"
? `Could not connect to Ollama. Start it with “ollama serve”, then try again. ${redactSensitive(error)}`
: `Could not connect to ${provider.label}: ${redactSensitive(error, secret ? [secret] : [])}`,
};
} finally {
clearTimeout(timeout);
}
}
83 changes: 83 additions & 0 deletions apps/desktop/src/settings/ProviderSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/** @vitest-environment jsdom */

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AiSettings } from "../../electron/ipc/types";
import { ProviderSettings } from "./ProviderSettings";

const settings: AiSettings = {
defaultProfileId: "local",
profiles: [
{
id: "local",
name: "Local",
kind: "lmstudio",
baseUrl: "http://127.0.0.1:1234",
model: "local/model",
},
],
};

let root: Root | undefined;
let container: HTMLDivElement | undefined;

afterEach(() => {
if (root)
act(() => {
root?.unmount();
});
container?.remove();
root = undefined;
container = undefined;
});

describe("ProviderSettings", () => {
it("shows a recoverable error when the provider IPC call rejects", async () => {
Object.defineProperty(window, "lucid", {
configurable: true,
value: {
credentials: {
status: vi.fn().mockResolvedValue({
present: false,
storage: "none",
securePersistenceAvailable: true,
}),
},
providers: {
test: vi.fn().mockRejectedValue(new Error("IPC failed")),
models: vi.fn(),
},
},
});
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);

await act(async () => {
root?.render(
<ProviderSettings
value={settings}
lmStudioUrlLocked={false}
onChange={vi.fn()}
onPersist={vi.fn().mockResolvedValue(true)}
/>,
);
await Promise.resolve();
});
const button = [...container.querySelectorAll("button")].find(
(candidate) => candidate.textContent === "Test connection",
);
expect(button).toBeDefined();

await act(async () => {
button?.click();
await Promise.resolve();
});

expect(container.querySelector('[role="status"]')?.textContent).toBe(
"The connection check could not finish. Try again.",
);
expect(button?.disabled).toBe(false);
});
});
5 changes: 5 additions & 0 deletions apps/desktop/src/settings/ProviderSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ export function ProviderSettings({ value, lmStudioUrlLocked, onChange, onPersist
setModels(result.models);
if (!active.model && result.models[0]) replace({ model: result.models[0] });
}
} catch {
setConnection({
ok: false,
message: "The connection check could not finish. Try again.",
});
} finally {
setBusy(undefined);
}
Expand Down
Loading