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

+23 -4
View File
@@ -4477,10 +4477,10 @@ 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."
description: "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.
@@ -21942,6 +21942,7 @@ components:
- OPENAI
- ANTHROPIC
- LOCAL
- OPENAI_COMPATIBLE
- CHATGPT_OAUTH
- type: "null"
description: The user's selected provider, or null when none is set.
@@ -21984,6 +21985,21 @@ components:
- type: string
- type: "null"
description: "`...` + last 4 chars of the stored OpenAI key, or null."
compatBaseUrl:
anyOf:
- type: string
- type: "null"
description: "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:
anyOf:
- type: string
- type: "null"
description: Model the gateway should route. Null falls back to `model`; with neither set the gateway entry does not
resolve.
hasCompatKey:
type: boolean
description: Whether a bearer is stored for the gateway. The bearer is optional — a gateway without auth needs none.
responseTimeoutSeconds:
anyOf:
- type: integer
@@ -22003,6 +22019,9 @@ components:
- hasLocalKey
- hasOpenaiKey
- openaiKeyPreview
- compatBaseUrl
- compatModel
- hasCompatKey
- responseTimeoutSeconds
additionalProperties: false
description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`).
+7
View File
@@ -4937,6 +4937,7 @@
"openai": "OpenAI (eigener Key)",
"anthropic": "Anthropic (Claude)",
"local": "Lokales Modell",
"openai-compatible": "OpenAI-kompatibles Gateway",
"admin-openai": "Admin-OpenAI"
},
"title": "Fallback-Kette",
@@ -4963,8 +4964,14 @@
"openai": "OpenAI (eigener API-Key)",
"anthropic": "Anthropic (Claude)",
"local": "Lokales Modell (OpenAI-kompatibel)",
"openai-compatible": "OpenAI-kompatibles Gateway",
"admin-openai": "Admin-OpenAI"
},
"compat": {
"description": "Ein vollwertiger Anbieter für jeden Endpunkt, der die OpenAI-API /v1/chat/completions spricht: LiteLLM, OpenRouter, vLLM oder ein anderes Gateway. Die Basis-URL endet auf /v1, der API-Key ist optional, und das Modell ist das, was dein Gateway weiterleitet. Dein OpenAI-Key erreicht diesen Endpunkt nie — dieser Anbieter hat einen eigenen.",
"keyLabel": "API-Key (optional)",
"modelHint": "Der Modellname, den dein Gateway erwartet. Bleibt das Feld leer, wird das für deine anderen Anbieter gespeicherte Modell verwendet; ist auch das leer, wird dieser Anbieter übersprungen."
},
"openai": {
"modelSelect": "Modell",
"modelOptionCustom": "Eigener Slug…",
+7
View File
@@ -4937,6 +4937,7 @@
"openai": "OpenAI (your key)",
"anthropic": "Anthropic (Claude)",
"local": "Local model",
"openai-compatible": "OpenAI-compatible gateway",
"admin-openai": "Admin OpenAI"
},
"title": "Fallback chain",
@@ -4963,8 +4964,14 @@
"openai": "OpenAI (your API key)",
"anthropic": "Anthropic (Claude)",
"local": "Local model (OpenAI-compatible)",
"openai-compatible": "OpenAI-compatible gateway",
"admin-openai": "Admin-provided OpenAI"
},
"compat": {
"description": "A first-class provider for any endpoint that speaks the OpenAI /v1/chat/completions API: LiteLLM, OpenRouter, vLLM or another gateway. The base URL ends with /v1, the API key is optional, and the model is whatever your gateway routes. Your OpenAI key never reaches this endpoint — this provider has its own.",
"keyLabel": "API key (optional)",
"modelHint": "The model name your gateway expects. Left blank, the model saved for your other providers is used; with neither set, this provider is skipped."
},
"openai": {
"modelSelect": "Model",
"modelOptionCustom": "Custom slug…",
+7
View File
@@ -4937,6 +4937,7 @@
"openai": "OpenAI (tu clave)",
"anthropic": "Anthropic (Claude)",
"local": "Modelo local",
"openai-compatible": "Pasarela compatible con OpenAI",
"admin-openai": "OpenAI de administrador"
},
"title": "Cadena de respaldo",
@@ -4963,8 +4964,14 @@
"openai": "OpenAI (tu clave de API)",
"anthropic": "Anthropic (Claude)",
"local": "Modelo local (compatible con OpenAI)",
"openai-compatible": "Pasarela compatible con OpenAI",
"admin-openai": "OpenAI proporcionado por el administrador"
},
"compat": {
"description": "Un proveedor de pleno derecho para cualquier endpoint que hable la API /v1/chat/completions de OpenAI: LiteLLM, OpenRouter, vLLM u otra pasarela. La URL base termina en /v1, la clave de API es opcional y el modelo es el que enrute tu pasarela. Tu clave de OpenAI nunca llega a este endpoint: este proveedor tiene la suya.",
"keyLabel": "Clave de API (opcional)",
"modelHint": "El nombre de modelo que espera tu pasarela. Si lo dejas vacío, se usa el modelo guardado para tus otros proveedores; si tampoco hay uno, este proveedor se omite."
},
"openai": {
"modelSelect": "Modelo",
"modelOptionCustom": "Slug personalizado…",
+7
View File
@@ -4937,6 +4937,7 @@
"openai": "OpenAI (votre clé)",
"anthropic": "Anthropic (Claude)",
"local": "Modèle local",
"openai-compatible": "Passerelle compatible OpenAI",
"admin-openai": "OpenAI administrateur"
},
"title": "Chaîne de secours",
@@ -4963,8 +4964,14 @@
"openai": "OpenAI (votre clé API)",
"anthropic": "Anthropic (Claude)",
"local": "Modèle local (compatible OpenAI)",
"openai-compatible": "Passerelle compatible OpenAI",
"admin-openai": "OpenAI fourni par l’administrateur"
},
"compat": {
"description": "Un fournisseur à part entière pour tout point de terminaison qui parle l'API /v1/chat/completions d'OpenAI : LiteLLM, OpenRouter, vLLM ou une autre passerelle. L'URL de base se termine par /v1, la clé API est facultative et le modèle est celui que votre passerelle achemine. Votre clé OpenAI n'atteint jamais ce point de terminaison — ce fournisseur a la sienne.",
"keyLabel": "Clé API (facultative)",
"modelHint": "Le nom de modèle attendu par votre passerelle. Laissé vide, le modèle enregistré pour vos autres fournisseurs est utilisé ; si aucun n'est défini, ce fournisseur est ignoré."
},
"openai": {
"modelSelect": "Modèle",
"modelOptionCustom": "Slug personnalisé…",
+7
View File
@@ -4937,6 +4937,7 @@
"openai": "OpenAI (la tua chiave)",
"anthropic": "Anthropic (Claude)",
"local": "Modello locale",
"openai-compatible": "Gateway compatibile con OpenAI",
"admin-openai": "OpenAI amministratore"
},
"title": "Catena di fallback",
@@ -4963,8 +4964,14 @@
"openai": "OpenAI (la tua chiave API)",
"anthropic": "Anthropic (Claude)",
"local": "Modello locale (compatibile con OpenAI)",
"openai-compatible": "Gateway compatibile con OpenAI",
"admin-openai": "OpenAI fornito dall’amministratore"
},
"compat": {
"description": "Un provider a pieno titolo per qualsiasi endpoint che parli l'API /v1/chat/completions di OpenAI: LiteLLM, OpenRouter, vLLM o un altro gateway. L'URL di base termina con /v1, la chiave API è facoltativa e il modello è quello instradato dal gateway. La tua chiave OpenAI non raggiunge mai questo endpoint: questo provider ha la propria.",
"keyLabel": "Chiave API (facoltativa)",
"modelHint": "Il nome del modello che il gateway si aspetta. Se lasci il campo vuoto viene usato il modello salvato per gli altri provider; se non è impostato nessuno dei due, questo provider viene saltato."
},
"openai": {
"modelSelect": "Modello",
"modelOptionCustom": "Slug personalizzato…",
+7
View File
@@ -4937,6 +4937,7 @@
"openai": "OpenAI (Twój klucz)",
"anthropic": "Anthropic (Claude)",
"local": "Model lokalny",
"openai-compatible": "Brama zgodna z OpenAI",
"admin-openai": "OpenAI administratora"
},
"title": "Łańcuch zapasowy",
@@ -4963,8 +4964,14 @@
"openai": "OpenAI (Twój klucz API)",
"anthropic": "Anthropic (Claude)",
"local": "Model lokalny (zgodny z OpenAI)",
"openai-compatible": "Brama zgodna z OpenAI",
"admin-openai": "OpenAI udostępniony przez administratora"
},
"compat": {
"description": "Pełnoprawny dostawca dla każdego punktu końcowego mówiącego API /v1/chat/completions OpenAI: LiteLLM, OpenRouter, vLLM lub inna brama. Adres bazowy kończy się na /v1, klucz API jest opcjonalny, a model to ten, który kieruje twoja brama. Twój klucz OpenAI nigdy nie trafia do tego punktu końcowego — ten dostawca ma własny.",
"keyLabel": "Klucz API (opcjonalny)",
"modelHint": "Nazwa modelu, której oczekuje twoja brama. Pozostawione puste, użyty zostanie model zapisany dla pozostałych dostawców; gdy nie ustawiono żadnego, ten dostawca jest pomijany."
},
"openai": {
"modelSelect": "Model",
"modelOptionCustom": "Niestandardowy slug…",
@@ -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.",