From dae20c166a05d145fc9c6ad4990c6946b5086891 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 14 Oct 2026 10:45:38 +0200 Subject: [PATCH] fix(admin): keep an Anthropic Messages gateway as the AI parsing base URL (#2655) Both admin forms hid the field for Anthropic and the shared hook saved an empty address, wiping a gateway configured on the server. The field shows for every provider now and is saved as typed; switching provider empties it, so an address meant for one never receives another provider's key. --- .../components/Admin/AddonManager.test.tsx | 2 + client/src/components/Admin/AddonManager.tsx | 14 +++-- .../src/components/Admin/addons/addonModel.ts | 13 +++++ .../Admin/addons/useLlmParsingConfig.test.ts | 54 +++++++++++++++++-- .../Admin/addons/useLlmParsingConfig.ts | 19 +++++-- .../screens/admin/MAdminAddonManager.tsx | 29 +++++----- .../mobile/admin/MAdminAddonManager.test.tsx | 1 + 7 files changed, 102 insertions(+), 30 deletions(-) diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 248e4fda7..e1d0d42c8 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -646,6 +646,8 @@ describe('AddonManager', () => { await user.click(screen.getByRole('button', { name: 'OpenAI' })); await user.click(screen.getByRole('button', { name: 'Anthropic' })); expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument(); + // A gateway can stand in for Anthropic too, so the field stays (#2655). + expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue(''); expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument(); expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument(); }); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 92a2acb3e..ea57d7ddc 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -14,7 +14,7 @@ import { Tooltip } from '../shared/Tooltip' import { fs } from '../shared/DialogShell' import { EditorField, INPUT, Segmented } from '../shared/dialogParts' import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit' -import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel' +import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel' import { useAddonManager } from './addons/useAddonManager' import { useLlmParsingConfig } from './addons/useLlmParsingConfig' import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons' @@ -265,7 +265,7 @@ const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py function LlmParsingConfig({ addon }: { addon: Addon }) { const { t } = useTranslation() const { - provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving, + provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving, installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save, } = useLlmParsingConfig(addon) const fieldId = useId() @@ -288,15 +288,13 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { setProvider(String(v))} options={providerOptions} /> - {provider !== 'anthropic' && ( - - setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} /> - - )} + + setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} /> + setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} /> diff --git a/client/src/components/Admin/addons/addonModel.ts b/client/src/components/Admin/addons/addonModel.ts index 580a3bd78..a2e2da0e7 100644 --- a/client/src/components/Admin/addons/addonModel.ts +++ b/client/src/components/Admin/addons/addonModel.ts @@ -64,6 +64,19 @@ export function getAddonLabel(t: (key: string) => string, addon: Addon): { name: export const MASKED = '••••••••'; export const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'; +/** What an empty Base URL stands for with each provider, shown as its placeholder. */ +export const LLM_BASE_URL_PLACEHOLDER: Record = { + local: DEFAULT_OLLAMA_URL, + openai: 'https://api.openai.com/v1', + // The Anthropic client appends /v1/messages itself, so a gateway is given without /v1. + anthropic: 'https://api.anthropic.com', +}; + +/** Under the Anthropic key: what only Anthropic can read, and what a gateway in its place needs (#2655). */ +export const ANTHROPIC_HINT = + 'Anthropic reads PDFs (including scans) natively. Local and OpenAI models receive extracted text, so scanned PDFs need Anthropic. ' + + 'A gateway in place of api.anthropic.com goes in Base URL without /v1 and has to accept PDF document blocks and forced tool calls.'; + /** Curated models the local extractor is tuned for, pullable via Ollama. The router drives * one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled * automatically, so the Qwen3 family works without any tuning. A host only needs one. */ diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts index ef752bd22..c205c9dc0 100644 --- a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts +++ b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts @@ -52,15 +52,19 @@ describe('useLlmParsingConfig', () => { expect(result.current.isInstalled('llama3')).toBe(false); }); - it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing until switched to local, then uses the typed URL', async () => { + it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => { const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' })) ); expect(adminApi.llmLocalModels).not.toHaveBeenCalled(); await act(() => result.current.loadModels()); expect(adminApi.llmLocalModels).not.toHaveBeenCalled(); + // The OpenAI address does not follow to another provider (#2655). act(() => result.current.setProvider('local')); - await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith('http://box:11434/v1')); + await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL)); + act(() => result.current.setBaseUrl(' http://box:11434/v1 ')); + await act(() => result.current.loadModels()); + expect(adminApi.llmLocalModels).toHaveBeenLastCalledWith('http://box:11434/v1'); }); it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => { @@ -148,7 +152,7 @@ describe('useLlmParsingConfig', () => { expect(result.current.saving).toBe(false); }); - it('FE-HOOK-LLMCFG-009: Anthropic is saved with an empty base URL, whatever was typed before', async () => { + it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => { const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' }))); act(() => { result.current.setProvider('anthropic'); @@ -160,6 +164,50 @@ describe('useLlmParsingConfig', () => { }); }); + it('FE-HOOK-LLMCFG-009b: an Anthropic gateway URL survives a save (#2655)', async () => { + const { result } = renderHook(() => + useLlmParsingConfig(llm({ provider: 'anthropic', model: 'gateway-model', baseUrl: 'http://gateway.lan:4000' })) + ); + expect(result.current.baseUrl).toBe('http://gateway.lan:4000'); + await act(() => result.current.save()); + expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({ + provider: 'anthropic', + baseUrl: 'http://gateway.lan:4000', + }); + }); + + it('FE-HOOK-LLMCFG-009c: a URL typed after picking Anthropic is saved, trimmed', async () => { + const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' }))); + act(() => result.current.setProvider('anthropic')); + act(() => result.current.setBaseUrl(' http://gw:4000 ')); + await act(() => result.current.save()); + expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({ baseUrl: 'http://gw:4000' }); + }); + + it('FE-HOOK-LLMCFG-009d: away and back again brings the saved URL back; staying put keeps a typed one', () => { + const { result } = renderHook(() => + useLlmParsingConfig(llm({ provider: 'anthropic', baseUrl: 'http://gateway.lan:4000' })) + ); + act(() => result.current.setProvider('local')); + expect(result.current.baseUrl).toBe(''); + act(() => result.current.setProvider('openai')); + expect(result.current.baseUrl).toBe(''); + act(() => result.current.setProvider('anthropic')); + expect(result.current.baseUrl).toBe('http://gateway.lan:4000'); + act(() => result.current.setBaseUrl('http://typed:4000')); + act(() => result.current.setProvider('anthropic')); + expect(result.current.baseUrl).toBe('http://typed:4000'); + }); + + it('FE-HOOK-LLMCFG-009e: the placeholder names the address an empty field stands for', () => { + const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' }))); + expect(result.current.baseUrlPlaceholder).toBe('http://localhost:11434/v1'); + act(() => result.current.setProvider('openai')); + expect(result.current.baseUrlPlaceholder).toBe('https://api.openai.com/v1'); + act(() => result.current.setProvider('anthropic')); + expect(result.current.baseUrlPlaceholder).toBe('https://api.anthropic.com'); + }); + it('FE-HOOK-LLMCFG-010: a failed save toasts and clears saving', async () => { vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x')); const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' }))); diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.ts b/client/src/components/Admin/addons/useLlmParsingConfig.ts index 2084f6d3e..0e1b9d731 100644 --- a/client/src/components/Admin/addons/useLlmParsingConfig.ts +++ b/client/src/components/Admin/addons/useLlmParsingConfig.ts @@ -4,7 +4,7 @@ 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'; +import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel'; /** * Instance-wide AI-parsing config behind both admin shells. When set, it applies to the @@ -15,7 +15,7 @@ import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel'; export function useLlmParsingConfig(addon: Addon) { const toast = useToast(); const cfg = addon.config ?? {}; - const [provider, setProvider] = useState((cfg.provider as string) ?? 'local'); + const [provider, setProviderState] = useState((cfg.provider as string) ?? 'local'); const [model, setModel] = useState((cfg.model as string) ?? ''); const [baseUrl, setBaseUrl] = useState((cfg.baseUrl as string) ?? ''); const [apiKey, setApiKey] = useState((cfg.apiKey as string) ?? ''); @@ -30,6 +30,18 @@ export function useLlmParsingConfig(addon: Addon) { const [pullPct, setPullPct] = useState(0); const [pullStatus, setPullStatus] = useState(''); + /** + * A different provider never inherits the last one's endpoint: an Ollama address + * left in the field would quietly receive the OpenAI or Anthropic key. Back on the + * saved provider, its saved address returns. Any provider, Anthropic included, may + * keep an address the admin typed for it (#2655); empty means the provider default. + */ + const setProvider = (next: string) => { + if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : ''); + setProviderState(next); + }; + const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai; + const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL; const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id)); @@ -89,7 +101,7 @@ export function useLlmParsingConfig(addon: Addon) { config: { provider, model: model.trim(), - baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), + baseUrl: baseUrl.trim(), apiKey, vision, }, @@ -109,6 +121,7 @@ export function useLlmParsingConfig(addon: Addon) { setModel, baseUrl, setBaseUrl, + baseUrlPlaceholder, apiKey, setApiKey, vision, diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 4e161f5b5..387441c24 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -14,6 +14,7 @@ import { type Addon, type CollabFeatures, type ProviderOption, + ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, @@ -255,7 +256,7 @@ function MSubRow({ icon: Icon, providerIcon: ProviderIcon, title, subtitle, enab function LlmParsingConfig({ addon }: { addon: Addon }) { const { t } = useTranslation() const { - provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving, + provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving, installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save, } = useLlmParsingConfig(addon) @@ -302,18 +303,16 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { })} - {provider !== 'anthropic' && ( - - setBaseUrl(e.target.value)} - onBlur={loadModels} - placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} - /> - - )} + + setBaseUrl(e.target.value)} + onBlur={loadModels} + placeholder={baseUrlPlaceholder} + /> + {provider === 'anthropic' && ( -

- Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic. -

+

{ANTHROPIC_HINT}

)} diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx index d586b9ff4..b06d5f98b 100644 --- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx @@ -574,6 +574,7 @@ describe('MAdminAddonManager', () => { await user.click(screen.getByRole('button', { name: 'Anthropic' })); expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue(''); expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument(); expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument(); });