mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"src/nest/collab/collab.mcp.ts": 3,
|
||||
"src/nest/collab/collab.rpc.ts": 2,
|
||||
"src/nest/doc-sync/doc-sync-config.service.ts": 2,
|
||||
"src/nest/llm-parse/llm-local.service.ts": 7,
|
||||
"src/nest/llm-parse/llm-local.service.ts": 3,
|
||||
"src/nest/maps/maps-url.resolver.ts": 2,
|
||||
"src/nest/maps/providers/osm.client.ts": 4,
|
||||
"src/nest/memories/immich.controller.ts": 6,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user