feat(settings): configure the OpenAI-compatible gateway from Settings

Its own form with its own three fields, its own entry in the provider
dropdown and in the fallback-chain editor, and copy in six locales that
says plainly where the OpenAI key does not go.

The chain editor offers the entry but the default chain does not carry
it: it resolves only against a base URL somebody saved.

The read endpoint reports the gateway's base URL and model, and the
bearer as presence only. OpenAPI regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 22:27:48 +02:00
1 parent 0c7059804b
commit ee620b2707
11 files changed
+268 -6

No files matched your search

@@ -35,6 +35,7 @@ import { AnthropicProviderForm } from "./anthropic-provider-form";
import { AutoReadCard } from "./auto-read-card";
import { CentralCodexSwitch } from "./central-codex-switch";
import { CodexProviderForm } from "./codex-provider-form";
import { CompatProviderForm } from "./compat-provider-form";
import { FallbackChainCard } from "./fallback-chain-card";
import { LocalProviderForm } from "./local-provider-form";
import { OpenAIProviderForm } from "./openai-provider-form";
@@ -344,6 +345,9 @@ function ProviderConfigCard({
{provider === "local" && (
<LocalProviderForm userProvider={userProvider} />
)}
{provider === "openai-compatible" && (
<CompatProviderForm userProvider={userProvider} />
)}
{provider === "admin-openai" && (
<AdminOpenAIProviderForm hasAdminKey={insightsSettings?.hasAdminKey} />
)}
@@ -0,0 +1,159 @@
"use client";
/* ────────────────────────────────────────────────────────────────
* OpenAI-compatible gateway form (#470) — base URL + optional key +
* model. Three fields of its own, written to three columns of its own:
* the OpenAI provider stays pinned to api.openai.com and cannot see any
* of them, and the gateway never sees the OpenAI key.
* ──────────────────────────────────────────────────────────────── */
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared";
export function CompatProviderForm({
userProvider,
}: {
userProvider: UserAIProvider | null | undefined;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
// Seed from the server once per distinct persisted value, the same
// render-time pattern the sibling forms use (no setState-in-effect).
const seededKey =
userProvider != null
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}`
: null;
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
if (seededKey && seededKey !== previousSeed) {
setPreviousSeed(seededKey);
setBaseUrl(userProvider?.compatBaseUrl ?? "");
setModel(userProvider?.compatModel ?? "");
}
const saveMutation = useMutation({
mutationFn: async () => {
const body: Record<string, unknown> = {
provider: uiToLegacyProviderEnum("openai-compatible"),
compatBaseUrl: baseUrl.trim() || null,
compatModel: model.trim() || null,
};
if (apiKey.trim()) body.compatKey = apiKey.trim();
await apiPatch("/api/user/ai-provider", body);
},
onSuccess: () => {
setOk(true);
setMsg(t("settings.ai.saved"));
setApiKey("");
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<form
data-testid="ai-provider-config-openai-compatible"
className="space-y-4"
onSubmit={submit}
noValidate
>
<p className="text-muted-foreground text-xs">
{t("settings.ai.compat.description")}
</p>
<div>
<Label htmlFor="ai-compat-base-url">
{t("settings.ai.baseUrlLabel")}
</Label>
<Input
id="ai-compat-base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://litellm.example.com/v1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="ai-compat-key">
{t("settings.ai.compat.keyLabel")}
</Label>
<PasswordInput
id="ai-compat-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
userProvider?.hasCompatKey ? t("settings.ai.savedShort") : ""
}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="ai-compat-model">{t("settings.ai.modelLabel")}</Label>
<Input
id="ai-compat-model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="anthropic/claude-sonnet-4-6"
className="mt-1"
/>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.compat.modelHint")}
</p>
</div>
<div>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.ai.saveCta")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</form>
);
}
+15
View File
@@ -32,6 +32,12 @@ export interface UserAIProvider {
hasLocalKey: boolean;
hasOpenaiKey: boolean;
openaiKeyPreview: string | null;
// v1.33.1 (#470) — the OpenAI-compatible gateway's own configuration. Its
// own fields, because its own columns: the OpenAI provider is pinned to
// api.openai.com and shares nothing with a user-supplied host.
compatBaseUrl: string | null;
compatModel: string | null;
hasCompatKey: boolean;
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: number | null;
}
@@ -46,6 +52,7 @@ export const PROVIDER_TYPES = [
"openai",
"anthropic",
"local",
"openai-compatible",
"admin-openai",
] as const;
export type ProviderType = (typeof PROVIDER_TYPES)[number];
@@ -65,6 +72,12 @@ export interface ProviderChainData {
}[];
}
/**
* The chain a user starts with and returns to on reset. `openai-compatible`
* is deliberately absent: it resolves only against a base URL somebody
* configured, so a default entry would be dead weight for everyone else.
* Users add it from the chain editor once their gateway is saved.
*/
export const DEFAULT_CHAIN: readonly ChainEntry[] = [
{ providerType: "codex", enabled: true },
{ providerType: "openai", enabled: true },
@@ -114,6 +127,8 @@ export function uiToLegacyProviderEnum(p: ProviderType): string | null {
return "ANTHROPIC";
case "local":
return "LOCAL";
case "openai-compatible":
return "OPENAI_COMPATIBLE";
case "codex":
case "admin-openai":
default: