Add custom API URL and model to AI settings

- Settings → AI: optional base URL and model name fields
- Defaults to Anthropic/OpenAI public APIs when left blank
- Custom URL enables Open WebUI, LM Studio, Ollama, and any OpenAI-compatible endpoint
- Parse endpoint uses custom base URL and model if configured
- Migration 0004: ai_base_url + ai_model columns on users
- OpenAI provider label updated to "OpenAI-compatible"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-22 19:01:00 +00:00
parent b1c160f607
commit d6118bac54
6 changed files with 124 additions and 29 deletions

View file

@ -3,6 +3,15 @@ import { api } from "./client";
export interface AiSettings {
provider: string | null;
has_api_key: boolean;
base_url: string | null;
model: string | null;
}
export interface AiSettingsSave {
provider: string;
api_key?: string;
base_url?: string;
model?: string;
}
export interface ParsedReceipt {
@ -19,8 +28,8 @@ export async function getAiSettings(): Promise<AiSettings> {
return data;
}
export async function saveAiSettings(provider: string, api_key: string): Promise<AiSettings> {
const { data } = await api.put("/settings/ai", { provider, api_key });
export async function saveAiSettings(body: AiSettingsSave): Promise<AiSettings> {
const { data } = await api.put("/settings/ai", body);
return data;
}

View file

@ -658,6 +658,8 @@ function AiSection() {
const qc = useQueryClient();
const [provider, setProvider] = useState("anthropic");
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [model, setModel] = useState("");
const [showKey, setShowKey] = useState(false);
const [success, setSuccess] = useState("");
@ -666,16 +668,23 @@ function AiSection() {
queryFn: async () => {
const d = await getAiSettings();
if (d.provider) setProvider(d.provider);
if (d.base_url) setBaseUrl(d.base_url);
if (d.model) setModel(d.model);
return d;
},
});
const saveMutation = useMutation({
mutationFn: () => saveAiSettings(provider, apiKey),
mutationFn: () => saveAiSettings({
provider,
api_key: apiKey,
base_url: baseUrl,
model,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-settings"] });
setApiKey("");
setSuccess("API key saved");
setSuccess("Settings saved");
setTimeout(() => setSuccess(""), 3000);
},
});
@ -684,11 +693,15 @@ function AiSection() {
mutationFn: clearAiSettings,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-settings"] });
setSuccess("API key removed");
setBaseUrl(""); setModel("");
setSuccess("AI settings removed");
setTimeout(() => setSuccess(""), 3000);
},
});
const defaultModel = provider === "anthropic" ? "claude-haiku-4-5-20251001" : "gpt-4o-mini";
const defaultUrl = provider === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com";
return (
<div className={cardCls}>
<div className="flex items-center gap-2">
@ -723,8 +736,11 @@ function AiSection() {
className={inputCls}
>
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai">OpenAI (GPT-4o mini)</option>
<option value="openai">OpenAI-compatible</option>
</select>
<p className="text-xs text-muted-foreground mt-1">
OpenAI-compatible works with Open WebUI, LM Studio, Ollama, and any OpenAI-spec endpoint.
</p>
</div>
<div>
@ -737,7 +753,7 @@ function AiSection() {
value={apiKey}
onChange={e => setApiKey(e.target.value)}
className={cn(inputCls, "pr-10")}
placeholder={current?.has_api_key ? "••••••••••••••••" : provider === "anthropic" ? "sk-ant-..." : "sk-..."}
placeholder={current?.has_api_key ? "••••••••••••••••" : provider === "anthropic" ? "sk-ant-..." : "sk-... (use any value if not required)"}
/>
<button
type="button"
@ -747,10 +763,33 @@ function AiSection() {
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Custom API URL <span className="text-muted-foreground font-normal">(optional)</span></label>
<input
type="text"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
className={inputCls}
placeholder={defaultUrl}
/>
<p className="text-xs text-muted-foreground mt-1">
{provider === "anthropic"
? "Get your key at console.anthropic.com → API Keys"
: "Get your key at platform.openai.com → API Keys"}
Leave blank to use the default. For Open WebUI: <code className="bg-secondary px-1 rounded">http://your-server:3000</code>
</p>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Model <span className="text-muted-foreground font-normal">(optional)</span></label>
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
className={inputCls}
placeholder={defaultModel}
/>
<p className="text-xs text-muted-foreground mt-1">
Leave blank to use the default. For Open WebUI, enter the model name exactly as shown in its interface.
</p>
</div>
@ -761,7 +800,7 @@ function AiSection() {
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saveMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{current?.has_api_key && !apiKey ? "Update provider" : "Save API key"}
Save settings
</button>
{current?.has_api_key && (