Skip to content

Commit 24815f3

Browse files
Lxr-maxcursoragent
andauthored
feat(desktop): add LiteLLM-aware connection diagnostics (#464)
## Summary Connection tests and model discovery for the LiteLLM Gateway preset now explain the failure instead of showing a generic HTTP or network error. A 401 or 403 tells the user to set the LiteLLM master or virtual key, or to turn off keyless mode when the proxy requires a key. A 404 tells them to check that the base URL ends with `/v1`. That hint mentions port 4000 only when the URL is loopback (`localhost`, `127.0.0.1`, or `::1`). A connection refusal tells them to start the LiteLLM proxy. The hints are translated in en, zh-CN, es, and pt-BR. A target counts as LiteLLM when the preset id is `litellm`, the name or provider id says LiteLLM, or the base URL host or path says LiteLLM. Port 4000 alone does not. This finishes the diagnostics acceptance criterion that #463 left open. The preset itself is unchanged, and LiteLLM is still not bundled. ## Type of change - [ ] Bug fix - [x] New feature - [ ] Refactor (no behavior change) - [ ] Documentation - [ ] Build / CI / tooling - [ ] Breaking change ## Linked issue Closes #208 Together with #463, this covers the acceptance criteria on #208: the LiteLLM Gateway preset, proxy-key and keyless setup, LiteLLM hints on connection test and model discovery, and model discovery through the existing OpenAI-compatible path (with manual model entry in the same modal). Title generation, the agent runtime, and the generic connection-diagnostic panel are not part of #208. ## Checklist - [x] I checked the linked issue / relevant context before starting - [x] `pnpm lint && pnpm typecheck && pnpm test` passes locally - [x] Added/updated tests for the change - [x] Added a changeset (`pnpm changeset`) if user-visible - [x] Updated docs if behavior changed (the hints are the user-facing copy; no separate doc change) ### PRINCIPLES §5b - [x] **Compatibility**: additive. Other providers keep the existing generic hints. The optional `presetId` field on `config:v1:test-endpoint` is ignored unless it is `litellm`. Saved providers are recognized from the name, the provider id, or a host or path that says LiteLLM, not from port 4000. - [x] **Upgradeability**: no on-disk schema change. A saved LiteLLM provider is still a normal custom OpenAI-compatible entry. The new `hintKey` is an optional field on existing error responses. - [x] **No bloat**: no new dependencies. One preset's failure copy, wired through the existing connection-test and model-list errors. - [x] **Elegance**: hint selection lives in one helper. Connection test, endpoint probe, and model discovery all attach that key, and the renderer translates it. ## Screenshots / recordings (UI changes) No new screen. The LiteLLM sentence replaces the generic connection-test and discovery failure text in the add-provider modal, the provider-row test toast, the model list under a provider, and the model switcher. Covered by the connection-probe tests and the locale strings. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 7c02a60 commit 24815f3

18 files changed

Lines changed: 867 additions & 83 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@open-codesign/shared": patch
3+
"@open-codesign/i18n": patch
4+
"@open-codesign/desktop": patch
5+
---
6+
7+
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 only a localhost URL also mentions port 4000. A connection refusal says to start the LiteLLM proxy.

‎apps/desktop/src/main/connection-ipc.test.ts‎

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ vi.mock('./tls-override', () => ({
1414
}));
1515

1616
import { createHash } from 'node:crypto';
17+
import { hydrateConfig, LITELLM_CONNECTION_HINT_KEYS } from '@open-codesign/shared';
1718
import {
1819
_clearModelsCache,
1920
buildAuthHeaders,
@@ -26,11 +27,13 @@ import {
2627
fetchWithTimeout,
2728
getCacheKey,
2829
handleConfigV1TestEndpoint,
30+
handleModelsV1ListForProvider,
2931
handleOllamaV1Probe,
3032
normalizeBaseUrl,
3133
normalizeOllamaBaseUrl,
3234
runProviderTest,
3335
} from './connection-ipc';
36+
import { setCachedConfig } from './onboarding/config-cache';
3437
import { withTlsBypass } from './tls-override';
3538

3639
// ---------------------------------------------------------------------------
@@ -1629,3 +1632,307 @@ describe('TLS bypass routing', () => {
16291632
}
16301633
});
16311634
});
1635+
1636+
describe('LiteLLM connection diagnostics', () => {
1637+
beforeEach(() => {
1638+
vi.useRealTimers();
1639+
_clearModelsCache();
1640+
});
1641+
1642+
function statusFetch(status: number): () => void {
1643+
return installFakeFetch(() => ({ status, body: {} })).restore;
1644+
}
1645+
1646+
function refusedFetch(): () => void {
1647+
const original = globalThis.fetch;
1648+
globalThis.fetch = (async () => {
1649+
throw Object.assign(new TypeError('fetch failed'), {
1650+
cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:4000'), {
1651+
code: 'ECONNREFUSED',
1652+
}),
1653+
});
1654+
}) as typeof fetch;
1655+
return () => {
1656+
globalThis.fetch = original;
1657+
};
1658+
}
1659+
1660+
it('hints for LiteLLM connection tests and stays quiet for other gateways', async () => {
1661+
const auth = statusFetch(401);
1662+
try {
1663+
await expect(
1664+
handleConfigV1TestEndpoint({
1665+
wire: 'openai-chat',
1666+
baseUrl: 'https://llm.example/v1',
1667+
apiKey: 'sk-test',
1668+
presetId: 'litellm',
1669+
}),
1670+
).resolves.toMatchObject({
1671+
ok: false,
1672+
error: 'auth',
1673+
hintKey: LITELLM_CONNECTION_HINT_KEYS.auth,
1674+
});
1675+
} finally {
1676+
auth();
1677+
}
1678+
1679+
const missing = statusFetch(404);
1680+
try {
1681+
await expect(
1682+
handleConfigV1TestEndpoint({
1683+
wire: 'openai-chat',
1684+
baseUrl: 'http://127.0.0.1:4000',
1685+
apiKey: 'sk-test',
1686+
allowPrivateNetwork: true,
1687+
presetId: 'litellm',
1688+
}),
1689+
).resolves.toMatchObject({
1690+
ok: false,
1691+
hintKey: LITELLM_CONNECTION_HINT_KEYS.notFoundLocal,
1692+
});
1693+
} finally {
1694+
missing();
1695+
}
1696+
1697+
const remoteMissing = statusFetch(404);
1698+
try {
1699+
await expect(
1700+
handleConfigV1TestEndpoint({
1701+
wire: 'openai-chat',
1702+
baseUrl: 'https://gateway.example/litellm/v1',
1703+
apiKey: 'sk-test',
1704+
}),
1705+
).resolves.toMatchObject({
1706+
ok: false,
1707+
hintKey: LITELLM_CONNECTION_HINT_KEYS.notFound,
1708+
});
1709+
} finally {
1710+
remoteMissing();
1711+
}
1712+
1713+
const portOnly = statusFetch(404);
1714+
try {
1715+
const res = await handleConfigV1TestEndpoint({
1716+
wire: 'openai-chat',
1717+
baseUrl: 'http://127.0.0.1:4000',
1718+
apiKey: 'sk-test',
1719+
allowPrivateNetwork: true,
1720+
});
1721+
expect(res).toEqual({ ok: false, error: 'not-a-model-endpoint', message: 'HTTP 404' });
1722+
expect(res).not.toHaveProperty('hintKey');
1723+
} finally {
1724+
portOnly();
1725+
}
1726+
1727+
const refused = refusedFetch();
1728+
try {
1729+
await expect(
1730+
handleConfigV1TestEndpoint({
1731+
wire: 'openai-chat',
1732+
baseUrl: 'http://localhost:4000/v1',
1733+
apiKey: '',
1734+
requiresApiKey: false,
1735+
allowPrivateNetwork: true,
1736+
presetId: 'litellm',
1737+
}),
1738+
).resolves.toMatchObject({
1739+
ok: false,
1740+
error: 'network',
1741+
hintKey: LITELLM_CONNECTION_HINT_KEYS.unreachable,
1742+
});
1743+
} finally {
1744+
refused();
1745+
}
1746+
1747+
const refusedOther = refusedFetch();
1748+
try {
1749+
const res = await handleConfigV1TestEndpoint({
1750+
wire: 'openai-chat',
1751+
baseUrl: 'http://localhost:4000/v1',
1752+
apiKey: '',
1753+
requiresApiKey: false,
1754+
allowPrivateNetwork: true,
1755+
});
1756+
expect(res).toMatchObject({ ok: false, error: 'network' });
1757+
expect(res).not.toHaveProperty('hintKey');
1758+
} finally {
1759+
refusedOther();
1760+
}
1761+
1762+
const other = statusFetch(401);
1763+
try {
1764+
await expect(
1765+
handleConfigV1TestEndpoint({
1766+
wire: 'openai-chat',
1767+
baseUrl: 'https://api.example/v1',
1768+
apiKey: 'sk-test',
1769+
}),
1770+
).resolves.toEqual({ ok: false, error: 'auth', message: 'HTTP 401' });
1771+
} finally {
1772+
other();
1773+
}
1774+
1775+
const badPreset = statusFetch(200);
1776+
try {
1777+
await expect(
1778+
handleConfigV1TestEndpoint({
1779+
wire: 'openai-chat',
1780+
baseUrl: 'https://llm.example/v1',
1781+
apiKey: 'sk-test',
1782+
presetId: 'other',
1783+
}),
1784+
).resolves.toMatchObject({ ok: false, error: 'bad-input' });
1785+
} finally {
1786+
badPreset();
1787+
}
1788+
});
1789+
1790+
it('hints when a saved LiteLLM provider fails its connection test', async () => {
1791+
const auth = statusFetch(401);
1792+
try {
1793+
await expect(
1794+
runProviderTest({
1795+
provider: 'custom-litellm-gateway-ab12',
1796+
name: 'Renamed gateway',
1797+
wire: 'openai-chat',
1798+
apiKey: 'sk-test',
1799+
baseUrl: 'https://llm.example/v1',
1800+
}),
1801+
).resolves.toMatchObject({
1802+
ok: false,
1803+
code: '401',
1804+
hintKey: LITELLM_CONNECTION_HINT_KEYS.auth,
1805+
});
1806+
} finally {
1807+
auth();
1808+
}
1809+
1810+
const missing = statusFetch(404);
1811+
try {
1812+
await expect(
1813+
runProviderTest({
1814+
provider: 'custom-other',
1815+
name: 'LiteLLM Gateway',
1816+
wire: 'openai-chat',
1817+
apiKey: '',
1818+
baseUrl: 'https://llm.example/v1',
1819+
}),
1820+
).resolves.toMatchObject({
1821+
ok: false,
1822+
code: '404',
1823+
hintKey: LITELLM_CONNECTION_HINT_KEYS.notFound,
1824+
});
1825+
} finally {
1826+
missing();
1827+
}
1828+
1829+
const localMissing = statusFetch(404);
1830+
try {
1831+
await expect(
1832+
runProviderTest({
1833+
provider: 'custom-litellm-gateway-ab12',
1834+
name: 'Renamed gateway',
1835+
wire: 'openai-chat',
1836+
apiKey: '',
1837+
baseUrl: 'http://localhost:4000/v1',
1838+
}),
1839+
).resolves.toMatchObject({
1840+
ok: false,
1841+
code: '404',
1842+
hintKey: LITELLM_CONNECTION_HINT_KEYS.notFoundLocal,
1843+
});
1844+
} finally {
1845+
localMissing();
1846+
}
1847+
1848+
const portOnly = statusFetch(404);
1849+
try {
1850+
const res = await runProviderTest({
1851+
provider: 'custom-other',
1852+
name: 'Other gateway',
1853+
wire: 'openai-chat',
1854+
apiKey: '',
1855+
baseUrl: 'http://localhost:4000/v1',
1856+
});
1857+
expect(res.ok).toBe(false);
1858+
if (!res.ok) expect(res.hintKey).toBeUndefined();
1859+
} finally {
1860+
portOnly();
1861+
}
1862+
1863+
const unrelated = statusFetch(401);
1864+
try {
1865+
const res = await runProviderTest({
1866+
provider: 'custom-other',
1867+
name: 'Other gateway',
1868+
wire: 'openai-chat',
1869+
apiKey: 'sk-test',
1870+
baseUrl: 'https://api.example/v1',
1871+
});
1872+
expect(res.ok).toBe(false);
1873+
if (!res.ok) expect(res.hintKey).toBeUndefined();
1874+
} finally {
1875+
unrelated();
1876+
}
1877+
});
1878+
1879+
it('hints when model discovery fails for a saved LiteLLM provider', async () => {
1880+
const load = (id: string, name: string, baseUrl: string) => {
1881+
setCachedConfig(
1882+
hydrateConfig({
1883+
version: 3,
1884+
activeProvider: id,
1885+
activeModel: 'gpt-4o-mini',
1886+
secrets: {},
1887+
providers: {
1888+
[id]: {
1889+
id,
1890+
name,
1891+
builtin: false,
1892+
wire: 'openai-chat',
1893+
baseUrl,
1894+
defaultModel: 'gpt-4o-mini',
1895+
requiresApiKey: false,
1896+
},
1897+
},
1898+
}),
1899+
);
1900+
};
1901+
1902+
const id = 'custom-litellm-gateway-ab12';
1903+
load(id, 'LiteLLM Gateway', 'https://llm.example/v1');
1904+
const auth = statusFetch(401);
1905+
try {
1906+
await expect(handleModelsV1ListForProvider(id)).resolves.toMatchObject({
1907+
ok: false,
1908+
code: 'HTTP',
1909+
hintKey: LITELLM_CONNECTION_HINT_KEYS.auth,
1910+
});
1911+
} finally {
1912+
auth();
1913+
}
1914+
1915+
_clearModelsCache();
1916+
const refused = refusedFetch();
1917+
try {
1918+
await expect(handleModelsV1ListForProvider(id)).resolves.toMatchObject({
1919+
ok: false,
1920+
code: 'NETWORK',
1921+
hintKey: LITELLM_CONNECTION_HINT_KEYS.unreachable,
1922+
});
1923+
} finally {
1924+
refused();
1925+
}
1926+
1927+
_clearModelsCache();
1928+
load('custom-other', 'Other gateway', 'https://api.example/v1');
1929+
const other = statusFetch(404);
1930+
try {
1931+
const res = await handleModelsV1ListForProvider('custom-other');
1932+
expect(res).toMatchObject({ ok: false, code: 'HTTP' });
1933+
expect(res).not.toHaveProperty('hintKey');
1934+
} finally {
1935+
other();
1936+
}
1937+
});
1938+
});

0 commit comments

Comments
 (0)