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:
+25 -2
View File
@@ -346,7 +346,13 @@ const profileUpdateResponse = z
const aiProviderResponse = z
.object({
provider: z
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
.enum([
"OPENAI",
"ANTHROPIC",
"LOCAL",
"OPENAI_COMPATIBLE",
"CHATGPT_OAUTH",
])
.nullable()
.describe("The user's selected provider, or null when none is set."),
model: z.string().nullable(),
@@ -376,6 +382,23 @@ const aiProviderResponse = z
.string()
.nullable()
.describe("`...` + last 4 chars of the stored OpenAI key, or null."),
compatBaseUrl: z
.string()
.nullable()
.describe(
"v1.33.1 (#470) — base URL of the OpenAI-compatible gateway (LiteLLM / OpenRouter / vLLM). Its own column: the OpenAI provider stays pinned to api.openai.com and never reads it.",
),
compatModel: z
.string()
.nullable()
.describe(
"Model the gateway should route. Null falls back to `model`; with neither set the gateway entry does not resolve.",
),
hasCompatKey: z
.boolean()
.describe(
"Whether a bearer is stored for the gateway. The bearer is optional — a gateway without auth needs none.",
),
responseTimeoutSeconds: z
.number()
.int()
@@ -396,7 +419,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Read the calling user's AI-provider configuration",
description:
"Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char masked preview only.",
"Returns the user's selected provider/model/baseUrl (plus the OpenAI-compatible gateway's own base URL and model) and the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char masked preview only.",
responses: {
"200": {
description: "Resolved AI-provider configuration.",