Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions .changeset/litellm-connection-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@open-codesign/shared": patch
"@open-codesign/i18n": patch
"@open-codesign/desktop": patch
---

Show LiteLLM-specific hints when a LiteLLM Gateway connection test or model discovery fails. A 401 says to set the master or virtual key or turn off keyless mode, a 404 says to check that the base URL ends with /v1 and that the proxy is on port 4000, and a connection refusal says to start the LiteLLM proxy.
226 changes: 226 additions & 0 deletions apps/desktop/src/main/connection-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ vi.mock('./tls-override', () => ({
}));

import { createHash } from 'node:crypto';
import { hydrateConfig, LITELLM_CONNECTION_HINT_KEYS } from '@open-codesign/shared';
import {
_clearModelsCache,
buildAuthHeaders,
Expand All @@ -26,11 +27,13 @@ import {
fetchWithTimeout,
getCacheKey,
handleConfigV1TestEndpoint,
handleModelsV1ListForProvider,
handleOllamaV1Probe,
normalizeBaseUrl,
normalizeOllamaBaseUrl,
runProviderTest,
} from './connection-ipc';
import { setCachedConfig } from './onboarding/config-cache';
import { withTlsBypass } from './tls-override';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1629,3 +1632,226 @@ describe('TLS bypass routing', () => {
}
});
});

describe('LiteLLM connection diagnostics', () => {
beforeEach(() => {
vi.useRealTimers();
_clearModelsCache();
});

function statusFetch(status: number): () => void {
return installFakeFetch(() => ({ status, body: {} })).restore;
}

function refusedFetch(): () => void {
const original = globalThis.fetch;
globalThis.fetch = (async () => {
throw Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:4000'), {
code: 'ECONNREFUSED',
}),
});
}) as typeof fetch;
return () => {
globalThis.fetch = original;
};
}

it('hints for LiteLLM connection tests and stays quiet for other gateways', async () => {
const auth = statusFetch(401);
try {
await expect(
handleConfigV1TestEndpoint({
wire: 'openai-chat',
baseUrl: 'https://llm.example/v1',
apiKey: 'sk-test',
presetId: 'litellm',
}),
).resolves.toMatchObject({
ok: false,
error: 'auth',
hintKey: LITELLM_CONNECTION_HINT_KEYS.auth,
});
} finally {
auth();
}

const missing = statusFetch(404);
try {
await expect(
handleConfigV1TestEndpoint({
wire: 'openai-chat',
baseUrl: 'http://127.0.0.1:4000',
apiKey: 'sk-test',
allowPrivateNetwork: true,
}),
).resolves.toMatchObject({
ok: false,
hintKey: LITELLM_CONNECTION_HINT_KEYS.notFound,
});
} finally {
missing();
}

const refused = refusedFetch();
try {
await expect(
handleConfigV1TestEndpoint({
wire: 'openai-chat',
baseUrl: 'http://localhost:4000/v1',
apiKey: '',
requiresApiKey: false,
allowPrivateNetwork: true,
}),
).resolves.toMatchObject({
ok: false,
error: 'network',
hintKey: LITELLM_CONNECTION_HINT_KEYS.unreachable,
});
} finally {
refused();
}

const other = statusFetch(401);
try {
await expect(
handleConfigV1TestEndpoint({
wire: 'openai-chat',
baseUrl: 'https://api.example/v1',
apiKey: 'sk-test',
}),
).resolves.toEqual({ ok: false, error: 'auth', message: 'HTTP 401' });
} finally {
other();
}

const badPreset = statusFetch(200);
try {
await expect(
handleConfigV1TestEndpoint({
wire: 'openai-chat',
baseUrl: 'https://llm.example/v1',
apiKey: 'sk-test',
presetId: 'other',
}),
).resolves.toMatchObject({ ok: false, error: 'bad-input' });
} finally {
badPreset();
}
});

it('hints when a saved LiteLLM provider fails its connection test', async () => {
const auth = statusFetch(401);
try {
await expect(
runProviderTest({
provider: 'custom-litellm-gateway-ab12',
name: 'Renamed gateway',
wire: 'openai-chat',
apiKey: 'sk-test',
baseUrl: 'https://llm.example/v1',
}),
).resolves.toMatchObject({
ok: false,
code: '401',
hintKey: LITELLM_CONNECTION_HINT_KEYS.auth,
});
} finally {
auth();
}

const missing = statusFetch(404);
try {
await expect(
runProviderTest({
provider: 'custom-other',
name: 'LiteLLM Gateway',
wire: 'openai-chat',
apiKey: '',
baseUrl: 'https://llm.example/v1',
}),
).resolves.toMatchObject({
ok: false,
code: '404',
hintKey: LITELLM_CONNECTION_HINT_KEYS.notFound,
});
} finally {
missing();
}

const unrelated = statusFetch(401);
try {
const res = await runProviderTest({
provider: 'custom-other',
name: 'Other gateway',
wire: 'openai-chat',
apiKey: 'sk-test',
baseUrl: 'https://api.example/v1',
});
expect(res.ok).toBe(false);
if (!res.ok) expect(res.hintKey).toBeUndefined();
} finally {
unrelated();
}
});

it('hints when model discovery fails for a saved LiteLLM provider', async () => {
const load = (id: string, name: string, baseUrl: string) => {
setCachedConfig(
hydrateConfig({
version: 3,
activeProvider: id,
activeModel: 'gpt-4o-mini',
secrets: {},
providers: {
[id]: {
id,
name,
builtin: false,
wire: 'openai-chat',
baseUrl,
defaultModel: 'gpt-4o-mini',
requiresApiKey: false,
},
},
}),
);
};

const id = 'custom-litellm-gateway-ab12';
load(id, 'LiteLLM Gateway', 'https://llm.example/v1');
const auth = statusFetch(401);
try {
await expect(handleModelsV1ListForProvider(id)).resolves.toMatchObject({
ok: false,
code: 'HTTP',
hintKey: LITELLM_CONNECTION_HINT_KEYS.auth,
});
} finally {
auth();
}

_clearModelsCache();
const refused = refusedFetch();
try {
await expect(handleModelsV1ListForProvider(id)).resolves.toMatchObject({
ok: false,
code: 'NETWORK',
hintKey: LITELLM_CONNECTION_HINT_KEYS.unreachable,
});
} finally {
refused();
}

_clearModelsCache();
load('custom-other', 'Other gateway', 'https://api.example/v1');
const other = statusFetch(404);
try {
const res = await handleModelsV1ListForProvider('custom-other');
expect(res).toMatchObject({ ok: false, code: 'HTTP' });
expect(res).not.toHaveProperty('hintKey');
} finally {
other();
}
});
});
Loading
Loading