Browse Source

Merge: per-project embedding settings, outbound UA, session check (0.3.0)

fszontagh 1 tuần trước cách đây
mục cha
commit
dcd3d2e3a8

+ 8 - 4
CLAUDE.md

@@ -25,9 +25,9 @@ cd webui && npm install && npm run build   # → webui/dist ; `npm run dev` for
 ## Architecture (where things live)
 
 - `src/` — the server. `vectorapi_core` static lib + `main.cpp`. Namespace `svapi`. Flat headers, quoted includes.
-  - `db_gateway` (`qualify(project,coll)` + `DbGateway` over `smartbotic::database::Client`), `settings_store` (global settings, hot-reload), `key_store` (API keys, hot-reload, bootstrap admin key), `collection_registry` (per-project metadata), `embeddings` (OpenAI), `auth` (sessions, bearer/cookie), `server` (ApiServer + auth helpers), `handlers/*` (one file per route group).
+  - `db_gateway` (`qualify(project,coll)` + `DbGateway` over `smartbotic::database::Client`), `settings_store` (global settings **and per-project embedding overrides**, hot-reload, one subscribe stream for both), `project_settings` (`ProjectEmbeddingSettings` + the pure `resolveEmbedding()`), `outbound_http` (User-Agent + TLS defaults for every external call), `key_store` (API keys, hot-reload, bootstrap admin key), `collection_registry` (per-project metadata), `embeddings` (OpenAI), `auth` (sessions, bearer/cookie), `server` (ApiServer + auth helpers), `handlers/*` (one file per route group).
   - Auth model: pre-routing **authenticates** `/api/*` (401); each handler **authorizes** via `requireKey` → `requireAdmin` / `requireProjectAccess` (403). Keys are referenced by a stable **`id`** (not the secret).
-- `webui/` — React 19 + Vite + TS (strict, `erasableSyntaxOnly`) + Tailwind + Monaco. Cookie-session auth (`credentials:'include'`). Pages read `currentProject`/`admin` from `useAuthStore`; all calls go through `src/api/client.ts`.
+- `webui/` — React 19 + Vite + TS (strict, `erasableSyntaxOnly`) + Tailwind + Monaco. Cookie-session auth (`credentials:'include'`). Pages read `currentProject`/`admin` from `useAuthStore`; all calls go through `src/api/client.ts`. `pages/Settings.tsx` is a tab shell over `pages/settings/*` (Embeddings, Cache & performance, Access & security, Web UI are admin-only; the per-project embeddings tab is not, so `/settings` is **not** admin-gated in `App.tsx`). Shared controls live in `components/form.tsx`.
 - `api/` — `openapi.json` (3.1), `llms.txt` (llmstxt.org), `docs/` (Redoc page). Served at `/openapi.json`, `/llms.txt`, `/docs`. Shipped in the server `.deb`. **Keep these in sync when changing routes.**
 - `packaging/` — `build.sh` (`--local` + Docker trixie), `deb/create-debs.sh`, control templates, systemd unit, maintainer scripts. `docs/superpowers/` — design spec (esp. **Addendum A**) + the 3 implementation plans.
 
@@ -35,13 +35,15 @@ cd webui && npm install && npm run build   # → webui/dist ; `npm run dev` for
 
 - C++20, `nlohmann/json`, `spdlog`, cpp-httplib (FetchContent, OpenSSL on). Handlers `throw svapi::ApiError`; a global exception handler maps to `{error:{code,message}}` + HTTP status (`errors.hpp`: 400/401/403/404/422/503/500).
 - Filters: `field:op:value` (`op` ∈ eq/ne/gt/gte/lt/lte/in/contains/exists/regex/search).
+- **Embedding config precedence:** collection `embedding_model` pin → project override → global default; endpoint + API key are the project override when set, else global. Resolved by the pure `resolveEmbedding()` (`project_settings.hpp`) — unit-test precedence there, not through the handlers.
+- **Every outbound request to an external provider goes through `applyOutboundDefaults()`** (`outbound_http.hpp`): `User-Agent: smartbotic-vectorapi/<version>` and nothing else, TLS verification on, redirects off. Never let cpp-httplib's default UA reach a provider.
 - TS: no `any`, no TS `enum`, no constructor parameter-property shorthand (tsconfig `erasableSyntaxOnly`). Iterating parsed JSON: bind to a named var first (range-for over `nlohmann::json::parse(...)[...]` dangles).
 - Commits: conventional prefixes (`feat`/`fix`/`build`/`docs`/`test`). Branch for feature work; `main` is pushed to `ssh://git.smartbotics.ai:10022/fszontagh/smartbotic-vectorapi.git`.
 
 ## Data model & runtime config
 
 - Bootstrap `config.json` is minimal: `log_level`, `http.{bind_address,port}`, `database.address`. Everything else lives in the DB and **hot-reloads** (subscribe + atomic snapshot swap).
-- Global service collections (DB `default` project, **no leading underscore** — see gotcha): `vectorapi_keys`, `vectorapi_settings`. Per-project registry: `<project>:vectorapi_collections`. The `vectorapi_` prefix is reserved (collection-create rejects `_` and `vectorapi_`).
+- Global service collections (DB `default` project, **no leading underscore** — see gotcha): `vectorapi_keys`, `vectorapi_settings`. The settings collection holds the global doc (`current`) **and** one `project_<name>` doc per project carrying that project's embedding overrides (empty field = inherit; the body repeats `project` because the DB strips `id`). Per-project registry: `<project>:vectorapi_collections`. The `vectorapi_` prefix is reserved (collection-create rejects `_` and `vectorapi_`).
 - First run with no keys generates an admin key (`projects:["*"], admin:true`) and logs it once; `SMARTBOTIC_VECTORAPI_KEY` seeds it, `OPENAI_API_KEY` seeds embeddings.
 
 ## ⚠️ Gotchas (hard-won — don't relearn these)
@@ -60,4 +62,6 @@ cd webui && npm install && npm run build   # → webui/dist ; `npm run dev` for
 ./packaging/build.sh                                # Debian-13 .debs → dist/debian13/
 ./packaging/build.sh --repo --suite trixie --sync   # + publish to repository.smartbotics.ai (outward-facing!)
 ```
-Publishing uploads to the live apt repo — only with explicit confirmation. The deb-repo tooling is `/data/smartbotics-deb-repo/scripts/`.
+Publishing uploads to the live apt repo — only with explicit confirmation. The deb-repo tooling is `/data/dev/smartbotics/smartbotics-deb-repo/scripts/` (override with `DEB_REPO_DIR`).
+
+**Release packages always come from the Docker path.** The dev host (zeus) runs Void Linux, so `--local` there compiles, runs the tests and leaves a runnable binary in `build/src/` but refuses to build `.deb`s (and refuses `--install`/`--repo`/`--sync`) - a Void-linked package would not install on Debian 13. `--local` still packages normally on a Debian-family host.

+ 7 - 1
README.md

@@ -7,6 +7,7 @@ n8n workflows are the primary client, but any HTTP client can use it. A React ad
 - **Multi-project** — collections live in isolated project namespaces, addressed as a URL path segment (`/api/v1/projects/{project}/...`), like MySQL databases.
 - **Multi-key auth** — each API key is granted access to one or more projects (or all, `*`); `admin` keys manage projects and keys. Bearer-token for the API; cookie session for the web UI.
 - **RAG** — store a precomputed vector, or send text and have it embedded via OpenAI; then run cosine similarity search.
+- **Per-project embedding providers** — a project can override the endpoint, API key and model, so each customer's embedding usage meters against their own provider key. Precedence: collection model pin, then project override, then the global default.
 - **JSON CRUD** — full create/read/update/patch/delete with filtered find.
 - **Dynamic config** — only a minimal bootstrap `config.json` on disk; everything else (keys, OpenAI settings, CORS, etc.) lives in the database and **hot-reloads** without a restart.
 - **Self-describing** — `GET /openapi.json` (OpenAPI 3.1), `GET /llms.txt` (agent-friendly summary), and a rendered docs page at `/docs`.
@@ -37,7 +38,7 @@ The server is C++20 (cpp-httplib, OpenSSL). Project multi-tenancy is threaded at
 The packages are published to the smartbotics apt repository.
 
 ```bash
-# one-time repo setup (see /data/smartbotics-deb-repo for credentials)
+# one-time repo setup (see /data/dev/smartbotics/smartbotics-deb-repo for credentials)
 curl -fsSL https://repository.smartbotics.ai/add-repo.sh | sudo bash -s -- --user <user> --password <pass>
 
 sudo apt update
@@ -105,6 +106,11 @@ curl -s -X POST $BASE/api/v1/projects/acme/collections -H "Authorization: Bearer
   -H 'Content-Type: application/json' \
   -d '{"name":"memories","kind":"vector","vector_dimension":1536,"embedding_model":"text-embedding-3-small"}'
 
+# point this project at its own embedding provider (its own key gets metered)
+curl -s -X PUT $BASE/api/v1/projects/acme/settings/embeddings -H "Authorization: Bearer $KEY" \
+  -H 'Content-Type: application/json' \
+  -d '{"openai_api_base":"https://api.openai.com","openai_api_key":"sk-acme","default_embedding_model":"text-embedding-3-small"}'
+
 # store a RAG item from text (embedded server-side) and search it
 curl -s -X POST $BASE/api/v1/projects/acme/collections/memories/vectors -H "Authorization: Bearer $KEY" \
   -H 'Content-Type: application/json' -d '{"text":"the user prefers dark mode","metadata":{"src":"n8n"}}'

+ 1 - 1
VERSION

@@ -1 +1 @@
-0.2.0
+0.3.0

+ 39 - 28
api/llms.txt

@@ -11,19 +11,19 @@
 
 Requires a valid key. Admin-only operations are noted.
 
-- `GET  /api/v1/projects` — List projects the key may access (admin sees all).
-- `POST /api/v1/projects` `{name}` — Create a project (admin).
-- `GET  /api/v1/projects/{project}` — Get project info (collection/document counts).
-- `DELETE /api/v1/projects/{project}` — Drop a project (admin; the `default` project cannot be dropped).
+- `GET  /api/v1/projects` - List projects the key may access (admin sees all).
+- `POST /api/v1/projects` `{name}` - Create a project (admin).
+- `GET  /api/v1/projects/{project}` - Get project info (collection/document counts).
+- `DELETE /api/v1/projects/{project}` - Drop a project (admin; the `default` project cannot be dropped).
 
 ## Keys (admin)
 
 All key-management endpoints require an admin key.
 
-- `GET  /api/v1/keys` — List keys. Secret value is masked; returns `id`, `key_prefix`, `label`, `projects`, `admin`, `created_at`, and `scope` (if present). Use `id` to reference the key in PATCH/DELETE.
-- `POST /api/v1/keys` `{label, projects:[], admin?, scope?}` — Generate a new key. Returns the secret key value **once** (store it immediately) plus the stable `id`. Pass a `scope` object (see below) to create a capability-scoped key.
-- `PATCH /api/v1/keys/{id}` `{label?, projects?, admin?, scope?}` — Update grants for an existing key, identified by its `id` (not the secret). Pass `scope: null` to clear an existing scope.
-- `DELETE /api/v1/keys/{id}` — Revoke a key by `id`. Active sessions using it are immediately invalidated.
+- `GET  /api/v1/keys` - List keys. Secret value is masked; returns `id`, `key_prefix`, `label`, `projects`, `admin`, `created_at`, and `scope` (if present). Use `id` to reference the key in PATCH/DELETE.
+- `POST /api/v1/keys` `{label, projects:[], admin?, scope?}` - Generate a new key. Returns the secret key value **once** (store it immediately) plus the stable `id`. Pass a `scope` object (see below) to create a capability-scoped key.
+- `PATCH /api/v1/keys/{id}` `{label?, projects?, admin?, scope?}` - Update grants for an existing key, identified by its `id` (not the secret). Pass `scope: null` to clear an existing scope.
+- `DELETE /api/v1/keys/{id}` - Revoke a key by `id`. Active sessions using it are immediately invalidated.
 
 ### Capability-scoped keys
 
@@ -31,10 +31,10 @@ A non-admin key may carry a `scope` object that restricts what it can do. Scoped
 
 `scope` fields:
 
-- `rules` (required, non-empty array) — Each rule specifies a `collection` name and the permitted `ops` array (`read`, `list`, `search`, `insert`, `update`, `delete`). A request succeeds only if a matching `{collection, op}` rule exists. A rule may also set `require_human_token: true` (see CAPTCHA below).
-- `origins` (array of strings, default empty) — Allowed `Origin` header values. When non-empty, requests without a matching `Origin` header are rejected with 403 (**fails closed** — no origin header means denied). Use this to pin a key to a specific domain.
-- `rate_limit_per_min` (integer, default 0 = unlimited) — Per-key request rate limit. When the limit is exceeded the server returns **429** with a `Retry-After: 60` header.
-- `expires_at` (integer, default 0 = never) — Unix epoch seconds after which the key is treated as expired; auth returns 401.
+- `rules` (required, non-empty array) - Each rule specifies a `collection` name and the permitted `ops` array (`read`, `list`, `search`, `insert`, `update`, `delete`). A request succeeds only if a matching `{collection, op}` rule exists. A rule may also set `require_human_token: true` (see CAPTCHA below).
+- `origins` (array of strings, default empty) - Allowed `Origin` header values. When non-empty, requests without a matching `Origin` header are rejected with 403 (**fails closed** - no origin header means denied). Use this to pin a key to a specific domain.
+- `rate_limit_per_min` (integer, default 0 = unlimited) - Per-key request rate limit. When the limit is exceeded the server returns **429** with a `Retry-After: 60` header.
+- `expires_at` (integer, default 0 = never) - Unix epoch seconds after which the key is treated as expired; auth returns 401.
 
 ### CAPTCHA-gated operations
 
@@ -54,10 +54,10 @@ Example scope (insert-only, origin-pinned, rate-limited, expiring):
 
 Project-scoped. A key must be granted the project.
 
-- `POST   /api/v1/projects/{project}/collections` `{name, kind:"json"|"vector", vector_dimension?, embedding_model?}` — Create a collection. `vector_dimension` is required for `kind=vector`. `embedding_model` defaults to the server's `default_embedding_model` setting.
-- `GET    /api/v1/projects/{project}/collections` — List collections with metadata (incl. `document_count`, `size_bytes`). Query params (all applied server-side): `q` (case-insensitive name substring filter), `sort` (`name`|`kind`|`documents`|`size`|`created_at`, default `name`), `desc` (bool).
-- `GET    /api/v1/projects/{project}/collections/{name}` — Get collection metadata.
-- `DELETE /api/v1/projects/{project}/collections/{name}` — Drop a collection and all its documents.
+- `POST   /api/v1/projects/{project}/collections` `{name, kind:"json"|"vector", vector_dimension?, embedding_model?}` - Create a collection. `vector_dimension` is required for `kind=vector`. `embedding_model` defaults to the server's `default_embedding_model` setting.
+- `GET    /api/v1/projects/{project}/collections` - List collections with metadata (incl. `document_count`, `size_bytes`). Query params (all applied server-side): `q` (case-insensitive name substring filter), `sort` (`name`|`kind`|`documents`|`size`|`created_at`, default `name`), `desc` (bool).
+- `GET    /api/v1/projects/{project}/collections/{name}` - Get collection metadata.
+- `DELETE /api/v1/projects/{project}/collections/{name}` - Drop a collection and all its documents.
 
 Collection names may not start with `_` or the reserved prefix `vectorapi_`.
 
@@ -65,7 +65,7 @@ Collection names may not start with `_` or the reserved prefix `vectorapi_`.
 
 Arbitrary JSON CRUD under a `kind=json` collection.
 
-- `POST  /api/v1/projects/{project}/collections/{name}/documents` `{data:{…}}` - Insert a document. Returns `{id}`. Optional `?ttl_seconds=N` query param sets an expiry; absent means no expiry.
+- `POST  /api/v1/projects/{project}/collections/{name}/documents` `{data:{...}}` - Insert a document. Returns `{id}`. Optional `?ttl_seconds=N` query param sets an expiry; absent means no expiry.
 - `GET   /api/v1/projects/{project}/collections/{name}/documents/{id}` - Fetch a document by ID.
 - `PUT   /api/v1/projects/{project}/collections/{name}/documents/{id}` - Replace a document (full upsert).
 - `PATCH /api/v1/projects/{project}/collections/{name}/documents/{id}` - Partially update a document (merge fields). Optional `?ttl_seconds=N`: **omitted leaves the existing expiry untouched**; `ttl_seconds=0` **clears** it (document becomes permanent) - do not assume 0 means "no change".
@@ -100,23 +100,33 @@ Relation names are per-project and **unqualified**: requests and responses alway
 
 Requires a `kind=vector` collection. Vectors are stored with cosine-similarity indexing.
 
-- `POST /api/v1/projects/{project}/collections/{name}/vectors` `{id?, text?, vector?, metadata?}` — Store a vector. Supply either `text` (the server embeds it using the collection's `embedding_model`) or a pre-computed `vector` array. `vector` dimension must match the collection's `vector_dimension`. `metadata` is an arbitrary JSON object stored alongside the vector.
-- `POST /api/v1/projects/{project}/collections/{name}/search` `{query_text?, query_vector?, top_k, min_score?, filters?}` — Cosine-similarity search. Supply either `query_text` or `query_vector`. `top_k` (required) limits results. `min_score` (0–1) filters low-confidence matches. `filters` apply the same `field:op:value` grammar to vector metadata.
+- `POST /api/v1/projects/{project}/collections/{name}/vectors` `{id?, text?, vector?, metadata?}` - Store a vector. Supply either `text` (the server embeds it using the collection's `embedding_model`) or a pre-computed `vector` array. `vector` dimension must match the collection's `vector_dimension`. `metadata` is an arbitrary JSON object stored alongside the vector.
+- `POST /api/v1/projects/{project}/collections/{name}/search` `{query_text?, query_vector?, top_k, min_score?, filters?}` - Cosine-similarity search. Supply either `query_text` or `query_vector`. `top_k` (required) limits results. `min_score` (0-1) filters low-confidence matches. `filters` apply the same `field:op:value` grammar to vector metadata.
 
   **Latency tip:** `query_text` triggers a synchronous server-side embedding call to the configured provider, which usually dominates request time (seconds for large models). Cosine search itself is sub-millisecond. If your client issues many searches (e.g. an LLM/n8n agent doing RAG), embed the query once on your side and pass `query_vector` to skip server-side embedding entirely.
 
-  **Cache bypass:** Send `Cache-Control: no-store` on a `/vectors` or `/search` request to skip the server-side embedding cache — the text is re-embedded fresh and the result is not stored in the cache.
+  **Cache bypass:** Send `Cache-Control: no-store` on a `/vectors` or `/search` request to skip the server-side embedding cache - the text is re-embedded fresh and the result is not stored in the cache.
 
 ## Settings (admin)
 
 All settings endpoints require an admin key.
 
-- `GET  /api/v1/settings` — Return current settings as JSON. `openai_api_key` is masked: the field is absent and `openai_api_key_set` (bool) indicates whether a key is configured.
-- `PUT  /api/v1/settings` `{openai_api_base?, openai_api_key?, default_embedding_model?, cors_origins?, session_ttl_minutes?, webui_enabled?, default_project?, embedding_connect_timeout_sec?, embedding_read_timeout_sec?, embedding_cache_size?, embedding_cache_ttl_sec?, embedding_cache_max_bytes?, embedding_cache_normalize?, embedding_client_pool_size?, captcha_provider?, captcha_secret?, captcha_verify_url?}` — Merge the supplied fields into the current settings and persist. Omit `openai_api_key` or `captcha_secret` (or pass an empty string) to keep the existing secret. Changes are hot-reloaded immediately; cache settings are applied instantly via `reconfigure()`.
+- `GET  /api/v1/settings` - Return current settings as JSON. `openai_api_key` is masked: the field is absent and `openai_api_key_set` (bool) indicates whether a key is configured.
+- `PUT  /api/v1/settings` `{openai_api_base?, openai_api_key?, default_embedding_model?, cors_origins?, session_ttl_minutes?, webui_enabled?, default_project?, embedding_connect_timeout_sec?, embedding_read_timeout_sec?, embedding_cache_size?, embedding_cache_ttl_sec?, embedding_cache_max_bytes?, embedding_cache_normalize?, embedding_client_pool_size?, captcha_provider?, captcha_secret?, captcha_verify_url?}` - Merge the supplied fields into the current settings and persist. Omit `openai_api_key` or `captcha_secret` (or pass an empty string) to keep the existing secret. Changes are hot-reloaded immediately; cache settings are applied instantly via `reconfigure()`.
+
+## Project embedding settings
+
+A project can point its embeddings at its own provider endpoint, key and model, which is how per-customer usage gets metered at the provider. These routes require a key granted the project (admin keys reach every project); capability-scoped publishable keys are refused.
+
+Precedence for one embedding call: the collection's own `embedding_model` wins, then the project override, then the global default. The endpoint and key are the project override when set, otherwise the global ones.
+
+- `GET  /api/v1/projects/{project}/settings/embeddings` - Return `{effective, inherited, overrides}`. Each carries `openai_api_base`, `default_embedding_model` and `openai_api_key_set` (bool). The API key itself is never returned. In `overrides`, an empty string means the field is inherited.
+- `PUT  /api/v1/projects/{project}/settings/embeddings` `{openai_api_base?, openai_api_key?, default_embedding_model?}` - Omit a field to leave it unchanged; send `null` to clear the override and inherit the global value. An empty string is rejected with **422**. Setting `openai_api_base` without an `openai_api_key` for the same project is rejected with **422** `embedding_base_requires_key`, so the global credential is never sent to a foreign endpoint. A project key without a project base URL is allowed (own key, default endpoint).
+- `DELETE /api/v1/projects/{project}/settings/embeddings` - Drop every override; the project inherits the global settings again.
 
 ## Stats
 
-- `GET /api/v1/projects/{project}/stats` — Per-project stats (collections, document counts). Accessible to any key granted the project.
+- `GET /api/v1/projects/{project}/stats` - Per-project stats (collections, document counts). Accessible to any key granted the project.
 - `GET /api/v1/stats` - Server-wide stats including `memory_pressure_level`, an `embedding` object with cache telemetry (`cache_size`, `cache_capacity`, `cache_hits`, `cache_misses`, `cache_hit_ratio`, `cache_bytes`), and `db_client_version`/`db_client_commit` (the smartbotic-database client library the dynamic linker actually bound at process startup, not necessarily what the binary was compiled against (2.4.x and 2.11.x share a SONAME, so a mismatched library can load silently and only crash on a later restart)). Admin only.
 
 ## Admin
@@ -128,11 +138,12 @@ All settings endpoints require an admin key.
 
 Public (no auth required):
 
-- `GET /healthz` — Liveness probe; always 200 while the process is running.
-- `GET /readyz`  — Readiness probe; 200 when the database is reachable, 503 otherwise.
-- `GET /openapi.json` — OpenAPI 3.1 machine-readable spec.
-- `GET /llms.txt`     — This file.
+- `GET /healthz` - Liveness probe; always 200 while the process is running.
+- `GET /readyz`  - Readiness probe; 200 when the database is reachable, 503 otherwise.
+- `GET /openapi.json` - OpenAPI 3.1 machine-readable spec.
+- `GET /llms.txt`     - This file.
 
 Session login (used by the web UI):
 
-- `POST /ui/login` `{key}` — Exchange an API key for an HttpOnly session cookie (`svapi_session`). The cookie may be used in place of the `Authorization: Bearer` header for all `/api/v1/*` endpoints.
+- `POST /ui/login` `{key}` - Exchange an API key for an HttpOnly session cookie (`svapi_session`). The cookie may be used in place of the `Authorization: Bearer` header for all `/api/v1/*` endpoints.
+- `GET  /ui/session` - Return `{ok, admin, projects}` for the caller's current session (cookie or bearer), or **401** when there is none. Sessions are held in memory, so a server restart invalidates every cookie; a client must call this before trusting any locally stored "logged in" state.

+ 119 - 5
api/openapi.json

@@ -2,7 +2,7 @@
   "openapi": "3.1.0",
   "info": {
     "title": "smartbotic-vectorapi",
-    "version": "0.1.0",
+    "version": "0.3.0",
     "description": "General-purpose REST API fronting smartbotic-database. Supports multi-project namespacing and multi-key authorization with per-project grants."
   },
   "components": {
@@ -14,6 +14,23 @@
       }
     },
     "schemas": {
+      "ProjectEmbeddingSettings": {
+        "type": "object",
+        "description": "Three views of a project's embedding configuration. In overrides, an empty string means the field is inherited.",
+        "properties": {
+          "effective": { "$ref": "#/components/schemas/EmbeddingTier" },
+          "inherited": { "$ref": "#/components/schemas/EmbeddingTier" },
+          "overrides": { "$ref": "#/components/schemas/EmbeddingTier" }
+        }
+      },
+      "EmbeddingTier": {
+        "type": "object",
+        "properties": {
+          "openai_api_base": { "type": "string" },
+          "default_embedding_model": { "type": "string" },
+          "openai_api_key_set": { "type": "boolean" }
+        }
+      },
       "Error": {
         "type": "object",
         "properties": {
@@ -150,7 +167,7 @@
   "paths": {
     "/healthz": {
       "get": {
-        "summary": "Liveness check — always 200 if the process is running",
+        "summary": "Liveness check - always 200 if the process is running",
         "operationId": "healthz",
         "security": [],
         "responses": {
@@ -160,7 +177,7 @@
     },
     "/readyz": {
       "get": {
-        "summary": "Readiness check — 200 when the database is reachable",
+        "summary": "Readiness check - 200 when the database is reachable",
         "operationId": "readyz",
         "security": [],
         "responses": {
@@ -318,7 +335,7 @@
                   "type": "object",
                   "properties": {
                     "id": { "type": "string", "description": "Stable non-secret identifier for this key" },
-                    "key": { "type": "string", "description": "Secret key value — shown only once" },
+                    "key": { "type": "string", "description": "Secret key value - shown only once" },
                     "label": { "type": "string" },
                     "projects": { "type": "array", "items": { "type": "string" } },
                     "admin": { "type": "boolean" },
@@ -1199,7 +1216,7 @@
                   },
                   "min_score": {
                     "type": "number",
-                    "description": "Minimum cosine similarity score (0–1)"
+                    "description": "Minimum cosine similarity score (0-1)"
                   },
                   "filters": {
                     "type": "array",
@@ -1400,6 +1417,103 @@
         }
       }
     },
+    "/api/v1/projects/{project}/settings/embeddings": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        }
+      ],
+      "get": {
+        "summary": "Get this project's embedding settings: effective, inherited and overridden values. The API key is never returned, only openai_api_key_set.",
+        "operationId": "getProjectEmbeddingSettings",
+        "responses": {
+          "200": {
+            "description": "Project embedding settings",
+            "content": {
+              "application/json": {
+                "schema": { "$ref": "#/components/schemas/ProjectEmbeddingSettings" }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - requires a key granted this project; capability-scoped keys are refused" }
+        }
+      },
+      "put": {
+        "summary": "Set or clear this project's embedding overrides. Omit a field to leave it unchanged; send null to clear it and inherit the global value.",
+        "operationId": "updateProjectEmbeddingSettings",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "openai_api_base": {
+                    "type": ["string", "null"],
+                    "description": "Must start with http:// or https://. Empty string is rejected; send null to inherit the global value"
+                  },
+                  "openai_api_key": {
+                    "type": ["string", "null"],
+                    "description": "Write-only, never returned. Empty string is rejected; send null to inherit the global key"
+                  },
+                  "default_embedding_model": {
+                    "type": ["string", "null"],
+                    "description": "Empty string is rejected; send null to inherit the global default"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Updated project embedding settings",
+            "content": {
+              "application/json": {
+                "schema": { "$ref": "#/components/schemas/ProjectEmbeddingSettings" }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - requires a key granted this project; capability-scoped keys are refused" },
+          "422": {
+            "description": "Validation failed: empty value, malformed base URL, or openai_api_base without openai_api_key for the same project (code embedding_base_requires_key)",
+            "content": {
+              "application/json": {
+                "schema": { "$ref": "#/components/schemas/Error" }
+              }
+            }
+          }
+        }
+      },
+      "delete": {
+        "summary": "Drop every override so the project inherits the global embedding settings again",
+        "operationId": "deleteProjectEmbeddingSettings",
+        "responses": {
+          "200": {
+            "description": "Overrides removed",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "reverted": { "type": "string" },
+                    "had_overrides": { "type": "boolean" }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - requires a key granted this project; capability-scoped keys are refused" }
+        }
+      }
+    },
     "/api/v1/settings": {
       "get": {
         "summary": "Get current settings (admin only); openai_api_key is masked to a boolean openai_api_key_set",

+ 12 - 1
cmake/WebUI.cmake

@@ -16,12 +16,23 @@ add_custom_command(
     COMMENT "webui: npm ci"
     VERBATIM)
 
+# Everything the bundle is built from. Without these as DEPENDS the output
+# already exists after the first build, so editing a page or component would
+# never rebuild the UI and the server would keep serving a stale bundle.
+file(GLOB_RECURSE WEBUI_SOURCES CONFIGURE_DEPENDS
+    "${WEBUI_SRC}/src/*"
+    "${WEBUI_SRC}/index.html"
+    "${WEBUI_SRC}/package.json"
+    "${WEBUI_SRC}/package-lock.json"
+    "${WEBUI_SRC}/vite.config.ts"
+    "${WEBUI_SRC}/tsconfig*.json")
+
 # Build the UI into the CMake build tree (outDir override).
 add_custom_command(
     OUTPUT "${WEBUI_DIST}/index.html"
     COMMAND ${NPM_EXECUTABLE} run build -- --outDir "${WEBUI_DIST}" --emptyOutDir
     WORKING_DIRECTORY "${WEBUI_SRC}"
-    DEPENDS "${WEBUI_SRC}/node_modules/.package-lock.json"
+    DEPENDS "${WEBUI_SRC}/node_modules/.package-lock.json" ${WEBUI_SOURCES}
     COMMENT "webui: npm run build -> ${WEBUI_DIST}"
     VERBATIM)
 

+ 12 - 2
packaging/Dockerfile.base

@@ -20,7 +20,15 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
     && apt-get install -y --no-install-recommends nodejs \
     && rm -rf /var/lib/apt/lists/*
 
-# Smartbotics apt repo — provides libsmartbotic-db-client-dev (2.3.x).
+# Smartbotics apt repo - provides libsmartbotic-db-client-dev.
+#
+# The client version is PINNED on purpose. vectorapi links
+# libsmartbotic-db-client.so, and every 2.x shares one SONAME, so a deb built
+# against a newer client installs happily next to an older one on the target and
+# only segfaults on the next service restart. Keep this equal to the client
+# version installed on the deploy target; when the target's DB moves, bump this
+# and ship both together.
+ARG DB_CLIENT_VERSION=2.11.1-1
 RUN curl -fsSL https://repository.smartbotics.ai/smartbotics-repo.gpg \
         | gpg --dearmor -o /usr/share/keyrings/smartbotics-repo.gpg \
     && printf 'machine repository.smartbotics.ai\nlogin %s\npassword %s\n' "$REPO_USER" "$REPO_PASS" \
@@ -29,5 +37,7 @@ RUN curl -fsSL https://repository.smartbotics.ai/smartbotics-repo.gpg \
     && echo "deb [signed-by=/usr/share/keyrings/smartbotics-repo.gpg] https://repository.smartbotics.ai trixie main" \
         > /etc/apt/sources.list.d/smartbotics.list \
     && apt-get update \
-    && apt-get install -y --no-install-recommends libsmartbotic-db-client-dev \
+    && apt-get install -y --no-install-recommends \
+        "libsmartbotic-db-client-dev=${DB_CLIENT_VERSION}" \
+        "libsmartbotic-db-client=${DB_CLIENT_VERSION}" \
     && rm -rf /var/lib/apt/lists/*

+ 103 - 20
packaging/build.sh

@@ -4,7 +4,10 @@
 #
 # Supports two modes:
 #   Default:  Docker build targeting Debian 13 (production .debs)
-#   --local:  Native build on current system (development/testing)
+#   --local:  Native build on current system (development/testing).
+#             .deb packaging only happens on a Debian-family host; elsewhere
+#             (e.g. Void Linux) it compiles and tests only - packages for
+#             release always come from the Docker/Debian-13 path.
 #
 set -euo pipefail
 
@@ -16,7 +19,7 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
 
 BASE_IMAGE_NAME="smartbotic-vectorapi-build-base:debian13"
 BUILD_JOBS="${BUILD_JOBS:-$(nproc)}"
-DEB_REPO_DIR="${DEB_REPO_DIR:-/data/smartbotics-deb-repo}"
+DEB_REPO_DIR="${DEB_REPO_DIR:-/data/dev/smartbotics/smartbotics-deb-repo}"
 
 # Output directories — separate to prevent local/Docker overwriting each other
 #   dist/local/    — native build for current system
@@ -38,6 +41,7 @@ NO_CACHE=0
 DO_REPO=0
 SUITE=""
 DO_SYNC=0
+MAKE_DEBS=1
 
 # ---------------------------------------------------------------------------
 # Colored logging
@@ -47,6 +51,28 @@ log_success() { printf '\033[1;32m[OK]\033[0m    %s\n' "$*"; }
 log_warn()    { printf '\033[1;33m[WARN]\033[0m  %s\n' "$*"; }
 log_error()   { printf '\033[1;31m[ERROR]\033[0m %s\n' "$*"; }
 
+# ---------------------------------------------------------------------------
+# Host detection
+# ---------------------------------------------------------------------------
+# .debs are only meaningful when produced on a Debian-family host: a package
+# linked against another distro's glibc/abseil/gRPC will not install (or will
+# crash-loop) on Debian 13. Everything we ship comes from the Docker path.
+host_is_debian() {
+    [[ -r /etc/os-release ]] || return 1
+    local ID="" ID_LIKE=""
+    # shellcheck disable=SC1091
+    . /etc/os-release
+    [[ "$ID" == "debian" || "$ID" == "ubuntu" || "$ID_LIKE" == *debian* ]]
+}
+
+host_name() {
+    if [[ -r /etc/os-release ]]; then
+        ( . /etc/os-release; echo "${PRETTY_NAME:-${NAME:-unknown}}" )
+    else
+        echo "unknown"
+    fi
+}
+
 # ---------------------------------------------------------------------------
 # Usage
 # ---------------------------------------------------------------------------
@@ -56,11 +82,13 @@ Usage: packaging/build.sh [OPTIONS]
 
 Build modes:
   (default)            Docker build targeting Debian 13 (production)
-  --local              Native build for current system (development)
+  --local              Native compile + tests for current system (development).
+                       Builds .debs only on a Debian-family host; on any other
+                       distro packaging is refused - use the Docker mode.
 
 Options:
-  --install            Install .debs locally after build (--local only)
-  --skip-tests         Skip running tests (no-op; tests disabled by default)
+  --install            Install .debs locally after build (--local, Debian host only)
+  --skip-tests         Skip building and running the test suite (--local only)
   --deb-revision N     Debian revision suffix (default: 1)
   --rebuild-base       Force rebuild Docker base image
   --no-cache           Build without Docker caches
@@ -70,7 +98,7 @@ Options:
 
 Environment variables:
   BUILD_JOBS           Parallel build jobs (default: nproc)
-  DEB_REPO_DIR         Path to smartbotics-deb-repo (default: /data/smartbotics-deb-repo)
+  DEB_REPO_DIR         Path to smartbotics-deb-repo (default: /data/dev/smartbotics/smartbotics-deb-repo)
 EOF
     exit 0
 }
@@ -112,6 +140,24 @@ if [[ $DO_REPO -eq 1 && -z "$SUITE" ]]; then
     exit 1
 fi
 
+# Native packaging is only allowed on a Debian-family host. On anything else
+# (Void Linux on zeus, for example) --local compiles and tests, but must not
+# emit .debs: they would be linked against the wrong libc/abseil/gRPC.
+if [[ $LOCAL_MODE -eq 1 ]] && ! host_is_debian; then
+    MAKE_DEBS=0
+    log_warn "Non-Debian host detected ($(host_name)): --local will compile and test only."
+    log_warn "Release packages must be built with the Docker mode: packaging/build.sh"
+
+    if [[ $INSTALL_AFTER -eq 1 ]]; then
+        log_error "--install needs .deb packages, which are not built on a non-Debian host."
+        exit 1
+    fi
+    if [[ $DO_REPO -eq 1 ]]; then
+        log_error "--repo/--sync need .deb packages; run the Docker mode (drop --local)."
+        exit 1
+    fi
+fi
+
 # ---------------------------------------------------------------------------
 # Set output directory based on mode
 # ---------------------------------------------------------------------------
@@ -120,7 +166,9 @@ if [[ $LOCAL_MODE -eq 1 ]]; then
 else
     OUTPUT_DIR="${DIST_BASE}/debian13"
 fi
-mkdir -p "$OUTPUT_DIR"
+if [[ $MAKE_DEBS -eq 1 ]]; then
+    mkdir -p "$OUTPUT_DIR"
+fi
 
 # ---------------------------------------------------------------------------
 # Banner
@@ -128,27 +176,48 @@ mkdir -p "$OUTPUT_DIR"
 log_info "smartbotic-vectorapi build v${VERSION} (${GIT_COMMIT})"
 log_info "Build jobs: ${BUILD_JOBS}"
 if [[ $LOCAL_MODE -eq 1 ]]; then
-    log_info "Mode: local (native)"
+    log_info "Mode: local (native, $(host_name))"
 else
     log_info "Mode: Docker (Debian 13)"
 fi
-log_info "Output: ${OUTPUT_DIR}"
+if [[ $MAKE_DEBS -eq 1 ]]; then
+    log_info "Output: ${OUTPUT_DIR}"
+else
+    log_info "Output: ${PROJECT_DIR}/build (no packages on this host)"
+fi
 
 # ---------------------------------------------------------------------------
 # Local build
 # ---------------------------------------------------------------------------
 build_local() {
-    log_info "Configuring CMake (Release)..."
+    local tests="ON"
+    if [[ $SKIP_TESTS -eq 1 ]]; then
+        tests="OFF"
+    fi
+
+    log_info "Configuring CMake (Release, BUILD_TESTS=${tests})..."
     cmake -B "$PROJECT_DIR/build" -G Ninja \
         -DCMAKE_BUILD_TYPE=Release \
         -DBUILD_WEBUI=ON \
-        -DBUILD_TESTS=OFF \
+        -DBUILD_TESTS="$tests" \
         -S "$PROJECT_DIR"
 
     log_info "Building with ${BUILD_JOBS} jobs..."
     cmake --build "$PROJECT_DIR/build" -j"$BUILD_JOBS"
     log_success "Build completed"
 
+    if [[ $tests == "ON" ]]; then
+        log_info "Running tests..."
+        # Integration tests GTEST_SKIP() when smartbotic-database is unreachable.
+        ctest --test-dir "$PROJECT_DIR/build" --output-on-failure
+        log_success "Tests passed"
+    fi
+
+    if [[ $MAKE_DEBS -eq 0 ]]; then
+        log_success "Native build verified: $PROJECT_DIR/build/src/smartbotic-vectorapi"
+        return 0
+    fi
+
     log_info "Creating .deb packages..."
     OUTPUT_DIR="$OUTPUT_DIR" BUILD_DEB_REVISION="$DEB_REVISION" "$PROJECT_DIR/packaging/deb/create-debs.sh"
     log_success "Packages created"
@@ -246,12 +315,14 @@ fi
 # ---------------------------------------------------------------------------
 # List created packages
 # ---------------------------------------------------------------------------
-echo ""
-log_info "Created packages:"
-ls -lh "$OUTPUT_DIR"/*.deb 2>/dev/null || {
-    log_error "No .deb files found in $OUTPUT_DIR"
-    exit 1
-}
+if [[ $MAKE_DEBS -eq 1 ]]; then
+    echo ""
+    log_info "Created packages:"
+    ls -lh "$OUTPUT_DIR"/*.deb 2>/dev/null || {
+        log_error "No .deb files found in $OUTPUT_DIR"
+        exit 1
+    }
+fi
 
 # ---------------------------------------------------------------------------
 # Repository operations
@@ -282,15 +353,27 @@ fi
 # Next steps
 # ---------------------------------------------------------------------------
 echo ""
-log_success "Build complete! (v${VERSION}-${DEB_REVISION})"
+if [[ $MAKE_DEBS -eq 1 ]]; then
+    log_success "Build complete! (v${VERSION}-${DEB_REVISION})"
+else
+    log_success "Native build complete! (v${VERSION}, ${GIT_COMMIT})"
+fi
 echo ""
-if [[ $LOCAL_MODE -eq 1 && $INSTALL_AFTER -eq 0 ]]; then
+if [[ $MAKE_DEBS -eq 0 ]]; then
+    log_info "To run the server in place:"
+    echo "  SMARTBOTIC_VECTORAPI_KEY=dev ${PROJECT_DIR}/build/src/smartbotic-vectorapi \\"
+    echo "    --config ${PROJECT_DIR}/config/config.json \\"
+    echo "    --share-dir ${PROJECT_DIR}/api \\"
+    echo "    --webui-dir ${PROJECT_DIR}/build/webui/dist"
+    echo ""
+fi
+if [[ $MAKE_DEBS -eq 1 && $LOCAL_MODE -eq 1 && $INSTALL_AFTER -eq 0 ]]; then
     log_info "To install locally:"
     echo "  sudo dpkg -i ${OUTPUT_DIR}/*.deb || sudo apt-get install -f -y"
     echo ""
 fi
 if [[ $DO_REPO -eq 0 ]]; then
-    log_info "To publish to repository:"
+    log_info "To build release packages and publish:"
     echo "  packaging/build.sh --repo --suite trixie"
     echo ""
 fi

+ 3 - 0
src/CMakeLists.txt

@@ -5,6 +5,8 @@ add_library(vectorapi_core STATIC
     filters.cpp
     db_gateway.cpp
     settings.cpp
+    project_settings.cpp
+    outbound_http.cpp
     apikey.cpp
     key_store.cpp
     settings_store.cpp
@@ -25,6 +27,7 @@ add_library(vectorapi_core STATIC
     handlers/vectors.cpp
     handlers/stats.cpp
     handlers/settings.cpp
+    handlers/project_settings.cpp
     handlers/relations.cpp
 )
 target_include_directories(vectorapi_core PUBLIC

+ 2 - 1
src/captcha.cpp

@@ -1,4 +1,5 @@
 #include "captcha.hpp"
+#include "outbound_http.hpp"
 #include "settings.hpp"
 #include <httplib.h>
 #include <nlohmann/json.hpp>
@@ -13,8 +14,8 @@ bool verifyCaptcha(const Settings& s, const std::string& token, const std::strin
     std::string origin = (slash == std::string::npos) ? url : url.substr(0, slash);
     std::string path   = (slash == std::string::npos) ? "/"  : url.substr(slash);
     httplib::Client cli(origin);
+    applyOutboundDefaults(cli);
     cli.set_connection_timeout(5); cli.set_read_timeout(10);
-    cli.enable_server_certificate_verification(true);
     httplib::Params params{{"secret", s.captchaSecret}, {"response", token}, {"remoteip", remoteIp}};
     auto res = cli.Post(path, params);
     if (!res || res->status != 200) return false;

+ 2 - 1
src/embeddings.cpp

@@ -1,5 +1,6 @@
 #include "embeddings.hpp"
 #include "errors.hpp"
+#include "outbound_http.hpp"
 #include <httplib.h>
 #include <condition_variable>
 #include <map>
@@ -22,10 +23,10 @@ public:
         free_.reserve(n);
         for (uint32_t i = 0; i < n; ++i) {
             auto cli = std::make_unique<httplib::Client>(origin);
+            applyOutboundDefaults(*cli);
             cli->set_keep_alive(true);
             cli->set_connection_timeout(connectTimeoutSec);
             cli->set_read_timeout(readTimeoutSec);
-            cli->enable_server_certificate_verification(true);
             if (!apiKey.empty()) cli->set_bearer_token_auth(apiKey);
             free_.push_back(cli.get());
             clients_.push_back(std::move(cli));

+ 107 - 0
src/handlers/project_settings.cpp

@@ -0,0 +1,107 @@
+#include "collection_registry.hpp"
+#include "errors.hpp"
+#include "json_http.hpp"
+#include "project_settings.hpp"
+#include "server.hpp"
+
+namespace svapi {
+namespace {
+
+/// Read one override field out of a PUT body.
+///   absent        -> leave `field` untouched
+///   null          -> clear the override (inherit again)
+///   "" (empty)    -> 422; blank is never a meaningful base URL, key or model
+///   "value"       -> set the override
+void applyOverride(const nlohmann::json& body, const char* name, std::string& field) {
+    if (!body.contains(name)) return;
+    const nlohmann::json& v = body[name];
+    if (v.is_null()) { field.clear(); return; }
+    if (!v.is_string())
+        throw ApiError(ErrCode::Unprocessable, "validation",
+                       std::string(name) + " must be a string or null");
+    const std::string s = v.get<std::string>();
+    if (s.empty())
+        throw ApiError(ErrCode::Unprocessable, "validation",
+                       std::string(name) + " cannot be empty - send null to inherit the global value");
+    field = s;
+}
+
+nlohmann::json viewJson(const Settings& global, const ProjectEmbeddingSettings* p) {
+    // An empty CollectionMeta means "no per-collection model pin", so this shows
+    // what a collection without its own model would resolve to.
+    const CollectionMeta none;
+    ResolvedEmbedding eff = resolveEmbedding(global, p, none);
+    return {{"effective",
+             {{"openai_api_base", eff.apiBase},
+              {"default_embedding_model", eff.model},
+              {"openai_api_key_set", !eff.apiKey.empty()}}},
+            {"inherited",
+             {{"openai_api_base", global.openaiApiBase},
+              {"default_embedding_model", global.defaultEmbeddingModel},
+              {"openai_api_key_set", !global.openaiApiKey.empty()}}},
+            {"overrides",
+             {{"openai_api_base", p ? p->openaiApiBase : ""},
+              {"default_embedding_model", p ? p->defaultEmbeddingModel : ""},
+              {"openai_api_key_set", p && !p->openaiApiKey.empty()}}}};
+}
+
+}  // namespace
+
+void registerProjectSettingsRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/settings/embeddings)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        std::string project = req.matches[1];
+        requireProjectManage(requireKey(*d, req), project);
+        auto global = d->settings.snapshot();
+        auto proj   = d->settings.projectSnapshot(project);
+        sendJson(res, 200, viewJson(*global, proj.get()));
+    });
+
+    svr.Put(R"(/api/v1/projects/([^/]+)/settings/embeddings)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        std::string project = req.matches[1];
+        requireProjectManage(requireKey(*d, req), project);
+        auto body = bodyJson(req);
+
+        // Start from what is stored so a partial PUT leaves the rest alone.
+        auto current = d->settings.projectSnapshot(project);
+        ProjectEmbeddingSettings updated = current ? *current : ProjectEmbeddingSettings{};
+        updated.project = project;
+        applyOverride(body, "openai_api_base", updated.openaiApiBase);
+        applyOverride(body, "openai_api_key", updated.openaiApiKey);
+        applyOverride(body, "default_embedding_model", updated.defaultEmbeddingModel);
+
+        if (!updated.openaiApiBase.empty() &&
+            updated.openaiApiBase.rfind("http://", 0) != 0 &&
+            updated.openaiApiBase.rfind("https://", 0) != 0)
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "openai_api_base must start with http:// or https://");
+
+        // A project endpoint must carry its own credential, otherwise the global
+        // API key would be sent to a foreign host.
+        if (!updated.baseHasItsOwnKey())
+            throw ApiError(ErrCode::Unprocessable, "embedding_base_requires_key",
+                           "openai_api_base requires openai_api_key for the same project");
+
+        // No overrides left: drop the document so the project plainly inherits.
+        if (!updated.hasOverrides()) d->settings.removeProject(project);
+        else if (!d->settings.saveProject(updated))
+            throw ApiError(ErrCode::Unavailable, "db_error", "could not save project settings");
+
+        auto global = d->settings.snapshot();
+        auto proj   = d->settings.projectSnapshot(project);
+        sendJson(res, 200, viewJson(*global, proj.get()));
+    });
+
+    svr.Delete(R"(/api/v1/projects/([^/]+)/settings/embeddings)",
+               [d](const httplib::Request& req, httplib::Response& res) {
+        std::string project = req.matches[1];
+        requireProjectManage(requireKey(*d, req), project);
+        bool had = d->settings.removeProject(project);
+        sendJson(res, 200, {{"reverted", project}, {"had_overrides", had}});
+    });
+}
+
+}  // namespace svapi

+ 12 - 7
src/handlers/vectors.cpp

@@ -2,6 +2,7 @@
 #include "embedding_cache.hpp"
 #include "embeddings.hpp"
 #include "errors.hpp"
+#include "project_settings.hpp"
 #include "json_http.hpp"
 #include "server.hpp"
 namespace svapi {
@@ -21,28 +22,32 @@ bool wantsNoStore(const httplib::Request& req) {
     const std::string cc = req.get_header_value("Cache-Control");
     return cc.find("no-store") != std::string::npos;
 }
-std::vector<float> resolveVector(ServerDeps* d, const CollectionMeta& meta, const nlohmann::json& body,
+std::vector<float> resolveVector(ServerDeps* d, const std::string& project,
+                                 const CollectionMeta& meta, const nlohmann::json& body,
                                  const char* vecField, const char* textField, bool noStore) {
     std::vector<float> v;
     if (body.contains(vecField) && body[vecField].is_array()) {
         v = body[vecField].get<std::vector<float>>();
     } else if (body.contains(textField) && body[textField].is_string()) {
         auto snap = d->settings.snapshot();
-        std::string model = meta.embeddingModel.empty() ? snap->defaultEmbeddingModel : meta.embeddingModel;
+        auto proj = d->settings.projectSnapshot(project);
+        // Endpoint + key: project override, else global. Model: collection pin,
+        // else project override, else global default.
+        ResolvedEmbedding cfg = resolveEmbedding(*snap, proj.get(), meta);
         // This helper is shared by /search (query_text) AND /vectors insert (text), so the cache
         // intentionally covers both paths — re-embedding identical text is wasted work either way.
         // When noStore is true (Cache-Control: no-store), skip both the cache get and put.
         const std::string text = body[textField].get<std::string>();
-        EmbeddingCache::Key key{snap->openaiApiBase, model, text};
+        EmbeddingCache::Key key{cfg.apiBase, cfg.model, text};
         std::optional<std::vector<float>> hit;
         if (!noStore) hit = embeddingCache().get(key);
         if (hit) {
             v = std::move(*hit);
         } else {
-            EmbeddingClient emb(snap->openaiApiBase, snap->openaiApiKey,
+            EmbeddingClient emb(cfg.apiBase, cfg.apiKey,
                                 snap->embeddingConnectTimeoutSec, snap->embeddingReadTimeoutSec,
                                 snap->embeddingClientPoolSize);
-            v = emb.embed(model, text);
+            v = emb.embed(cfg.model, text);
             if (!noStore) embeddingCache().put(key, v);  // copy, not move: v is still needed for the dimension check below
         }
     } else {
@@ -61,7 +66,7 @@ void registerVectorRoutes(ApiServer& s) {
     svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/vectors)", [d](const httplib::Request& req, httplib::Response& res) {
         std::string project, name; CollectionMeta meta = requireVectorCollection(d, req, project, name, KeyOp::Insert);
         auto body = bodyJson(req);
-        std::vector<float> vec = resolveVector(d, meta, body, "vector", "text", wantsNoStore(req));
+        std::vector<float> vec = resolveVector(d, project, meta, body, "vector", "text", wantsNoStore(req));
         nlohmann::json doc = body.value("metadata", nlohmann::json::object());
         if (!doc.is_object()) doc = nlohmann::json::object();
         if (body.contains("text")) doc["text"] = body["text"];
@@ -74,7 +79,7 @@ void registerVectorRoutes(ApiServer& s) {
     svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/search)", [d](const httplib::Request& req, httplib::Response& res) {
         std::string project, name; CollectionMeta meta = requireVectorCollection(d, req, project, name, KeyOp::Search);
         auto body = bodyJson(req);
-        std::vector<float> q = resolveVector(d, meta, body, "query_vector", "query_text", wantsNoStore(req));
+        std::vector<float> q = resolveVector(d, project, meta, body, "query_vector", "query_text", wantsNoStore(req));
         uint32_t topK = body.value("top_k", 5u);
         float minScore = body.value("min_score", 0.0f);
         auto results = d->db.client().similaritySearch(qualify(project, name), q, topK, minScore);

+ 9 - 0
src/handlers/webui_auth.cpp

@@ -18,6 +18,15 @@ void registerWebuiAuthRoutes(ApiServer& s) {
             "; HttpOnly; SameSite=Strict; Path=/; Max-Age=" + std::to_string(snap->sessionTtlMinutes * 60));
         sendJson(res, 200, {{"ok", true}, {"admin", found->admin}, {"projects", found->projects}});
     });
+    // Who am I? The web UI calls this once at boot: a persisted "logged in" flag
+    // in the browser is not proof of a live session (sessions are in-memory and
+    // do not survive a restart), and without this the UI would render the
+    // authenticated shell first and only discover the 401 afterwards.
+    svr.Get("/ui/session", [d](const httplib::Request& req, httplib::Response& res) {
+        auto k = resolveKey(*d, req);
+        if (!k) throw ApiError(ErrCode::Unauthorized, "unauthorized", "no active session");
+        sendJson(res, 200, {{"ok", true}, {"admin", k->admin}, {"projects", k->projects}});
+    });
     svr.Post("/ui/logout", [d](const httplib::Request& req, httplib::Response& res) {
         if (auto it = req.headers.find("Cookie"); it != req.headers.end())
             if (auto c = cookieValue(it->second, "svapi_session")) d->sessions.remove(*c);

+ 18 - 0
src/outbound_http.cpp

@@ -0,0 +1,18 @@
+#include "outbound_http.hpp"
+#include "svapi/version.hpp"
+#include <httplib.h>
+
+namespace svapi {
+
+const std::string& outboundUserAgent() {
+    static const std::string ua = std::string("smartbotic-vectorapi/") + VERSION;
+    return ua;
+}
+
+void applyOutboundDefaults(httplib::Client& cli) {
+    cli.set_default_headers({{"User-Agent", outboundUserAgent()}});
+    cli.enable_server_certificate_verification(true);
+    cli.set_follow_location(false);
+}
+
+} // namespace svapi

+ 18 - 0
src/outbound_http.hpp

@@ -0,0 +1,18 @@
+#pragma once
+#include <string>
+
+namespace httplib { class Client; }
+
+namespace svapi {
+
+/// User-Agent sent on every outbound request to an external provider
+/// (embeddings, captcha verification): "smartbotic-vectorapi/<version>" and
+/// nothing else - no URL, no commit, no platform or library tokens.
+const std::string& outboundUserAgent();
+
+/// Apply the outbound request conventions shared by every external call:
+/// the User-Agent above, TLS certificate verification, and redirect handling
+/// left off so a provider cannot bounce a credentialed request elsewhere.
+void applyOutboundDefaults(httplib::Client& cli);
+
+} // namespace svapi

+ 37 - 0
src/project_settings.cpp

@@ -0,0 +1,37 @@
+#include "project_settings.hpp"
+#include "collection_registry.hpp"
+#include "settings.hpp"
+
+namespace svapi {
+
+ProjectEmbeddingSettings ProjectEmbeddingSettings::fromJson(const nlohmann::json& j) {
+    ProjectEmbeddingSettings p;
+    p.project               = j.value("project", p.project);
+    p.openaiApiBase         = j.value("openai_api_base", p.openaiApiBase);
+    p.openaiApiKey          = j.value("openai_api_key", p.openaiApiKey);
+    p.defaultEmbeddingModel = j.value("default_embedding_model", p.defaultEmbeddingModel);
+    return p;
+}
+
+nlohmann::json ProjectEmbeddingSettings::toJson() const {
+    return {{"project", project},
+            {"openai_api_base", openaiApiBase},
+            {"openai_api_key", openaiApiKey},
+            {"default_embedding_model", defaultEmbeddingModel}};
+}
+
+std::string projectSettingsDocId(const std::string& project) { return "project_" + project; }
+
+ResolvedEmbedding resolveEmbedding(const Settings& global, const ProjectEmbeddingSettings* proj,
+                                   const CollectionMeta& coll) {
+    ResolvedEmbedding r{global.openaiApiBase, global.openaiApiKey, global.defaultEmbeddingModel};
+    if (proj) {
+        if (!proj->openaiApiBase.empty())         r.apiBase = proj->openaiApiBase;
+        if (!proj->openaiApiKey.empty())          r.apiKey  = proj->openaiApiKey;
+        if (!proj->defaultEmbeddingModel.empty()) r.model   = proj->defaultEmbeddingModel;
+    }
+    if (!coll.embeddingModel.empty()) r.model = coll.embeddingModel;
+    return r;
+}
+
+} // namespace svapi

+ 56 - 0
src/project_settings.hpp

@@ -0,0 +1,56 @@
+#pragma once
+#include <nlohmann/json.hpp>
+#include <string>
+
+namespace svapi {
+
+struct Settings;
+struct CollectionMeta;
+
+/// Per-project embedding overrides. An empty field means "inherit the global
+/// value" - there is no separate presence flag, because an empty base URL, API
+/// key or model is meaningless on its own.
+///
+/// Stored as one document per project in the (encrypted) global
+/// `vectorapi_settings` collection under projectSettingsDocId(project). The DB
+/// client strips a top-level `id` from stored bodies, so the project name also
+/// travels in the body as `project` (same approach as KeyStore's `key_id`).
+struct ProjectEmbeddingSettings {
+    std::string project;
+    std::string openaiApiBase;
+    std::string openaiApiKey;
+    std::string defaultEmbeddingModel;
+
+    /// True when at least one field overrides the global value.
+    bool hasOverrides() const {
+        return !openaiApiBase.empty() || !openaiApiKey.empty() || !defaultEmbeddingModel.empty();
+    }
+
+    /// A project base URL must carry its own API key, otherwise the global
+    /// credential would be sent to a foreign endpoint. A project key without a
+    /// base URL is fine (own key, default endpoint).
+    bool baseHasItsOwnKey() const { return openaiApiBase.empty() || !openaiApiKey.empty(); }
+
+    static ProjectEmbeddingSettings fromJson(const nlohmann::json& j);
+    nlohmann::json toJson() const;
+};
+
+/// Document id holding `project`'s overrides. Prefixed so it can never collide
+/// with the global settings document ("current").
+std::string projectSettingsDocId(const std::string& project);
+
+/// The endpoint actually used for one embedding call.
+struct ResolvedEmbedding {
+    std::string apiBase;
+    std::string apiKey;
+    std::string model;
+};
+
+/// Resolve the effective embedding endpoint for a collection.
+///   apiBase / apiKey : project override, else global.
+///   model            : collection pin, else project override, else global default.
+/// `proj` may be null when the project has no overrides stored.
+ResolvedEmbedding resolveEmbedding(const Settings& global, const ProjectEmbeddingSettings* proj,
+                                   const CollectionMeta& coll);
+
+} // namespace svapi

+ 1 - 0
src/server.cpp

@@ -139,6 +139,7 @@ void ApiServer::registerRoutes() {
     registerVectorRoutes(*this);
     registerStatsRoutes(*this);
     registerSettingsRoutes(*this);
+    registerProjectSettingsRoutes(*this);
     registerRelationRoutes(*this);
 
     if (!d_.shareDir.empty()) svr_.set_mount_point("/docs", d_.shareDir + "/docs");

+ 1 - 0
src/server.hpp

@@ -65,6 +65,7 @@ void registerDocumentRoutes(ApiServer&);
 void registerVectorRoutes(ApiServer&);
 void registerStatsRoutes(ApiServer&);
 void registerSettingsRoutes(ApiServer&);
+void registerProjectSettingsRoutes(ApiServer&);
 void registerRelationRoutes(ApiServer&);
 
 } // namespace svapi

+ 48 - 1
src/settings_store.cpp

@@ -21,6 +21,53 @@ void SettingsStore::bootstrap(const std::string& envOpenAiKey) {
     if (seeded || !db_.client().exists(coll_, docId_)) db_.client().upsert(coll_, s.toJson(), docId_);
 
     setSnapshot(s);
+    reloadProjectsFromDb();
+}
+
+void SettingsStore::reloadProjectsFromDb() {
+    smartbotic::database::Client::QueryOptions opts;
+    opts.limit = 100000;
+    auto docs = db_.client().find(coll_, opts);
+    auto m = std::make_shared<ProjectMap>();
+    for (const auto& d : docs) {
+        ProjectEmbeddingSettings p = ProjectEmbeddingSettings::fromJson(d);
+        // The global settings document lives in the same collection and carries
+        // no `project` field - that absence is what tells the two apart.
+        if (p.project.empty()) continue;
+        // Bind the key first: in an assignment the right operand is sequenced
+        // before the left, so inlining std::move(p) would move the name out
+        // from under (*m)[p.project] and file the entry under "".
+        const std::string name = p.project;
+        (*m)[name] = std::make_shared<const ProjectEmbeddingSettings>(std::move(p));
+    }
+    std::lock_guard<std::mutex> lk(m_);
+    projects_ = m;
+}
+
+std::shared_ptr<const ProjectEmbeddingSettings>
+SettingsStore::projectSnapshot(const std::string& project) const {
+    std::shared_ptr<const ProjectMap> m;
+    {
+        std::lock_guard<std::mutex> lk(m_);
+        m = projects_;
+    }
+    auto it = m->find(project);
+    return it == m->end() ? nullptr : it->second;
+}
+
+bool SettingsStore::saveProject(const ProjectEmbeddingSettings& p) {
+    if (p.project.empty()) return false;
+    db_.client().upsert(coll_, p.toJson(), projectSettingsDocId(p.project));
+    reloadProjectsFromDb();
+    return true;
+}
+
+bool SettingsStore::removeProject(const std::string& project) {
+    const std::string id = projectSettingsDocId(project);
+    if (!db_.client().exists(coll_, id)) return false;
+    db_.client().remove(coll_, id);
+    reloadProjectsFromDb();
+    return true;
 }
 
 void SettingsStore::setSnapshot(Settings s) {
@@ -52,7 +99,7 @@ void SettingsStore::startWatch() {
         {coll_},
         [this](const std::string&, const std::string&,
                const std::string&, const std::optional<nlohmann::json>&) {
-            try { reloadFromDb(); }
+            try { reloadFromDb(); reloadProjectsFromDb(); }
             catch (const std::exception& e) {
                 spdlog::warn("settings reload failed: {}", e.what());
             }

+ 26 - 2
src/settings_store.hpp

@@ -1,14 +1,20 @@
 #pragma once
 #include "db_gateway.hpp"
+#include "project_settings.hpp"
 #include "settings.hpp"
 #include <memory>
 #include <mutex>
 #include <string>
+#include <unordered_map>
 
 namespace svapi {
 
-/// DB-backed global settings store (single document in `vectorapi_settings`).
-/// Reads are served from a mutex-guarded shared_ptr snapshot.
+/// DB-backed settings store. Holds two things that share one collection and
+/// therefore one change subscription: the global settings document
+/// (`vectorapi_settings/current`) and the per-project embedding overrides
+/// (`vectorapi_settings/project_<name>`). Keeping them together means a single
+/// subscribe() stream - each open stream costs real time at shutdown.
+/// Reads are served from mutex-guarded shared_ptr snapshots.
 class SettingsStore {
 public:
     SettingsStore(DbGateway& db,
@@ -30,17 +36,35 @@ public:
     /// Persist settings and update the snapshot.
     bool save(const Settings& s);
 
+    /// Per-project embedding overrides, or nullptr when the project inherits
+    /// everything (thread-safe, lock-free after copy).
+    std::shared_ptr<const ProjectEmbeddingSettings> projectSnapshot(const std::string& project) const;
+
+    /// Persist one project's overrides (`p.project` names it) and refresh the
+    /// project snapshot. Returns true on success.
+    bool saveProject(const ProjectEmbeddingSettings& p);
+
+    /// Drop a project's overrides so it inherits the global settings again.
+    /// Returns false when the project had no overrides stored.
+    bool removeProject(const std::string& project);
+
     /// Subscribe to DB change events so the snapshot stays current automatically.
     void startWatch();
 
 private:
     void setSnapshot(Settings s);
 
+    /// Reload every `project_*` document into the project snapshot map.
+    void reloadProjectsFromDb();
+
+    using ProjectMap = std::unordered_map<std::string, std::shared_ptr<const ProjectEmbeddingSettings>>;
+
     DbGateway&   db_;
     std::string  coll_;
     std::string  docId_;
     mutable std::mutex m_;
     std::shared_ptr<const Settings> snap_;
+    std::shared_ptr<const ProjectMap> projects_ = std::make_shared<const ProjectMap>();
     std::shared_ptr<void> sub_;
 };
 

+ 21 - 1
tests/CMakeLists.txt

@@ -1,4 +1,16 @@
-find_package(GTest REQUIRED)
+# Prefer a system GoogleTest (Debian build images ship libgtest-dev); fall back
+# to FetchContent so the test suite also configures on distros without it
+# (e.g. the Void Linux dev host).
+find_package(GTest QUIET)
+if(NOT GTest_FOUND)
+    message(STATUS "GTest not found on the system - fetching googletest")
+    include(FetchContent)
+    FetchContent_Declare(googletest
+        GIT_REPOSITORY https://github.com/google/googletest.git
+        GIT_TAG        v1.15.2)
+    set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
+    FetchContent_MakeAvailable(googletest)
+endif()
 include(GoogleTest)
 
 add_executable(test_smoke test_smoke.cpp)
@@ -57,3 +69,11 @@ add_executable(test_api_integration test_api_integration.cpp)
 target_link_libraries(test_api_integration PRIVATE vectorapi_core GTest::gtest_main)
 target_compile_definitions(test_api_integration PRIVATE SVAPI_API_DOCS_DIR="${CMAKE_SOURCE_DIR}/api")
 gtest_discover_tests(test_api_integration)
+
+add_executable(test_project_settings test_project_settings.cpp)
+target_link_libraries(test_project_settings PRIVATE vectorapi_core GTest::gtest_main)
+gtest_discover_tests(test_project_settings)
+
+add_executable(test_outbound_http test_outbound_http.cpp)
+target_link_libraries(test_outbound_http PRIVATE vectorapi_core GTest::gtest_main)
+gtest_discover_tests(test_outbound_http)

+ 8 - 1
tests/api_fixture.hpp

@@ -28,6 +28,10 @@ protected:
     httplib::Server mockOpenAi_;
     std::thread mockThread_;
     int mockPort_ = 0, mockDim_ = 4;
+    // What the last outbound embedding request actually carried, so tests can
+    // assert on the request we send to a provider, not just on our own helpers.
+    std::string lastEmbedUserAgent_, lastEmbedAuth_;
+    nlohmann::json lastEmbedBody_ = nlohmann::json::object();
     std::string settingsColl_ = "svapitest_api_settings";
     std::string keysColl_     = "svapitest_api_keys";
     std::string webuiDir_     = "/tmp/svapi_webui_test";   // sentinel SPA root for fallback tests
@@ -53,7 +57,10 @@ protected:
         adminKey_ = keys_->list().at(0).key;
         sessions_ = std::make_unique<SessionStore>(std::chrono::minutes(60));
 
-        mockOpenAi_.Post("/v1/embeddings", [this](const httplib::Request&, httplib::Response& res) {
+        mockOpenAi_.Post("/v1/embeddings", [this](const httplib::Request& req, httplib::Response& res) {
+            lastEmbedUserAgent_ = req.get_header_value("User-Agent");
+            lastEmbedAuth_      = req.get_header_value("Authorization");
+            try { lastEmbedBody_ = nlohmann::json::parse(req.body); } catch (...) {}
             nlohmann::json emb = nlohmann::json::array();
             for (int i = 0; i < mockDim_; ++i) emb.push_back(0.1f * (i + 1));
             res.set_content(nlohmann::json{{"data", {{{"embedding", emb}}}}}.dump(), "application/json");

+ 221 - 0
tests/test_api_integration.cpp

@@ -1,4 +1,5 @@
 #include "api_fixture.hpp"
+#include "svapi/version.hpp"
 #include "embedding_cache.hpp"
 #include <thread>
 #include <chrono>
@@ -1256,3 +1257,223 @@ TEST_F(ApiFixture, ReadonlyRequiresAdmin) {
                      nlohmann::json{{"readonly", true}}.dump(), "application/json", nonAdmin);
     ASSERT_TRUE(r); EXPECT_EQ(r->status, 403);
 }
+
+// --- outbound request hygiene -----------------------------------------------
+
+TEST_F(ApiFixture, OutboundEmbeddingRequestCarriesProductUserAgent) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(),
+        nlohmann::json{{"name","ua"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),
+        "application/json")->status, 201);
+    ASSERT_EQ(c.Post((base + "/ua/vectors").c_str(),
+        nlohmann::json{{"text","user agent check"}}.dump(), "application/json")->status, 201);
+
+    EXPECT_EQ(lastEmbedUserAgent_, std::string("smartbotic-vectorapi/") + svapi::VERSION);
+    EXPECT_EQ(lastEmbedUserAgent_.find("cpp-httplib"), std::string::npos);
+    EXPECT_EQ(lastEmbedAuth_, "Bearer test");
+}
+
+// --- per-project embedding settings ------------------------------------------
+
+namespace {
+std::string projSettingsPath(const std::string& project) {
+    return "/api/v1/projects/" + project + "/settings/embeddings";
+}
+}  // namespace
+
+TEST_F(ApiFixture, ProjectEmbeddingSettingsDefaultToInherited) {
+    auto c = admin();
+    auto r = c.Get(projSettingsPath(project_).c_str());
+    ASSERT_EQ(r->status, 200);
+    auto j = nlohmann::json::parse(r->body);
+    // Nothing overridden yet: effective == inherited, and no secret anywhere.
+    EXPECT_EQ(j["effective"]["openai_api_base"], j["inherited"]["openai_api_base"]);
+    EXPECT_EQ(j["effective"]["default_embedding_model"], "text-embedding-3-small");
+    EXPECT_FALSE(j["overrides"]["openai_api_key_set"].get<bool>());
+    EXPECT_FALSE(j["overrides"].contains("openai_api_key"));
+    EXPECT_FALSE(j["effective"].contains("openai_api_key"));
+    EXPECT_FALSE(j["inherited"].contains("openai_api_key"));
+}
+
+TEST_F(ApiFixture, ProjectEmbeddingModelOverrideAppliesAndReverts) {
+    auto c = admin();
+    auto put = c.Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"default_embedding_model", "text-embedding-3-large"}}.dump(), "application/json");
+    ASSERT_EQ(put->status, 200);
+
+    auto j = nlohmann::json::parse(c.Get(projSettingsPath(project_).c_str())->body);
+    EXPECT_EQ(j["effective"]["default_embedding_model"], "text-embedding-3-large");
+    EXPECT_EQ(j["overrides"]["default_embedding_model"], "text-embedding-3-large");
+    EXPECT_EQ(j["inherited"]["default_embedding_model"], "text-embedding-3-small");
+
+    ASSERT_EQ(c.Delete(projSettingsPath(project_).c_str())->status, 200);
+    auto j2 = nlohmann::json::parse(c.Get(projSettingsPath(project_).c_str())->body);
+    EXPECT_EQ(j2["effective"]["default_embedding_model"], "text-embedding-3-small");
+}
+
+TEST_F(ApiFixture, ProjectBaseUrlWithoutKeyIs422) {
+    auto c = admin();
+    auto r = c.Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"openai_api_base", "https://api.mistral.ai"}}.dump(), "application/json");
+    ASSERT_EQ(r->status, 422);
+    EXPECT_EQ(nlohmann::json::parse(r->body)["error"]["code"], "embedding_base_requires_key");
+}
+
+TEST_F(ApiFixture, ProjectBaseUrlWithKeyIsAcceptedAndSecretNeverReturned) {
+    auto c = admin();
+    ASSERT_EQ(c.Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"openai_api_base", "https://api.mistral.ai"},
+                       {"openai_api_key", "sk-project-secret"}}.dump(), "application/json")->status, 200);
+    auto body = c.Get(projSettingsPath(project_).c_str())->body;
+    EXPECT_EQ(body.find("sk-project-secret"), std::string::npos);
+    auto j = nlohmann::json::parse(body);
+    EXPECT_TRUE(j["overrides"]["openai_api_key_set"].get<bool>());
+    EXPECT_EQ(j["effective"]["openai_api_base"], "https://api.mistral.ai");
+}
+
+TEST_F(ApiFixture, ProjectOverrideClearedWithExplicitNull) {
+    auto c = admin();
+    ASSERT_EQ(c.Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"default_embedding_model", "text-embedding-3-large"}}.dump(),
+        "application/json")->status, 200);
+    ASSERT_EQ(c.Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"default_embedding_model", nullptr}}.dump(), "application/json")->status, 200);
+    auto j = nlohmann::json::parse(c.Get(projSettingsPath(project_).c_str())->body);
+    EXPECT_EQ(j["effective"]["default_embedding_model"], "text-embedding-3-small");
+    EXPECT_EQ(j["overrides"]["default_embedding_model"], "");
+}
+
+TEST_F(ApiFixture, ProjectEmbeddingSettingsRejectEmptyStringValues) {
+    auto c = admin();
+    auto r = c.Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"default_embedding_model", ""}}.dump(), "application/json");
+    EXPECT_EQ(r->status, 422);
+}
+
+TEST_F(ApiFixture, ProjectEmbeddingSettingsAuthorization) {
+    auto c = admin();
+    // A plain (non-admin, unscoped) key granted this project may manage it.
+    auto mk = c.Post("/api/v1/keys",
+        nlohmann::json{{"label","pm"},{"projects",{project_}},{"admin",false}}.dump(), "application/json");
+    ASSERT_EQ(mk->status, 201);
+    auto mkJson = nlohmann::json::parse(mk->body);
+    std::string pmKey = mkJson["key"], pmId = mkJson["id"];
+    EXPECT_EQ(http(pmKey).Get(projSettingsPath(project_).c_str())->status, 200);
+    EXPECT_EQ(http(pmKey).Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"default_embedding_model","text-embedding-3-large"}}.dump(),
+        "application/json")->status, 200);
+    // ... but not another project's.
+    EXPECT_EQ(http(pmKey).Get(projSettingsPath("svapitest_other").c_str())->status, 403);
+
+    // A capability-scoped (publishable) key must never reach these routes.
+    auto mk2 = c.Post("/api/v1/keys",
+        nlohmann::json{{"label","scoped"},{"projects",{project_}},{"admin",false},
+                       {"scope",{{"rules",{{{"collection","*"},{"ops",{"read"}}}}}}}}.dump(),
+        "application/json");
+    ASSERT_EQ(mk2->status, 201);
+    auto mk2Json = nlohmann::json::parse(mk2->body);
+    std::string scopedKey = mk2Json["key"], scopedId = mk2Json["id"];
+    EXPECT_EQ(http(scopedKey).Get(projSettingsPath(project_).c_str())->status, 403);
+    EXPECT_EQ(http(scopedKey).Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"default_embedding_model","x"}}.dump(), "application/json")->status, 403);
+
+    EXPECT_EQ(noAuth().Get(projSettingsPath(project_).c_str())->status, 401);
+
+    c.Delete(("/api/v1/keys/" + pmId).c_str());
+    c.Delete(("/api/v1/keys/" + scopedId).c_str());
+}
+
+TEST_F(ApiFixture, ProjectOverrideRedirectsTheOutboundEmbeddingRequest) {
+    // The point of the feature: a project's own endpoint and key are what the
+    // provider actually sees, so usage meters against that key.
+    httplib::Server altProvider;
+    std::string altUserAgent, altAuth, altModel;
+    altProvider.Post("/v1/embeddings", [&](const httplib::Request& req, httplib::Response& res) {
+        altUserAgent = req.get_header_value("User-Agent");
+        altAuth      = req.get_header_value("Authorization");
+        try { altModel = nlohmann::json::parse(req.body).value("model", ""); } catch (...) {}
+        nlohmann::json emb = nlohmann::json::array();
+        for (int i = 0; i < mockDim_; ++i) emb.push_back(0.1f * (i + 1));
+        res.set_content(nlohmann::json{{"data", {{{"embedding", emb}}}}}.dump(), "application/json");
+    });
+    int altPort = altProvider.bind_to_any_port("127.0.0.1");
+    std::thread altThread([&]{ altProvider.listen_after_bind(); });
+
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(),
+        nlohmann::json{{"name","alt"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),
+        "application/json")->status, 201);
+    ASSERT_EQ(c.Put(projSettingsPath(project_).c_str(),
+        nlohmann::json{{"openai_api_base", "http://127.0.0.1:" + std::to_string(altPort)},
+                       {"openai_api_key", "sk-project-only"},
+                       {"default_embedding_model", "project-model"}}.dump(),
+        "application/json")->status, 200);
+
+    ASSERT_EQ(c.Post((base + "/alt/vectors").c_str(),
+        nlohmann::json{{"text","routed to the project endpoint"}}.dump(),
+        "application/json")->status, 201);
+
+    EXPECT_EQ(altAuth, "Bearer sk-project-only");
+    EXPECT_EQ(altModel, "project-model");
+    EXPECT_EQ(altUserAgent, std::string("smartbotic-vectorapi/") + svapi::VERSION);
+
+    altProvider.stop();
+    if (altThread.joinable()) altThread.join();
+}
+
+// --- session validation (the web UI asks this before it renders) -------------
+
+TEST_F(ApiFixture, SessionEndpointRejectsMissingCookie) {
+    // Without this, the UI has no way to tell a live session from a stale
+    // localStorage flag, and renders the authenticated shell before finding out.
+    auto r = noAuth().Get("/ui/session");
+    ASSERT_TRUE(r);
+    EXPECT_EQ(r->status, 401);
+}
+
+TEST_F(ApiFixture, SessionEndpointRejectsUnknownCookie) {
+    httplib::Client c("127.0.0.1", port_);
+    c.set_default_headers({{"Cookie", "svapi_session=deadbeefdeadbeefdeadbeefdeadbeef"}});
+    auto r = c.Get("/ui/session");
+    ASSERT_TRUE(r);
+    EXPECT_EQ(r->status, 401);
+}
+
+TEST_F(ApiFixture, SessionEndpointReturnsIdentityForALiveCookie) {
+    auto login = noAuth().Post("/ui/login", nlohmann::json{{"key", adminKey_}}.dump(), "application/json");
+    ASSERT_TRUE(login); ASSERT_EQ(login->status, 200);
+    std::string cookie = login->get_header_value("Set-Cookie");
+    cookie = cookie.substr(0, cookie.find(';'));
+
+    httplib::Client c("127.0.0.1", port_);
+    c.set_default_headers({{"Cookie", cookie}});
+    auto r = c.Get("/ui/session");
+    ASSERT_TRUE(r); ASSERT_EQ(r->status, 200);
+    auto j = nlohmann::json::parse(r->body);
+    EXPECT_TRUE(j["admin"].get<bool>());
+    EXPECT_TRUE(j.contains("projects"));
+    // The session endpoint identifies the caller; it must never echo the secret.
+    EXPECT_EQ(r->body.find(adminKey_), std::string::npos);
+}
+
+TEST_F(ApiFixture, SessionEndpointIsDeadAfterLogout) {
+    auto login = noAuth().Post("/ui/login", nlohmann::json{{"key", adminKey_}}.dump(), "application/json");
+    ASSERT_TRUE(login); ASSERT_EQ(login->status, 200);
+    std::string cookie = login->get_header_value("Set-Cookie");
+    cookie = cookie.substr(0, cookie.find(';'));
+
+    httplib::Client c("127.0.0.1", port_);
+    c.set_default_headers({{"Cookie", cookie}});
+    ASSERT_EQ(c.Get("/ui/session")->status, 200);
+    ASSERT_EQ(c.Post("/ui/logout", "", "application/json")->status, 200);
+    EXPECT_EQ(c.Get("/ui/session")->status, 401);
+}
+
+TEST_F(ApiFixture, SessionEndpointAcceptsABearerKeyToo) {
+    // Same shape for an API-key caller, so one endpoint answers "who am I".
+    auto r = admin().Get("/ui/session");
+    ASSERT_TRUE(r); ASSERT_EQ(r->status, 200);
+    EXPECT_TRUE(nlohmann::json::parse(r->body)["admin"].get<bool>());
+}

+ 26 - 0
tests/test_outbound_http.cpp

@@ -0,0 +1,26 @@
+#include <gtest/gtest.h>
+#include "outbound_http.hpp"
+#include "svapi/version.hpp"
+#include <algorithm>
+#include <string>
+using namespace svapi;
+
+TEST(OutboundHttp, UserAgentIsProductSlashVersion) {
+    EXPECT_EQ(outboundUserAgent(), std::string("smartbotic-vectorapi/") + VERSION);
+}
+
+TEST(OutboundHttp, UserAgentCarriesNothingButNameAndVersion) {
+    const std::string ua = outboundUserAgent();
+    // No URL, no git commit, no platform or library tokens - outbound requests
+    // must not disclose more than the product and its version.
+    EXPECT_EQ(ua.find("http"), std::string::npos);
+    EXPECT_EQ(ua.find("smartbotics.ai"), std::string::npos);
+    EXPECT_EQ(ua.find(GIT_COMMIT), std::string::npos);
+    EXPECT_EQ(ua.find("cpp-httplib"), std::string::npos);
+    EXPECT_EQ(ua.find(' '), std::string::npos);
+    EXPECT_EQ(std::count(ua.begin(), ua.end(), '/'), 1);
+}
+
+TEST(OutboundHttp, UserAgentIsStableAcrossCalls) {
+    EXPECT_EQ(outboundUserAgent(), outboundUserAgent());
+}

+ 142 - 0
tests/test_project_settings.cpp

@@ -0,0 +1,142 @@
+#include <gtest/gtest.h>
+#include "collection_registry.hpp"
+#include "project_settings.hpp"
+#include "settings.hpp"
+using namespace svapi;
+
+namespace {
+Settings globalSettings() {
+    Settings s;
+    s.openaiApiBase         = "https://api.openai.com";
+    s.openaiApiKey          = "sk-global";
+    s.defaultEmbeddingModel = "text-embedding-3-small";
+    return s;
+}
+CollectionMeta unpinnedCollection() {
+    CollectionMeta m;
+    m.name = "docs";
+    m.kind = "vector";
+    return m;  // embeddingModel empty = no per-collection pin
+}
+}  // namespace
+
+TEST(ProjectSettings, DefaultsAreAllEmptyMeaningInherit) {
+    ProjectEmbeddingSettings p = ProjectEmbeddingSettings::fromJson(nlohmann::json::object());
+    EXPECT_EQ(p.openaiApiBase, "");
+    EXPECT_EQ(p.openaiApiKey, "");
+    EXPECT_EQ(p.defaultEmbeddingModel, "");
+    EXPECT_FALSE(p.hasOverrides());
+}
+
+TEST(ProjectSettings, RoundTripPreservesFields) {
+    nlohmann::json j{{"openai_api_base", "https://api.mistral.ai"},
+                     {"openai_api_key", "sk-proj"},
+                     {"default_embedding_model", "mistral-embed"}};
+    ProjectEmbeddingSettings p = ProjectEmbeddingSettings::fromJson(j);
+    EXPECT_EQ(p.openaiApiBase, "https://api.mistral.ai");
+    EXPECT_EQ(p.openaiApiKey, "sk-proj");
+    EXPECT_EQ(p.defaultEmbeddingModel, "mistral-embed");
+    EXPECT_TRUE(p.hasOverrides());
+    EXPECT_EQ(ProjectEmbeddingSettings::fromJson(p.toJson()).toJson(), p.toJson());
+}
+
+TEST(ProjectSettings, NoProjectOverrideResolvesToGlobal) {
+    Settings g = globalSettings();
+    ResolvedEmbedding r = resolveEmbedding(g, nullptr, unpinnedCollection());
+    EXPECT_EQ(r.apiBase, "https://api.openai.com");
+    EXPECT_EQ(r.apiKey, "sk-global");
+    EXPECT_EQ(r.model, "text-embedding-3-small");
+}
+
+TEST(ProjectSettings, EmptyProjectOverrideResolvesToGlobal) {
+    Settings g = globalSettings();
+    ProjectEmbeddingSettings p;
+    ResolvedEmbedding r = resolveEmbedding(g, &p, unpinnedCollection());
+    EXPECT_EQ(r.apiBase, "https://api.openai.com");
+    EXPECT_EQ(r.apiKey, "sk-global");
+    EXPECT_EQ(r.model, "text-embedding-3-small");
+}
+
+TEST(ProjectSettings, ProjectBaseAndKeyOverrideGlobal) {
+    Settings g = globalSettings();
+    ProjectEmbeddingSettings p;
+    p.openaiApiBase = "https://api.mistral.ai";
+    p.openaiApiKey  = "sk-proj";
+    ResolvedEmbedding r = resolveEmbedding(g, &p, unpinnedCollection());
+    EXPECT_EQ(r.apiBase, "https://api.mistral.ai");
+    EXPECT_EQ(r.apiKey, "sk-proj");
+    EXPECT_EQ(r.model, "text-embedding-3-small");  // model still inherited
+}
+
+TEST(ProjectSettings, ProjectKeyAloneKeepsGlobalBase) {
+    // The metering case: a customer's own key billed against the default endpoint.
+    Settings g = globalSettings();
+    ProjectEmbeddingSettings p;
+    p.openaiApiKey = "sk-customer";
+    ResolvedEmbedding r = resolveEmbedding(g, &p, unpinnedCollection());
+    EXPECT_EQ(r.apiBase, "https://api.openai.com");
+    EXPECT_EQ(r.apiKey, "sk-customer");
+}
+
+TEST(ProjectSettings, ProjectModelOverridesGlobalDefault) {
+    Settings g = globalSettings();
+    ProjectEmbeddingSettings p;
+    p.defaultEmbeddingModel = "text-embedding-3-large";
+    ResolvedEmbedding r = resolveEmbedding(g, &p, unpinnedCollection());
+    EXPECT_EQ(r.model, "text-embedding-3-large");
+    EXPECT_EQ(r.apiBase, "https://api.openai.com");
+    EXPECT_EQ(r.apiKey, "sk-global");
+}
+
+TEST(ProjectSettings, CollectionModelPinBeatsProjectAndGlobal) {
+    Settings g = globalSettings();
+    ProjectEmbeddingSettings p;
+    p.defaultEmbeddingModel = "text-embedding-3-large";
+    CollectionMeta m = unpinnedCollection();
+    m.embeddingModel = "bge-m3";
+    ResolvedEmbedding r = resolveEmbedding(g, &p, m);
+    EXPECT_EQ(r.model, "bge-m3");
+}
+
+TEST(ProjectSettings, CollectionModelPinBeatsGlobalWithoutProject) {
+    Settings g = globalSettings();
+    CollectionMeta m = unpinnedCollection();
+    m.embeddingModel = "bge-m3";
+    ResolvedEmbedding r = resolveEmbedding(g, nullptr, m);
+    EXPECT_EQ(r.model, "bge-m3");
+}
+
+TEST(ProjectSettings, BaseWithoutKeyIsRejected) {
+    // Guards the global credential: a foreign endpoint must carry its own key.
+    ProjectEmbeddingSettings p;
+    p.openaiApiBase = "https://api.mistral.ai";
+    EXPECT_FALSE(p.baseHasItsOwnKey());
+}
+
+TEST(ProjectSettings, BaseWithKeyIsAccepted) {
+    ProjectEmbeddingSettings p;
+    p.openaiApiBase = "https://api.mistral.ai";
+    p.openaiApiKey  = "sk-proj";
+    EXPECT_TRUE(p.baseHasItsOwnKey());
+}
+
+TEST(ProjectSettings, KeyWithoutBaseNeedsNoPairing) {
+    ProjectEmbeddingSettings p;
+    p.openaiApiKey = "sk-customer";
+    EXPECT_TRUE(p.baseHasItsOwnKey());
+}
+
+TEST(ProjectSettings, DocIdIsDerivedFromProjectName) {
+    EXPECT_EQ(projectSettingsDocId("acme"), "project_acme");
+    EXPECT_NE(projectSettingsDocId("current"), "current");
+}
+
+TEST(ProjectSettings, JsonCarriesProjectNameBecauseDbStripsId) {
+    // The DB client strips a top-level `id` from stored bodies, so the project
+    // name has to live in the body itself (same trick as key_store's key_id).
+    ProjectEmbeddingSettings p;
+    p.project      = "acme";
+    p.openaiApiKey = "sk-proj";
+    EXPECT_EQ(p.toJson().value("project", ""), "acme");
+    EXPECT_EQ(ProjectEmbeddingSettings::fromJson(p.toJson()).project, "acme");
+}

+ 92 - 0
tests/test_stores.cpp

@@ -1,5 +1,6 @@
 #include "db_test_util.hpp"
 #include "key_store.hpp"
+#include "project_settings.hpp"
 #include "settings_store.hpp"
 using namespace svapi;
 
@@ -73,3 +74,94 @@ TEST(SettingsStore, BootstrapAndSave) {
     EXPECT_EQ(ss.snapshot()->defaultEmbeddingModel, "text-embedding-3-large");
     db->client().dropCollection(coll);
 }
+
+// --- per-project embedding overrides (stored in the same settings collection) --
+
+TEST(SettingsStore, ProjectOverridesRoundTripThroughDb) {
+    SVAPI_REQUIRE_DB(db);
+    const std::string coll = "svapitest_settings_proj_a";
+    db->client().dropCollection(coll);
+
+    SettingsStore ss(*db, coll);
+    ss.bootstrap("");
+    EXPECT_EQ(ss.projectSnapshot("acme"), nullptr);
+
+    ProjectEmbeddingSettings p;
+    p.project               = "acme";
+    p.openaiApiBase         = "https://api.mistral.ai";
+    p.openaiApiKey          = "sk-acme";
+    p.defaultEmbeddingModel = "mistral-embed";
+    EXPECT_TRUE(ss.saveProject(p));
+
+    auto got = ss.projectSnapshot("acme");
+    ASSERT_NE(got, nullptr);
+    EXPECT_EQ(got->openaiApiBase, "https://api.mistral.ai");
+    EXPECT_EQ(got->openaiApiKey, "sk-acme");
+    EXPECT_EQ(got->defaultEmbeddingModel, "mistral-embed");
+    EXPECT_EQ(got->project, "acme");
+
+    db->client().dropCollection(coll);
+}
+
+TEST(SettingsStore, ProjectOverridesSurviveAFreshStore) {
+    SVAPI_REQUIRE_DB(db);
+    const std::string coll = "svapitest_settings_proj_b";
+    db->client().dropCollection(coll);
+
+    SettingsStore ss(*db, coll);
+    ss.bootstrap("");
+    ProjectEmbeddingSettings p;
+    p.project      = "acme";
+    p.openaiApiKey = "sk-acme";
+    ss.saveProject(p);
+
+    // A second store over the same collection must see it after bootstrap.
+    SettingsStore ss2(*db, coll);
+    ss2.bootstrap("");
+    auto got = ss2.projectSnapshot("acme");
+    ASSERT_NE(got, nullptr);
+    EXPECT_EQ(got->openaiApiKey, "sk-acme");
+
+    db->client().dropCollection(coll);
+}
+
+TEST(SettingsStore, RemoveProjectRevertsToInherit) {
+    SVAPI_REQUIRE_DB(db);
+    const std::string coll = "svapitest_settings_proj_c";
+    db->client().dropCollection(coll);
+
+    SettingsStore ss(*db, coll);
+    ss.bootstrap("");
+    ProjectEmbeddingSettings p;
+    p.project      = "acme";
+    p.openaiApiKey = "sk-acme";
+    ss.saveProject(p);
+    ASSERT_NE(ss.projectSnapshot("acme"), nullptr);
+
+    EXPECT_TRUE(ss.removeProject("acme"));
+    EXPECT_EQ(ss.projectSnapshot("acme"), nullptr);
+    EXPECT_FALSE(ss.removeProject("acme"));  // already gone
+
+    db->client().dropCollection(coll);
+}
+
+TEST(SettingsStore, ProjectDocsDoNotCorruptGlobalSettings) {
+    SVAPI_REQUIRE_DB(db);
+    const std::string coll = "svapitest_settings_proj_d";
+    db->client().dropCollection(coll);
+
+    SettingsStore ss(*db, coll);
+    ss.bootstrap("sk-global");
+    ProjectEmbeddingSettings p;
+    p.project       = "acme";
+    p.openaiApiBase = "https://api.mistral.ai";
+    p.openaiApiKey  = "sk-acme";
+    ss.saveProject(p);
+
+    // Saving a project document must not leak into the global snapshot.
+    auto g = ss.snapshot();
+    EXPECT_EQ(g->openaiApiBase, "https://api.openai.com");
+    EXPECT_EQ(g->openaiApiKey, "sk-global");
+
+    db->client().dropCollection(coll);
+}

+ 47 - 11
webui/src/App.tsx

@@ -1,3 +1,4 @@
+import { useEffect } from 'react'
 import { Navigate, Outlet } from 'react-router-dom'
 import type { RouteObject } from 'react-router-dom'
 import { useAuthStore } from '@/stores/authStore'
@@ -11,9 +12,37 @@ import Settings from '@/pages/Settings'
 import ProjectsAdmin from '@/pages/ProjectsAdmin'
 import KeysAdmin from '@/pages/KeysAdmin'
 
+/// Shown while the server is being asked whether the restored session is real.
+/// Rendering the shell here is what used to flash the authenticated UI at a
+/// caller who turned out to be logged out.
+function AuthSplash() {
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-[#0b0f17]">
+      <div className="flex flex-col items-center gap-3">
+        <div className="h-6 w-6 animate-spin rounded-full border-2 border-slate-600 border-t-indigo-500" />
+        <p className="text-sm text-slate-500">Restoring session...</p>
+      </div>
+    </div>
+  )
+}
+
+/// Runs once per page load: if the browser restored a session, confirm it with
+/// the server before anything downstream renders.
+function AuthBoot() {
+  const status = useAuthStore((s) => s.status)
+  const checkSession = useAuthStore((s) => s.checkSession)
+  useEffect(() => {
+    if (status === 'checking') void checkSession()
+    // Deliberately once per mount: later status changes come from login/logout.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+  return <Outlet />
+}
+
 function ProtectedRoute() {
-  const loggedIn = useAuthStore((s) => s.loggedIn)
-  if (!loggedIn) return <Navigate to="/login" replace />
+  const status = useAuthStore((s) => s.status)
+  if (status === 'checking') return <AuthSplash />
+  if (status !== 'authed') return <Navigate to="/login" replace />
   return <AppShell />
 }
 
@@ -24,20 +53,27 @@ function AdminRoute() {
 }
 
 export const routes: RouteObject[] = [
-  { path: '/login', element: <Login /> },
   {
-    element: <ProtectedRoute />,
+    element: <AuthBoot />,
     children: [
-      { index: true, element: <Dashboard /> },
-      { path: 'collections', element: <Collections /> },
-      { path: 'documents', element: <Documents /> },
-      { path: 'rag', element: <RagTester /> },
+      { path: '/login', element: <Login /> },
       {
-        element: <AdminRoute />,
+        element: <ProtectedRoute />,
         children: [
+          { index: true, element: <Dashboard /> },
+          { path: 'collections', element: <Collections /> },
+          { path: 'documents', element: <Documents /> },
+          { path: 'rag', element: <RagTester /> },
+          // Settings is not admin-gated: a project manager reaches the per-project
+          // embedding tab here. The page renders only the tabs the key may use.
           { path: 'settings', element: <Settings /> },
-          { path: 'projects', element: <ProjectsAdmin /> },
-          { path: 'keys', element: <KeysAdmin /> },
+          {
+            element: <AdminRoute />,
+            children: [
+              { path: 'projects', element: <ProjectsAdmin /> },
+              { path: 'keys', element: <KeysAdmin /> },
+            ],
+          },
         ],
       },
     ],

+ 10 - 0
webui/src/api/client.ts

@@ -2,6 +2,7 @@ import type {
   LoginResponse, CollectionMeta, ApiKeyPublic, ApiKeyCreated,
   SearchResult, ProjectStats, GlobalStats, FindResult, SettingsView,
   KeyScope, IndexDef, IndexValue, CreateIndexResult,
+  ProjectEmbeddingsView, ProjectEmbeddingsPatch,
 } from '@/types'
 import { ApiError } from '@/types'
 import { serverUrl } from '@/api/base'
@@ -33,6 +34,8 @@ const P = (project: string) => `/api/v1/projects/${enc(project)}`
 export const api = {
   login: (key: string) => request<LoginResponse>('/ui/login', { method: 'POST', body: JSON.stringify({ key }) }),
   logout: () => request<{ ok: boolean }>('/ui/logout', { method: 'POST' }),
+  // Boot-time "who am I". 401 here means the stored session is stale.
+  session: () => request<LoginResponse>('/ui/session'),
 
   // projects
   listProjects: () => request<{ projects: string[] }>('/api/v1/projects'),
@@ -98,6 +101,13 @@ export const api = {
   globalStats: () => request<GlobalStats>('/api/v1/stats'),
 
   // settings (admin)
+  getProjectEmbeddings: (project: string) =>
+    request<ProjectEmbeddingsView>(`${P(project)}/settings/embeddings`),
+  updateProjectEmbeddings: (project: string, patch: ProjectEmbeddingsPatch) =>
+    request<ProjectEmbeddingsView>(`${P(project)}/settings/embeddings`, { method: 'PUT', body: JSON.stringify(patch) }),
+  resetProjectEmbeddings: (project: string) =>
+    request<{ reverted: string; had_overrides: boolean }>(`${P(project)}/settings/embeddings`, { method: 'DELETE' }),
+
   getSettings: () => request<SettingsView>('/api/v1/settings'),
   updateSettings: (patch: Partial<Omit<SettingsView, 'openai_api_key_set'> & { openai_api_key?: string }>) =>
     request<{ ok: boolean }>('/api/v1/settings', { method: 'PUT', body: JSON.stringify(patch) }),

+ 3 - 1
webui/src/components/AppShell.tsx

@@ -26,10 +26,12 @@ const mainNav: NavItem[] = [
   { to: '/collections', label: 'Collections', icon: <Database size={18} /> },
   { to: '/documents', label: 'Documents', icon: <FileText size={18} /> },
   { to: '/rag', label: 'RAG Tester', icon: <Search size={18} /> },
+  // Every key that can manage a project can manage that project's embedding
+  // settings, so Settings is not in the admin-only group.
+  { to: '/settings', label: 'Settings', icon: <Settings size={18} /> },
 ]
 
 const adminNav: NavItem[] = [
-  { to: '/settings', label: 'Settings', icon: <Settings size={18} /> },
   { to: '/projects', label: 'Projects', icon: <FolderKanban size={18} /> },
   { to: '/keys', label: 'Keys', icon: <KeyRound size={18} /> },
 ]

+ 87 - 0
webui/src/components/form.tsx

@@ -0,0 +1,87 @@
+// Shared form primitives. Extracted from the old single-page Settings form so
+// every settings tab renders identical controls.
+
+export const inputCls =
+  'w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40'
+
+export function FormRow({
+  label,
+  hint,
+  badge,
+  children,
+}: {
+  label: string
+  hint?: string
+  badge?: React.ReactNode
+  children: React.ReactNode
+}) {
+  return (
+    <div className="flex flex-col gap-1">
+      <div className="flex items-center gap-2">
+        <label className="text-xs font-medium text-slate-400">{label}</label>
+        {badge}
+      </div>
+      {children}
+      {hint && <p className="text-xs text-slate-500">{hint}</p>}
+    </div>
+  )
+}
+
+export function Toggle({
+  checked,
+  onChange,
+  onLabel = 'Enabled',
+  offLabel = 'Disabled',
+}: {
+  checked: boolean
+  onChange: (next: boolean) => void
+  onLabel?: string
+  offLabel?: string
+}) {
+  return (
+    <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
+      <div
+        role="switch"
+        aria-checked={checked}
+        onClick={() => onChange(!checked)}
+        className={[
+          'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none',
+          checked ? 'bg-indigo-600' : 'bg-slate-600',
+        ].join(' ')}
+      >
+        <span
+          className={[
+            'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform',
+            checked ? 'translate-x-5' : 'translate-x-0',
+          ].join(' ')}
+        />
+      </div>
+      <span className="text-sm text-slate-300">{checked ? onLabel : offLabel}</span>
+    </label>
+  )
+}
+
+export function Badge({ tone = 'slate', children }: { tone?: 'slate' | 'indigo' | 'amber'; children: React.ReactNode }) {
+  const tones = {
+    slate: 'bg-slate-700/60 text-slate-300 border-slate-600',
+    indigo: 'bg-indigo-500/15 text-indigo-300 border-indigo-500/30',
+    amber: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
+  }
+  return (
+    <span className={`rounded border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${tones[tone]}`}>
+      {children}
+    </span>
+  )
+}
+
+export function SaveButton({ saving, disabled }: { saving: boolean; disabled?: boolean }) {
+  return (
+    <button
+      type="submit"
+      disabled={saving || disabled}
+      className="inline-flex items-center gap-2 self-start rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
+    >
+      {saving ? 'Saving...' : 'Save'}
+    </button>
+  )
+}

+ 58 - 457
webui/src/pages/Settings.tsx

@@ -1,212 +1,58 @@
-import { useState, useEffect } from 'react'
-import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { Save } from 'lucide-react'
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
 import { api } from '@/api/client'
-import { useToastStore } from '@/stores/toastStore'
-import { ApiError } from '@/types'
-import type { SettingsView } from '@/types'
-
-// ─── field helpers ────────────────────────────────────────────────────────────
-
-const inputCls =
-  'w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40'
-
-function FormRow({
-  label,
-  hint,
-  children,
-}: {
+import { useAuthStore } from '@/stores/authStore'
+import EmbeddingsTab from '@/pages/settings/EmbeddingsTab'
+import CacheTab from '@/pages/settings/CacheTab'
+import AccessTab from '@/pages/settings/AccessTab'
+import WebuiTab from '@/pages/settings/WebuiTab'
+import ProjectEmbeddingsTab from '@/pages/settings/ProjectEmbeddingsTab'
+
+interface TabDef {
+  id: string
   label: string
-  hint?: string
-  children: React.ReactNode
-}) {
-  return (
-    <div className="flex flex-col gap-1">
-      <label className="text-xs font-medium text-slate-400">{label}</label>
-      {children}
-      {hint && <p className="text-xs text-slate-500">{hint}</p>}
-    </div>
-  )
-}
-
-// ─── form state ───────────────────────────────────────────────────────────────
-
-interface FormValues {
-  openai_api_base: string
-  openai_api_key: string        // blank = don't update
-  default_embedding_model: string
-  cors_origins: string          // comma-separated in the input
-  session_ttl_minutes: string   // string for controlled input
-  webui_enabled: boolean
-  default_project: string
-  embedding_connect_timeout_sec: string
-  embedding_read_timeout_sec: string
-  embedding_cache_size: string
-  embedding_cache_ttl_sec: string
-  embedding_cache_max_bytes: string
-  embedding_cache_normalize: boolean
-  embedding_client_pool_size: string
-}
-
-function settingsToForm(s: SettingsView): FormValues {
-  return {
-    openai_api_base: s.openai_api_base,
-    openai_api_key: '',          // never prefill a secret
-    default_embedding_model: s.default_embedding_model,
-    cors_origins: s.cors_origins.join(', '),
-    session_ttl_minutes: String(s.session_ttl_minutes),
-    webui_enabled: s.webui_enabled,
-    default_project: s.default_project,
-    embedding_connect_timeout_sec: String(s.embedding_connect_timeout_sec),
-    embedding_read_timeout_sec: String(s.embedding_read_timeout_sec),
-    embedding_cache_size: String(s.embedding_cache_size),
-    embedding_cache_ttl_sec: String(s.embedding_cache_ttl_sec),
-    embedding_cache_max_bytes: String(s.embedding_cache_max_bytes),
-    embedding_cache_normalize: s.embedding_cache_normalize,
-    embedding_client_pool_size: String(s.embedding_client_pool_size),
-  }
+  adminOnly: boolean
+  render: () => React.ReactNode
 }
 
-// ─── page ─────────────────────────────────────────────────────────────────────
-
 export default function Settings() {
-  const push = useToastStore((s) => s.push)
-  const queryClient = useQueryClient()
-
-  const { data, isLoading, isError, error } = useQuery({
+  const admin = useAuthStore((s) => s.admin)
+  const project = useAuthStore((s) => s.currentProject)
+
+  // Global settings are admin-only; the project tab is not, so a project
+  // manager landing here sees exactly the one tab they may use.
+  const tabs: TabDef[] = [
+    { id: 'embeddings', label: 'Embeddings', adminOnly: true, render: () => <EmbeddingsTab /> },
+    { id: 'cache', label: 'Cache & performance', adminOnly: true, render: () => <CacheTab /> },
+    { id: 'access', label: 'Access & security', adminOnly: true, render: () => <AccessTab /> },
+    { id: 'webui', label: 'Web UI', adminOnly: true, render: () => <WebuiTab /> },
+    {
+      id: 'project',
+      label: project ? `Project: ${project}` : 'Project',
+      adminOnly: false,
+      render: () => <ProjectEmbeddingsTab />,
+    },
+  ].filter((t) => admin || !t.adminOnly)
+
+  const [active, setActive] = useState(tabs[0]?.id ?? 'project')
+  const current = tabs.find((t) => t.id === active) ?? tabs[0]
+
+  // Only used to surface a load failure once, at the page level.
+  const { isError, error } = useQuery({
     queryKey: ['settings'],
     queryFn: () => api.getSettings(),
+    enabled: admin,
   })
 
-  const [form, setForm] = useState<FormValues>({
-    openai_api_base: '',
-    openai_api_key: '',
-    default_embedding_model: '',
-    cors_origins: '',
-    session_ttl_minutes: '60',
-    webui_enabled: true,
-    default_project: 'default',
-    embedding_connect_timeout_sec: '10',
-    embedding_read_timeout_sec: '60',
-    embedding_cache_size: '4096',
-    embedding_cache_ttl_sec: '86400',
-    embedding_cache_max_bytes: '268435456',
-    embedding_cache_normalize: false,
-    embedding_client_pool_size: '4',
-  })
-
-  // Populate the form once settings load, but don't overwrite user's edits on re-render.
-  const [populated, setPopulated] = useState(false)
-  useEffect(() => {
-    if (data && !populated) {
-      setForm(settingsToForm(data))
-      setPopulated(true)
-    }
-  }, [data, populated])
-
-  const [saving, setSaving] = useState(false)
-
-  function setField<K extends keyof FormValues>(key: K, value: FormValues[K]) {
-    setForm((prev) => ({ ...prev, [key]: value }))
-  }
-
-  const handleSubmit = async (e: React.FormEvent) => {
-    e.preventDefault()
-
-    const ttl = parseInt(form.session_ttl_minutes, 10)
-    if (isNaN(ttl) || ttl <= 0) {
-      push('Session TTL must be a positive integer.', 'error')
-      return
-    }
-
-    const connectTimeout = parseInt(form.embedding_connect_timeout_sec, 10)
-    if (isNaN(connectTimeout) || connectTimeout < 1) {
-      push('Embedding connect timeout must be a positive integer.', 'error')
-      return
-    }
-
-    const readTimeout = parseInt(form.embedding_read_timeout_sec, 10)
-    if (isNaN(readTimeout) || readTimeout < 1) {
-      push('Embedding read timeout must be a positive integer.', 'error')
-      return
-    }
-
-    const cacheSize = parseInt(form.embedding_cache_size, 10)
-    if (isNaN(cacheSize) || cacheSize < 1) {
-      push('Embedding cache size must be a positive integer.', 'error')
-      return
-    }
-
-    const cacheTtl = parseInt(form.embedding_cache_ttl_sec, 10)
-    if (isNaN(cacheTtl) || cacheTtl < 0) {
-      push('Embedding cache TTL must be a non-negative integer (0 = never expire).', 'error')
-      return
-    }
-
-    const cacheMaxBytes = parseInt(form.embedding_cache_max_bytes, 10)
-    if (isNaN(cacheMaxBytes) || cacheMaxBytes < 1) {
-      push('Embedding cache max bytes must be a positive integer.', 'error')
-      return
-    }
-
-    const poolSize = parseInt(form.embedding_client_pool_size, 10)
-    if (isNaN(poolSize) || poolSize < 1) {
-      push('Embedding client pool size must be a positive integer.', 'error')
-      return
-    }
-
-    const patch: Partial<Omit<SettingsView, 'openai_api_key_set'> & { openai_api_key?: string }> = {
-      openai_api_base: form.openai_api_base.trim(),
-      default_embedding_model: form.default_embedding_model.trim(),
-      cors_origins: form.cors_origins
-        .split(',')
-        .map((s) => s.trim())
-        .filter(Boolean),
-      session_ttl_minutes: ttl,
-      webui_enabled: form.webui_enabled,
-      default_project: form.default_project.trim(),
-      embedding_connect_timeout_sec: connectTimeout,
-      embedding_read_timeout_sec: readTimeout,
-      embedding_cache_size: cacheSize,
-      embedding_cache_ttl_sec: cacheTtl,
-      embedding_cache_max_bytes: cacheMaxBytes,
-      embedding_cache_normalize: form.embedding_cache_normalize,
-      embedding_client_pool_size: poolSize,
-    }
-
-    // Only include the key if the user typed something
-    if (form.openai_api_key.trim()) {
-      patch.openai_api_key = form.openai_api_key.trim()
-    }
-
-    setSaving(true)
-    try {
-      await api.updateSettings(patch)
-      await queryClient.invalidateQueries({ queryKey: ['settings'] })
-      // Reset populated so the form re-syncs from the refreshed data
-      setPopulated(false)
-      // Clear the key field after a successful save
-      setForm((prev) => ({ ...prev, openai_api_key: '' }))
-      push('Settings saved. Changes apply immediately (hot reload).', 'success')
-    } catch (err) {
-      const msg = err instanceof ApiError ? err.message : String(err)
-      push(`Failed to save settings: ${msg}`, 'error')
-    } finally {
-      setSaving(false)
-    }
-  }
-
   return (
-    <div className="flex flex-col gap-6 max-w-2xl">
-      {/* Page header */}
+    <div className="flex flex-col gap-6 max-w-3xl">
       <div>
         <h1 className="text-2xl font-semibold text-slate-100">Settings</h1>
         <p className="text-slate-400 text-sm mt-1">
-          Global settings — changes apply immediately (hot reload).
+          Changes apply immediately (hot reload). Each tab saves on its own.
         </p>
       </div>
 
-      {/* Error */}
       {isError && (
         <div className="rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-4">
           <p className="text-red-400 text-sm">
@@ -215,272 +61,27 @@ export default function Settings() {
         </div>
       )}
 
-      {/* Loading skeleton */}
-      {isLoading && (
-        <div className="flex flex-col gap-4 animate-pulse">
-          {[1, 2, 3, 4, 5, 6].map((i) => (
-            <div key={i} className="flex flex-col gap-1">
-              <div className="h-3 w-24 rounded bg-slate-700/60" />
-              <div className="h-9 rounded-md bg-slate-800/60" />
-            </div>
-          ))}
-        </div>
-      )}
-
-      {/* Form */}
-      {!isLoading && (
-        <form onSubmit={handleSubmit} className="flex flex-col gap-5">
-          {/* OpenAI API base */}
-          <FormRow
-            label="OpenAI API Base"
-            hint="Base URL for the OpenAI-compatible embeddings endpoint."
-          >
-            <input
-              type="text"
-              value={form.openai_api_base}
-              onChange={(e) => setField('openai_api_base', e.target.value)}
-              placeholder="https://api.openai.com/v1"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* OpenAI API key */}
-          <FormRow
-            label="OpenAI API Key"
-            hint={
-              data?.openai_api_key_set
-                ? 'A key is currently set. Leave blank to keep it unchanged.'
-                : 'No key is currently set. Enter one to configure it.'
-            }
-          >
-            <input
-              type="password"
-              value={form.openai_api_key}
-              onChange={(e) => setField('openai_api_key', e.target.value)}
-              placeholder={data?.openai_api_key_set ? 'set — leave blank to keep' : 'not set'}
-              autoComplete="new-password"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Default embedding model */}
-          <FormRow
-            label="Default Embedding Model"
-            hint="Used when a vector collection doesn't specify a model."
-          >
-            <input
-              type="text"
-              value={form.default_embedding_model}
-              onChange={(e) => setField('default_embedding_model', e.target.value)}
-              placeholder="text-embedding-3-small"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* CORS origins */}
-          <FormRow
-            label="CORS Origins"
-            hint="Comma-separated list of allowed origins (e.g. http://localhost:3000, https://app.example.com)."
-          >
-            <input
-              type="text"
-              value={form.cors_origins}
-              onChange={(e) => setField('cors_origins', e.target.value)}
-              placeholder="http://localhost:3000, https://app.example.com"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Session TTL */}
-          <FormRow
-            label="Session TTL (minutes)"
-            hint="How long an API-key session cookie remains valid."
-          >
-            <input
-              type="number"
-              value={form.session_ttl_minutes}
-              onChange={(e) => setField('session_ttl_minutes', e.target.value)}
-              min={1}
-              placeholder="60"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Default project */}
-          <FormRow
-            label="Default Project"
-            hint="The project used when no project is specified."
-          >
-            <input
-              type="text"
-              value={form.default_project}
-              onChange={(e) => setField('default_project', e.target.value)}
-              placeholder="default"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* WebUI enabled */}
-          <FormRow label="WebUI Enabled" hint="Disable to turn off the built-in web interface.">
-            <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
-              <div
-                role="switch"
-                aria-checked={form.webui_enabled}
-                onClick={() => setField('webui_enabled', !form.webui_enabled)}
-                className={[
-                  'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none',
-                  form.webui_enabled ? 'bg-indigo-600' : 'bg-slate-600',
-                ].join(' ')}
-              >
-                <span
-                  className={[
-                    'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform',
-                    form.webui_enabled ? 'translate-x-5' : 'translate-x-0',
-                  ].join(' ')}
-                />
-              </div>
-              <span className="text-sm text-slate-300">
-                {form.webui_enabled ? 'Enabled' : 'Disabled'}
-              </span>
-            </label>
-          </FormRow>
-
-          {/* ── Embedding performance ─────────────────────────────────── */}
-          <h2 className="text-sm font-semibold text-slate-300 border-t border-slate-700 pt-4 mt-1">
-            Embedding performance
-          </h2>
-
-          {/* Embedding connect timeout */}
-          <FormRow
-            label="Embedding connect timeout (s)"
-            hint="TCP connection timeout when reaching the embeddings endpoint."
+      <div className="flex flex-wrap gap-1 border-b border-slate-700">
+        {tabs.map((t) => (
+          <button
+            key={t.id}
+            type="button"
+            onClick={() => setActive(t.id)}
+            className={[
+              'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition',
+              t.id === current?.id
+                ? 'border-indigo-500 text-slate-100'
+                : 'border-transparent text-slate-400 hover:text-slate-200',
+            ].join(' ')}
           >
-            <input
-              type="number"
-              value={form.embedding_connect_timeout_sec}
-              onChange={(e) => setField('embedding_connect_timeout_sec', e.target.value)}
-              min={1}
-              placeholder="10"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Embedding read timeout */}
-          <FormRow
-            label="Embedding read timeout (s)"
-            hint="Read timeout for each embeddings API response."
-          >
-            <input
-              type="number"
-              value={form.embedding_read_timeout_sec}
-              onChange={(e) => setField('embedding_read_timeout_sec', e.target.value)}
-              min={1}
-              placeholder="60"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Embedding cache size */}
-          <FormRow
-            label="Embedding cache size (entries)"
-            hint="Max distinct (model, text) embeddings cached. 0 disables via size, but use the toggle/TTL instead."
-          >
-            <input
-              type="number"
-              value={form.embedding_cache_size}
-              onChange={(e) => setField('embedding_cache_size', e.target.value)}
-              min={1}
-              placeholder="4096"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Embedding cache TTL */}
-          <FormRow
-            label="Embedding cache TTL (s)"
-            hint="0 = never expire."
-          >
-            <input
-              type="number"
-              value={form.embedding_cache_ttl_sec}
-              onChange={(e) => setField('embedding_cache_ttl_sec', e.target.value)}
-              min={0}
-              placeholder="86400"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Embedding cache max bytes */}
-          <FormRow
-            label="Embedding cache max bytes"
-            hint="Byte budget for cached vectors (e.g. 268435456 = 256 MB)."
-          >
-            <input
-              type="number"
-              value={form.embedding_cache_max_bytes}
-              onChange={(e) => setField('embedding_cache_max_bytes', e.target.value)}
-              min={1}
-              placeholder="268435456"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Embedding client pool size */}
-          <FormRow
-            label="Embedding client pool size"
-            hint="Keep-alive HTTP clients per upstream origin (concurrency)."
-          >
-            <input
-              type="number"
-              value={form.embedding_client_pool_size}
-              onChange={(e) => setField('embedding_client_pool_size', e.target.value)}
-              min={1}
-              placeholder="4"
-              className={inputCls}
-            />
-          </FormRow>
-
-          {/* Normalize cache keys */}
-          <FormRow
-            label="Normalize cache keys"
-            hint="Trim + lowercase query text before caching (collapses near-duplicate queries)."
-          >
-            <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
-              <div
-                role="switch"
-                aria-checked={form.embedding_cache_normalize}
-                onClick={() => setField('embedding_cache_normalize', !form.embedding_cache_normalize)}
-                className={[
-                  'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none',
-                  form.embedding_cache_normalize ? 'bg-indigo-600' : 'bg-slate-600',
-                ].join(' ')}
-              >
-                <span
-                  className={[
-                    'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform',
-                    form.embedding_cache_normalize ? 'translate-x-5' : 'translate-x-0',
-                  ].join(' ')}
-                />
-              </div>
-              <span className="text-sm text-slate-300">
-                {form.embedding_cache_normalize ? 'Enabled' : 'Disabled'}
-              </span>
-            </label>
-          </FormRow>
+            {t.label}
+          </button>
+        ))}
+      </div>
 
-          {/* Save */}
-          <div className="pt-2">
-            <button
-              type="submit"
-              disabled={saving}
-              className="flex items-center gap-2 px-5 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
-            >
-              <Save size={15} />
-              {saving ? 'Saving…' : 'Save settings'}
-            </button>
-          </div>
-        </form>
-      )}
+      <div className="rounded-xl border border-slate-700 bg-slate-800/30 px-6 py-5">
+        {current?.render()}
+      </div>
     </div>
   )
 }

+ 75 - 0
webui/src/pages/settings/AccessTab.tsx

@@ -0,0 +1,75 @@
+import { useState } from 'react'
+import { FormRow, inputCls, SaveButton } from '@/components/form'
+import { useSettingsForm, usePopulate, parseIntField } from './useSettingsForm'
+
+export default function AccessTab() {
+  const { data, saving, save, push } = useSettingsForm()
+  const [corsOrigins, setCorsOrigins] = useState('')
+  const [sessionTtl, setSessionTtl] = useState('720')
+  const [captchaProvider, setCaptchaProvider] = useState('')
+  const [captchaSecret, setCaptchaSecret] = useState('')
+  const [captchaVerifyUrl, setCaptchaVerifyUrl] = useState('')
+
+  usePopulate(data, (d) => {
+    setCorsOrigins(d.cors_origins.join(', '))
+    setSessionTtl(String(d.session_ttl_minutes))
+    setCaptchaProvider(d.captcha_provider ?? '')
+    setCaptchaVerifyUrl(d.captcha_verify_url ?? '')
+  })
+
+  async function onSubmit(e: React.FormEvent) {
+    e.preventDefault()
+    const ttl = parseIntField(sessionTtl, 'Session TTL', push)
+    if (ttl === null) return
+    const patch: Record<string, unknown> = {
+      cors_origins: corsOrigins.split(',').map((s) => s.trim()).filter(Boolean),
+      session_ttl_minutes: ttl,
+      captcha_provider: captchaProvider.trim(),
+      captcha_verify_url: captchaVerifyUrl.trim(),
+    }
+    if (captchaSecret.trim()) patch.captcha_secret = captchaSecret.trim()
+    if (await save(patch)) setCaptchaSecret('')
+  }
+
+  return (
+    <form onSubmit={onSubmit} className="flex flex-col gap-4">
+      <p className="text-sm text-slate-400">Browser access rules and the human-verification provider.</p>
+
+      <FormRow label="CORS origins" hint="Comma separated. Use * to allow any origin.">
+        <input type="text" value={corsOrigins} onChange={(e) => setCorsOrigins(e.target.value)}
+          placeholder="*" className={inputCls} />
+      </FormRow>
+
+      <FormRow label="Session TTL (minutes)" hint="Lifetime of a web UI cookie session.">
+        <input type="number" value={sessionTtl} onChange={(e) => setSessionTtl(e.target.value)} className={inputCls} />
+      </FormRow>
+
+      <h2 className="text-sm font-semibold text-slate-300 border-t border-slate-700 pt-4 mt-1">
+        Captcha (scope rules with require_human_token)
+      </h2>
+
+      <FormRow label="Provider" hint="Blank disables captcha verification.">
+        <select value={captchaProvider} onChange={(e) => setCaptchaProvider(e.target.value)} className={inputCls}>
+          <option value="">Disabled</option>
+          <option value="turnstile">Cloudflare Turnstile</option>
+          <option value="hcaptcha">hCaptcha</option>
+        </select>
+      </FormRow>
+
+      <FormRow
+        label="Secret"
+        hint={data?.captcha_secret_set ? 'A secret is configured. Leave blank to keep it.' : 'No secret configured yet.'}
+      >
+        <input type="password" value={captchaSecret} onChange={(e) => setCaptchaSecret(e.target.value)}
+          placeholder={data?.captcha_secret_set ? '........ (unchanged)' : ''} className={inputCls} />
+      </FormRow>
+
+      <FormRow label="Verify URL" hint="Full verification endpoint of the provider.">
+        <input type="text" value={captchaVerifyUrl} onChange={(e) => setCaptchaVerifyUrl(e.target.value)}
+          placeholder="https://challenges.cloudflare.com/turnstile/v0/siteverify" className={inputCls} />
+      </FormRow>
+
+      <SaveButton saving={saving} />
+    </form>
+  )
+}

+ 80 - 0
webui/src/pages/settings/CacheTab.tsx

@@ -0,0 +1,80 @@
+import { useState } from 'react'
+import { FormRow, inputCls, Toggle, SaveButton } from '@/components/form'
+import { useSettingsForm, usePopulate, parseIntField } from './useSettingsForm'
+
+export default function CacheTab() {
+  const { data, saving, save, push } = useSettingsForm()
+  const [connectTimeout, setConnectTimeout] = useState('10')
+  const [readTimeout, setReadTimeout] = useState('60')
+  const [poolSize, setPoolSize] = useState('4')
+  const [cacheSize, setCacheSize] = useState('4096')
+  const [cacheTtl, setCacheTtl] = useState('86400')
+  const [cacheMaxBytes, setCacheMaxBytes] = useState('268435456')
+  const [normalize, setNormalize] = useState(false)
+
+  usePopulate(data, (d) => {
+    setConnectTimeout(String(d.embedding_connect_timeout_sec))
+    setReadTimeout(String(d.embedding_read_timeout_sec))
+    setPoolSize(String(d.embedding_client_pool_size))
+    setCacheSize(String(d.embedding_cache_size))
+    setCacheTtl(String(d.embedding_cache_ttl_sec))
+    setCacheMaxBytes(String(d.embedding_cache_max_bytes))
+    setNormalize(d.embedding_cache_normalize)
+  })
+
+  async function onSubmit(e: React.FormEvent) {
+    e.preventDefault()
+    const ct = parseIntField(connectTimeout, 'Connect timeout', push)
+    const rt = parseIntField(readTimeout, 'Read timeout', push)
+    const ps = parseIntField(poolSize, 'Client pool size', push)
+    const cs = parseIntField(cacheSize, 'Cache size', push)
+    const ttl = parseIntField(cacheTtl, 'Cache TTL', push, 0)
+    const mb = parseIntField(cacheMaxBytes, 'Cache max bytes', push)
+    if (ct === null || rt === null || ps === null || cs === null || ttl === null || mb === null) return
+    await save({
+      embedding_connect_timeout_sec: ct,
+      embedding_read_timeout_sec: rt,
+      embedding_client_pool_size: ps,
+      embedding_cache_size: cs,
+      embedding_cache_ttl_sec: ttl,
+      embedding_cache_max_bytes: mb,
+      embedding_cache_normalize: normalize,
+    })
+  }
+
+  return (
+    <form onSubmit={onSubmit} className="flex flex-col gap-4">
+      <p className="text-sm text-slate-400">
+        Server-wide embedding transport and cache tuning. The cache is a single process-wide store shared
+        by every project, keyed on endpoint + model + text.
+      </p>
+
+      <div className="grid gap-4 sm:grid-cols-2">
+        <FormRow label="Connect timeout (s)" hint="TCP connect timeout to the embeddings endpoint.">
+          <input type="number" value={connectTimeout} onChange={(e) => setConnectTimeout(e.target.value)} className={inputCls} />
+        </FormRow>
+        <FormRow label="Read timeout (s)" hint="How long to wait for the embedding response.">
+          <input type="number" value={readTimeout} onChange={(e) => setReadTimeout(e.target.value)} className={inputCls} />
+        </FormRow>
+        <FormRow label="Client pool size" hint="Keep-alive clients per upstream origin.">
+          <input type="number" value={poolSize} onChange={(e) => setPoolSize(e.target.value)} className={inputCls} />
+        </FormRow>
+        <FormRow label="Cache size (entries)" hint="Maximum cached vectors.">
+          <input type="number" value={cacheSize} onChange={(e) => setCacheSize(e.target.value)} className={inputCls} />
+        </FormRow>
+        <FormRow label="Cache TTL (s)" hint="0 means entries never expire.">
+          <input type="number" value={cacheTtl} onChange={(e) => setCacheTtl(e.target.value)} className={inputCls} />
+        </FormRow>
+        <FormRow label="Cache max bytes" hint="Total vector bytes held before eviction.">
+          <input type="number" value={cacheMaxBytes} onChange={(e) => setCacheMaxBytes(e.target.value)} className={inputCls} />
+        </FormRow>
+      </div>
+
+      <FormRow label="Normalize cache text" hint="Trim and lower-case text before keying the cache.">
+        <Toggle checked={normalize} onChange={setNormalize} onLabel="Normalized" offLabel="Verbatim" />
+      </FormRow>
+
+      <SaveButton saving={saving} />
+    </form>
+  )
+}

+ 54 - 0
webui/src/pages/settings/EmbeddingsTab.tsx

@@ -0,0 +1,54 @@
+import { useState } from 'react'
+import { FormRow, inputCls, SaveButton } from '@/components/form'
+import { useSettingsForm, usePopulate } from './useSettingsForm'
+
+export default function EmbeddingsTab() {
+  const { data, saving, save } = useSettingsForm()
+  const [base, setBase] = useState('')
+  const [key, setKey] = useState('')
+  const [model, setModel] = useState('')
+
+  usePopulate(data, (d) => {
+    setBase(d.openai_api_base)
+    setModel(d.default_embedding_model)
+  })
+
+  async function onSubmit(e: React.FormEvent) {
+    e.preventDefault()
+    const patch: { openai_api_base: string; default_embedding_model: string; openai_api_key?: string } = {
+      openai_api_base: base.trim(),
+      default_embedding_model: model.trim(),
+    }
+    if (key.trim()) patch.openai_api_key = key.trim()
+    if (await save(patch)) setKey('')
+  }
+
+  return (
+    <form onSubmit={onSubmit} className="flex flex-col gap-4">
+      <p className="text-sm text-slate-400">
+        The default embedding provider for every project. A project can point at its own endpoint and key
+        on the project tab.
+      </p>
+
+      <FormRow label="OpenAI API base" hint="Origin plus any path prefix; /v1/embeddings is appended.">
+        <input type="text" value={base} onChange={(e) => setBase(e.target.value)}
+          placeholder="https://api.openai.com" className={inputCls} />
+      </FormRow>
+
+      <FormRow
+        label="OpenAI API key"
+        hint={data?.openai_api_key_set ? 'A key is configured. Leave blank to keep it.' : 'No key configured yet.'}
+      >
+        <input type="password" value={key} onChange={(e) => setKey(e.target.value)}
+          placeholder={data?.openai_api_key_set ? '........ (unchanged)' : 'sk-...'} className={inputCls} />
+      </FormRow>
+
+      <FormRow label="Default embedding model" hint="Used when a collection pins no model of its own.">
+        <input type="text" value={model} onChange={(e) => setModel(e.target.value)}
+          placeholder="text-embedding-3-small" className={inputCls} />
+      </FormRow>
+
+      <SaveButton saving={saving} />
+    </form>
+  )
+}

+ 223 - 0
webui/src/pages/settings/ProjectEmbeddingsTab.tsx

@@ -0,0 +1,223 @@
+import { useEffect, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { RotateCcw } from 'lucide-react'
+import { api } from '@/api/client'
+import { Badge, FormRow, inputCls, SaveButton } from '@/components/form'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
+import { useAuthStore } from '@/stores/authStore'
+import { useToastStore } from '@/stores/toastStore'
+import { ApiError } from '@/types'
+import type { ProjectEmbeddingsPatch } from '@/types'
+
+/// A field is either inherited from the global settings or overridden here.
+/// `value` is only meaningful while overriding; the inherited value is shown
+/// as the placeholder so the effective setting is always visible.
+function OverrideRow({
+  label,
+  hint,
+  inherited,
+  overriding,
+  value,
+  onToggle,
+  onChange,
+  type = 'text',
+  placeholderWhenSecret,
+}: {
+  label: string
+  hint?: string
+  inherited: string
+  overriding: boolean
+  value: string
+  onToggle: (next: boolean) => void
+  onChange: (next: string) => void
+  type?: string
+  placeholderWhenSecret?: string
+}) {
+  return (
+    <FormRow
+      label={label}
+      hint={hint}
+      badge={
+        overriding ? <Badge tone="indigo">overridden</Badge> : <Badge>inherited</Badge>
+      }
+    >
+      <div className="flex items-center gap-2">
+        <input
+          type={type}
+          value={overriding ? value : ''}
+          disabled={!overriding}
+          onChange={(e) => onChange(e.target.value)}
+          placeholder={overriding ? placeholderWhenSecret ?? '' : inherited}
+          className={`${inputCls} ${overriding ? '' : 'opacity-60'}`}
+        />
+        <button
+          type="button"
+          onClick={() => onToggle(!overriding)}
+          className="shrink-0 rounded-md border border-slate-600 px-3 py-2 text-xs text-slate-300 hover:bg-slate-700"
+        >
+          {overriding ? 'Inherit' : 'Override'}
+        </button>
+      </div>
+    </FormRow>
+  )
+}
+
+export default function ProjectEmbeddingsTab() {
+  const project = useAuthStore((s) => s.currentProject)
+  const push = useToastStore((s) => s.push)
+  const queryClient = useQueryClient()
+  const [saving, setSaving] = useState(false)
+  const [confirmReset, setConfirmReset] = useState(false)
+
+  const { data, isLoading, isError, error } = useQuery({
+    queryKey: ['projectEmbeddings', project],
+    queryFn: () => api.getProjectEmbeddings(project as string),
+    enabled: !!project,
+  })
+
+  const [baseOn, setBaseOn] = useState(false)
+  const [base, setBase] = useState('')
+  const [keyOn, setKeyOn] = useState(false)
+  const [key, setKey] = useState('')
+  const [modelOn, setModelOn] = useState(false)
+  const [model, setModel] = useState('')
+
+  useEffect(() => {
+    if (!data) return
+    setBaseOn(!!data.overrides.openai_api_base)
+    setBase(data.overrides.openai_api_base)
+    setKeyOn(data.overrides.openai_api_key_set)
+    setKey('')
+    setModelOn(!!data.overrides.default_embedding_model)
+    setModel(data.overrides.default_embedding_model)
+  }, [data])
+
+  if (!project) return <p className="text-sm text-slate-400">Select a project first.</p>
+  if (isLoading) return <p className="text-sm text-slate-400">Loading...</p>
+  if (isError)
+    return (
+      <p className="text-sm text-red-400">
+        Failed to load project settings: {error instanceof Error ? error.message : 'Unknown error'}
+      </p>
+    )
+  if (!data) return null
+
+  const keyAlreadyStored = data.overrides.openai_api_key_set
+
+  async function onSubmit(e: React.FormEvent) {
+    e.preventDefault()
+    // The server refuses a project endpoint without a project key, so catch it
+    // here too and say why before a round trip.
+    if (baseOn && !keyOn) {
+      push('A project endpoint needs its own API key, otherwise the global key would be sent to it.', 'error')
+      return
+    }
+    const patch: ProjectEmbeddingsPatch = {
+      openai_api_base: baseOn ? base.trim() : null,
+      default_embedding_model: modelOn ? model.trim() : null,
+    }
+    // Key: null clears it, a typed value sets it, and leaving a stored key
+    // untouched means sending nothing at all.
+    if (!keyOn) patch.openai_api_key = null
+    else if (key.trim()) patch.openai_api_key = key.trim()
+    else if (!keyAlreadyStored) {
+      push('Enter the project API key, or switch the field back to inherited.', 'error')
+      return
+    }
+
+    setSaving(true)
+    try {
+      await api.updateProjectEmbeddings(project as string, patch)
+      await queryClient.invalidateQueries({ queryKey: ['projectEmbeddings', project] })
+      setKey('')
+      push('Project embedding settings saved.', 'success')
+    } catch (err) {
+      push(`Failed to save: ${err instanceof ApiError ? err.message : String(err)}`, 'error')
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  async function onReset() {
+    try {
+      await api.resetProjectEmbeddings(project as string)
+      await queryClient.invalidateQueries({ queryKey: ['projectEmbeddings', project] })
+      push(`${project} now inherits the global embedding settings.`, 'success')
+    } catch (err) {
+      push(`Failed to revert: ${err instanceof ApiError ? err.message : String(err)}`, 'error')
+    }
+  }
+
+  return (
+    <form onSubmit={onSubmit} className="flex flex-col gap-4">
+      <p className="text-sm text-slate-400">
+        Point <span className="text-slate-200 font-medium">{project}</span> at its own embedding provider so its
+        usage meters against its own key. Anything left inherited follows the global settings.
+      </p>
+
+      <OverrideRow
+        label="API base URL"
+        hint="Must start with http:// or https://. Overriding this requires a project API key."
+        inherited={data.inherited.openai_api_base}
+        overriding={baseOn}
+        value={base}
+        onToggle={(next) => { setBaseOn(next); if (!next) setBase('') }}
+        onChange={setBase}
+      />
+
+      <OverrideRow
+        label="API key"
+        hint={
+          keyOn
+            ? keyAlreadyStored
+              ? 'A project key is stored. Leave blank to keep it, or type a new one.'
+              : 'Enter the key this project should bill against.'
+            : 'Using the global API key.'
+        }
+        inherited={data.inherited.openai_api_key_set ? 'global key configured' : 'no global key configured'}
+        overriding={keyOn}
+        value={key}
+        type="password"
+        placeholderWhenSecret={keyAlreadyStored ? '........ (unchanged)' : 'sk-...'}
+        onToggle={(next) => { setKeyOn(next); if (!next) setKey('') }}
+        onChange={setKey}
+      />
+
+      <OverrideRow
+        label="Embedding model"
+        hint="A collection that pins its own model still wins over this."
+        inherited={data.inherited.default_embedding_model}
+        overriding={modelOn}
+        value={model}
+        onToggle={(next) => { setModelOn(next); if (!next) setModel('') }}
+        onChange={setModel}
+      />
+
+      <div className="rounded-lg border border-slate-700 bg-slate-800/40 px-4 py-3 text-xs text-slate-400">
+        <span className="text-slate-300 font-medium">Effective now:</span>{' '}
+        {data.effective.default_embedding_model} via {data.effective.openai_api_base}
+        {data.effective.openai_api_key_set ? ' (key configured)' : ' (no key configured)'}
+      </div>
+
+      <div className="flex items-center gap-3">
+        <SaveButton saving={saving} />
+        <button
+          type="button"
+          onClick={() => setConfirmReset(true)}
+          className="inline-flex items-center gap-2 rounded-md border border-slate-600 px-3 py-2 text-sm text-slate-300 hover:bg-slate-700"
+        >
+          <RotateCcw size={14} /> Revert to global
+        </button>
+      </div>
+
+      <ConfirmDialog
+        open={confirmReset}
+        title="Revert to global settings?"
+        message={`${project} will drop its endpoint, key and model overrides and follow the global embedding settings.`}
+        confirmLabel="Revert"
+        onConfirm={onReset}
+        onClose={() => setConfirmReset(false)}
+      />
+    </form>
+  )
+}

+ 36 - 0
webui/src/pages/settings/WebuiTab.tsx

@@ -0,0 +1,36 @@
+import { useState } from 'react'
+import { FormRow, inputCls, Toggle, SaveButton } from '@/components/form'
+import { useSettingsForm, usePopulate } from './useSettingsForm'
+
+export default function WebuiTab() {
+  const { data, saving, save } = useSettingsForm()
+  const [enabled, setEnabled] = useState(true)
+  const [defaultProject, setDefaultProject] = useState('default')
+
+  usePopulate(data, (d) => {
+    setEnabled(d.webui_enabled)
+    setDefaultProject(d.default_project)
+  })
+
+  async function onSubmit(e: React.FormEvent) {
+    e.preventDefault()
+    await save({ webui_enabled: enabled, default_project: defaultProject.trim() })
+  }
+
+  return (
+    <form onSubmit={onSubmit} className="flex flex-col gap-4">
+      <p className="text-sm text-slate-400">How the built-in web interface behaves.</p>
+
+      <FormRow label="Web UI" hint="Disabling turns off this interface on the next request.">
+        <Toggle checked={enabled} onChange={setEnabled} />
+      </FormRow>
+
+      <FormRow label="Default project" hint="Project selected for a session that names none.">
+        <input type="text" value={defaultProject} onChange={(e) => setDefaultProject(e.target.value)}
+          placeholder="default" className={inputCls} />
+      </FormRow>
+
+      <SaveButton saving={saving} />
+    </form>
+  )
+}

+ 62 - 0
webui/src/pages/settings/useSettingsForm.ts

@@ -0,0 +1,62 @@
+import { useEffect, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { api } from '@/api/client'
+import { useToastStore } from '@/stores/toastStore'
+import { ApiError } from '@/types'
+import type { SettingsView } from '@/types'
+
+export type SettingsPatch = Partial<Omit<SettingsView, 'openai_api_key_set'> & { openai_api_key?: string }>
+
+/// Shared plumbing for the global settings tabs: one query, and a save that
+/// sends only the fields the calling tab owns (the API merges partial bodies).
+export function useSettingsForm() {
+  const push = useToastStore((s) => s.push)
+  const queryClient = useQueryClient()
+  const [saving, setSaving] = useState(false)
+
+  const { data, isLoading, isError, error } = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => api.getSettings(),
+  })
+
+  async function save(patch: SettingsPatch): Promise<boolean> {
+    setSaving(true)
+    try {
+      await api.updateSettings(patch)
+      await queryClient.invalidateQueries({ queryKey: ['settings'] })
+      push('Settings saved. Changes apply immediately (hot reload).', 'success')
+      return true
+    } catch (err) {
+      push(`Failed to save settings: ${err instanceof ApiError ? err.message : String(err)}`, 'error')
+      return false
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  return { data, isLoading, isError, error, saving, save, push }
+}
+
+/// Populate a tab's local form once its data arrives, without clobbering edits
+/// on every re-render. Re-syncs whenever the query data identity changes.
+export function usePopulate<T>(data: T | undefined, apply: (d: T) => void) {
+  useEffect(() => {
+    if (data) apply(data)
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [data])
+}
+
+/// Parse a positive integer field, reporting a toast on failure.
+export function parseIntField(
+  raw: string,
+  label: string,
+  push: (m: string, kind: 'error' | 'success') => void,
+  min = 1,
+): number | null {
+  const n = parseInt(raw, 10)
+  if (isNaN(n) || n < min) {
+    push(`${label} must be an integer >= ${min}.`, 'error')
+    return null
+  }
+  return n
+}

+ 43 - 4
webui/src/stores/authStore.ts

@@ -2,13 +2,22 @@ import { create } from 'zustand'
 import { persist } from 'zustand/middleware'
 import { api } from '@/api/client'
 
+/// 'checking' until the server has confirmed the cookie session. The persisted
+/// `loggedIn` flag only says "this browser logged in once" - sessions live in
+/// memory server-side, so a restart invalidates them while localStorage still
+/// claims otherwise. Routing on the flag alone renders the authenticated shell
+/// and only discovers the 401 afterwards, which is the login-page flash.
+export type AuthStatus = 'checking' | 'authed' | 'anon'
+
 interface AuthState {
   loggedIn: boolean
+  status: AuthStatus
   admin: boolean
   projects: string[]          // [] with admin=true means "all" (resolve via listProjects)
   currentProject: string | null
   login: (key: string) => Promise<void>
   logout: () => Promise<void>
+  checkSession: () => Promise<void>
   setCurrentProject: (p: string) => void
   reset: () => void
 }
@@ -16,7 +25,9 @@ interface AuthState {
 export const useAuthStore = create<AuthState>()(
   persist(
     (set, get) => ({
-      loggedIn: false, admin: false, projects: [], currentProject: null,
+      // Start as 'checking' whenever the browser claims a previous session, so
+      // the router waits for the server instead of guessing.
+      loggedIn: false, status: 'anon', admin: false, projects: [], currentProject: null,
       login: async (key) => {
         const r = await api.login(key)
         // For admin or "*" grants, fetch the real project list to populate the selector.
@@ -24,12 +35,40 @@ export const useAuthStore = create<AuthState>()(
         if (r.admin || projects.includes('*')) {
           try { projects = (await api.listProjects()).projects } catch { /* keep */ }
         }
-        set({ loggedIn: true, admin: r.admin, projects, currentProject: projects[0] ?? 'default' })
+        set({ loggedIn: true, status: 'authed', admin: r.admin, projects,
+              currentProject: projects[0] ?? 'default' })
       },
       logout: async () => { try { await api.logout() } finally { get().reset() } },
+      /// Ask the server whether the cookie session is still alive. Keeps the
+      /// selected project when it is still one the key may reach.
+      checkSession: async () => {
+        try {
+          const r = await api.session()
+          let projects = r.projects
+          if (r.admin || projects.includes('*')) {
+            try { projects = (await api.listProjects()).projects } catch { /* keep */ }
+          }
+          const kept = get().currentProject
+          set({
+            loggedIn: true, status: 'authed', admin: r.admin, projects,
+            currentProject: kept && projects.includes(kept) ? kept : projects[0] ?? 'default',
+          })
+        } catch {
+          // Includes the 401 case: the browser's flag was stale.
+          set({ loggedIn: false, status: 'anon', admin: false, projects: [], currentProject: null })
+        }
+      },
       setCurrentProject: (p) => set({ currentProject: p }),
-      reset: () => set({ loggedIn: false, admin: false, projects: [], currentProject: null }),
+      reset: () => set({ loggedIn: false, status: 'anon', admin: false, projects: [], currentProject: null }),
     }),
-    { name: 'svapi-auth', partialize: (s) => ({ loggedIn: s.loggedIn, admin: s.admin, projects: s.projects, currentProject: s.currentProject }) },
+    {
+      name: 'svapi-auth',
+      // `status` is deliberately not persisted: it describes this page load only.
+      partialize: (s) => ({ loggedIn: s.loggedIn, admin: s.admin, projects: s.projects, currentProject: s.currentProject }),
+      // A restored session is unverified until checkSession() says otherwise.
+      onRehydrateStorage: () => (state) => {
+        if (state?.loggedIn) state.status = 'checking'
+      },
+    },
   ),
 )

+ 24 - 0
webui/src/types/index.ts

@@ -44,7 +44,31 @@ export interface SettingsView {
   embedding_cache_max_bytes: number
   embedding_cache_normalize: boolean
   embedding_client_pool_size: number
+  captcha_provider: string
+  captcha_secret_set: boolean
+  captcha_verify_url: string
 }
+/// One tier of a project's embedding configuration. In `overrides`, an empty
+/// string means the field is inherited from the global settings.
+export interface EmbeddingTier {
+  openai_api_base: string
+  default_embedding_model: string
+  openai_api_key_set: boolean
+}
+
+export interface ProjectEmbeddingsView {
+  effective: EmbeddingTier
+  inherited: EmbeddingTier
+  overrides: EmbeddingTier
+}
+
+/// PUT body: omit a field to leave it unchanged, null clears the override.
+export interface ProjectEmbeddingsPatch {
+  openai_api_base?: string | null
+  openai_api_key?: string | null
+  default_embedding_model?: string | null
+}
+
 export class ApiError extends Error {
   status: number
   details?: unknown