diff --git a/Core/Resgrid.Chatbot.NLU/LlmEndpointValidator.cs b/Core/Resgrid.Chatbot.NLU/LlmEndpointValidator.cs
new file mode 100644
index 000000000..1f62f43c6
--- /dev/null
+++ b/Core/Resgrid.Chatbot.NLU/LlmEndpointValidator.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Net;
+using System.Net.Sockets;
+
+namespace Resgrid.Chatbot.NLU
+{
+ ///
+ /// SSRF guard for LLM endpoints (system-level ChatbotConfig.CloudNluApiEndpoint and per-department
+ /// overrides). Only absolute https URIs whose host is — and resolves to — public addresses are
+ /// accepted; loopback, private, link-local and reserved ranges are rejected so a configured
+ /// endpoint can never point the server at internal infrastructure.
+ ///
+ public static class LlmEndpointValidator
+ {
+ public static bool IsValid(string endpoint, out string error)
+ {
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(endpoint))
+ {
+ error = "Endpoint is empty.";
+ return false;
+ }
+
+ if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
+ {
+ error = "Endpoint must be an absolute URI.";
+ return false;
+ }
+
+ if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
+ {
+ error = "Endpoint must use the https scheme.";
+ return false;
+ }
+
+ var host = uri.Host;
+ if (host.Length > 2 && host[0] == '[' && host[host.Length - 1] == ']')
+ host = host.Substring(1, host.Length - 2);
+
+ if (IPAddress.TryParse(host, out var literal))
+ return IsPublicAddress(literal, out error);
+
+ try
+ {
+ var addresses = Dns.GetHostAddresses(host);
+ if (addresses == null || addresses.Length == 0)
+ {
+ error = "Endpoint host did not resolve to any address.";
+ return false;
+ }
+
+ foreach (var address in addresses)
+ {
+ if (!IsPublicAddress(address, out error))
+ return false;
+ }
+
+ return true;
+ }
+ catch (Exception)
+ {
+ error = "Endpoint host could not be resolved.";
+ return false;
+ }
+ }
+
+ private static bool IsPublicAddress(IPAddress address, out string error)
+ {
+ if (IsBlockedAddress(address))
+ {
+ error = $"Endpoint host resolves to a loopback/private/link-local/reserved address ({address}).";
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+
+ private static bool IsBlockedAddress(IPAddress address)
+ {
+ if (address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ var bytes = address.GetAddressBytes();
+
+ if (bytes[0] == 0)
+ return true; // 0.0.0.0/8 (incl. 0.0.0.0)
+
+ if (bytes[0] == 10)
+ return true; // 10.0.0.0/8
+
+ if (bytes[0] == 127)
+ return true; // 127.0.0.0/8 (loopback)
+
+ if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31)
+ return true; // 172.16.0.0/12
+
+ if (bytes[0] == 192 && bytes[1] == 168)
+ return true; // 192.168.0.0/16
+
+ if (bytes[0] == 169 && bytes[1] == 254)
+ return true; // 169.254.0.0/16 (link-local)
+
+ return false;
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ if (address.IsIPv4MappedToIPv6)
+ return IsBlockedAddress(address.MapToIPv4()); // ::ffff:a.b.c.d -> run IPv4 checks
+
+ if (IPAddress.IPv6Loopback.Equals(address))
+ return true; // ::1
+
+ var bytes = address.GetAddressBytes();
+
+ if ((bytes[0] & 0xFE) == 0xFC)
+ return true; // fc00::/7 (unique local)
+
+ if (bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80)
+ return true; // fe80::/10 (link-local)
+
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/Core/Resgrid.Chatbot.NLU/NLUModule.cs b/Core/Resgrid.Chatbot.NLU/NLUModule.cs
index 165f803b1..1b84a7c07 100644
--- a/Core/Resgrid.Chatbot.NLU/NLUModule.cs
+++ b/Core/Resgrid.Chatbot.NLU/NLUModule.cs
@@ -28,6 +28,11 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType()
.As()
.InstancePerLifetimeScope();
+
+ // Free-form chat completion (conversational fallback) sharing the cloud provider resolution
+ builder.RegisterType()
+ .As()
+ .InstancePerLifetimeScope();
}
}
}
diff --git a/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleChatCompletionClient.cs b/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleChatCompletionClient.cs
new file mode 100644
index 000000000..76591a638
--- /dev/null
+++ b/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleChatCompletionClient.cs
@@ -0,0 +1,267 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Resgrid.Chatbot.Interfaces;
+using Resgrid.Chatbot.Models;
+using Resgrid.Config;
+using Resgrid.Framework;
+
+namespace Resgrid.Chatbot.NLU.Providers
+{
+ ///
+ /// Free-form chat completion sharing the cloud NLU classifier's provider resolution: system-level
+ /// ChatbotConfig (OpenAI / Azure OpenAI / DeepSeek / Anthropic) with per-department LLM overrides
+ /// honored. Used by the chatbot's conversational fallback; failures return null, never throw.
+ ///
+ public class OpenAiCompatibleChatCompletionClient : IChatCompletionClient
+ {
+ // Shared client to avoid socket exhaustion; per-request timeout via CancellationToken
+ // (same rationale as OpenAiCompatibleNluProvider).
+ private static readonly HttpClient _httpClient = new HttpClient();
+ private readonly IChatbotDepartmentConfigService _configService;
+
+ public OpenAiCompatibleChatCompletionClient(IChatbotDepartmentConfigService configService)
+ {
+ _configService = configService;
+ }
+
+ public async Task IsAvailableAsync(int departmentId)
+ {
+ var (_, apiKey, _, _, _) = await ResolveAsync(departmentId);
+ return !string.IsNullOrWhiteSpace(apiKey);
+ }
+
+ public async Task CompleteAsync(int departmentId, string systemPrompt, List turns, int? maxTokens = null)
+ {
+ try
+ {
+ if (turns == null || turns.Count == 0)
+ return null;
+
+ var (endpoint, apiKey, model, isAnthropic, isDepartmentOverride) = await ResolveAsync(departmentId);
+ if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(endpoint))
+ return null;
+
+ // SSRF guard: the effective endpoint (system config or department override) must be an
+ // absolute https URI resolving only to public addresses.
+ if (!LlmEndpointValidator.IsValid(endpoint, out var endpointError))
+ {
+ Logging.LogError($"Chat completion rejected for department {departmentId}: invalid LLM endpoint ({endpointError})");
+ return null;
+ }
+
+ var effectiveMaxTokens = maxTokens ?? (ChatbotConfig.CloudNluMaxTokens > 0 ? ChatbotConfig.CloudNluMaxTokens : 512);
+
+ object requestBody;
+ if (isAnthropic)
+ {
+ requestBody = new
+ {
+ model,
+ max_tokens = effectiveMaxTokens,
+ temperature = ChatbotConfig.CloudNluTemperature,
+ system = systemPrompt,
+ messages = turns.Select(t => new { role = NormalizeRole(t.Role), content = t.Content }).ToArray()
+ };
+ }
+ else
+ {
+ var messages = new List