diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts index adbde8631..ef752bd22 100644 --- a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts +++ b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts @@ -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; diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.ts b/client/src/components/Admin/addons/useLlmParsingConfig.ts index bd73bf335..2084f6d3e 100644 --- a/client/src/components/Admin/addons/useLlmParsingConfig.ts +++ b/client/src/components/Admin/addons/useLlmParsingConfig.ts @@ -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); diff --git a/server/scripts/service-http-baseline.json b/server/scripts/service-http-baseline.json index 741483c4e..556ead4a3 100644 --- a/server/scripts/service-http-baseline.json +++ b/server/scripts/service-http-baseline.json @@ -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, diff --git a/server/src/nest/llm-parse/llm-config.resolver.ts b/server/src/nest/llm-parse/llm-config.resolver.ts index ef57eb383..84b8f3317 100644 --- a/server/src/nest/llm-parse/llm-config.resolver.ts +++ b/server/src/nest/llm-parse/llm-config.resolver.ts @@ -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 { const settings = await this.settings.getUserSettings(userId); const provider = asProvider(settings.llm_provider); diff --git a/server/src/nest/llm-parse/llm-local.controller.ts b/server/src/nest/llm-parse/llm-local.controller.ts index 670638cfb..0349b9e9b 100644 --- a/server/src/nest/llm-parse/llm-local.controller.ts +++ b/server/src/nest/llm-parse/llm-local.controller.ts @@ -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 { - 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 { + // 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; + } } diff --git a/server/src/nest/llm-parse/llm-local.service.ts b/server/src/nest/llm-parse/llm-local.service.ts index 4dfeae1c2..370d10b6c 100644 --- a/server/src/nest/llm-parse/llm-local.service.ts +++ b/server/src/nest/llm-parse/llm-local.service.ts @@ -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 { + 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(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 { + 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> { + async pull(baseUrl: string | undefined, model: string, apiKey?: string): Promise> { 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), }); diff --git a/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts b/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts index 892cee126..a9e694579 100644 --- a/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts +++ b/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts @@ -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(); + }); +}); diff --git a/server/tests/unit/nest/llm-parse/llm-local.controller.test.ts b/server/tests/unit/nest/llm-parse/llm-local.controller.test.ts new file mode 100644 index 000000000..7083caa9d --- /dev/null +++ b/server/tests/unit/nest/llm-parse/llm-local.controller.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/server/tests/unit/nest/llm-parse/llm-local.service.test.ts b/server/tests/unit/nest/llm-parse/llm-local.service.test.ts index 5a461342b..521b6bfa8 100644 --- a/server/tests/unit/nest/llm-parse/llm-local.service.test.ts +++ b/server/tests/unit/nest/llm-parse/llm-local.service.test.ts @@ -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', () => {