fix(llm-parse): the local model list and pull send the saved api key to an ollama behind an auth proxy

The admin screen's model list (/api/tags) and pull (/api/pull) went out
without the Authorization header that the extraction and /api/show already
send, so an Ollama behind a Bearer proxy answered 401 and the list came back
as a 502. The controller now adds the key saved with the local provider, read
server side through LlmConfigResolver.readLocalServerAccess, and only when the
requested base URL reaches the same server root as the saved one, so another
address in the form never receives it.

A 401 or 403 now says whether the key was rejected or none is saved for that
address, and the admin form shows the server's reason instead of the axios
status line. A server without /api/tags (LM Studio, vLLM) is asked for its
OpenAI-compatible /v1/models instead, and the list body is read with a size
cap and checked before use.

Refs #2649
This commit is contained in:
Maurice
2026-10-12 21:04:23 +02:00
parent accad52ed5
commit 66e574fcc0
9 changed files with 332 additions and 25 deletions
@@ -5,7 +5,7 @@ import { adminApi } from '../../../api/client';
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
import { useLlmParsingConfig } from './useLlmParsingConfig';
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-010
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -74,6 +74,15 @@ describe('useLlmParsingConfig', () => {
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
});
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
const refused = Object.assign(new Error('Request failed with status code 502'), {
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
});
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
});
it('FE-HOOK-LLMCFG-005: a pull reports progress, selects the model and lists again', async () => {
let progress!: (p: { status?: string; total?: number; completed?: number; error?: string }) => void;
let finish!: () => void;
@@ -2,6 +2,7 @@ import { type LlmVision, asLlmVision } from '@trek/shared';
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
@@ -40,7 +41,8 @@ export function useLlmParsingConfig(addon: Addon) {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server');
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server'));
setInstalled([]);
} finally {
setLoadingModels(false);