diff --git a/backend/alembic/versions/0004_ai_base_url.py b/backend/alembic/versions/0004_ai_base_url.py new file mode 100644 index 0000000..6677205 --- /dev/null +++ b/backend/alembic/versions/0004_ai_base_url.py @@ -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") diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index f49969e..10c32f2 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -20,11 +20,15 @@ SUPPORTED_PROVIDERS = {"anthropic", "openai"} class AiSettingsResponse(BaseModel): provider: str | None has_api_key: bool + base_url: str | None + model: str | None class AiSettingsSave(BaseModel): provider: str - api_key: str + api_key: str = "" + base_url: str = "" + model: str = "" @router.get("/ai", response_model=AiSettingsResponse) @@ -32,6 +36,8 @@ async def get_ai_settings(user: User = Depends(get_current_user)): return AiSettingsResponse( provider=user.ai_provider, 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: 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()) - await db.execute( - update(User) - .where(User.id == user.id) - .values(ai_provider=body.provider, ai_api_key_enc=encrypted) - ) + values: dict = { + "ai_provider": body.provider, + "ai_base_url": body.base_url.rstrip("/") or None, + "ai_model": body.model.strip() or None, + } + + 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() - 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) @@ -64,6 +79,6 @@ async def clear_ai_settings( await db.execute( update(User) .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() diff --git a/backend/app/api/v1/transactions.py b/backend/app/api/v1/transactions.py index f34d8f6..58e645c 100644 --- a/backend/app/api/v1/transactions.py +++ b/backend/app/api/v1/transactions.py @@ -331,8 +331,13 @@ async def parse_attachment( "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: 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": content_block = { "type": "document", @@ -343,16 +348,16 @@ async def parse_attachment( "type": "image", "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( - "https://api.anthropic.com/v1/messages", + f"{base_url}/v1/messages", headers={ "x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ - "model": "claude-haiku-4-5-20251001", + "model": model, "max_tokens": 512, "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() 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.") - async with httpx.AsyncClient(timeout=30) as client: + async with httpx.AsyncClient(timeout=60) as client: 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"}, json={ - "model": "gpt-4o-mini", + "model": model, "max_tokens": 512, "messages": [{ "role": "user", diff --git a/backend/app/db/models/user.py b/backend/app/db/models/user.py index 0f67bbc..b5c6897 100644 --- a/backend/app/db/models/user.py +++ b/backend/app/db/models/user.py @@ -31,6 +31,8 @@ class User(Base): ai_provider: Mapped[str | None] = mapped_column(Text, 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] sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined] diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index eee55a7..e9b6ed5 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -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 { return data; } -export async function saveAiSettings(provider: string, api_key: string): Promise { - const { data } = await api.put("/settings/ai", { provider, api_key }); +export async function saveAiSettings(body: AiSettingsSave): Promise { + const { data } = await api.put("/settings/ai", body); return data; } diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index b57ed35..77d98a8 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -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 (
@@ -723,8 +736,11 @@ function AiSection() { className={inputCls} > - + +

+ OpenAI-compatible works with Open WebUI, LM Studio, Ollama, and any OpenAI-spec endpoint. +

@@ -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)"} />
+
+ +
+ + setBaseUrl(e.target.value)} + className={inputCls} + placeholder={defaultUrl} + />

- {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: http://your-server:3000 +

+
+ +
+ + setModel(e.target.value)} + className={inputCls} + placeholder={defaultModel} + /> +

+ Leave blank to use the default. For Open WebUI, enter the model name exactly as shown in its interface.

@@ -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 && } - {current?.has_api_key && !apiKey ? "Update provider" : "Save API key"} + Save settings {current?.has_api_key && (