mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user