From ee620b270792aff5effcc62d8cbf857d1b78ee40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:12:17 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 27 ++- messages/de.json | 7 + messages/en.json | 7 + messages/es.json | 7 + messages/fr.json | 7 + messages/it.json | 7 + messages/pl.json | 7 + .../settings/ai/ai-insights-card.tsx | 4 + .../settings/ai/compat-provider-form.tsx | 159 ++++++++++++++++++ src/components/settings/ai/shared.ts | 15 ++ src/lib/openapi/routes/profile.ts | 27 ++- 11 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 src/components/settings/ai/compat-provider-form.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 704a2ba43..8c734b58e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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`). diff --git a/messages/de.json b/messages/de.json index 5e38d38b1..6cacb7616 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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…", diff --git a/messages/en.json b/messages/en.json index 8f5a8b52b..f7e401020 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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…", diff --git a/messages/es.json b/messages/es.json index 7f2b2817c..a856d5850 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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…", diff --git a/messages/fr.json b/messages/fr.json index 16e785bca..052b02a26 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é…", diff --git a/messages/it.json b/messages/it.json index c366d144c..deda44729 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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…", diff --git a/messages/pl.json b/messages/pl.json index 72518273c..cb49beb28 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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…", diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 47bcc65c5..b342015a6 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -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" && ( )} + {provider === "openai-compatible" && ( + + )} {provider === "admin-openai" && ( )} diff --git a/src/components/settings/ai/compat-provider-form.tsx b/src/components/settings/ai/compat-provider-form.tsx new file mode 100644 index 000000000..a91e444a7 --- /dev/null +++ b/src/components/settings/ai/compat-provider-form.tsx @@ -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(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(null); + if (seededKey && seededKey !== previousSeed) { + setPreviousSeed(seededKey); + setBaseUrl(userProvider?.compatBaseUrl ?? ""); + setModel(userProvider?.compatModel ?? ""); + } + + const saveMutation = useMutation({ + mutationFn: async () => { + const body: Record = { + 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) { + event.preventDefault(); + if (submitInFlightRef.current || saveMutation.isPending) return; + submitInFlightRef.current = true; + saveMutation.mutate(); + } + + return ( +
+

+ {t("settings.ai.compat.description")} +

+
+ + setBaseUrl(e.target.value)} + placeholder="https://litellm.example.com/v1" + className="mt-1" + /> +
+
+ + setApiKey(e.target.value)} + placeholder={ + userProvider?.hasCompatKey ? t("settings.ai.savedShort") : "" + } + className="mt-1" + /> +
+
+ + setModel(e.target.value)} + placeholder="anthropic/claude-sonnet-4-6" + className="mt-1" + /> +

+ {t("settings.ai.compat.modelHint")} +

+
+ +
+ +
+ + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/ai/shared.ts b/src/components/settings/ai/shared.ts index e3b922b4c..70dea2ac3 100644 --- a/src/components/settings/ai/shared.ts +++ b/src/components/settings/ai/shared.ts @@ -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: diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index a75fe9a11..9271ef953 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -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 = { 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.",