fix(local): give a CORS-aware error and a Test Connection button

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.
This commit is contained in:
vee1e 2026-06-29 18:06:34 +05:30
parent 0bb9cd320e
commit 000f105d70
No known key found for this signature in database
GPG key ID: EB498AFC60A7A01A
5 changed files with 451 additions and 21 deletions

View file

@ -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.

View file

@ -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 () => '<html>not json</html>',
});
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', () => {

View file

@ -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<LocalConnectionTestResult> {
const endpoint = normalizeEndpoint(options.endpoint);
const headers: Record<string, string> = {};
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>): LocalProviderConfig {
return {
endpoint: override?.endpoint?.trim() || CONFIG.DEFAULT_LOCAL_ENDPOINT,

View file

@ -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<typeof import('../lib/api/local')>('../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(<InputScreen />);
@ -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(<InputScreen />);
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(<InputScreen />);
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 <code> 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(<InputScreen />);
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(<InputScreen />);
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/);
});
});
});

View file

@ -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<HTMLInputElement>(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
</label>
<div style={{ position: 'relative' }}>
<input
@ -770,11 +790,12 @@ export function InputScreen() {
onChange={(e) => {
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)',
}}
/>
<button
type="button"
onClick={handleTestConnection}
disabled={connectionTest.state === 'testing'}
style={{
position: 'absolute',
right: '6px',
top: '50%',
transform: 'translateY(-50%)',
padding: '4px 10px',
fontSize: '11px',
fontFamily: 'var(--font-mono)',
background: 'var(--text)',
color: 'var(--bg)',
border: 'none',
borderRadius: '4px',
cursor: connectionTest.state === 'testing' ? 'wait' : 'pointer',
opacity: connectionTest.state === 'testing' ? 0.7 : 1,
}}
>
{connectionTest.state === 'testing' ? 'Testing…' : 'Test'}
</button>
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-dim)',
marginTop: '4px',
}}
>
Must include the <code>/v1</code> path: Ollama {' '}
<code>http://localhost:11434/v1</code> · LMStudio →{' '}
<code>http://localhost:1234/v1</code>
</div>
{connectionTest.state === 'ok' && (
<div
style={{
fontSize: '11px',
color: 'var(--status-success)',
marginTop: '6px',
display: 'flex',
alignItems: 'flex-start',
gap: '4px',
}}
>
<span></span>
<span>
Connected.
{connectionTest.models.length > 0 ? (
<>
{' '}
{connectionTest.models.length} model
{connectionTest.models.length === 1 ? '' : 's'} available
{connectionTest.models.length <= 5 && (
<>: {connectionTest.models.join(', ')}</>
)}
.
</>
) : (
' Server reachable.'
)}
</span>
</div>
)}
{connectionTest.state === 'error' && (
<div
style={{
fontSize: '11px',
color: 'var(--status-error)',
marginTop: '6px',
display: 'flex',
alignItems: 'flex-start',
gap: '4px',
}}
>
<span></span>
<span>{connectionTest.message}</span>
</div>
)}
</div>
<div>