From 05b0ab953bbcefeb9e80cf3ba4c2179e2b151641 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Mon, 14 Sep 2026 10:27:16 +0000 Subject: [PATCH 1/2] feat(providers): add first-class LiteLLM Gateway preset Allow Settings users to add an externally hosted LiteLLM gateway without hand-rolling a custom provider. Support proxy-key and keyless/IP-allowlist deployments, LiteLLM-specific diagnostics, and /models discovery when the gateway exposes it. LiteLLM is not bundled. --- .changeset/litellm-gateway-preset.md | 7 ++ README.md | 2 +- README.zh-CN.md | 2 +- apps/desktop/src/main/connection-ipc.test.ts | 15 ++-- apps/desktop/src/main/connection-ipc.ts | 8 +-- apps/desktop/src/main/onboarding-ipc.test.ts | 72 +++++++++++++++++++ .../src/main/onboarding/provider-parsers.ts | 15 +++- .../src/main/onboarding/providers-crud.ts | 8 ++- apps/desktop/src/preload/index.ts | 2 + .../AddCustomProviderModal.test.tsx | 46 ++++++++++++ .../src/components/AddCustomProviderModal.tsx | 67 ++++++++++++++--- .../ConnectionDiagnosticPanel.test.ts | 20 +++++- .../components/ConnectionDiagnosticPanel.tsx | 24 ++++++- .../src/components/settings/ModelsTab.tsx | 32 ++++++++- packages/i18n/src/locales/en.json | 16 +++++ packages/i18n/src/locales/es.json | 16 +++++ packages/i18n/src/locales/pt-BR.json | 16 +++++ packages/i18n/src/locales/zh-CN.json | 16 +++++ packages/shared/src/diagnostics.test.ts | 39 ++++++++++ packages/shared/src/diagnostics.ts | 44 ++++++++++++ packages/shared/src/index.ts | 4 ++ packages/shared/src/proxy-presets.test.ts | 45 ++++++++++++ packages/shared/src/proxy-presets.ts | 53 ++++++++++++++ 23 files changed, 535 insertions(+), 34 deletions(-) create mode 100644 .changeset/litellm-gateway-preset.md diff --git a/.changeset/litellm-gateway-preset.md b/.changeset/litellm-gateway-preset.md new file mode 100644 index 000000000..30cdf4a40 --- /dev/null +++ b/.changeset/litellm-gateway-preset.md @@ -0,0 +1,7 @@ +--- +"@open-codesign/shared": minor +"@open-codesign/i18n": patch +"@open-codesign/desktop": patch +--- + +Add a first-class LiteLLM Gateway provider preset for externally hosted gateways. Users can add it from Settings without hand-rolling a custom provider, using either a proxy key or a keyless / IP-allowlist deployment. LiteLLM is not bundled. diff --git a/README.md b/README.md index b32bdca0e..1e22688df 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ Add a `SKILL.md` to any project to teach the model your own taste. ## What you get ### Models and providers -- **Unified provider model** — Anthropic, OpenAI, Gemini, DeepSeek, OpenRouter, SiliconFlow, local Ollama, or any OpenAI-compatible relay; keyless (IP-allowlisted) proxies supported +- **Unified provider model** — Anthropic, OpenAI, Gemini, DeepSeek, OpenRouter, SiliconFlow, local Ollama, LiteLLM Gateway, or any OpenAI-compatible relay; keyless (IP-allowlisted) proxies supported - **One-click import and sign-in** — bring Claude Code / Codex API-key provider configs across, or sign in with ChatGPT subscription for Codex models - **Dynamic model picker** — every provider exposes its real model catalogue, not a hardcoded shortlist diff --git a/README.zh-CN.md b/README.zh-CN.md index 14de979a3..70ca48be4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -220,7 +220,7 @@ scoop install opencoworkai/open-codesign ## 你能得到什么 ### 模型与提供商 -- **统一的 provider 抽象**:支持 Anthropic、OpenAI、Gemini、DeepSeek、OpenRouter、SiliconFlow、本地 Ollama,以及任意 OpenAI-compatible relay;同时支持无 key 的 IP 白名单代理 +- **统一的 provider 抽象**:支持 Anthropic、OpenAI、Gemini、DeepSeek、OpenRouter、SiliconFlow、本地 Ollama、LiteLLM Gateway,以及任意 OpenAI-compatible relay;同时支持无 key 的 IP 白名单代理 - **一键导入和登录**:Claude Code / Codex 的 API key provider 配置可以直接带进来,也可以用 ChatGPT 订阅登录使用 Codex 模型 - **动态模型选择器**:每个 provider 都会展示真实模型列表,而不是一小撮写死的选项 diff --git a/apps/desktop/src/main/connection-ipc.test.ts b/apps/desktop/src/main/connection-ipc.test.ts index 5a101f81b..e9fd51958 100644 --- a/apps/desktop/src/main/connection-ipc.test.ts +++ b/apps/desktop/src/main/connection-ipc.test.ts @@ -1374,10 +1374,11 @@ describe('config:v1:test-endpoint response parsing', () => { } }); - it('rejects empty API keys before attempting fetch', async () => { - const { restore } = installFakeFetch(() => { - throw new Error('fetch should not be called'); - }); + it('allows empty API keys so keyless gateways can be probed', async () => { + const { restore } = installFakeFetch(() => ({ + status: 200, + body: { data: [{ id: 'gpt-4o' }] }, + })); try { await expect( handleConfigV1TestEndpoint({ @@ -1385,11 +1386,7 @@ describe('config:v1:test-endpoint response parsing', () => { baseUrl: 'https://provider.example/v1', apiKey: ' ', }), - ).resolves.toEqual({ - ok: false, - error: 'bad-input', - message: 'apiKey must be a non-empty string', - }); + ).resolves.toEqual({ ok: true, modelCount: 1, models: ['gpt-4o'] }); } finally { restore(); } diff --git a/apps/desktop/src/main/connection-ipc.ts b/apps/desktop/src/main/connection-ipc.ts index 1041c9d0d..c1b041628 100644 --- a/apps/desktop/src/main/connection-ipc.ts +++ b/apps/desktop/src/main/connection-ipc.ts @@ -1275,14 +1275,12 @@ function parseTestEndpointPayload(raw: unknown): TestEndpointPayload { if (typeof apiKey !== 'string') { throw new CodesignError('apiKey must be a string', ERROR_CODES.IPC_BAD_INPUT); } - const trimmedApiKey = apiKey.trim(); - if (trimmedApiKey.length === 0) { - throw new CodesignError('apiKey must be a non-empty string', ERROR_CODES.IPC_BAD_INPUT); - } + // Empty apiKey is a keyless probe (LiteLLM IP-allowlist / disable_auth, + // CLIProxyAPI without api-keys). Auth headers already omit Bearer when empty. const out: TestEndpointPayload = { wire, baseUrl: parseHttpBaseUrl(baseUrl, 'baseUrl'), - apiKey: trimmedApiKey, + apiKey: apiKey.trim(), }; if (r['allowPrivateNetwork'] !== undefined) { if (typeof r['allowPrivateNetwork'] !== 'boolean') { diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts index 686e67c84..abadc00a1 100644 --- a/apps/desktop/src/main/onboarding-ipc.test.ts +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -469,6 +469,78 @@ describe('config:v1 provider mutations — fail-fast key handling', () => { expect(buildSecretRef).not.toHaveBeenCalled(); }); + it('lets LiteLLM-style custom providers save keyless without storing an empty secret', async () => { + const { readConfig, writeConfig } = await import('./config'); + const { buildSecretRef } = await import('./keychain'); + vi.mocked(readConfig).mockResolvedValueOnce(null); + vi.mocked(writeConfig).mockClear(); + vi.mocked(buildSecretRef).mockClear(); + const { loadConfigOnBoot, registerOnboardingIpc } = await import('./onboarding-ipc'); + await loadConfigOnBoot(); + registerOnboardingIpc(); + const handler = handlers.get('config:v1:add-provider'); + if (!handler) throw new Error('handler missing'); + + const state = await handler({} as never, { + id: 'custom-litellm-gateway-ab12', + name: 'LiteLLM Gateway', + wire: 'openai-chat', + baseUrl: 'http://localhost:4000/v1', + apiKey: '', + defaultModel: 'gpt-4o', + setAsActive: true, + requiresApiKey: false, + }); + + expect(buildSecretRef).not.toHaveBeenCalled(); + const written = vi.mocked(writeConfig).mock.calls.at(-1)?.[0]; + expect(written?.activeProvider).toBe('custom-litellm-gateway-ab12'); + expect(written?.secrets['custom-litellm-gateway-ab12']).toBeUndefined(); + expect(written?.providers['custom-litellm-gateway-ab12']).toMatchObject({ + id: 'custom-litellm-gateway-ab12', + name: 'LiteLLM Gateway', + wire: 'openai-chat', + baseUrl: 'http://localhost:4000/v1', + defaultModel: 'gpt-4o', + requiresApiKey: false, + }); + expect(state).toMatchObject({ + hasKey: true, + provider: 'custom-litellm-gateway-ab12', + }); + }); + + it('stores a LiteLLM proxy key while keeping the entry explicitly keyless-capable', async () => { + const { readConfig, writeConfig } = await import('./config'); + const { buildSecretRef } = await import('./keychain'); + vi.mocked(readConfig).mockResolvedValueOnce(null); + vi.mocked(writeConfig).mockClear(); + vi.mocked(buildSecretRef).mockClear(); + const { loadConfigOnBoot, registerOnboardingIpc } = await import('./onboarding-ipc'); + await loadConfigOnBoot(); + registerOnboardingIpc(); + const handler = handlers.get('config:v1:add-provider'); + if (!handler) throw new Error('handler missing'); + + await handler({} as never, { + id: 'custom-litellm-gateway-key', + name: 'LiteLLM Gateway', + wire: 'openai-chat', + baseUrl: 'https://litellm.internal.example/v1', + apiKey: 'sk-litellm-master', + defaultModel: 'gpt-4o', + setAsActive: true, + requiresApiKey: false, + }); + + expect(buildSecretRef).toHaveBeenCalledWith('sk-litellm-master'); + const written = vi.mocked(writeConfig).mock.calls.at(-1)?.[0]; + expect(written?.secrets['custom-litellm-gateway-key']).toEqual( + expect.objectContaining({ ciphertext: 'enc:sk-litellm-master' }), + ); + expect(written?.providers['custom-litellm-gateway-key']?.requiresApiKey).toBe(false); + }); + it('rejects malformed custom-provider header maps instead of dropping bad entries', async () => { const { registerOnboardingIpc } = await import('./onboarding-ipc'); registerOnboardingIpc(); diff --git a/apps/desktop/src/main/onboarding/provider-parsers.ts b/apps/desktop/src/main/onboarding/provider-parsers.ts index 65b2dfa27..71ebd8895 100644 --- a/apps/desktop/src/main/onboarding/provider-parsers.ts +++ b/apps/desktop/src/main/onboarding/provider-parsers.ts @@ -40,6 +40,13 @@ export interface AddCustomProviderInput { /** Per-provider TLS verification opt-out (#229). Built-in providers * force-ignore this flag at runtime. */ tlsRejectUnauthorized?: boolean; + /** + * When false, an empty apiKey is allowed and the stored entry is marked + * keyless (LiteLLM IP-allowlist / `disable_auth` deployments). Omit or + * true keeps the existing "key required" contract for generic custom + * providers. + */ + requiresApiKey?: boolean; setAsActive: boolean; } @@ -74,6 +81,7 @@ const ADD_PROVIDER_FIELDS = [ 'queryParams', 'envKey', 'tlsRejectUnauthorized', + 'requiresApiKey', 'setAsActive', ] as const; const UPDATE_PROVIDER_FIELDS = [ @@ -287,7 +295,11 @@ export function parseAddProviderPayload(raw: unknown): AddCustomProviderInput { if (typeof apiKey !== 'string') { throw new CodesignError('apiKey must be a string', ERROR_CODES.IPC_BAD_INPUT); } - if (apiKey.trim().length === 0) { + if (r['requiresApiKey'] !== undefined && typeof r['requiresApiKey'] !== 'boolean') { + throw new CodesignError('requiresApiKey must be a boolean', ERROR_CODES.IPC_BAD_INPUT); + } + const allowEmptyKey = r['requiresApiKey'] === false; + if (apiKey.trim().length === 0 && !allowEmptyKey) { throw new CodesignError('apiKey must be a non-empty string', ERROR_CODES.IPC_BAD_INPUT); } if (typeof defaultModel !== 'string' || defaultModel.trim().length === 0) { @@ -306,6 +318,7 @@ export function parseAddProviderPayload(raw: unknown): AddCustomProviderInput { defaultModel: defaultModel.trim(), setAsActive, }; + if (r['requiresApiKey'] === false) out.requiresApiKey = false; const headers = stringMapFromOptional(r['httpHeaders'], 'httpHeaders'); if (headers !== undefined && Object.keys(headers).length > 0) out.httpHeaders = headers; const qp = stringMapFromOptional(r['queryParams'], 'queryParams'); diff --git a/apps/desktop/src/main/onboarding/providers-crud.ts b/apps/desktop/src/main/onboarding/providers-crud.ts index 107d954a4..27a7421ac 100644 --- a/apps/desktop/src/main/onboarding/providers-crud.ts +++ b/apps/desktop/src/main/onboarding/providers-crud.ts @@ -204,6 +204,7 @@ export async function runAddCustomProvider( input: AddCustomProviderInput, ): Promise { const cachedConfig = getCachedConfig(); + const allowKeyless = input.requiresApiKey === false; const entry: ProviderEntry = { id: input.id, name: input.name, @@ -215,10 +216,13 @@ export async function runAddCustomProvider( ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}), ...(input.envKey !== undefined ? { envKey: input.envKey } : {}), ...(input.tlsRejectUnauthorized === true ? { tlsRejectUnauthorized: true } : {}), + ...(allowKeyless ? { requiresApiKey: false } : {}), }; - const secretRef = buildSecretRef(input.apiKey); const nextProviders = { ...(cachedConfig?.providers ?? {}), [entry.id]: entry }; - const nextSecrets = { ...(cachedConfig?.secrets ?? {}), [entry.id]: secretRef }; + const nextSecrets = { ...(cachedConfig?.secrets ?? {}) }; + if (input.apiKey.length > 0) { + nextSecrets[entry.id] = buildSecretRef(input.apiKey); + } const shouldActivate = input.setAsActive || cachedConfig === null; const next = hydrateConfig({ version: 3, diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 31003fe62..e832bd4ea 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -525,6 +525,8 @@ const api = { queryParams?: Record; envKey?: string; tlsRejectUnauthorized?: boolean; + /** When false, empty apiKey is stored as a keyless custom provider. */ + requiresApiKey?: boolean; setAsActive: boolean; }) => ipcRenderer.invoke('config:v1:add-provider', input) as Promise, updateProvider: (input: { diff --git a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx index bdf494281..d5081beda 100644 --- a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx +++ b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx @@ -46,4 +46,50 @@ describe('AddCustomProviderModal', () => { allowPrivateNetwork: true, }); }); + + it('includes a typed proxy key in the discovery payload', () => { + expect( + buildEndpointDiscoveryPayload( + 'openai-chat', + 'http://localhost:4000/v1', + true, + false, + ' sk-litellm-master ', + ), + ).toEqual({ + wire: 'openai-chat', + baseUrl: 'http://localhost:4000/v1', + apiKey: 'sk-litellm-master', + allowPrivateNetwork: true, + }); + }); + + it('shows LiteLLM-specific keyless and proxy-key help when opened from the preset', () => { + const html = renderToStaticMarkup( + undefined} + onClose={() => undefined} + initialValues={{ + name: 'LiteLLM Gateway', + baseUrl: 'http://localhost:4000/v1', + wire: 'openai-chat', + helpPreset: 'litellm', + supportsKeyless: true, + allowPrivateNetwork: true, + }} + />, + ); + + expect(html).toContain('settings.providers.litellmGateway.helpTitle'); + expect(html).toContain('settings.providers.litellmGateway.helpBody'); + expect(html).toContain('settings.providers.litellmGateway.apiKeyOptional'); + expect(html).toContain('settings.providers.litellmGateway.apiKeyPlaceholder'); + }); + + it('does not show LiteLLM help on a generic custom provider form', () => { + const html = renderToStaticMarkup( + undefined} onClose={() => undefined} />, + ); + expect(html).not.toContain('settings.providers.litellmGateway.helpTitle'); + }); }); diff --git a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx index c0b1f6597..e1a87081a 100644 --- a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx +++ b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx @@ -2,7 +2,7 @@ import { useT } from '@open-codesign/i18n'; import { canonicalBaseUrl, detectWireFromBaseUrl, type WireApi } from '@open-codesign/shared'; import { Button } from '@open-codesign/ui'; import { AlertCircle, Check, CheckCircle, Loader2, X } from 'lucide-react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; interface Props { onSave: () => void; @@ -19,6 +19,12 @@ interface Props { baseUrl?: string; wire?: WireApi; defaultModel?: string; + /** When 'litellm', show LiteLLM-specific proxy-key / keyless help copy. */ + helpPreset?: 'litellm'; + /** Allow saving without an API key (keyless / IP-allowlist gateways). */ + supportsKeyless?: boolean; + /** Pre-check the private-network confirmation for localhost presets. */ + allowPrivateNetwork?: boolean; }; /** * Edit-mode: pre-fill every field from an existing provider and save via @@ -78,6 +84,7 @@ export function buildEndpointDiscoveryPayload( baseUrl: string, allowPrivateNetwork: boolean, tlsRejectUnauthorized = false, + apiKey = '', ): { wire: WireApi; baseUrl: string; @@ -88,7 +95,7 @@ export function buildEndpointDiscoveryPayload( return { wire, baseUrl: baseUrl.trim(), - apiKey: '', + apiKey: apiKey.trim(), allowPrivateNetwork, ...(tlsRejectUnauthorized ? { tlsRejectUnauthorized: true } : {}), }; @@ -124,7 +131,9 @@ export function AddCustomProviderModal({ const [test, setTest] = useState({ kind: 'idle' }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(false); + const [allowPrivateNetwork, setAllowPrivateNetwork] = useState( + initialValues?.allowPrivateNetwork === true, + ); // Per-provider TLS verification opt-out. Gated to non-builtin entries // because connection-ipc / generate.ts force-ignore the flag for builtins. const [tlsRejectUnauthorized, setTlsRejectUnauthorized] = useState( @@ -139,6 +148,11 @@ export function AddCustomProviderModal({ const [manualModel, setManualModel] = useState(false); // Track whether user has explicitly typed/picked a model so auto-pick doesn't override it. const userPickedModel = useRef(false); + const supportsKeyless = initialValues?.supportsKeyless === true; + const isLiteLLMPreset = + initialValues?.helpPreset === 'litellm' || + (editTarget?.id.toLowerCase().includes('litellm') ?? false) || + (editTarget?.name.toLowerCase().includes('litellm') ?? false); const debounceTimer = useRef | null>(null); const discoverySeq = useRef(0); @@ -147,6 +161,7 @@ export function AddCustomProviderModal({ currentBaseUrl: string, currentWire: WireApi, privateNetworkAllowed = allowPrivateNetwork, + currentApiKey = apiKey, ) { if (debounceTimer.current !== null) clearTimeout(debounceTimer.current); if (!currentBaseUrl.trim().match(/^https?:\/\//)) { @@ -155,7 +170,7 @@ export function AddCustomProviderModal({ return; } debounceTimer.current = setTimeout(() => { - void runDiscovery(currentBaseUrl, currentWire, privateNetworkAllowed); + void runDiscovery(currentBaseUrl, currentWire, privateNetworkAllowed, currentApiKey); }, 500); } @@ -163,6 +178,7 @@ export function AddCustomProviderModal({ currentBaseUrl: string, currentWire: WireApi, privateNetworkAllowed = allowPrivateNetwork, + currentApiKey = apiKey, ) { if (!window.codesign?.config) return; const seq = ++discoverySeq.current; @@ -174,6 +190,7 @@ export function AddCustomProviderModal({ currentBaseUrl, privateNetworkAllowed, tlsRejectUnauthorized, + currentApiKey, ), ); if (seq !== discoverySeq.current) return; @@ -200,8 +217,20 @@ export function AddCustomProviderModal({ function handleApiKeyChange(v: string) { setApiKey(v); + setTest({ kind: 'idle' }); + scheduleDiscovery(baseUrl, wire, allowPrivateNetwork, v); } + // biome-ignore lint/correctness/useExhaustiveDependencies: preset-prefilled URLs should discover models on open + useEffect(() => { + if (baseUrl.trim().match(/^https?:\/\//)) { + scheduleDiscovery(baseUrl, wire); + } + return () => { + if (debounceTimer.current !== null) clearTimeout(debounceTimer.current); + }; + }, []); + function handleWireChange(v: WireApi) { setWire(v); setWireAuto(false); @@ -314,6 +343,7 @@ export function AddCustomProviderModal({ defaultModel: defaultModel.trim(), setAsActive: initialSetAsActive, ...(tlsRejectUnauthorized ? { tlsRejectUnauthorized: true } : {}), + ...(supportsKeyless ? { requiresApiKey: false } : {}), }); } onSave(); @@ -327,12 +357,11 @@ export function AddCustomProviderModal({ const canTest = baseUrl.trim().length > 0 && test.kind !== 'testing'; const canSave = (() => { if (saving) return false; - if (isEdit) { - // In edit mode, require at least the mandatory fields still hold values - // — but don't require the user to re-enter the API key. - return baseUrl.trim().length > 0 && defaultModel.trim().length > 0 && name.trim().length > 0; - } - return canTest && defaultModel.trim().length > 0 && name.trim().length > 0; + const hasBasics = + baseUrl.trim().length > 0 && defaultModel.trim().length > 0 && name.trim().length > 0; + if (isEdit) return hasBasics; + if (!supportsKeyless && apiKey.trim().length === 0) return false; + return hasBasics; })(); const title = isEdit @@ -372,6 +401,15 @@ export function AddCustomProviderModal({ + {isLiteLLMPreset && ( +
+

+ {t('settings.providers.litellmGateway.helpTitle')} +

+

{t('settings.providers.litellmGateway.helpBody')}

+
+ )} + {!lockEndpoint && (
@@ -475,9 +513,16 @@ export function AddCustomProviderModal({ ? t('settings.providers.custom.apiKeyEditPlaceholder', { mask: editTarget.keyMask, }) - : 'sk-...' + : isLiteLLMPreset + ? t('settings.providers.litellmGateway.apiKeyPlaceholder') + : 'sk-...' } /> + {isLiteLLMPreset && ( +

+ {t('settings.providers.litellmGateway.apiKeyOptional')} +

+ )} ({ useCodesignStore: () => vi.fn(), })); -import { isAbsoluteHttpUrl, shouldShowGatewayAllowlistHint } from './ConnectionDiagnosticPanel'; +import { + isAbsoluteHttpUrl, + shouldShowGatewayAllowlistHint, + shouldShowLiteLLMHint, +} from './ConnectionDiagnosticPanel'; describe('isAbsoluteHttpUrl', () => { it('rejects an empty string so /v1 quick-fix cannot produce a bare "/v1"', () => { @@ -58,3 +62,17 @@ describe('shouldShowGatewayAllowlistHint', () => { ); }); }); + +describe('shouldShowLiteLLMHint', () => { + it('shows LiteLLM help for the first-class preset and default listen address', () => { + expect( + shouldShowLiteLLMHint('custom-litellm-gateway-ab12', 'https://relay.example.com/v1'), + ).toBe(true); + expect(shouldShowLiteLLMHint('openai', 'http://localhost:4000/v1')).toBe(true); + }); + + it('hides LiteLLM help for unrelated providers', () => { + expect(shouldShowLiteLLMHint('openai', 'https://api.openai.com/v1')).toBe(false); + expect(shouldShowLiteLLMHint('cli-proxy-api', 'http://127.0.0.1:8317')).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx b/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx index 2703aefeb..802e415c4 100644 --- a/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx +++ b/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx @@ -1,6 +1,11 @@ import { useT } from '@open-codesign/i18n'; -import type { ErrorCode } from '@open-codesign/shared'; -import { type DiagnoseContext, type DiagnosticHypothesis, diagnose } from '@open-codesign/shared'; +import { + type DiagnoseContext, + type DiagnosticHypothesis, + diagnose, + type ErrorCode, + looksLikeLiteLLMGateway, +} from '@open-codesign/shared'; import { AlertCircle, ExternalLink, FileText, RefreshCw, X } from 'lucide-react'; import { useState } from 'react'; import { useCodesignStore } from '../store'; @@ -42,6 +47,10 @@ function isOfficialProviderHost(hostname: string | null): boolean { ); } +export function shouldShowLiteLLMHint(provider: string, baseUrl: string): boolean { + return looksLikeLiteLLMGateway(provider, baseUrl); +} + export function shouldShowGatewayAllowlistHint( errorCode: ErrorCode, baseUrl: string, @@ -102,6 +111,7 @@ export function ConnectionDiagnosticPanel({ : undefined; const canApplyFix = suggestedUrl !== undefined || fix?.externalUrl !== undefined; const showGatewayAllowlistHint = shouldShowGatewayAllowlistHint(errorCode, baseUrl, attemptedUrl); + const showLiteLLMHint = shouldShowLiteLLMHint(provider, baseUrl); function handleApplyFix() { if (suggestedUrl !== undefined) { @@ -181,6 +191,16 @@ export function ConnectionDiagnosticPanel({ {t('diagnostics.fix.addV1')}: {suggestedUrl}

)} + {showLiteLLMHint && ( +
+

+ {t('diagnostics.litellmHintTitle')} +

+

+ {t('diagnostics.litellmHintBody')} +

+
+ )} {showGatewayAllowlistHint && (

diff --git a/apps/desktop/src/renderer/src/components/settings/ModelsTab.tsx b/apps/desktop/src/renderer/src/components/settings/ModelsTab.tsx index 6e355fcd7..049fc4cc1 100644 --- a/apps/desktop/src/renderer/src/components/settings/ModelsTab.tsx +++ b/apps/desktop/src/renderer/src/components/settings/ModelsTab.tsx @@ -1,8 +1,9 @@ import { useT } from '@open-codesign/i18n'; -import type { WireApi } from '@open-codesign/shared'; import { isSupportedOnboardingProvider, + LITELLM_GATEWAY_PRESET, PROVIDER_SHORTLIST as SHORTLIST, + type WireApi, } from '@open-codesign/shared'; import { Button } from '@open-codesign/ui'; import { Check, Loader2, Plus, Zap } from 'lucide-react'; @@ -170,6 +171,7 @@ interface AddProviderMenuProps { onAddOllama: () => void; onAddCustom: () => void; onAddCliProxyApi: () => void; + onAddLiteLLM: () => void; } function AddProviderMenu({ @@ -182,6 +184,7 @@ function AddProviderMenu({ onAddOllama, onAddCustom, onAddCliProxyApi, + onAddLiteLLM, }: AddProviderMenuProps) { const t = useT(); const rootRef = useRef(null); @@ -254,6 +257,17 @@ function AddProviderMenu({ disabled: false, onClick: onAddCliProxyApi, }, + { + key: 'litellm', + label: t('settings.providers.litellmGateway.presetName', { + defaultValue: 'LiteLLM Gateway', + }), + desc: t('settings.providers.litellmGateway.presetDescription', { + defaultValue: 'OpenAI-compatible gateway — proxy key or keyless IP-allowlist', + }), + disabled: false, + onClick: onAddLiteLLM, + }, ]; return ( @@ -347,6 +361,9 @@ export function ModelsTab() { baseUrl: string; wire: WireApi; defaultModel?: string; + helpPreset?: 'litellm'; + supportsKeyless?: boolean; + allowPrivateNetwork?: boolean; } | undefined >(undefined); @@ -1035,6 +1052,19 @@ export function ModelsTab() { }); setShowAddCustom(true); }} + onAddLiteLLM={() => { + setShowAddMenu(false); + setCustomProviderPreset({ + name: LITELLM_GATEWAY_PRESET.label, + baseUrl: LITELLM_GATEWAY_PRESET.baseUrl, + wire: LITELLM_GATEWAY_PRESET.wire, + defaultModel: '', + helpPreset: 'litellm', + supportsKeyless: true, + allowPrivateNetwork: LITELLM_GATEWAY_PRESET.allowPrivateNetworkByDefault, + }); + setShowAddCustom(true); + }} />

diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 348521a29..1c19d9b6c 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -551,6 +551,14 @@ "apiKeyOptional": "API key only required if you configured `api-keys` in CPA config.yaml", "thinkingHint": "Tip: append `(high)` / `(xhigh)` / `(8192)` to model name to control thinking budget" }, + "litellmGateway": { + "presetName": "LiteLLM Gateway", + "presetDescription": "OpenAI-compatible gateway — proxy key or keyless IP-allowlist", + "helpTitle": "Connect to an existing LiteLLM gateway", + "helpBody": "Open CoDesign does not bundle LiteLLM. Point this preset at a gateway you already host. Default wire is OpenAI Chat at http://localhost:4000/v1 — edit the URL for a remote deployment. Paste a proxy key (master or virtual key) if the gateway requires one, or leave the key empty for keyless / IP-allowlist setups. If /models is available, pick a discovered model; otherwise enter a model id manually.", + "apiKeyOptional": "Optional. Leave empty for keyless or IP-allowlisted LiteLLM deployments. Paste LITELLM_MASTER_KEY or a virtual key when the gateway requires a proxy key.", + "apiKeyPlaceholder": "Proxy key (optional)" + }, "cpaDetection": { "title": "CLIProxyAPI detected on your machine", "body": "Import it as a provider to use your OAuth-authenticated Claude / Codex / Gemini accounts.", @@ -1043,14 +1051,17 @@ "mostLikelyCause": "Most likely cause:", "cause": { "keyInvalid": "API key invalid or revoked.", + "litellmAuth": "LiteLLM rejected the request. If this gateway uses a proxy key, paste LITELLM_MASTER_KEY or a virtual key. If it is keyless / IP-allowlisted, leave the key empty and confirm this app is allowed.", "balanceEmpty": "Account balance is empty.", "missingV1": "Base URL path is likely missing the /v1 suffix.", "rateLimit": "Rate limit exceeded.", "hostUnreachable": "Cannot reach host — check domain, port, or VPN.", + "litellmUnreachable": "Cannot reach the LiteLLM gateway. LiteLLM is not bundled — start your existing proxy (default http://localhost:4000) or update the base URL.", "timedOut": "Request timed out — check firewall or VPN.", "corsError": "CORS error (should not happen in main process). This is a bug.", "sslError": "SSL / certificate error (self-signed or private-CA cert?). Enable Settings → Providers → → Disable TLS verification to connect to trusted internal endpoints.", "endpointNotFound": "The endpoint path exists in the Base URL but the provider did not expose this route.", + "litellmModelsDisabled": "This LiteLLM gateway did not expose /models. Enter a known model id manually, or enable model listing on the gateway.", "gatewayIncompatible": "The gateway accepted the connection but does not implement this provider's API. Try switching wire (e.g. openai-chat).", "gatewayWafBlocked": "The gateway or reverse proxy blocked the generation request before it reached the model. Test Connection can still pass because it only probes the /models endpoint.", "openaiResponsesMisconfigured": "The endpoint rejected the request shape. The wire may be wrong — try switching to openai-chat.", @@ -1069,6 +1080,9 @@ }, "fix": { "updateKey": "Update key", + "litellmCheckKeyOrAllowlist": "Check proxy key or IP allowlist", + "litellmStartGateway": "Start LiteLLM or update URL", + "litellmEnterModelManually": "Enter a model id", "addCredits": "Add credits →", "addCreditsGeneric": "Check your provider's billing page", "addV1": "Add /v1", @@ -1093,6 +1107,8 @@ "showLogFailed": "Failed to open logs folder", "gatewayAllowlistHintTitle": "This endpoint may restrict which apps can connect", "gatewayAllowlistHintBody": "Some coding plans and protocol-conversion gateways keep an app allowlist. Even when they advertise an OpenAI-compatible API, they may still reject Open CoDesign unless this app is explicitly allowed.", + "litellmHintTitle": "LiteLLM gateway notes", + "litellmHintBody": "LiteLLM is not bundled. Use a proxy key (master/virtual) when the gateway requires auth, or leave the key empty for keyless / IP-allowlist deployments. Model discovery uses GET /v1/models when the gateway exposes it; otherwise enter a model id manually.", "dismiss": "Dismiss", "report": { "title": "Report a bug", diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index 789c051ad..a00400d2f 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -489,6 +489,14 @@ "apiKeyOptional": "Clave API solo requerida si configuraste `api-keys` en config.yaml de CPA", "thinkingHint": "Consejo: añade `(high)` / `(xhigh)` / `(8192)` al nombre del modelo para controlar el presupuesto de razonamiento" }, + "litellmGateway": { + "presetName": "LiteLLM Gateway", + "presetDescription": "Pasarela compatible con OpenAI — clave proxy o sin clave / lista de IPs", + "helpTitle": "Conéctate a una pasarela LiteLLM existente", + "helpBody": "Open CoDesign no incluye LiteLLM. Apunta este ajuste a una pasarela que ya alojas. El protocolo predeterminado es OpenAI Chat en http://localhost:4000/v1; cambia la URL para un despliegue remoto. Pega una clave proxy (master o virtual) si la pasarela la exige, o déjala vacía para despliegues sin clave / lista de IPs. Si /models está disponible, elige un modelo descubierto; si no, introduce el id manualmente.", + "apiKeyOptional": "Opcional. Déjala vacía para LiteLLM sin clave o con lista de IPs. Pega LITELLM_MASTER_KEY o una clave virtual cuando la pasarela exija autenticación.", + "apiKeyPlaceholder": "Clave proxy (opcional)" + }, "cpaDetection": { "title": "CLIProxyAPI detectado en tu máquina", "body": "Impórtalo como proveedor para usar tus cuentas de Claude / Codex / Gemini autenticadas por OAuth.", @@ -979,13 +987,16 @@ "mostLikelyCause": "Causa más probable:", "cause": { "keyInvalid": "Clave API inválida o revocada.", + "litellmAuth": "LiteLLM rechazó la solicitud. Si esta pasarela usa una clave proxy, pega LITELLM_MASTER_KEY o una clave virtual. Si es sin clave / lista de IPs, deja la clave vacía y confirma que esta app está permitida.", "balanceEmpty": "El saldo de la cuenta está vacío.", "missingV1": "Probablemente falte el sufijo /v1 en la ruta de la URL base.", "rateLimit": "Límite de tasa excedido.", "hostUnreachable": "No se puede alcanzar el host — revisa dominio, puerto o VPN.", + "litellmUnreachable": "No se puede alcanzar la pasarela LiteLLM. LiteLLM no está incluido — inicia tu proxy existente (predeterminado http://localhost:4000) o actualiza la URL base.", "timedOut": "Tiempo de espera de la solicitud agotado — revisa el cortafuegos o VPN.", "corsError": "Error de CORS (no debería pasar en el proceso principal). Esto es un error.", "sslError": "Error de SSL / certificado (¿certificado autofirmado o CA privada?). Actívalo en Ajustes → Proveedores → este proveedor → Desactivar verificación TLS para conectarte a endpoints internos de confianza.", + "litellmModelsDisabled": "Esta pasarela LiteLLM no expuso /models. Introduce un id de modelo conocido o habilita el listado de modelos en la pasarela.", "gatewayIncompatible": "La pasarela aceptó la conexión pero no implementa la API de este proveedor. Intenta cambiar de protocolo (ej. openai-chat).", "gatewayWafBlocked": "La pasarela o el proxy inverso bloqueó la generación antes de llegar al modelo. Test Connection puede pasar porque solo prueba el endpoint /models.", "openaiResponsesMisconfigured": "El punto final rechazó la forma de la solicitud. El protocolo puede ser incorrecto — intenta cambiar a openai-chat.", @@ -1004,6 +1015,9 @@ }, "fix": { "updateKey": "Actualizar clave", + "litellmCheckKeyOrAllowlist": "Revisar clave proxy o lista de IPs", + "litellmStartGateway": "Iniciar LiteLLM o actualizar URL", + "litellmEnterModelManually": "Introducir un id de modelo", "addCredits": "Agregar créditos →", "addCreditsGeneric": "Revisa la página de facturación de tu proveedor", "addV1": "Agregar /v1", @@ -1028,6 +1042,8 @@ "showLogFailed": "Error al abrir la carpeta de registros", "gatewayAllowlistHintTitle": "Este punto final puede restringir qué aplicaciones pueden conectarse", "gatewayAllowlistHintBody": "Algunos planes de programación y pasarelas de conversión de protocolos mantienen una lista de aplicaciones permitidas. Incluso cuando anuncian una API compatible con OpenAI, podrían rechazar Open CoDesign a menos que esta app esté explícitamente permitida.", + "litellmHintTitle": "Notas de pasarela LiteLLM", + "litellmHintBody": "LiteLLM no está incluido. Usa una clave proxy (master/virtual) si la pasarela exige autenticación, o déjala vacía para despliegues sin clave / lista de IPs. El descubrimiento de modelos usa GET /v1/models cuando la pasarela lo expone; si no, introduce el id manualmente.", "dismiss": "Descartar", "report": { "title": "Reportar un error", diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index 520e584c2..b4b1b7de7 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -468,6 +468,14 @@ "discoveringModels": "Descobrindo modelos...", "discoveredModels": "{{count}} modelos encontrados", "discoveryFailed": "Não foi possível conectar ao CPA" + }, + "litellmGateway": { + "presetName": "LiteLLM Gateway", + "presetDescription": "Gateway compatível com OpenAI — chave de proxy ou sem chave / lista de IPs", + "helpTitle": "Conecte-se a um gateway LiteLLM existente", + "helpBody": "O Open CoDesign não inclui o LiteLLM. Aponte este preset para um gateway que você já hospeda. O wire padrão é OpenAI Chat em http://localhost:4000/v1; edite a URL para um deploy remoto. Cole uma chave de proxy (master ou virtual) se o gateway exigir, ou deixe vazia para deploys sem chave / lista de IPs. Se /models estiver disponível, escolha um modelo descoberto; senão, informe o id manualmente.", + "apiKeyOptional": "Opcional. Deixe vazio para LiteLLM sem chave ou com lista de IPs. Cole LITELLM_MASTER_KEY ou uma chave virtual quando o gateway exigir autenticação.", + "apiKeyPlaceholder": "Chave de proxy (opcional)" } }, "appearance": { @@ -940,14 +948,17 @@ "mostLikelyCause": "Causa mais provável:", "cause": { "keyInvalid": "Chave de API inválida ou revogada.", + "litellmAuth": "O LiteLLM rejeitou a solicitação. Se este gateway usa uma chave de proxy, cole LITELLM_MASTER_KEY ou uma chave virtual. Se for sem chave / lista de IPs, deixe a chave vazia e confirme que este app está na lista de permissão.", "balanceEmpty": "O saldo da conta está vazio.", "missingV1": "O caminho da URL base provavelmente não tem o sufixo /v1.", "rateLimit": "Limite de requisições excedido.", "hostUnreachable": "Não foi possível acessar o host — verifique domínio, porta ou VPN.", + "litellmUnreachable": "Não foi possível acessar o gateway LiteLLM. O LiteLLM não está incluído — inicie o proxy que você já hospeda (padrão http://localhost:4000) ou atualize a URL base.", "timedOut": "Tempo da requisição esgotado — verifique firewall ou VPN.", "corsError": "Erro de CORS (não deveria acontecer no processo principal). Isso é um bug.", "sslError": "Erro de SSL / certificado (certificado autoassinado ou CA privada?). Ative em Configurações → Provedores → este provedor → Desativar verificação TLS para conectar a endpoints internos confiáveis.", "endpointNotFound": "A URL base já contém um caminho de versão, mas o provedor não expôs esta rota.", + "litellmModelsDisabled": "Este gateway LiteLLM não expôs /models. Informe um id de modelo conhecido ou habilite a listagem de modelos no gateway.", "gatewayIncompatible": "O gateway aceitou a conexão, mas não implementa a API deste provedor. Tente trocar o wire, por exemplo para openai-chat.", "gatewayWafBlocked": "O gateway ou proxy reverso bloqueou a geração antes de ela chegar ao modelo. Test Connection ainda pode passar porque só verifica o endpoint /models.", "openaiResponsesMisconfigured": "O endpoint rejeitou o formato da requisição. O wire pode estar errado; tente openai-chat.", @@ -966,6 +977,9 @@ }, "fix": { "updateKey": "Atualizar chave", + "litellmCheckKeyOrAllowlist": "Verificar chave de proxy ou lista de IPs", + "litellmStartGateway": "Iniciar LiteLLM ou atualizar URL", + "litellmEnterModelManually": "Informar um id de modelo", "addCredits": "Adicionar créditos →", "addCreditsGeneric": "Veja a página de faturamento do seu provedor", "addV1": "Adicionar /v1", @@ -990,6 +1004,8 @@ "showLogFailed": "Falha ao abrir a pasta de logs", "gatewayAllowlistHintTitle": "Este endpoint pode restringir quais apps podem se conectar", "gatewayAllowlistHintBody": "Alguns coding plans e gateways de conversão de protocolo mantêm uma lista de apps permitidos. Mesmo anunciando uma API compatível com OpenAI, eles ainda podem recusar o Open CoDesign se este app não estiver explicitamente liberado.", + "litellmHintTitle": "Notas do gateway LiteLLM", + "litellmHintBody": "O LiteLLM não está incluído. Use uma chave de proxy (master/virtual) quando o gateway exigir autenticação, ou deixe vazia para deploys sem chave / lista de IPs. A descoberta de modelos usa GET /v1/models quando o gateway expõe isso; senão, informe o id manualmente.", "dismiss": "Dispensar", "report": { "title": "Reportar um bug", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index ffede1799..08d7375ef 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -551,6 +551,14 @@ "apiKeyOptional": "仅当你在 CPA config.yaml 里配置了 api-keys 才需要填", "thinkingHint": "提示:在 model 名后加 `(high)` / `(xhigh)` / `(8192)` 可控制思考力度" }, + "litellmGateway": { + "presetName": "LiteLLM Gateway", + "presetDescription": "OpenAI 兼容网关 — 支持代理 Key 或无 Key / IP 白名单", + "helpTitle": "连接到已有的 LiteLLM 网关", + "helpBody": "Open CoDesign 不会内置 LiteLLM。请指向你已经部署的网关。默认协议是 OpenAI Chat,地址 http://localhost:4000/v1;远程部署请改 URL。若网关需要鉴权,粘贴代理 Key(master 或 virtual key);无 Key / IP 白名单部署可留空。若网关提供 /models,可从发现列表选择模型,否则手动填写模型 ID。", + "apiKeyOptional": "可选。无 Key 或 IP 白名单部署请留空。需要鉴权时粘贴 LITELLM_MASTER_KEY 或 virtual key。", + "apiKeyPlaceholder": "代理 Key(可选)" + }, "cpaDetection": { "title": "检测到本机运行的 CLIProxyAPI", "body": "一键导入即可使用你已登录的 Claude / Codex / Gemini 订阅账号。", @@ -1039,14 +1047,17 @@ "mostLikelyCause": "最可能的原因:", "cause": { "keyInvalid": "API Key 无效或已被撤销。", + "litellmAuth": "LiteLLM 拒绝了请求。若网关使用代理 Key,请粘贴 LITELLM_MASTER_KEY 或 virtual key。若为无 Key / IP 白名单部署,请将 Key 留空并确认本应用在允许列表中。", "balanceEmpty": "账户余额不足。", "missingV1": "Base URL 路径可能缺少 /v1 后缀。", "rateLimit": "请求被限流,超过了速率限制。", "hostUnreachable": "无法连接到主机——检查域名、端口或 VPN。", + "litellmUnreachable": "无法连接到 LiteLLM 网关。应用不会内置 LiteLLM — 请先启动你已有的代理(默认 http://localhost:4000)或更新 Base URL。", "timedOut": "请求超时——检查防火墙或 VPN。", "corsError": "CORS 跨域错误(主进程中不应出现此错误,这是一个 Bug)。", "sslError": "SSL / 证书错误(自签名或私有 CA 证书?)。可在「设置 → 服务商 → 该条目 → 禁用 TLS 校验」启用对可信内网端点的连接。", "endpointNotFound": "Base URL 已包含版本路径,但 Provider 没有暴露这个接口路径。", + "litellmModelsDisabled": "该 LiteLLM 网关没有暴露 /models。请手动填写已知模型 ID,或在网关上启用模型列表。", "gatewayIncompatible": "网关接受了连接但没有实现该 Provider 的 API。尝试切换 wire(例如改为 openai-chat)。", "gatewayWafBlocked": "网关或反代在请求到达模型前拦截了生成请求。测试连接可能仍然通过,因为它只探测 /models 端点。", "openaiResponsesMisconfigured": "端点拒绝了请求格式。wire 可能配错了——尝试切换到 openai-chat。", @@ -1065,6 +1076,9 @@ }, "fix": { "updateKey": "更新 Key", + "litellmCheckKeyOrAllowlist": "检查代理 Key 或 IP 白名单", + "litellmStartGateway": "启动 LiteLLM 或更新 URL", + "litellmEnterModelManually": "手动填写模型 ID", "addCredits": "充值 →", "addCreditsGeneric": "请前往你的 Provider 充值页面", "addV1": "添加 /v1", @@ -1089,6 +1103,8 @@ "showLogFailed": "无法打开日志文件夹", "gatewayAllowlistHintTitle": "\u8fd9\u4e2a\u7aef\u70b9\u53ef\u80fd\u9650\u5236\u53ef\u63a5\u5165\u7684\u5e94\u7528", "gatewayAllowlistHintBody": "\u90e8\u5206 coding plan \u548c\u534f\u8bae\u8f6c\u6362\u7f51\u5173\u4f1a\u914d\u7f6e\u5e94\u7528\u767d\u540d\u5355\u3002\u5b83\u4eec\u5373\u4f7f\u58f0\u79f0\u63d0\u4f9b OpenAI \u517c\u5bb9 API\uff0c\u4e5f\u53ef\u80fd\u5728\u6ca1\u6709\u663e\u5f0f\u5141\u8bb8 Open CoDesign \u7684\u60c5\u51b5\u4e0b\u62d2\u7edd\u8bf7\u6c42\u3002", + "litellmHintTitle": "LiteLLM 网关提示", + "litellmHintBody": "应用不会内置 LiteLLM。网关需要鉴权时使用代理 Key(master / virtual);无 Key / IP 白名单部署请将 Key 留空。模型发现走 GET /v1/models(若网关提供);否则手动填写模型 ID。", "dismiss": "关闭", "report": { "title": "上报问题", diff --git a/packages/shared/src/diagnostics.test.ts b/packages/shared/src/diagnostics.test.ts index f6afc5fd7..e4f8a78cc 100644 --- a/packages/shared/src/diagnostics.test.ts +++ b/packages/shared/src/diagnostics.test.ts @@ -123,6 +123,35 @@ describe('diagnose', () => { expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.disableTls'); }); + it('maps LiteLLM 401/403 to proxy-key vs keyless allowlist hints', () => { + const result = diagnose('401', { + provider: 'custom-litellm-gateway-ab12', + baseUrl: 'http://localhost:4000/v1', + }); + expect(result[0]?.cause).toBe('diagnostics.cause.litellmAuth'); + expect(result[0]?.category).toBe('auth'); + expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.litellmCheckKeyOrAllowlist'); + }); + + it('maps LiteLLM ECONNREFUSED to a start-gateway hint', () => { + const result = diagnose('ECONNREFUSED', { + provider: 'openai', + baseUrl: 'http://127.0.0.1:4000/v1', + }); + expect(result[0]?.cause).toBe('diagnostics.cause.litellmUnreachable'); + expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.litellmStartGateway'); + }); + + it('maps LiteLLM /v1 404 to models-disabled rather than add-/v1', () => { + const result = diagnose('404', { + provider: 'custom-litellm-gateway-ab12', + baseUrl: 'http://localhost:4000/v1', + }); + expect(result[0]?.cause).toBe('diagnostics.cause.litellmModelsDisabled'); + expect(result[0]?.category).toBe('model-discovery-degraded'); + expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.litellmEnterModelManually'); + }); + it('maps unknown codes to generic unknown cause', () => { const result = diagnose('SOME_UNKNOWN_CODE', baseCtx); expect(result[0]?.cause).toBe('diagnostics.cause.unknown'); @@ -145,6 +174,16 @@ describe('diagnose', () => { describe('diagnoseGenerateFailure', () => { const ctx = { provider: 'openai', baseUrl: 'https://relay.example.com' }; + it('maps LiteLLM generate 401 onto the gateway-specific auth hint', () => { + const result = diagnoseGenerateFailure({ + provider: 'custom-litellm-gateway-ab12', + baseUrl: 'http://localhost:4000/v1', + status: 401, + }); + expect(result[0]?.cause).toBe('diagnostics.cause.litellmAuth'); + expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.litellmCheckKeyOrAllowlist'); + }); + it('maps 404 to missingV1 with an /v1 baseUrl transform', () => { const result = diagnoseGenerateFailure({ ...ctx, status: 404 }); expect(result[0]?.cause).toBe('diagnostics.cause.missingV1'); diff --git a/packages/shared/src/diagnostics.ts b/packages/shared/src/diagnostics.ts index bf3008f05..80467c06f 100644 --- a/packages/shared/src/diagnostics.ts +++ b/packages/shared/src/diagnostics.ts @@ -1,4 +1,5 @@ import type { ReasoningLevel, WireApi } from './config'; +import { looksLikeLiteLLMGateway } from './proxy-presets'; export type ErrorCode = | '401' @@ -119,8 +120,23 @@ function stripModelsPrefix(modelId: string): string { export function diagnose(code: ErrorCode, ctx: DiagnoseContext): DiagnosticHypothesis[] { // Normalise the code — some callers pass the HTTP status as a string like "404" const normalised = String(code).toUpperCase(); + const litellm = looksLikeLiteLLMGateway(ctx.provider, ctx.baseUrl); if (normalised === '401' || normalised === '403') { + if (litellm) { + return [ + h({ + cause: 'diagnostics.cause.litellmAuth', + category: 'auth', + severity: 'error', + suggestedFix: { + kind: 'openSettings', + label: 'diagnostics.fix.litellmCheckKeyOrAllowlist', + settingsTab: 'models', + }, + }), + ]; + } return [ h({ cause: 'diagnostics.cause.keyInvalid', @@ -161,6 +177,20 @@ export function diagnose(code: ErrorCode, ctx: DiagnoseContext): DiagnosticHypot // hypothesis so the user isn't pushed into corrupting a correct baseUrl. const hasVersionSegment = /\/v\d+[a-z]*(?:\/|$)/i.test(ctx.baseUrl); if (hasVersionSegment) { + if (litellm) { + return [ + h({ + cause: 'diagnostics.cause.litellmModelsDisabled', + category: 'model-discovery-degraded', + severity: 'warning', + suggestedFix: { + kind: 'openSettings', + label: 'diagnostics.fix.litellmEnterModelManually', + settingsTab: 'models', + }, + }), + ]; + } return [ h({ cause: 'diagnostics.cause.endpointNotFound', @@ -198,6 +228,20 @@ export function diagnose(code: ErrorCode, ctx: DiagnoseContext): DiagnosticHypot } if (normalised === 'ECONNREFUSED' || normalised === 'ENOTFOUND') { + if (litellm) { + return [ + h({ + cause: 'diagnostics.cause.litellmUnreachable', + category: 'network-unreachable', + severity: 'error', + suggestedFix: { + kind: 'openSettings', + label: 'diagnostics.fix.litellmStartGateway', + settingsTab: 'models', + }, + }), + ]; + } return [ h({ cause: 'diagnostics.cause.hostUnreachable', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 00ba1848b..f4b2becf9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -364,6 +364,10 @@ export type { FingerprintInput } from './fingerprint'; export type { ProxyPresetId } from './proxy-presets'; export { getPresetById, + LITELLM_DEFAULT_BASE_URL, + LITELLM_DEFAULT_WIRE, + LITELLM_GATEWAY_PRESET, + looksLikeLiteLLMGateway, PROXY_PRESET_SCHEMA_VERSION, PROXY_PRESETS, ProxyPreset, diff --git a/packages/shared/src/proxy-presets.test.ts b/packages/shared/src/proxy-presets.test.ts index 91b923a7d..d5870b6ea 100644 --- a/packages/shared/src/proxy-presets.test.ts +++ b/packages/shared/src/proxy-presets.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + LITELLM_DEFAULT_BASE_URL, + LITELLM_DEFAULT_WIRE, + LITELLM_GATEWAY_PRESET, + looksLikeLiteLLMGateway, PROXY_PRESET_SCHEMA_VERSION, PROXY_PRESETS, ProxyPreset, @@ -35,6 +39,8 @@ describe('PROXY_PRESETS', () => { expect(ids).toContain('openrouter'); expect(ids).toContain('siliconflow'); expect(ids).toContain('one-api'); + expect(ids).toContain('cli-proxy-api'); + expect(ids).toContain('litellm'); expect(ids).toContain('custom'); }); @@ -54,3 +60,42 @@ describe('PROXY_PRESETS', () => { expect(custom?.baseUrl).toBe(''); }); }); + +describe('LITELLM_GATEWAY_PRESET', () => { + it('registers OpenAI-compatible defaults without bundling LiteLLM', () => { + expect(LITELLM_GATEWAY_PRESET.id).toBe('litellm'); + expect(LITELLM_GATEWAY_PRESET.label).toBe('LiteLLM Gateway'); + expect(LITELLM_GATEWAY_PRESET.wire).toBe(LITELLM_DEFAULT_WIRE); + expect(LITELLM_GATEWAY_PRESET.wire).toBe('openai-chat'); + expect(LITELLM_GATEWAY_PRESET.baseUrl).toBe(LITELLM_DEFAULT_BASE_URL); + expect(LITELLM_GATEWAY_PRESET.baseUrl).toBe('http://localhost:4000/v1'); + expect(LITELLM_GATEWAY_PRESET.supportsKeyless).toBe(true); + expect(LITELLM_GATEWAY_PRESET.supportsModelsEndpoint).toBe(true); + expect(LITELLM_GATEWAY_PRESET.modelDiscoveryMode).toBe('models'); + expect(LITELLM_GATEWAY_PRESET.notes.toLowerCase()).toContain('not bundled'); + }); + + it('keeps PROXY_PRESETS in sync with the first-class LiteLLM entry', () => { + const listed = PROXY_PRESETS.find((p) => p.id === 'litellm'); + expect(listed?.label).toBe(LITELLM_GATEWAY_PRESET.label); + expect(listed?.baseUrl).toBe(LITELLM_GATEWAY_PRESET.baseUrl); + expect(listed?.provider).toBe('openai'); + }); +}); + +describe('looksLikeLiteLLMGateway', () => { + it('matches preset ids, custom ids, and the default listen address', () => { + expect(looksLikeLiteLLMGateway('litellm', '')).toBe(true); + expect(looksLikeLiteLLMGateway('custom-litellm-gateway-ab12')).toBe(true); + expect(looksLikeLiteLLMGateway('openai', LITELLM_DEFAULT_BASE_URL)).toBe(true); + expect(looksLikeLiteLLMGateway('openai', 'http://127.0.0.1:4000/v1')).toBe(true); + expect(looksLikeLiteLLMGateway('openai', 'https://litellm.internal.example/v1')).toBe(true); + }); + + it('does not match unrelated providers or ports', () => { + expect(looksLikeLiteLLMGateway('openai', 'https://api.openai.com/v1')).toBe(false); + expect(looksLikeLiteLLMGateway('cli-proxy-api', 'http://127.0.0.1:8317')).toBe(false); + expect(looksLikeLiteLLMGateway('ollama', 'http://localhost:11434/v1')).toBe(false); + expect(looksLikeLiteLLMGateway('custom-relay', 'http://localhost:8080/v1')).toBe(false); + }); +}); diff --git a/packages/shared/src/proxy-presets.ts b/packages/shared/src/proxy-presets.ts index fd1a2fb7c..4e98543e0 100644 --- a/packages/shared/src/proxy-presets.ts +++ b/packages/shared/src/proxy-presets.ts @@ -2,6 +2,31 @@ import { z } from 'zod'; export const PROXY_PRESET_SCHEMA_VERSION = 1 as const; +/** Default LiteLLM proxy listen address. LiteLLM is not bundled — users point + * this preset at an already-running gateway. */ +export const LITELLM_DEFAULT_BASE_URL = 'http://localhost:4000/v1'; +export const LITELLM_DEFAULT_WIRE = 'openai-chat' as const; + +/** + * First-class LiteLLM Gateway preset. Used by Settings → Add provider and + * diagnostics. Does not ship or spawn LiteLLM; it only pre-fills an + * OpenAI-compatible custom provider that can run with a proxy key or keyless + * (IP-allowlist / `disable_auth`) deployments. + */ +export const LITELLM_GATEWAY_PRESET = { + id: 'litellm', + label: 'LiteLLM Gateway', + provider: 'openai', + wire: LITELLM_DEFAULT_WIRE, + baseUrl: LITELLM_DEFAULT_BASE_URL, + notes: + 'Externally hosted LiteLLM gateway (OpenAI-compatible). Supports a proxy key or keyless IP-allowlist. LiteLLM is not bundled.', + supportsKeyless: true, + supportsModelsEndpoint: true, + modelDiscoveryMode: 'models' as const, + allowPrivateNetworkByDefault: true, +} as const; + export const PROXY_PRESETS = [ { id: 'official-openai', @@ -59,6 +84,13 @@ export const PROXY_PRESETS = [ baseUrl: 'http://127.0.0.1:8317', notes: '', }, + { + id: 'litellm', + label: LITELLM_GATEWAY_PRESET.label, + provider: LITELLM_GATEWAY_PRESET.provider, + baseUrl: LITELLM_GATEWAY_PRESET.baseUrl, + notes: LITELLM_GATEWAY_PRESET.notes, + }, { id: 'custom', label: 'Custom...', @@ -85,3 +117,24 @@ export type ProxyPreset = z.infer; export function getPresetById(id: ProxyPresetId): (typeof PROXY_PRESETS)[number] | undefined { return PROXY_PRESETS.find((p) => p.id === id); } + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * True when a stored provider or connection-test context is a LiteLLM gateway. + * Matches the first-class preset id, custom ids/names that contain "litellm", + * and the default localhost:4000 listen address. + */ +export function looksLikeLiteLLMGateway(provider: string, baseUrl?: string): boolean { + if (provider.toLowerCase().includes('litellm')) return true; + if (!baseUrl) return false; + try { + const url = new URL(baseUrl); + const host = url.hostname.toLowerCase(); + if (host.includes('litellm')) return true; + const port = url.port === '' ? (url.protocol === 'https:' ? '443' : '80') : url.port; + return LOOPBACK_HOSTS.has(host) && port === '4000'; + } catch { + return /\blitellm\b/i.test(baseUrl); + } +} From ec16a6b43b8a986cd08af3bc501428cb78d01e82 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Mon, 14 Sep 2026 14:48:03 +0000 Subject: [PATCH 2/2] fix(desktop): stabilize Chrome preview launch timeout on CI The first real-Chrome runPreview test raced Puppeteer's 30s launch default on busy GitHub runners and surfaced as a Vitest timeout. Raise the launch timeout, warm Chrome once, and keep the Chrome suite budget above launch plus navigation so Lint/typecheck/test can finish. --- apps/desktop/src/main/preview-runtime.test.ts | 353 ++++++++++-------- apps/desktop/src/main/preview-runtime.ts | 6 + 2 files changed, 210 insertions(+), 149 deletions(-) diff --git a/apps/desktop/src/main/preview-runtime.test.ts b/apps/desktop/src/main/preview-runtime.test.ts index 61a5b48a3..18de15a84 100644 --- a/apps/desktop/src/main/preview-runtime.test.ts +++ b/apps/desktop/src/main/preview-runtime.test.ts @@ -7,6 +7,7 @@ import { isPreviewFileUrlAllowed, isRuntimeConsoleNoise, isRuntimeOptionalFontUrl, + PREVIEW_CHROME_LAUNCH_TIMEOUT_MS, runPreview, } from './preview-runtime'; @@ -27,6 +28,10 @@ async function canRunChrome(): Promise { const chromeAvailable = await canRunChrome(); // eslint-disable-next-line @typescript-eslint/no-unused-expressions const describeIfChrome = chromeAvailable ? describe : describe.skip; +// Real Chrome (unlike the mocked exporter tests). Budget must exceed +// puppeteer's launch timeout plus navigation/settle, or a cold CI start +// is reported as "Test timed out" instead of a Chrome error. +const CHROME_TEST_TIMEOUT_MS = 90_000; let tempDir = ''; @@ -39,6 +44,10 @@ afterAll(() => { }); describe('runPreview path guards', () => { + it('keeps the Chrome test budget above the launch timeout', () => { + expect(CHROME_TEST_TIMEOUT_MS).toBeGreaterThan(PREVIEW_CHROME_LAUNCH_TIMEOUT_MS); + }); + it('refuses paths that escape the workspace', async () => { const result = await runPreview({ path: '../etc/passwd', @@ -187,164 +196,210 @@ describe('runtime noise filtering', () => { }); describeIfChrome('runPreview with real Chrome', () => { - it('captures console errors from the rendered page', async () => { - const file = join(tempDir, 'boom.html'); - writeFileSync( - file, - '

Hi

', - 'utf8', - ); - const result = await runPreview({ - path: 'boom.html', + beforeAll(async () => { + const file = join(tempDir, '__chrome-warmup.html'); + writeFileSync(file, 'warmup', 'utf8'); + await runPreview({ + path: '__chrome-warmup.html', vision: false, workspaceRoot: tempDir, }); - expect(result.consoleErrors.some((e) => /boom/.test(e.message))).toBe(true); - expect(result.metrics.nodes).toBeGreaterThan(0); - }, 30_000); + }, CHROME_TEST_TIMEOUT_MS); - it('returns a DOM outline (not a screenshot) when vision=false', async () => { - const file = join(tempDir, 'plain.html'); - writeFileSync( - file, - '

A

', - 'utf8', - ); - const result = await runPreview({ - path: 'plain.html', - vision: false, - workspaceRoot: tempDir, - }); - expect(result.screenshot).toBeUndefined(); - expect(typeof result.domOutline).toBe('string'); - expect((result.domOutline ?? '').length).toBeGreaterThan(0); - }, 30_000); + it( + 'captures console errors from the rendered page', + async () => { + const file = join(tempDir, 'boom.html'); + writeFileSync( + file, + '

Hi

', + 'utf8', + ); + const result = await runPreview({ + path: 'boom.html', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.consoleErrors.some((e) => /boom/.test(e.message))).toBe(true); + expect(result.metrics.nodes).toBeGreaterThan(0); + }, + CHROME_TEST_TIMEOUT_MS, + ); - it('resolves relative scripts from HTML files against the workspace', async () => { - writeFileSync( - join(tempDir, 'relative.html'), - '
', - 'utf8', - ); - writeFileSync( - join(tempDir, 'relative.js'), - 'document.getElementById("root").innerHTML = "
Relative
";', - 'utf8', - ); - const result = await runPreview({ - path: 'relative.html', - vision: false, - workspaceRoot: tempDir, - }); - expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); - expect(result.domOutline).toContain('main#relative-root'); - }, 30_000); + it( + 'returns a DOM outline (not a screenshot) when vision=false', + async () => { + const file = join(tempDir, 'plain.html'); + writeFileSync( + file, + '

A

', + 'utf8', + ); + const result = await runPreview({ + path: 'plain.html', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.screenshot).toBeUndefined(); + expect(typeof result.domOutline).toBe('string'); + expect((result.domOutline ?? '').length).toBeGreaterThan(0); + }, + CHROME_TEST_TIMEOUT_MS, + ); - it('renders standalone JSX files through the preview runtime', async () => { - writeFileSync( - join(tempDir, 'App.jsx'), - 'function App() { return
Hello JSX
; }', - 'utf8', - ); - const result = await runPreview({ - path: 'App.jsx', - vision: false, - workspaceRoot: tempDir, - }); - expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); - expect(result.domOutline).toContain('main#jsx-root'); - }, 30_000); + it( + 'resolves relative scripts from HTML files against the workspace', + async () => { + writeFileSync( + join(tempDir, 'relative.html'), + '
', + 'utf8', + ); + writeFileSync( + join(tempDir, 'relative.js'), + 'document.getElementById("root").innerHTML = "
Relative
";', + 'utf8', + ); + const result = await runPreview({ + path: 'relative.html', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); + expect(result.domOutline).toContain('main#relative-root'); + }, + CHROME_TEST_TIMEOUT_MS, + ); - it('renders JSX saved as HTML when it uses vendored runtime components', async () => { - writeFileSync( - join(tempDir, 'RuntimeFrame.html'), - [ - 'function App() {', - ' return
Inside iOS frame
;', - '}', - 'ReactDOM.createRoot(document.getElementById("root")).render();', - ].join('\n'), - 'utf8', - ); - const result = await runPreview({ - path: 'RuntimeFrame.html', - vision: false, - workspaceRoot: tempDir, - }); - expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); - expect(result.metrics.nodes).toBeGreaterThan(20); - }, 30_000); + it( + 'renders standalone JSX files through the preview runtime', + async () => { + writeFileSync( + join(tempDir, 'App.jsx'), + 'function App() { return
Hello JSX
; }', + 'utf8', + ); + const result = await runPreview({ + path: 'App.jsx', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); + expect(result.domOutline).toContain('main#jsx-root'); + }, + CHROME_TEST_TIMEOUT_MS, + ); - it('renders mixed HTML + inline JSX without relying on user-added CDN runtimes', async () => { - writeFileSync( - join(tempDir, 'Mixed.html'), - [ - '', - '', - '', - '', - '
', - '', - '', - ].join('\n'), - 'utf8', - ); - const result = await runPreview({ - path: 'Mixed.html', - vision: false, - workspaceRoot: tempDir, - }); - expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); - expect(result.domOutline).toContain('main#mixed-jsx-root'); - }, 30_000); + it( + 'renders JSX saved as HTML when it uses vendored runtime components', + async () => { + writeFileSync( + join(tempDir, 'RuntimeFrame.html'), + [ + 'function App() {', + ' return
Inside iOS frame
;', + '}', + 'ReactDOM.createRoot(document.getElementById("root")).render();', + ].join('\n'), + 'utf8', + ); + const result = await runPreview({ + path: 'RuntimeFrame.html', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); + expect(result.metrics.nodes).toBeGreaterThan(20); + }, + CHROME_TEST_TIMEOUT_MS, + ); - it('does not follow source-reference-looking strings inside JSX files', async () => { - writeFileSync( - join(tempDir, 'Marker.jsx'), - 'const marker = "";\nfunction App() { return
{marker}
; }', - 'utf8', - ); - const result = await runPreview({ - path: 'Marker.jsx', - vision: false, - workspaceRoot: tempDir, - }); - expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); - expect(result.domOutline).toContain('main#marker-root'); - }, 30_000); + it( + 'renders mixed HTML + inline JSX without relying on user-added CDN runtimes', + async () => { + writeFileSync( + join(tempDir, 'Mixed.html'), + [ + '', + '', + '', + '', + '
', + '', + '', + ].join('\n'), + 'utf8', + ); + const result = await runPreview({ + path: 'Mixed.html', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); + expect(result.domOutline).toContain('main#mixed-jsx-root'); + }, + CHROME_TEST_TIMEOUT_MS, + ); - it('renders placeholder HTML files through their referenced JSX source', async () => { - writeFileSync( - join(tempDir, 'index.html'), - '', - 'utf8', - ); - writeFileSync( - join(tempDir, 'index.jsx'), - 'function App() { return
Placeholder JSX
; }', - 'utf8', - ); - const result = await runPreview({ - path: 'index.html', - vision: false, - workspaceRoot: tempDir, - }); - expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); - expect(result.domOutline).toContain('main#placeholder-jsx-root'); - }, 30_000); + it( + 'does not follow source-reference-looking strings inside JSX files', + async () => { + writeFileSync( + join(tempDir, 'Marker.jsx'), + 'const marker = "";\nfunction App() { return
{marker}
; }', + 'utf8', + ); + const result = await runPreview({ + path: 'Marker.jsx', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); + expect(result.domOutline).toContain('main#marker-root'); + }, + CHROME_TEST_TIMEOUT_MS, + ); - it('renders standalone TSX files through the preview runtime', async () => { - writeFileSync( - join(tempDir, 'App.tsx'), - 'function App(): JSX.Element { const label: string = "Hello TSX"; return
{label}
; }', - 'utf8', - ); - const result = await runPreview({ - path: 'App.tsx', - vision: false, - workspaceRoot: tempDir, - }); - expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); - expect(result.domOutline).toContain('main#tsx-root'); - }, 30_000); + it( + 'renders placeholder HTML files through their referenced JSX source', + async () => { + writeFileSync( + join(tempDir, 'index.html'), + '', + 'utf8', + ); + writeFileSync( + join(tempDir, 'index.jsx'), + 'function App() { return
Placeholder JSX
; }', + 'utf8', + ); + const result = await runPreview({ + path: 'index.html', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); + expect(result.domOutline).toContain('main#placeholder-jsx-root'); + }, + CHROME_TEST_TIMEOUT_MS, + ); + + it( + 'renders standalone TSX files through the preview runtime', + async () => { + writeFileSync( + join(tempDir, 'App.tsx'), + 'function App(): JSX.Element { const label: string = "Hello TSX"; return
{label}
; }', + 'utf8', + ); + const result = await runPreview({ + path: 'App.tsx', + vision: false, + workspaceRoot: tempDir, + }); + expect(result.ok, JSON.stringify(result, null, 2)).toBe(true); + expect(result.domOutline).toContain('main#tsx-root'); + }, + CHROME_TEST_TIMEOUT_MS, + ); }); diff --git a/apps/desktop/src/main/preview-runtime.ts b/apps/desktop/src/main/preview-runtime.ts index d47839572..29362938c 100644 --- a/apps/desktop/src/main/preview-runtime.ts +++ b/apps/desktop/src/main/preview-runtime.ts @@ -35,6 +35,11 @@ export interface RunPreviewOptions { const LOAD_TIMEOUT_MS = 15_000; const SETTLE_AFTER_LOAD_MS = 800; +// Puppeteer's launch default is 30s. The first headless Chrome start on a +// busy CI runner can exceed that (fontconfig / crashpad / CPU contention +// while Vitest is importing other files), which races the 30s test timeout +// and surfaces as "Test timed out" instead of a launch error. +export const PREVIEW_CHROME_LAUNCH_TIMEOUT_MS = 60_000; const MAX_CONSOLE_ENTRIES = 50; const MAX_ASSET_ERRORS = 20; const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const; @@ -110,6 +115,7 @@ export async function runPreview(opts: RunPreviewOptions): Promise