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.
This commit is contained in:
Maurice
2026-10-14 10:45:38 +02:00
committed by Maurice
parent 0d4ddb2089
commit dae20c166a
7 changed files with 102 additions and 30 deletions
@@ -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();
});
+6 -8
View File
@@ -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 }) {
<EditorField label="Provider">
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</EditorField>
{provider !== 'anthropic' && (
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</EditorField>
)}
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
</EditorField>
<EditorField
label="API key"
htmlFor={`${fieldId}-key`}
hint={provider === 'anthropic' ? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.' : undefined}
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
>
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</EditorField>
@@ -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<string, string> = {
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. */
@@ -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' })));
@@ -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<string>((cfg.provider as string) ?? 'local');
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
const [apiKey, setApiKey] = useState<string>((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,
@@ -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 }) {
})}
</div>
</MAdminField>
{provider !== 'anthropic' && (
<MAdminField label="Base URL">
<MAdminInput
type="url"
autoComplete="off"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
onBlur={loadModels}
placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'}
/>
</MAdminField>
)}
<MAdminField label="Base URL">
<MAdminInput
type="url"
autoComplete="off"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
onBlur={loadModels}
placeholder={baseUrlPlaceholder}
/>
</MAdminField>
<MAdminField label="API key">
<MAdminSecretInput
value={apiKey}
@@ -322,9 +321,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
/>
</MAdminField>
{provider === 'anthropic' && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">
Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.
</p>
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">{ANTHROPIC_HINT}</p>
)}
</section>
@@ -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();
});