diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..11160d9ff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# Open WebUI - Agent Instructions + +## Quick Commands + +```bash +# Frontend +npm install # Install dependencies +npm run pyodide:fetch # Required! Fetch Pyodide before dev +npm run dev # Dev server (port 5173) +npm run dev:5050 # Alternative dev server (port 5050) +npm run build # Build frontend +npm run test:frontend # Run vitest +npm run check # Typecheck (one-time) +npm run check:watch # Typecheck (watch mode) +npm run lint:frontend # ESLint frontend +npm run format # Prettier (frontend) + +# Backend +./backend/dev.sh # Dev server (port 8080) +uvicorn open_webui.main:app # Manual backend start +npm run lint:backend # Pylint backend +npm run format:backend # Ruff format backend +pytest # Backend tests (from backend/open_webui/) + +# Full stack +npm run lint # Full lint (frontend + types + backend) +npm run format # Full format + +# Docker +docker run -d -p 3000:8080 -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main +``` + +## Critical Constraints + +**Python:** 3.11 or 3.12 only (NOT 3.13+) +**Node:** 18.13.0 - 22.x.x (enforced in package.json) +**Pyodide:** MUST fetch first (`npm run pyodide:fetch`) - required for code interpreter + +## Architecture + +**Monorepo:** SvelteKit frontend + FastAPI backend + +- **Frontend (`/src`)**: Svelte 5, SvelteKit, Vite + - Components: `/src/lib/components` + - API clients: `/src/lib/apis` + - Stores: `/src/lib/stores` + - Utils: `/src/lib/utils` + +- **Backend (`/backend/open_webui`)**: FastAPI, SQLAlchemy + - Routers: `/backend/open_webui/routers` + - Models: `/backend/open_webui/models` + - Utils: `/backend/open_webui/utils` + - Config: `/backend/open_webui/config.py` + - Migrations: `/backend/open_webui/migrations/` + +## Key Development Patterns + +**Adding a new API endpoint:** +1. Define Pydantic model in `backend/open_webui/routers/` (e.g., `configs.py`, `openai.py`) +2. Add `@router` decorator function +3. Import router in `backend/open_webui/main.py` via `app.include_router()` +4. Create frontend client in `/src/lib/apis/` + +**Adding a new model/preset:** +- Admin Panel → Settings → Models → Create Model +- Or `/backend/open_webui/models/models.py` schema + +**Adding translations:** +1. Add key to translation files (e.g., `/src/locales/en.json`) +2. Run `npm run i18n:parse` to sync +3. Use `$t('key')` in templates + +**Adding a new UI component:** +- Create in `/src/lib/components/` structure matching parent +- Use Svelte 5 runes (`$state`, `$derived`, `$effect`) +- Export types in `/src/lib/types.ts` + +## Environment Variables + +**Required defaults (auto-generated if not set):** +- `OLLAMA_BASE_URL='http://localhost:11434'` +- `OPENAI_API_BASE_URL=''` +- `OPENAI_API_KEY=''` +- `CORS_ALLOW_ORIGIN='*'` +- `FORWARDED_ALLOW_IPS='*'` + +**Critical for Docker:** +- `--add-host=host.docker.internal:host-gateway` (macOS/Linux - required for container → host communication) +- `-v open-webui:/app/backend/data` (persist webui.db) + +## Testing + +- **Unit tests (frontend):** `npm run test:frontend` (vitest) +- **E2E tests:** `npm run cy:open` (Cypress UI) +- **Backend tests:** `pytest` from `/backend/open_webui/test/` +- Note: Integration tests may require running services (Ollama, vector DBs) + +## Formatting / Linting + +**Pre-commit hooks:** ruff (fix) + ruff-format (backend only) + +```bash +# Full format +npm run format # Frontend (prettier) +npm run format:backend # Backend (ruff format) + +# Full lint +npm run lint:frontend # ESLint +npm run lint:backend # Pylint +npm run check # Typecheck +``` + +## Migrations + +SQLite default (webui.db). PostgreSQL/MySQL supported. + +```bash +# After code change affecting DB schema +cd backend/open_webui +DATABASE_URL=sqlite:///webui.db alembic revision --autogenerate -m "description" +``` + +## Known Gotchas + +1. **pyodide:fetch not run** → Code interpreter fails silently +2. **Docker on macOS** → Missing `--add-host=host.docker.internal:host-gateway` → Backend cannot reach Ollama +3. **Python 3.13+** → Dependencies incompatible +4. **OpenAI URLs trailing slash** → Normalize with `.rstrip('/')` before use +5. **Custom API endpoints** → Use backend proxy for credentials security (see `back/open_webui/routers/configs.py` line 709) + +## File Ownership + +| Directory | Purpose | +|-----------|---------| +| `/backend/open_webui/routers` | API endpoints | +| `/backend/open_webui/models` | Pydantic + SQLAlchemy models | +| `/backend/open_webui/models/*.py` | DB schemas (ChatModel, UserModel, etc.) | +| `/backend/open_webui/config.py` | Persistent config (OPENAI_API_BASE_URLS, etc.) | +| `/src/lib/apis/*` | Frontend API clients | +| `/src/lib/components` | Svelte components | +| `/src/lib/stores` | Svelte stores | +| `/cypress/e2e` | E2E test specs | +| `/scripts` | Dev scripts | + +## CI Workflows + +- `docker-build.yaml` → Multi-platform Docker images +- `build-release.yml` → Release trigger (package.json change) +- `format-backend.yaml` → Python formatting (ruff) on backend changes +- `format-build-frontend.yaml` → Frontend format + build + vitest diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 06178d385..c8e9b9601 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1209,6 +1209,16 @@ TERMINAL_SERVER_CONNECTIONS = PersistentConfig( terminal_server_connections, ) +#################################### +# CUSTOM API ENDPOINTS +#################################### + +CUSTOM_API_ENDPOINTS = PersistentConfig( + 'CUSTOM_API_ENDPOINTS', + 'custom_api.endpoints', + [], +) + #################################### # WEBUI #################################### diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 02b16d8e5..159eb254a 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -662,3 +662,102 @@ async def get_banners( user=Depends(get_verified_user), ): return request.app.state.config.BANNERS + + +############################ +# Custom API Endpoints Config +############################ + + +class CustomApiEndpoint(BaseModel): + id: str + name: str + connection_idx: int + path: str + method: Optional[str] = 'GET' + refresh_interval: Optional[int] = 60 + enabled: Optional[bool] = True + + model_config = ConfigDict(extra='allow') + + +class CustomApiEndpointsConfigForm(BaseModel): + CUSTOM_API_ENDPOINTS: list[CustomApiEndpoint] + + +@router.get('/custom_api_endpoints', response_model=CustomApiEndpointsConfigForm) +async def get_custom_api_endpoints_config(request: Request, user=Depends(get_verified_user)): + return { + 'CUSTOM_API_ENDPOINTS': request.app.state.config.CUSTOM_API_ENDPOINTS, + } + + +@router.post('/custom_api_endpoints', response_model=CustomApiEndpointsConfigForm) +async def set_custom_api_endpoints_config( + request: Request, + form_data: CustomApiEndpointsConfigForm, + user=Depends(get_admin_user), +): + request.app.state.config.CUSTOM_API_ENDPOINTS = [ + endpoint.model_dump() for endpoint in form_data.CUSTOM_API_ENDPOINTS + ] + return { + 'CUSTOM_API_ENDPOINTS': request.app.state.config.CUSTOM_API_ENDPOINTS, + } + + +@router.post('/custom_api_endpoints/execute') +async def execute_custom_api_endpoint( + request: Request, + endpoint_id: str, + user=Depends(get_verified_user), +): + endpoints = request.app.state.config.CUSTOM_API_ENDPOINTS + endpoint = next((e for e in endpoints if e.get('id') == endpoint_id), None) + if not endpoint: + raise HTTPException(status_code=404, detail='Custom API endpoint not found') + + if not endpoint.get('enabled', True): + raise HTTPException(status_code=403, detail='Custom API endpoint is disabled') + + connection_idx = endpoint.get('connection_idx', 0) + openai_base_urls = request.app.state.config.OPENAI_API_BASE_URLS + openai_keys = request.app.state.config.OPENAI_API_KEYS + openai_configs = request.app.state.config.OPENAI_API_CONFIGS + + if connection_idx >= len(openai_base_urls): + raise HTTPException(status_code=400, detail='Connection index out of range') + + base_url = openai_base_urls[connection_idx].rstrip('/') + key = openai_keys[connection_idx] if connection_idx < len(openai_keys) else '' + config = openai_configs.get(str(connection_idx), {}) + + url = f"{base_url}{endpoint['path']}" + method = endpoint.get('method', 'GET').upper() + + headers = { + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), + } + if config.get('headers') and isinstance(config.get('headers'), dict): + headers = {**headers, **config.get('headers')} + + try: + async with aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) as session: + async with session.request( + method, url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: + try: + data = await resp.json() + except Exception: + data = {'text': await resp.text()} + return { + 'status': resp.status, + 'data': data, + } + except Exception as e: + log.debug(f'Failed to execute custom API endpoint: {e}') + raise HTTPException(status_code=400, detail='Failed to execute custom API endpoint') diff --git a/docker-compose.simple.yaml b/docker-compose.simple.yaml new file mode 100644 index 000000000..d8f57868c --- /dev/null +++ b/docker-compose.simple.yaml @@ -0,0 +1,19 @@ +services: + open-webui: + build: + context: . + dockerfile: Dockerfile + image: ghcr.io/open-webui/open-webui:main + container_name: open-webui + volumes: + - open-webui:/app/backend/data + ports: + - ${OPEN_WEBUI_PORT-3000}:8080 + environment: + - OPENAI_API_BASE_URL=${OPENAI_API_BASE_URL:-https://api.openai.com/v1} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - WEBUI_SECRET_KEY= + restart: unless-stopped + +volumes: + open-webui: {} diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 6b7bf6f47..14e903878 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -647,3 +647,100 @@ export const setBanners = async (token: string, banners: Banner[]) => { return res; }; + +export type CustomApiEndpoint = { + id: string; + name: string; + connection_idx: number; + path: string; + method?: string; + refresh_interval?: number; + enabled?: boolean; +}; + +export const getCustomApiEndpoints = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/custom_api_endpoints`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res?.CUSTOM_API_ENDPOINTS ?? []; +}; + +export const setCustomApiEndpoints = async (token: string, endpoints: CustomApiEndpoint[]) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/custom_api_endpoints`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + CUSTOM_API_ENDPOINTS: endpoints + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const executeCustomApiEndpoint = async (token: string, endpointId: string) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/configs/custom_api_endpoints/execute?endpoint_id=${encodeURIComponent(endpointId)}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 03af994a6..b046829a4 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -103,6 +103,7 @@ import EventConfirmDialog from '../common/ConfirmDialog.svelte'; import Placeholder from './Placeholder.svelte'; import FilesOverlay from './MessageInput/FilesOverlay.svelte'; + import CustomApiEndpointCards from './CustomApiEndpointCards.svelte'; import NotificationToast from '../NotificationToast.svelte'; import Spinner from '../common/Spinner.svelte'; import Tooltip from '../common/Tooltip.svelte'; @@ -2913,6 +2914,9 @@ }} >
+
+ +
+ import { onMount, onDestroy, getContext, createEventDispatcher } from 'svelte'; + import { fade } from 'svelte/transition'; + import { toast } from 'svelte-sonner'; + import { + executeCustomApiEndpoint, + getCustomApiEndpoints, + setCustomApiEndpoints, + type CustomApiEndpoint + } from '$lib/apis/configs'; + import { getOpenAIConfig } from '$lib/apis/openai'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; + import Spinner from '$lib/components/common/Spinner.svelte'; + + const i18n = getContext('i18n'); + const dispatch = createEventDispatcher(); + + let endpoints: CustomApiEndpoint[] = []; + let openaiUrls: string[] = []; + let results: Record = {}; + let loading: Record = {}; + let intervals: Record> = {}; + + let showManage = false; + let editing: CustomApiEndpoint | null = null; + + // Form fields + let formName = ''; + let formConnectionIdx = 0; + let formPath = ''; + let formMethod = 'GET'; + let formRefresh = 60; + + const fetchEndpoints = async () => { + try { + endpoints = await getCustomApiEndpoints(localStorage.token); + } catch (e) { + console.error(e); + } + }; + + const fetchUrls = async () => { + try { + const cfg = await getOpenAIConfig(localStorage.token); + openaiUrls = cfg?.OPENAI_API_BASE_URLS ?? []; + } catch (e) { + console.error(e); + } + }; + + const execute = async (ep: CustomApiEndpoint) => { + if (!ep.enabled) return; + loading[ep.id] = true; + try { + const res = await executeCustomApiEndpoint(localStorage.token, ep.id); + results[ep.id] = res; + } catch (e: any) { + results[ep.id] = { error: e?.detail || e?.message || 'Error' }; + } + loading[ep.id] = false; + }; + + const startIntervals = () => { + stopIntervals(); + for (const ep of endpoints) { + if (!ep.enabled) continue; + execute(ep); + const ms = (ep.refresh_interval || 60) * 1000; + intervals[ep.id] = setInterval(() => execute(ep), ms); + } + }; + + const stopIntervals = () => { + Object.values(intervals).forEach(clearInterval); + intervals = {}; + }; + + const openManage = async () => { + await fetchEndpoints(); + await fetchUrls(); + editing = null; + resetForm(); + showManage = true; + }; + + const resetForm = () => { + formName = ''; + formConnectionIdx = 0; + formPath = ''; + formMethod = 'GET'; + formRefresh = 60; + }; + + const editEndpoint = (ep: CustomApiEndpoint) => { + editing = ep; + formName = ep.name; + formConnectionIdx = ep.connection_idx; + formPath = ep.path; + formMethod = ep.method || 'GET'; + formRefresh = ep.refresh_interval || 60; + }; + + const saveEndpoint = async () => { + if (!formName.trim() || !formPath.trim()) { + toast.error('Name and path are required'); + return; + } + let list = [...endpoints]; + const payload: CustomApiEndpoint = { + id: editing?.id || crypto.randomUUID(), + name: formName.trim(), + connection_idx: Number(formConnectionIdx), + path: formPath.trim(), + method: formMethod, + refresh_interval: Number(formRefresh), + enabled: true + }; + if (editing) { + list = list.map((e) => (e.id === editing.id ? payload : e)); + } else { + list.push(payload); + } + try { + await setCustomApiEndpoints(localStorage.token, list); + endpoints = list; + editing = null; + resetForm(); + startIntervals(); + toast.success('Saved'); + } catch (e: any) { + toast.error(e?.detail || 'Failed to save'); + } + }; + + const deleteEndpoint = async (id: string) => { + const list = endpoints.filter((e) => e.id !== id); + try { + await setCustomApiEndpoints(localStorage.token, list); + endpoints = list; + delete results[id]; + startIntervals(); + toast.success('Deleted'); + } catch (e: any) { + toast.error(e?.detail || 'Failed to delete'); + } + }; + + const toggleEnabled = async (ep: CustomApiEndpoint) => { + const list = endpoints.map((e) => (e.id === ep.id ? { ...e, enabled: !e.enabled } : e)); + try { + await setCustomApiEndpoints(localStorage.token, list); + endpoints = list; + startIntervals(); + } catch (e: any) { + toast.error(e?.detail || 'Failed to update'); + } + }; + + onMount(async () => { + await fetchEndpoints(); + await fetchUrls(); + startIntervals(); + }); + + onDestroy(() => { + stopIntervals(); + }); + + $: if (endpoints) { + startIntervals(); + } + + function formatResult(data: any): string { + if (data?.error) return `Error: ${data.error}`; + if (data?.text) return data.text; + if (data?.data && typeof data.data === 'object') { + return JSON.stringify(data.data, null, 2); + } + return JSON.stringify(data, null, 2); + } + + +
+
+
+ {$i18n.t('Custom API Endpoints')} +
+ +
+ + {#if endpoints.length === 0} +
{$i18n.t('No custom endpoints configured')}
+ {:else} +
+ {#each endpoints as ep (ep.id)} +
+
+
+
{ep.name}
+ +
+ {#if loading[ep.id]} + + {/if} +
+
+ {openaiUrls[ep.connection_idx] ?? ''}{ep.path} +
+
+ {formatResult(results[ep.id] ?? {})} +
+
+ +
+
+ {/each} +
+ {/if} +
+ +{#if showManage} +
(showManage = false)} + > +
+
+

{$i18n.t('Manage Custom API Endpoints')}

+ +
+ +
+
{editing ? $i18n.t('Edit Endpoint') : $i18n.t('Add Endpoint')}
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ {#if editing} + + {/if} + +
+
+ +
+
{$i18n.t('Configured Endpoints')}
+ {#if endpoints.length === 0} +
{$i18n.t('No endpoints')}
+ {:else} + {#each endpoints as ep (ep.id)} +
+
+
{ep.name}
+
+ {openaiUrls[ep.connection_idx] ?? ''}{ep.path} +
+
+
+ + +
+
+ {/each} + {/if} +
+
+
+{/if} + + diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index 8c998b357..164ea4a8d 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -29,6 +29,7 @@ import MessageInput from './MessageInput.svelte'; import FolderPlaceholder from './Placeholder/FolderPlaceholder.svelte'; import FolderTitle from './Placeholder/FolderTitle.svelte'; + import CustomApiEndpointCards from './CustomApiEndpointCards.svelte'; const i18n = getContext('i18n'); @@ -75,6 +76,10 @@
+
+ +
+ {#if $temporaryChatEnabled}