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:
parent
b1c160f607
commit
d6118bac54
6 changed files with 124 additions and 29 deletions
23
backend/alembic/versions/0004_ai_base_url.py
Normal file
23
backend/alembic/versions/0004_ai_base_url.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""add ai_base_url and ai_model to users
|
||||||
|
|
||||||
|
Revision ID: 0004
|
||||||
|
Revises: 0003
|
||||||
|
Create Date: 2026-04-22
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0004"
|
||||||
|
down_revision = "0003"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("ai_base_url", sa.Text, nullable=True))
|
||||||
|
op.add_column("users", sa.Column("ai_model", sa.Text, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "ai_model")
|
||||||
|
op.drop_column("users", "ai_base_url")
|
||||||
|
|
@ -20,11 +20,15 @@ SUPPORTED_PROVIDERS = {"anthropic", "openai"}
|
||||||
class AiSettingsResponse(BaseModel):
|
class AiSettingsResponse(BaseModel):
|
||||||
provider: str | None
|
provider: str | None
|
||||||
has_api_key: bool
|
has_api_key: bool
|
||||||
|
base_url: str | None
|
||||||
|
model: str | None
|
||||||
|
|
||||||
|
|
||||||
class AiSettingsSave(BaseModel):
|
class AiSettingsSave(BaseModel):
|
||||||
provider: str
|
provider: str
|
||||||
api_key: str
|
api_key: str = ""
|
||||||
|
base_url: str = ""
|
||||||
|
model: str = ""
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ai", response_model=AiSettingsResponse)
|
@router.get("/ai", response_model=AiSettingsResponse)
|
||||||
|
|
@ -32,6 +36,8 @@ async def get_ai_settings(user: User = Depends(get_current_user)):
|
||||||
return AiSettingsResponse(
|
return AiSettingsResponse(
|
||||||
provider=user.ai_provider,
|
provider=user.ai_provider,
|
||||||
has_api_key=bool(user.ai_api_key_enc),
|
has_api_key=bool(user.ai_api_key_enc),
|
||||||
|
base_url=user.ai_base_url,
|
||||||
|
model=user.ai_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,17 +49,26 @@ async def save_ai_settings(
|
||||||
):
|
):
|
||||||
if body.provider not in SUPPORTED_PROVIDERS:
|
if body.provider not in SUPPORTED_PROVIDERS:
|
||||||
raise HTTPException(status_code=400, detail=f"Unsupported provider. Choose: {', '.join(SUPPORTED_PROVIDERS)}")
|
raise HTTPException(status_code=400, detail=f"Unsupported provider. Choose: {', '.join(SUPPORTED_PROVIDERS)}")
|
||||||
if not body.api_key.strip():
|
|
||||||
raise HTTPException(status_code=400, detail="api_key must not be empty")
|
|
||||||
|
|
||||||
encrypted = encrypt_field(body.api_key.strip())
|
values: dict = {
|
||||||
await db.execute(
|
"ai_provider": body.provider,
|
||||||
update(User)
|
"ai_base_url": body.base_url.rstrip("/") or None,
|
||||||
.where(User.id == user.id)
|
"ai_model": body.model.strip() or None,
|
||||||
.values(ai_provider=body.provider, ai_api_key_enc=encrypted)
|
}
|
||||||
)
|
|
||||||
|
if body.api_key.strip():
|
||||||
|
values["ai_api_key_enc"] = encrypt_field(body.api_key.strip())
|
||||||
|
elif not user.ai_api_key_enc:
|
||||||
|
raise HTTPException(status_code=400, detail="api_key is required when no key is saved yet")
|
||||||
|
|
||||||
|
await db.execute(update(User).where(User.id == user.id).values(**values))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return AiSettingsResponse(provider=body.provider, has_api_key=True)
|
return AiSettingsResponse(
|
||||||
|
provider=body.provider,
|
||||||
|
has_api_key=True,
|
||||||
|
base_url=values["ai_base_url"],
|
||||||
|
model=values["ai_model"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/ai", status_code=204)
|
@router.delete("/ai", status_code=204)
|
||||||
|
|
@ -64,6 +79,6 @@ async def clear_ai_settings(
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(User)
|
update(User)
|
||||||
.where(User.id == user.id)
|
.where(User.id == user.id)
|
||||||
.values(ai_provider=None, ai_api_key_enc=None)
|
.values(ai_provider=None, ai_api_key_enc=None, ai_base_url=None, ai_model=None)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
|
||||||
|
|
@ -331,8 +331,13 @@ async def parse_attachment(
|
||||||
"Return ONLY the JSON object. No markdown, no explanation, no code fences."
|
"Return ONLY the JSON object. No markdown, no explanation, no code fences."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
custom_base_url = (user_row.ai_base_url or "").rstrip("/")
|
||||||
|
custom_model = (user_row.ai_model or "").strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if user_row.ai_provider == "anthropic":
|
if user_row.ai_provider == "anthropic":
|
||||||
|
base_url = custom_base_url or "https://api.anthropic.com"
|
||||||
|
model = custom_model or "claude-haiku-4-5-20251001"
|
||||||
if mime_type == "application/pdf":
|
if mime_type == "application/pdf":
|
||||||
content_block = {
|
content_block = {
|
||||||
"type": "document",
|
"type": "document",
|
||||||
|
|
@ -343,16 +348,16 @@ async def parse_attachment(
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"source": {"type": "base64", "media_type": mime_type, "data": b64},
|
"source": {"type": "base64", "media_type": mime_type, "data": b64},
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"https://api.anthropic.com/v1/messages",
|
f"{base_url}/v1/messages",
|
||||||
headers={
|
headers={
|
||||||
"x-api-key": api_key,
|
"x-api-key": api_key,
|
||||||
"anthropic-version": "2023-06-01",
|
"anthropic-version": "2023-06-01",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
"model": "claude-haiku-4-5-20251001",
|
"model": model,
|
||||||
"max_tokens": 512,
|
"max_tokens": 512,
|
||||||
"messages": [{"role": "user", "content": [content_block, {"type": "text", "text": prompt}]}],
|
"messages": [{"role": "user", "content": [content_block, {"type": "text", "text": prompt}]}],
|
||||||
},
|
},
|
||||||
|
|
@ -361,14 +366,16 @@ async def parse_attachment(
|
||||||
text = resp.json()["content"][0]["text"].strip()
|
text = resp.json()["content"][0]["text"].strip()
|
||||||
|
|
||||||
elif user_row.ai_provider == "openai":
|
elif user_row.ai_provider == "openai":
|
||||||
if mime_type == "application/pdf":
|
base_url = custom_base_url or "https://api.openai.com"
|
||||||
|
model = custom_model or "gpt-4o-mini"
|
||||||
|
if mime_type == "application/pdf" and not custom_base_url:
|
||||||
raise HTTPException(status_code=400, detail="PDF parsing is not supported with the OpenAI provider. Use an image format or switch to Anthropic.")
|
raise HTTPException(status_code=400, detail="PDF parsing is not supported with the OpenAI provider. Use an image format or switch to Anthropic.")
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"https://api.openai.com/v1/chat/completions",
|
f"{base_url}/v1/chat/completions",
|
||||||
headers={"Authorization": f"Bearer {api_key}", "content-type": "application/json"},
|
headers={"Authorization": f"Bearer {api_key}", "content-type": "application/json"},
|
||||||
json={
|
json={
|
||||||
"model": "gpt-4o-mini",
|
"model": model,
|
||||||
"max_tokens": 512,
|
"max_tokens": 512,
|
||||||
"messages": [{
|
"messages": [{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ class User(Base):
|
||||||
|
|
||||||
ai_provider: Mapped[str | None] = mapped_column(Text, nullable=True)
|
ai_provider: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
ai_api_key_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
ai_api_key_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||||
|
ai_base_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ai_model: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
accounts: Mapped[list["Account"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined]
|
accounts: Mapped[list["Account"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined]
|
||||||
sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined]
|
sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined]
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@ import { api } from "./client";
|
||||||
export interface AiSettings {
|
export interface AiSettings {
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
has_api_key: boolean;
|
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 {
|
export interface ParsedReceipt {
|
||||||
|
|
@ -19,8 +28,8 @@ export async function getAiSettings(): Promise<AiSettings> {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveAiSettings(provider: string, api_key: string): Promise<AiSettings> {
|
export async function saveAiSettings(body: AiSettingsSave): Promise<AiSettings> {
|
||||||
const { data } = await api.put("/settings/ai", { provider, api_key });
|
const { data } = await api.put("/settings/ai", body);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -658,6 +658,8 @@ function AiSection() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [provider, setProvider] = useState("anthropic");
|
const [provider, setProvider] = useState("anthropic");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
|
const [model, setModel] = useState("");
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
|
@ -666,16 +668,23 @@ function AiSection() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const d = await getAiSettings();
|
const d = await getAiSettings();
|
||||||
if (d.provider) setProvider(d.provider);
|
if (d.provider) setProvider(d.provider);
|
||||||
|
if (d.base_url) setBaseUrl(d.base_url);
|
||||||
|
if (d.model) setModel(d.model);
|
||||||
return d;
|
return d;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () => saveAiSettings(provider, apiKey),
|
mutationFn: () => saveAiSettings({
|
||||||
|
provider,
|
||||||
|
api_key: apiKey,
|
||||||
|
base_url: baseUrl,
|
||||||
|
model,
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-settings"] });
|
qc.invalidateQueries({ queryKey: ["ai-settings"] });
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
setSuccess("API key saved");
|
setSuccess("Settings saved");
|
||||||
setTimeout(() => setSuccess(""), 3000);
|
setTimeout(() => setSuccess(""), 3000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -684,11 +693,15 @@ function AiSection() {
|
||||||
mutationFn: clearAiSettings,
|
mutationFn: clearAiSettings,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-settings"] });
|
qc.invalidateQueries({ queryKey: ["ai-settings"] });
|
||||||
setSuccess("API key removed");
|
setBaseUrl(""); setModel("");
|
||||||
|
setSuccess("AI settings removed");
|
||||||
setTimeout(() => setSuccess(""), 3000);
|
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 (
|
return (
|
||||||
<div className={cardCls}>
|
<div className={cardCls}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -723,8 +736,11 @@ function AiSection() {
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
>
|
>
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
<option value="openai">OpenAI (GPT-4o mini)</option>
|
<option value="openai">OpenAI-compatible</option>
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -737,7 +753,7 @@ function AiSection() {
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={e => setApiKey(e.target.value)}
|
onChange={e => setApiKey(e.target.value)}
|
||||||
className={cn(inputCls, "pr-10")}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -747,10 +763,33 @@ function AiSection() {
|
||||||
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{provider === "anthropic"
|
Leave blank to use the default. For Open WebUI: <code className="bg-secondary px-1 rounded">http://your-server:3000</code>
|
||||||
? "Get your key at console.anthropic.com → API Keys"
|
</p>
|
||||||
: "Get your key at platform.openai.com → API Keys"}
|
</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>
|
</p>
|
||||||
</div>
|
</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"
|
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" />}
|
{saveMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
{current?.has_api_key && !apiKey ? "Update provider" : "Save API key"}
|
Save settings
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{current?.has_api_key && (
|
{current?.has_api_key && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue