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.",