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
@@ -57,6 +57,23 @@ export class LlmConfigResolver {
};
}
/**
* The saved local server and its decrypted key, for the admin screen's model
* list and pull (#2649): a server behind an auth proxy answers neither without
* the key. Read before a model is chosen, since listing the models is how one
* gets chosen, and only while the saved provider is local. The caller decides
* whether the address it is about to call is this one.
*/
async readLocalServerAccess(): Promise<{ baseUrl?: string; apiKey?: string } | null> {
const row = await this.addonsRepo.findById(ADDON_IDS.LLM_PARSING);
const cfg = row?.config;
if (!cfg || asProvider(cfg.provider) !== 'local') return null;
return {
baseUrl: typeof cfg.baseUrl === 'string' && cfg.baseUrl.trim() ? cfg.baseUrl.trim() : undefined,
apiKey: decryptLlmApiKey(cfg.apiKey),
};
}
private async readUserConfig(userId: number): Promise<ResolvedLlmConfig | null> {
const settings = await this.settings.getUserSettings(userId);
const provider = asProvider(settings.llm_provider);
@@ -1,6 +1,7 @@
import { AdminGuard } from '../auth-core/admin.guard';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ManagedForbidden } from '../common/managed';
import { LlmConfigResolver } from './llm-config.resolver';
import { LlmLocalPullDto } from './llm-local.dto';
import { LlmLocalService } from './llm-local.service';
import { Controller, Get, Post, Query, Body, Res, UseGuards } from '@nestjs/common';
@@ -10,16 +11,22 @@ import type { Response } from 'express';
/**
* Admin-only management of a local LLM server (Ollama): list installed models and
* pull new ones (e.g. NuExtract). Used by the AI-parsing addon config UI.
*
* The form never holds the saved API key (it gets the mask), so the key is added
* here from the saved config, and only when the request names the saved server.
*/
@Controller('api/admin/llm/local')
@UseGuards(JwtAuthGuard, AdminGuard)
export class LlmLocalController {
constructor(private readonly local: LlmLocalService) {}
constructor(
private readonly local: LlmLocalService,
private readonly llmConfig: LlmConfigResolver,
) {}
@ManagedForbidden('the model list belongs to the operator runtime, not to one instance')
@Get('models')
models(@Query('baseUrl') baseUrl?: string) {
return this.local.listModels(baseUrl);
async models(@Query('baseUrl') baseUrl?: string) {
return this.local.listModels(baseUrl, await this.savedKeyFor(baseUrl));
}
/**
@@ -30,7 +37,7 @@ export class LlmLocalController {
@ManagedForbidden('pulling a model spends the operator disk and GPU from inside a customer instance')
@Post('pull')
async pull(@Body() body: LlmLocalPullDto, @Res() res: Response): Promise<void> {
const stream = await this.local.pull(body?.baseUrl, body?.model ?? '');
const stream = await this.local.pull(body?.baseUrl, body?.model ?? '', await this.savedKeyFor(body?.baseUrl));
res.status(200);
res.setHeader('Content-Type', 'application/x-ndjson');
res.setHeader('Cache-Control', 'no-cache');
@@ -47,4 +54,13 @@ export class LlmLocalController {
res.end();
}
}
/** The saved key, if `baseUrl` reaches the saved server; never for another address. */
private async savedKeyFor(baseUrl: string | undefined): Promise<string | undefined> {
// A base URL that is no URL is refused as before, without reading the saved config.
this.local.ollamaRoot(baseUrl);
const saved = await this.llmConfig.readLocalServerAccess();
if (!saved?.apiKey || !this.local.sameServer(saved.baseUrl, baseUrl)) return undefined;
return saved.apiKey;
}
}
+95 -18
View File
@@ -1,5 +1,6 @@
import { readCappedJson } from '../../utils/cappedFetch';
import { discardBody, readCappedJson } from '../../utils/cappedFetch';
import { safeFetchLlm } from '../../utils/ssrfGuard';
import { DomainError } from '../common/domain-error';
import { Injectable, HttpException } from '@nestjs/common';
import { createHash } from 'node:crypto';
@@ -15,6 +16,38 @@ const CAPABILITIES_MISS_TTL_MS = 60_000;
const showResponseSchema = z.object({ capabilities: z.array(z.string()).optional() });
/** A model list is a few hundred bytes per model; a megabyte is thousands of them. */
const MAX_LIST_BYTES = 1024 * 1024;
const LIST_TIMEOUT_MS = 10_000;
const tagsResponseSchema = z.object({
models: z.array(z.object({ name: z.string().optional(), size: z.number().optional() })).optional(),
});
/** The OpenAI-compatible list (`GET /v1/models`) that LM Studio, vLLM and llama.cpp serve. */
const openAiModelsSchema = z.object({ data: z.array(z.object({ id: z.string().optional() })) });
/** The Bearer header the extraction sends, when a key is configured. */
function authHeader(apiKey: string | undefined): Record<string, string> {
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
}
/**
* The refusal for an upstream answer that is not ok. A 401 or 403 says what to
* fix: the key that went along was turned down, or the server wants one and
* none is saved for this address yet.
*/
function upstreamRefusal(status: number, apiKey: string | undefined, fallback: string): DomainError {
if (status === 401 || status === 403) {
return new DomainError(
502,
apiKey
? `Local LLM server rejected the API key (${status})`
: `Local LLM server requires an API key (${status}). Save the key for this address first.`,
);
}
return new DomainError(502, fallback);
}
/**
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
@@ -46,26 +79,69 @@ export class LlmLocalService {
return raw.slice(0, end).replace(/\/v1$/, '');
}
/** List models already pulled on the local server. */
async listModels(baseUrl: string | undefined): Promise<{ models: { name: string; size: number }[] }> {
const root = this.ollamaRoot(baseUrl);
let res: Response;
/**
* True when two base URLs reach the same server root: the same scheme, host,
* port and path once `/v1` and trailing slashes are gone. The stored key only
* ever goes to the address it was saved for, so a mistyped or foreign base URL
* in the admin form cannot carry it to another host.
*/
sameServer(a: string | undefined, b: string | undefined): boolean {
try {
res = await safeFetchLlm(`${root}/api/tags`, { signal: AbortSignal.timeout(10_000) });
return new URL(this.ollamaRoot(a)).href === new URL(this.ollamaRoot(b)).href;
} catch {
throw new HttpException({ error: `Could not reach local LLM server at ${root}` }, 502);
return false;
}
if (!res.ok) throw new HttpException({ error: `Local LLM server error (${res.status})` }, 502);
const data = (await res.json()) as { models?: { name?: string; size?: number }[] };
const models = (data.models ?? []).map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name);
}
/**
* List models already pulled on the local server. A server without Ollama's
* `/api/tags` (LM Studio, vLLM, llama.cpp) answers 404 there, so its
* OpenAI-compatible `/v1/models` is asked instead.
*/
async listModels(
baseUrl: string | undefined,
apiKey?: string,
): Promise<{ models: { name: string; size: number }[] }> {
const root = this.ollamaRoot(baseUrl);
let res = await this.getModelList(`${root}/api/tags`, root, apiKey);
let openAi = false;
if (res.status === 404) {
const fallback = await this.getModelList(`${root}/v1/models`, root, apiKey);
if (fallback.ok) {
discardBody(res);
res = fallback;
openAi = true;
} else {
// Neither list exists: report the first answer, as before.
discardBody(fallback);
}
}
if (!res.ok) {
discardBody(res);
throw upstreamRefusal(res.status, apiKey, `Local LLM server error (${res.status})`);
}
const body = await readCappedJson<unknown>(res, MAX_LIST_BYTES);
const listed = openAi
? openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 }))
: tagsResponseSchema.safeParse(body).data?.models;
if (!listed) throw new DomainError(502, 'Local LLM server sent an unreadable model list');
const models = listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name);
return { models };
}
private async getModelList(url: string, root: string, apiKey: string | undefined): Promise<Response> {
try {
return await safeFetchLlm(url, { headers: authHeader(apiKey), signal: AbortSignal.timeout(LIST_TIMEOUT_MS) });
} catch {
throw new DomainError(502, `Could not reach local LLM server at ${root}`);
}
}
/**
* Start a streamed pull. Returns the upstream NDJSON body so the controller can
* pipe Ollama's progress lines straight to the client.
*/
async pull(baseUrl: string | undefined, model: string): Promise<ReadableStream<Uint8Array>> {
async pull(baseUrl: string | undefined, model: string, apiKey?: string): Promise<ReadableStream<Uint8Array>> {
if (!model?.trim()) throw new HttpException({ error: 'model is required' }, 400);
const root = this.ollamaRoot(baseUrl);
let res: Response;
@@ -76,16 +152,20 @@ export class LlmLocalService {
`${root}/api/pull`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', ...authHeader(apiKey) },
body: JSON.stringify({ model: model.trim(), stream: true }),
},
5,
null,
);
} catch {
throw new HttpException({ error: `Could not reach local LLM server at ${root}` }, 502);
throw new DomainError(502, `Could not reach local LLM server at ${root}`);
}
if (!res.ok || !res.body) throw new HttpException({ error: `Pull failed (${res.status})` }, 502);
if (!res.ok) {
discardBody(res);
throw upstreamRefusal(res.status, apiKey, `Pull failed (${res.status})`);
}
if (!res.body) throw new DomainError(502, `Pull failed (${res.status})`);
return res.body;
}
@@ -114,10 +194,7 @@ export class LlmLocalService {
const root = this.ollamaRoot(baseUrl);
const res = await safeFetchLlm(`${root}/api/show`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
},
headers: { 'content-type': 'application/json', ...authHeader(apiKey) },
body: JSON.stringify({ model }),
signal: AbortSignal.timeout(SHOW_TIMEOUT_MS),
});