From 000f105d70fa346ade01dd8b19719eaa67664066 Mon Sep 17 00:00:00 2001 From: vee1e Date: Mon, 29 Jun 2026 18:06:34 +0530 Subject: [PATCH] fix(local): give a CORS-aware error and a Test Connection button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `callLocal` re-threw the raw browser `NetworkError` ("NetworkError when attempting to fetch resource" / "Failed to fetch"), which is what happens when the browser blocks a cross-origin request because the local server has not enabled CORS. Users running LMStudio or older Ollama versions had no way to know that — the error just said "network error" with no actionable hint. - `callLocal` now catches the fetch `TypeError` and throws a descriptive `Error` that names the endpoint and includes the exact CORS fix for the detected server (LMStudio → "Local Server → ⚙ → enable CORS", Ollama → set `OLLAMA_ORIGINS="*"`). The original error is preserved via `Error.cause`. - New `testLocalConnection` helper that hits `${endpoint}/models` and returns either `{ ok, models }` or `{ ok, error }` with the same CORS-aware error message. The Authorization header is sent when an API key is configured. - New `detectLocalServer` and `corsHelpText` helpers (port-based detection for LMStudio 1234 / Ollama 11434). - InputScreen now renders a "Test" button next to the Local Endpoint field. Success shows the model list ("2 models available: llama3.2, qwen2.5-coder:7b"); failure shows the CORS-aware error inline. The label is now just "Local Endpoint" with the per-server URLs moved to a hint line that explicitly mentions the required `/v1` path (LMStudio users were previously typing `http://127.0.0.1:1234` without `/v1`, which would 404 once CORS was fixed). - README: new "Local LLM provider (Ollama / LMStudio)" section with startup commands, CORS instructions, and a troubleshooting line pointing users to the in-app error message. - 11 new tests for the local client (network error wrapping, test connection success/failure, non-JSON response, server detection, CORS help text). 4 new InputScreen tests for the provider UI and Test button. 186/186 pass. --- README.md | 14 +++ src/lib/api/local.test.ts | 149 ++++++++++++++++++++++++++++++- src/lib/api/local.ts | 108 +++++++++++++++++++--- src/screens/InputScreen.test.tsx | 94 ++++++++++++++++++- src/screens/InputScreen.tsx | 107 +++++++++++++++++++++- 5 files changed, 451 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1a363bf..4bc4e0e 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,20 @@ The application requires API keys to function. You can configure these directly - **OpenRouter API Key**: Required for AI analysis. - **GitHub Token**: Optional, but recommended for higher API rate limits. +### Local LLM provider (Ollama / LMStudio) + +You can run analysis against a local model served by [Ollama](https://ollama.com) or [LMStudio](https://lmstudio.ai) instead of OpenRouter — no API key required. Both expose an OpenAI-compatible `/v1/chat/completions` endpoint. + +1. **Start your local server.** + - **Ollama:** `ollama serve` (default endpoint: `http://localhost:11434/v1`). Pull a model first, e.g. `ollama pull llama3.2`. + - **LMStudio:** Open the "Local Server" tab and click Start Server (default endpoint: `http://localhost:1234/v1`). +2. **Enable CORS** so the browser can call the server from this app: + - **LMStudio:** in the Local Server tab, click the ⚙ settings icon and enable **CORS**. + - **Ollama:** Ollama 0.1.14+ allows browser origins by default. For older versions, set the `OLLAMA_ORIGINS="*"` environment variable before starting the server. +3. In the app, open **Configure API Keys → AI Provider → Local (Ollama / LMStudio)**, set the endpoint, model name, and (optionally) an API key, then click **Test** to verify the connection before saving. + +If you see `NetworkError when attempting to fetch resource` (Firefox) or `Failed to fetch` (Chrome) when starting an analysis, the server is unreachable or CORS is disabled — the in-app error message includes the exact fix for your server. + ## Usage 1. Enter a GitHub repository (e.g., `owner/repo`) or a full URL in the input field. diff --git a/src/lib/api/local.test.ts b/src/lib/api/local.test.ts index 952c8ed..0a27ece 100644 --- a/src/lib/api/local.test.ts +++ b/src/lib/api/local.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { callLocal, resolveLocalConfig, isLocalConfigured } from './local'; +import { + callLocal, + resolveLocalConfig, + isLocalConfigured, + testLocalConnection, + detectLocalServer, + corsHelpText, +} from './local'; describe('Local LLM client', () => { let mockFetch: any; @@ -48,8 +55,6 @@ describe('Local LLM client', () => { { role: 'system', content: 'system' }, { role: 'user', content: 'user' }, ]); - // Local providers should not get the strict json_object response_format - // (some local backends reject it). expect(body.response_format).toBeUndefined(); }); @@ -101,6 +106,144 @@ describe('Local LLM client', () => { /Local LLM error 503/, ); }); + + it('wraps network errors (e.g. CORS rejections) with a helpful, endpoint-specific message', async () => { + const networkError = new TypeError('NetworkError when attempting to fetch resource.'); + mockFetch.mockRejectedValue(networkError); + + await expect( + callLocal('s', 'u', { endpoint: 'http://127.0.0.1:1234/v1', model: 'm' }), + ).rejects.toThrow(/Could not reach the local LLM server at http:\/\/127\.0\.0\.1:1234\/v1/); + + await expect( + callLocal('s', 'u', { endpoint: 'http://127.0.0.1:1234/v1', model: 'm' }), + ).rejects.toThrow(/LMStudio/); + + mockFetch.mockClear(); + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); + + await expect( + callLocal('s', 'u', { endpoint: 'http://localhost:11434/v1', model: 'm' }), + ).rejects.toThrow(/Ollama/); + }); + }); + + describe('testLocalConnection', () => { + it('returns ok with the list of model ids on success', async () => { + mockFetch.mockResolvedValue( + createMockResponse(200, { + data: [{ id: 'llama3.2' }, { id: 'qwen2.5-coder:7b' }, { id: 42 }], + }), + ); + + const result = await testLocalConnection({ + endpoint: 'http://localhost:11434/v1', + model: 'llama3.2', + }); + + expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:11434/v1/models'); + expect(mockFetch.mock.calls[0][1].method).toBe('GET'); + expect(result.ok).toBe(true); + expect(result.models).toEqual(['llama3.2', 'qwen2.5-coder:7b']); + }); + + it('returns ok with empty model list when response has no data array', async () => { + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + const result = await testLocalConnection({ + endpoint: 'http://localhost:1234/v1', + model: 'm', + }); + + expect(result.ok).toBe(true); + expect(result.models).toEqual([]); + }); + + it('returns a CORS-aware error when fetch itself rejects', async () => { + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); + + const result = await testLocalConnection({ + endpoint: 'http://127.0.0.1:1234/v1', + model: 'm', + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/Could not reach the local LLM server/); + expect(result.error).toMatch(/LMStudio/); + expect(result.models).toBeUndefined(); + }); + + it('returns a status error when the server responds with non-OK', async () => { + mockFetch.mockResolvedValue(createMockResponse(404, 'not here')); + + const result = await testLocalConnection({ + endpoint: 'http://localhost:11434/v1', + model: 'm', + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/404/); + }); + + it('returns a parse error when the server returns non-JSON', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + // Simulate a real browser response.json() that throws on invalid JSON + json: async () => { + throw new SyntaxError('Unexpected token < in JSON at position 0'); + }, + text: async () => 'not json', + }); + + const result = await testLocalConnection({ + endpoint: 'http://localhost:11434/v1', + model: 'm', + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/non-JSON/); + }); + + it('sends the Authorization header when an API key is configured', async () => { + mockFetch.mockResolvedValue(createMockResponse(200, { data: [] })); + + await testLocalConnection({ + endpoint: 'http://localhost:1234/v1', + model: 'm', + apiKey: 'lm-studio', + }); + expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe('Bearer lm-studio'); + }); + }); + + describe('detectLocalServer', () => { + it('detects LMStudio by port 1234', () => { + expect(detectLocalServer('http://127.0.0.1:1234/v1')).toBe('lmstudio'); + expect(detectLocalServer('http://localhost:1234/v1')).toBe('lmstudio'); + }); + + it('detects Ollama by port 11434', () => { + expect(detectLocalServer('http://127.0.0.1:11434/v1')).toBe('ollama'); + expect(detectLocalServer('http://localhost:11434')).toBe('ollama'); + }); + + it('falls back to "other" for unknown ports', () => { + expect(detectLocalServer('http://example.com:9000/v1')).toBe('other'); + }); + }); + + describe('corsHelpText', () => { + it('mentions LMStudio CORS setting for port 1234', () => { + expect(corsHelpText('http://127.0.0.1:1234/v1')).toMatch(/LMStudio/); + expect(corsHelpText('http://127.0.0.1:1234/v1')).toMatch(/CORS/); + }); + + it('mentions Ollama CORS guidance for port 11434', () => { + expect(corsHelpText('http://localhost:11434/v1')).toMatch(/Ollama/); + expect(corsHelpText('http://localhost:11434/v1')).toMatch(/OLLAMA_ORIGINS/); + }); }); describe('resolveLocalConfig', () => { diff --git a/src/lib/api/local.ts b/src/lib/api/local.ts index 8d5b144..8855c88 100644 --- a/src/lib/api/local.ts +++ b/src/lib/api/local.ts @@ -11,6 +11,33 @@ function normalizeEndpoint(endpoint: string): string { return endpoint.replace(/\/+$/, ''); } +export function detectLocalServer(endpoint: string): 'lmstudio' | 'ollama' | 'other' { + const url = endpoint.toLowerCase(); + if (url.includes(':1234')) return 'lmstudio'; + if (url.includes(':11434')) return 'ollama'; + return 'other'; +} + +export function corsHelpText(endpoint: string): string { + const server = detectLocalServer(endpoint); + if (server === 'lmstudio') { + return 'In LMStudio, open the "Local Server" tab, click the ⚙ settings icon, and enable "CORS".'; + } + if (server === 'ollama') { + return 'In Ollama, set the OLLAMA_ORIGINS="*" environment variable before starting the server (or update to Ollama 0.1.14+, which allows browser origins by default).'; + } + return 'Make sure the server allows browser (CORS) requests from this origin.'; +} + +function describeNetworkError(endpoint: string, original: string): string { + return ( + `Could not reach the local LLM server at ${endpoint}. ` + + `Verify the server is running and reachable. ` + + `If the server is up, this is almost always a CORS issue — ${corsHelpText(endpoint)} ` + + `(Original error: ${original})` + ); +} + export async function callLocal( prompt: string, userMessage: string, @@ -24,19 +51,25 @@ export async function callLocal( headers.Authorization = `Bearer ${options.apiKey}`; } - const response = await fetch(`${endpoint}/chat/completions`, { - method: 'POST', - headers, - body: JSON.stringify({ - model: options.model, - messages: [ - { role: 'system', content: prompt }, - { role: 'user', content: userMessage }, - ], - temperature: 0.3, - max_tokens: 1024, - }), - }); + let response: Response; + try { + response = await fetch(`${endpoint}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: options.model, + messages: [ + { role: 'system', content: prompt }, + { role: 'user', content: userMessage }, + ], + temperature: 0.3, + max_tokens: 1024, + }), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(describeNetworkError(endpoint, message), { cause: err }); + } if (response.status === 429) { throw new Error('RATE_LIMITED'); @@ -51,6 +84,55 @@ export async function callLocal( return data.choices?.[0]?.message?.content || ''; } +export interface LocalConnectionTestResult { + ok: boolean; + models?: string[]; + error?: string; +} + +export async function testLocalConnection( + options: LocalProviderOptions, +): Promise { + const endpoint = normalizeEndpoint(options.endpoint); + const headers: Record = {}; + if (options.apiKey) { + headers.Authorization = `Bearer ${options.apiKey}`; + } + + let response: Response; + try { + response = await fetch(`${endpoint}/models`, { method: 'GET', headers }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: describeNetworkError(endpoint, message) }; + } + + if (!response.ok) { + return { + ok: false, + error: `Local server responded with ${response.status} ${response.statusText}.`, + }; + } + + let data: any; + try { + data = await response.json(); + } catch { + return { + ok: false, + error: 'Local server responded with non-JSON data — is this an OpenAI-compatible endpoint?', + }; + } + + const models: string[] = Array.isArray(data?.data) + ? data.data + .map((m: any) => (typeof m?.id === 'string' ? m.id : null)) + .filter((m: string | null): m is string => m !== null) + : []; + + return { ok: true, models }; +} + export function resolveLocalConfig(override?: Partial): LocalProviderConfig { return { endpoint: override?.endpoint?.trim() || CONFIG.DEFAULT_LOCAL_ENDPOINT, diff --git a/src/screens/InputScreen.test.tsx b/src/screens/InputScreen.test.tsx index 3853c32..e7e10d0 100644 --- a/src/screens/InputScreen.test.tsx +++ b/src/screens/InputScreen.test.tsx @@ -1,9 +1,10 @@ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; import { InputScreen } from './InputScreen'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useAppStore } from '../store/appStore'; import React from 'react'; import { historyService } from '../lib/history/historyService'; +import { testLocalConnection } from '../lib/api/local'; vi.mock('../hooks/useTheme', () => ({ useTheme: () => ({ @@ -19,6 +20,14 @@ vi.mock('../lib/history/historyService', () => ({ }, })); +vi.mock('../lib/api/local', async () => { + const actual = await vi.importActual('../lib/api/local'); + return { + ...actual, + testLocalConnection: vi.fn(), + }; +}); + describe('InputScreen', () => { beforeEach(() => { useAppStore.getState().reset(); @@ -26,6 +35,10 @@ describe('InputScreen', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('renders input screen and allows typing', () => { render(); @@ -138,4 +151,83 @@ describe('InputScreen', () => { expect(useAppStore.getState().currentScreen).toBe('report'); }); }); + + describe('Local provider UI', () => { + function openConfigAndSelectLocal() { + act(() => { + fireEvent.click(screen.getByRole('button', { name: /Configure API Keys/i })); + }); + act(() => { + fireEvent.click(screen.getByRole('button', { name: /Local \(Ollama \/ LMStudio\)/i })); + }); + } + + it('hides the local fields until the provider is set to Local', () => { + render(); + act(() => { + fireEvent.click(screen.getByRole('button', { name: /Configure API Keys/i })); + }); + + expect(screen.queryByText('Local Endpoint')).not.toBeInTheDocument(); + expect(screen.queryByText('Local Model Name')).not.toBeInTheDocument(); + }); + + it('renders the local fields and a Test button once Local is selected', () => { + render(); + openConfigAndSelectLocal(); + + expect(screen.getByText('Local Endpoint')).toBeInTheDocument(); + expect(screen.getByText('Local Model Name')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Test$/ })).toBeInTheDocument(); + // The hint should make the /v1 path obvious — verify the suggested + // endpoint elements for both servers are present. + expect(screen.getByText('http://localhost:11434/v1')).toBeInTheDocument(); + expect(screen.getByText('http://localhost:1234/v1')).toBeInTheDocument(); + }); + + it('shows a success message with model names when the test connection succeeds', async () => { + vi.mocked(testLocalConnection).mockResolvedValue({ + ok: true, + models: ['llama3.2', 'qwen2.5-coder:7b'], + }); + + render(); + openConfigAndSelectLocal(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^Test$/ })); + }); + + await waitFor(() => { + expect(screen.getByText(/2 models available/)).toBeInTheDocument(); + }); + expect(screen.getByText(/llama3\.2/)).toBeInTheDocument(); + expect(screen.getByText(/qwen2\.5-coder:7b/)).toBeInTheDocument(); + }); + + it('shows a CORS-aware error message when the test connection fails', async () => { + vi.mocked(testLocalConnection).mockResolvedValue({ + ok: false, + error: + 'Could not reach the local LLM server at http://127.0.0.1:1234/v1. ' + + 'In LMStudio, open the "Local Server" tab, click the ⚙ settings icon, and enable "CORS". ' + + '(Original error: NetworkError when attempting to fetch resource.)', + }); + + render(); + openConfigAndSelectLocal(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^Test$/ })); + }); + + await waitFor(() => { + expect(screen.getByText(/Could not reach the local LLM server/)).toBeInTheDocument(); + }); + // The error message itself contains "LMStudio" and "CORS". + const errorText = screen.getByText(/Could not reach the local LLM server/); + expect(errorText.textContent).toMatch(/LMStudio/); + expect(errorText.textContent).toMatch(/CORS/); + }); + }); }); diff --git a/src/screens/InputScreen.tsx b/src/screens/InputScreen.tsx index b7f21fc..23fab17 100644 --- a/src/screens/InputScreen.tsx +++ b/src/screens/InputScreen.tsx @@ -21,6 +21,7 @@ import { import { CONFIG } from '../lib/constants'; import { historyService } from '../lib/history/historyService'; import { formatTimeAgo } from '../lib/utils/formatters'; +import { testLocalConnection, type LocalConnectionTestResult } from '../lib/api/local'; import type { AIProvider } from '../lib/types'; // Storage keys for localStorage @@ -73,6 +74,12 @@ export function InputScreen() { const [localMaxIssues, setLocalMaxIssues] = useState(maxIssues); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [rememberKeys, setRememberKeys] = useState(false); + const [connectionTest, setConnectionTest] = useState< + | { state: 'idle' } + | { state: 'testing' } + | { state: 'ok'; models: string[] } + | { state: 'error'; message: string } + >({ state: 'idle' }); const inputRef = React.useRef(null); @@ -163,6 +170,20 @@ export function InputScreen() { } }; + const handleTestConnection = async () => { + setConnectionTest({ state: 'testing' }); + const result: LocalConnectionTestResult = await testLocalConnection({ + endpoint: localEndpointInput, + model: localModelInput, + apiKey: localApiKeyInput, + }); + if (result.ok) { + setConnectionTest({ state: 'ok', models: result.models ?? [] }); + } else { + setConnectionTest({ state: 'error', message: result.error || 'Unknown error' }); + } + }; + const handleSubmit = (e?: React.FormEvent) => { if (e) e.preventDefault(); const validation = validateRepoInput(repoInput); @@ -760,8 +781,7 @@ export function InputScreen() { marginBottom: '6px', }} > - Local Endpoint (Ollama: http://localhost:11434/v1 · LMStudio: - http://localhost:1234/v1) + Local Endpoint
{ setLocalEndpointInput(e.target.value); setSaveStatus('idle'); + setConnectionTest({ state: 'idle' }); }} - placeholder={CONFIG.DEFAULT_LOCAL_ENDPOINT} + placeholder="http://localhost:11434/v1" style={{ width: '100%', - padding: '10px 10px 10px 36px', + padding: '10px 90px 10px 36px', fontSize: '13px', background: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)', @@ -794,7 +815,85 @@ export function InputScreen() { color: 'var(--text-dim)', }} /> +
+
+ Must include the /v1 path: Ollama →{' '} + http://localhost:11434/v1 · LMStudio →{' '} + http://localhost:1234/v1 +
+ {connectionTest.state === 'ok' && ( +
+ + + Connected. + {connectionTest.models.length > 0 ? ( + <> + {' '} + {connectionTest.models.length} model + {connectionTest.models.length === 1 ? '' : 's'} available + {connectionTest.models.length <= 5 && ( + <>: {connectionTest.models.join(', ')} + )} + . + + ) : ( + ' Server reachable.' + )} + +
+ )} + {connectionTest.state === 'error' && ( +
+ + {connectionTest.message} +
+ )}