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 (
+
+ );
+}
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.",