:)
This commit is contained in:
@@ -114,6 +114,14 @@ Models are addressed as `"driver:model"` (split on the first `:`).
|
|||||||
(SSE) implementation; concrete drivers only set base URL, key, and fallback
|
(SSE) implementation; concrete drivers only set base URL, key, and fallback
|
||||||
model list. A driver is enabled only when its `*_ENABLED` flag is set **and**
|
model list. A driver is enabled only when its `*_ENABLED` flag is set **and**
|
||||||
its key is non-empty; failing drivers are skipped (not fatal) in listings.
|
its key is non-empty; failing drivers are skipped (not fatal) in listings.
|
||||||
|
- Personal providers (BYOK, `src/userProviders.ts`): users attach their own
|
||||||
|
`openai` / `anthropic` / `deepseek` / `gemini` keys in settings
|
||||||
|
(`GET/PUT/DELETE /api/me/providers`, keys stored in `user_provider_keys`,
|
||||||
|
never returned — GET exposes presence + last4 only). `GET /api/models`
|
||||||
|
merges the caller's own-key models into their menu, and `POST /api/chat`
|
||||||
|
prefers the personal key for those four providers (also when the global
|
||||||
|
driver is disabled). The admin model policy still applies. Driver
|
||||||
|
constructors accept an `apiKeyOverride` for these per-user instances.
|
||||||
- `ArcaicSubDriver` ×3 (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`,
|
- `ArcaicSubDriver` ×3 (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`,
|
||||||
one model each: `chat`) + `browser.ts` engine: web-UI sessions driven by a
|
one model each: `chat`) + `browser.ts` engine: web-UI sessions driven by a
|
||||||
headless browser (WebDriver BiDi) with a **throwaway profile** (mkdtemp
|
headless browser (WebDriver BiDi) with a **throwaway profile** (mkdtemp
|
||||||
|
|||||||
+18
-1
@@ -24,7 +24,7 @@ Copy `.env.example` to `.env`. Full list:
|
|||||||
| `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) |
|
| `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) |
|
||||||
| `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP |
|
| `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP |
|
||||||
| `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver |
|
| `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver |
|
||||||
| `MISTRAL_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
|
| `MISTRAL_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*`, `GEMINI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
|
||||||
| `GLM_BASE_URL` | `https://api.z.ai/api/paas/v4` | Zhipu platform override (mainland China: `https://open.bigmodel.cn/api/paas/v4`) |
|
| `GLM_BASE_URL` | `https://api.z.ai/api/paas/v4` | Zhipu platform override (mainland China: `https://open.bigmodel.cn/api/paas/v4`) |
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
@@ -89,6 +89,23 @@ an **Enabled** switch and **hourly / daily** per-user request caps
|
|||||||
- Routes: `GET /api/admin/model-policy[?refresh=1]`,
|
- Routes: `GET /api/admin/model-policy[?refresh=1]`,
|
||||||
`PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`).
|
`PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`).
|
||||||
|
|
||||||
|
## Personal providers (BYOK)
|
||||||
|
|
||||||
|
Users can connect their own `OpenAI` / `Anthropic` / `DeepSeek` / `Gemini`
|
||||||
|
API keys in **Settings → Providers** (per account, no admin involved).
|
||||||
|
Connected providers add their models to that user's model menu at the top
|
||||||
|
(no page refresh needed); usage is billed to the user's own provider
|
||||||
|
account, and the admin model policy (kill-switch + rate limits) still
|
||||||
|
applies.
|
||||||
|
|
||||||
|
- Keys are stored server-side in `user_provider_keys` and **never returned**
|
||||||
|
to any client (`GET /api/me/providers` exposes presence + last 4 chars
|
||||||
|
only; never logged). A stored key greys out its field (Replace to rotate,
|
||||||
|
Remove to disconnect). Deleting the account wipes its keys.
|
||||||
|
- Routes: `GET /api/me/providers`, `PUT /api/me/providers/:id`
|
||||||
|
(`{apiKey}`, 8–256 chars), `DELETE /api/me/providers/:id` (all Bearer,
|
||||||
|
owner-scoped).
|
||||||
|
|
||||||
## Access-request wishlist (registration approval queue)
|
## Access-request wishlist (registration approval queue)
|
||||||
|
|
||||||
When public registration is off, visitors can still reserve a username via
|
When public registration is off, visitors can still reserve a username via
|
||||||
|
|||||||
@@ -1019,6 +1019,9 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) {
|
|||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [list, setList] = useState<UserProvider[] | null>(null);
|
const [list, setList] = useState<UserProvider[] | null>(null);
|
||||||
const [keys, setKeys] = useState<Record<string, string>>({});
|
const [keys, setKeys] = useState<Record<string, string>>({});
|
||||||
|
// Connected providers show a greyed-out (disabled) key field; "Replace"
|
||||||
|
// unlocks it for rotation.
|
||||||
|
const [editing, setEditing] = useState<Record<string, boolean>>({});
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
@@ -1050,6 +1053,7 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) {
|
|||||||
const res = await api.setProvider(id, v);
|
const res = await api.setProvider(id, v);
|
||||||
setList(res.providers);
|
setList(res.providers);
|
||||||
setKeys((k) => ({ ...k, [id]: "" }));
|
setKeys((k) => ({ ...k, [id]: "" }));
|
||||||
|
setEditing((e) => ({ ...e, [id]: false }));
|
||||||
setNotice(t("providers.keySaved"));
|
setNotice(t("providers.keySaved"));
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1066,6 +1070,7 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.deleteProvider(id);
|
const res = await api.deleteProvider(id);
|
||||||
setList(res.providers);
|
setList(res.providers);
|
||||||
|
setEditing((e) => ({ ...e, [id]: false }));
|
||||||
setNotice(t("providers.keyRemoved"));
|
setNotice(t("providers.keyRemoved"));
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1091,6 +1096,8 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) {
|
|||||||
const st = byId.get(m.id);
|
const st = byId.get(m.id);
|
||||||
const connected = !!st?.hasKey;
|
const connected = !!st?.hasKey;
|
||||||
const busy = busyId === m.id;
|
const busy = busyId === m.id;
|
||||||
|
// A stored key locks the field (greyed out) until "Replace".
|
||||||
|
const locked = connected && !editing[m.id];
|
||||||
return (
|
return (
|
||||||
<li key={m.id} className="space-y-2.5 rounded-2xl border border-stone-200/70 p-3.5 dark:border-zinc-800">
|
<li key={m.id} className="space-y-2.5 rounded-2xl border border-stone-200/70 p-3.5 dark:border-zinc-800">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -1129,18 +1136,20 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
value={keys[m.id] ?? ""}
|
value={locked ? "" : (keys[m.id] ?? "")}
|
||||||
onChange={(e) => setKeys((k) => ({ ...k, [m.id]: e.target.value }))}
|
onChange={(e) => setKeys((k) => ({ ...k, [m.id]: e.target.value }))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter" && !locked) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
save(m.id);
|
save(m.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={connected ? `${t("providers.keyPh")} (${t("providers.connected").toLowerCase()})` : t("providers.keyPh")}
|
placeholder={locked && st?.last4 ? `••••${st.last4}` : t("providers.keyPh")}
|
||||||
aria-label={`${m.label} API key`}
|
aria-label={`${m.label} API key`}
|
||||||
disabled={busy}
|
disabled={busy || locked}
|
||||||
|
className="disabled:cursor-not-allowed disabled:bg-stone-100 disabled:opacity-60 dark:disabled:bg-zinc-800"
|
||||||
/>
|
/>
|
||||||
|
{!locked && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !(keys[m.id] ?? "").trim()}
|
disabled={busy || !(keys[m.id] ?? "").trim()}
|
||||||
@@ -1149,9 +1158,32 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) {
|
|||||||
>
|
>
|
||||||
{busy ? <Spinner size={15} /> : t("providers.saveKey")}
|
{busy ? <Spinner size={15} /> : t("providers.saveKey")}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{connected && (
|
{connected && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-1">
|
||||||
|
{locked ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditing((e) => ({ ...e, [m.id]: true }))}
|
||||||
|
>
|
||||||
|
<Pencil size={14} /> {t("providers.replaceKey")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing((e) => ({ ...e, [m.id]: false }));
|
||||||
|
setKeys((k) => ({ ...k, [m.id]: "" }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
Reference in New Issue
Block a user