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
@@ -147,3 +147,25 @@ describe('resolveLlmConfig', () => {
expect(findById).toHaveBeenCalledWith('llm_parsing');
});
});
describe('readLocalServerAccess (#2649)', () => {
it('answers the saved local server and its decrypted key, before a model is chosen', async () => {
setInstanceConfig({ provider: 'local', baseUrl: ' https://ollama.example.org/v1 ', apiKey: 'proxy-key' });
expect(await resolver.readLocalServerAccess()).toEqual({
baseUrl: 'https://ollama.example.org/v1',
apiKey: 'proxy-key',
});
});
it('answers without a key when none is saved, and a blank base URL as the default', async () => {
setInstanceConfig({ provider: 'local', model: 'nuextract', baseUrl: '' });
expect(await resolver.readLocalServerAccess()).toEqual({ baseUrl: undefined, apiKey: undefined });
});
it('answers null when the saved provider is not local, or nothing is saved', async () => {
setInstanceConfig({ provider: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk' });
expect(await resolver.readLocalServerAccess()).toBeNull();
setInstanceConfig(undefined);
expect(await resolver.readLocalServerAccess()).toBeNull();
});
});
@@ -0,0 +1,89 @@
import { AdminGuard } from '../../../../src/nest/auth-core/admin.guard';
import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard';
import { MANAGED_FORBIDDEN } from '../../../../src/nest/common/managed';
import type { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver';
import { LlmLocalController } from '../../../../src/nest/llm-parse/llm-local.controller';
import { LlmLocalService } from '../../../../src/nest/llm-parse/llm-local.service';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const readLocalServerAccess = vi.fn();
const resolver = { readLocalServerAccess } as unknown as LlmConfigResolver;
// The real service, so the root comparison under test is the one the
// controller runs; only the two network calls are replaced.
const local = new LlmLocalService();
const listModels = vi.spyOn(local, 'listModels');
const pull = vi.spyOn(local, 'pull');
const controller = new LlmLocalController(local, resolver);
function fakeRes() {
return { status: vi.fn(), setHeader: vi.fn(), write: vi.fn(), end: vi.fn() };
}
function emptyStream(): ReadableStream<Uint8Array> {
return new ReadableStream({ start: (c) => c.close() });
}
beforeEach(() => {
vi.clearAllMocks();
listModels.mockResolvedValue({ models: [] });
pull.mockImplementation(async () => emptyStream());
readLocalServerAccess.mockResolvedValue({ baseUrl: 'https://ollama.example.org/v1', apiKey: 'stored-key' });
});
describe('LlmLocalController (#2649)', () => {
it('lists the models with the stored key when the URL is the saved server', async () => {
await controller.models('https://ollama.example.org/v1');
expect(listModels).toHaveBeenCalledWith('https://ollama.example.org/v1', 'stored-key');
await controller.models('https://ollama.example.org/');
expect(listModels).toHaveBeenLastCalledWith('https://ollama.example.org/', 'stored-key');
});
it.each([
'https://evil.example.com/v1',
'http://ollama.example.org/v1',
'https://ollama.example.org:8443/v1',
'https://ollama.example.org/other',
'https://ollama.example.org@evil.example.com/v1',
undefined,
])('never hands the stored key to a different server (%s)', async (url) => {
await controller.models(url);
expect(listModels).toHaveBeenLastCalledWith(url, undefined);
});
it('sends no key when none is stored or the provider is not local', async () => {
readLocalServerAccess.mockResolvedValue(null);
await controller.models('https://ollama.example.org/v1');
expect(listModels).toHaveBeenLastCalledWith('https://ollama.example.org/v1', undefined);
readLocalServerAccess.mockResolvedValue({ baseUrl: 'https://ollama.example.org/v1', apiKey: undefined });
await controller.models('https://ollama.example.org/v1');
expect(listModels).toHaveBeenLastCalledWith('https://ollama.example.org/v1', undefined);
});
it('pulls with the stored key only for the saved server', async () => {
await controller.pull({ baseUrl: 'https://ollama.example.org', model: 'nuextract' }, fakeRes() as never);
expect(pull).toHaveBeenLastCalledWith('https://ollama.example.org', 'nuextract', 'stored-key');
await controller.pull({ baseUrl: 'https://evil.example.com', model: 'nuextract' }, fakeRes() as never);
expect(pull).toHaveBeenLastCalledWith('https://evil.example.com', 'nuextract', undefined);
});
it('answers a bad base URL before reading the stored key', async () => {
readLocalServerAccess.mockClear();
listModels.mockRestore();
const fresh = new LlmLocalController(new LlmLocalService(), resolver);
await expect(fresh.models('ftp://x')).rejects.toMatchObject({ status: 400 });
expect(readLocalServerAccess).not.toHaveBeenCalled();
});
it('stays admin-only and forbidden on a managed instance', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, LlmLocalController)).toEqual([JwtAuthGuard, AdminGuard]);
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, LlmLocalController.prototype.models)).toMatchObject({
enforcedInHandler: false,
});
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, LlmLocalController.prototype.pull)).toMatchObject({
enforcedInHandler: false,
});
});
});
@@ -35,6 +35,24 @@ describe('LlmLocalService.ollamaRoot', () => {
});
});
describe('LlmLocalService.sameServer', () => {
it('matches the same root however the base URL is spelled', () => {
expect(svc().sameServer('https://ollama.example.org/v1', 'https://ollama.example.org')).toBe(true);
expect(svc().sameServer('https://OLLAMA.example.org:443/v1/', 'https://ollama.example.org/v1')).toBe(true);
expect(svc().sameServer(undefined, 'http://localhost:11434/v1')).toBe(true);
});
it('does not match another host, scheme, port or path', () => {
const s = svc();
expect(s.sameServer('https://ollama.example.org/v1', 'https://evil.example.com/v1')).toBe(false);
expect(s.sameServer('https://ollama.example.org/v1', 'http://ollama.example.org/v1')).toBe(false);
expect(s.sameServer('https://ollama.example.org/v1', 'https://ollama.example.org:8443/v1')).toBe(false);
expect(s.sameServer('https://proxy.example.org/ollama', 'https://proxy.example.org/other')).toBe(false);
expect(s.sameServer('https://ollama.example.org', 'https://ollama.example.org@evil.example.com')).toBe(false);
expect(s.sameServer('https://ollama.example.org', 'not a url')).toBe(false);
});
});
describe('LlmLocalService.listModels', () => {
it('returns named models from /api/tags', async () => {
const fetchFn = mockFetch(async () => ({
@@ -46,6 +64,45 @@ describe('LlmLocalService.listModels', () => {
expect(fetchFn.mock.calls[0][0]).toBe('http://localhost:11434/api/tags');
});
it('sends the configured key as a Bearer header, and none without one (#2649)', async () => {
const fetchFn = mockFetch(async () => ({ ok: true, json: async () => ({ models: [] }) }));
await svc().listModels('https://ollama.example.org/v1', 'proxy-key');
expect(fetchFn.mock.calls[0][1].headers).toMatchObject({ authorization: 'Bearer proxy-key' });
await svc().listModels('https://ollama.example.org/v1');
expect(fetchFn.mock.calls[1][1].headers ?? {}).not.toHaveProperty('authorization');
expect(fetchFn.mock.calls[0][0]).not.toContain('proxy-key');
});
it('says the key was rejected when the server answers 401 or 403', async () => {
mockFetch(async () => ({ ok: false, status: 401, body: null }));
await expect(svc().listModels('http://ollama:11434', 'wrong')).rejects.toMatchObject({
response: { error: 'Local LLM server rejected the API key (401)' },
});
mockFetch(async () => ({ ok: false, status: 403, body: null }));
await expect(svc().listModels('http://ollama:11434')).rejects.toMatchObject({
response: { error: expect.stringContaining('requires an API key (403)') },
});
});
it('falls back to the OpenAI model list when the server has no /api/tags', async () => {
const fetchFn = mockFetch(async (url?: string) =>
url?.endsWith('/api/tags')
? { ok: false, status: 404, body: null }
: { ok: true, json: async () => ({ data: [{ id: 'qwen3-8b' }, { id: '' }, {}] }) },
);
const out = await svc().listModels('http://lmstudio:1234/v1', 'k');
expect(out.models).toEqual([{ name: 'qwen3-8b', size: 0 }]);
expect(fetchFn.mock.calls[1][0]).toBe('http://lmstudio:1234/v1/models');
expect(fetchFn.mock.calls[1][1].headers).toMatchObject({ authorization: 'Bearer k' });
});
it('keeps the 404 when the OpenAI model list is missing too', async () => {
mockFetch(async () => ({ ok: false, status: 404, body: null }));
await expect(svc().listModels('http://nothing:1')).rejects.toMatchObject({
response: { error: 'Local LLM server error (404)' },
});
});
it('502s when the server is unreachable', async () => {
// Reject only the one call listModels makes (mockImplementationOnce): vitest
// probes the mock a second time and a persistent rejection there would surface
@@ -70,6 +127,24 @@ describe('LlmLocalService.pull', () => {
const init = fetchFn.mock.calls[0][1];
expect(JSON.parse(init.body)).toEqual({ model: 'nuextract', stream: true });
});
it('sends the configured key with the pull, and none without one (#2649)', async () => {
const fetchFn = mockFetch(async () => ({ ok: true, body: {} }));
await svc().pull('https://ollama.example.org/v1', 'nuextract', 'proxy-key');
expect(fetchFn.mock.calls[0][1].headers).toMatchObject({
'content-type': 'application/json',
authorization: 'Bearer proxy-key',
});
await svc().pull('https://ollama.example.org/v1', 'nuextract');
expect(fetchFn.mock.calls[1][1].headers).not.toHaveProperty('authorization');
});
it('says the key was rejected when the pull answers 401', async () => {
mockFetch(async () => ({ ok: false, status: 401, body: null }));
await expect(svc().pull('http://ollama:11434', 'nuextract', 'wrong')).rejects.toMatchObject({
response: { error: 'Local LLM server rejected the API key (401)' },
});
});
});
describe('LlmLocalService.modelCapabilities', () => {