33using System . Diagnostics ;
44using System . Net . Http ;
55using System . Text ;
6+ using System . Threading ;
67using System . Threading . Tasks ;
78using Newtonsoft . Json ;
89using Newtonsoft . Json . Linq ;
10+ using Resgrid . Framework ;
911using Resgrid . Chatbot . Config ;
1012using Resgrid . Chatbot . Interfaces ;
1113using Resgrid . Chatbot . Models ;
@@ -36,7 +38,11 @@ public class OpenAiCompatibleNluProvider : INLUProvider
3638 public string ProviderName => "CloudLLM" ;
3739 public int Priority => 100 ;
3840
39- private readonly HttpClient _httpClient ;
41+ // A single shared HttpClient avoids socket exhaustion. This provider is registered
42+ // InstancePerLifetimeScope, so a per-instance client would leak sockets under load.
43+ // Per-request timeouts are enforced via a CancellationToken (see ClassifyAsync) rather
44+ // than the shared client's Timeout, which cannot be varied safely across concurrent callers.
45+ private static readonly HttpClient _httpClient = new HttpClient ( ) ;
4046 private readonly IChatbotDepartmentConfigService _configService ;
4147
4248 private static readonly string IntentSystemPrompt = @"You are a classification engine for emergency service chatbot commands.
@@ -101,12 +107,6 @@ Confidence should be between 0.0 and 1.0 based on how certain you are.
101107 public OpenAiCompatibleNluProvider ( IChatbotDepartmentConfigService configService )
102108 {
103109 _configService = configService ;
104- _httpClient = new HttpClient
105- {
106- Timeout = TimeSpan . FromSeconds ( ChatbotConfig . CloudNluTimeoutSeconds > 0
107- ? ChatbotConfig . CloudNluTimeoutSeconds
108- : 10 )
109- } ;
110110 }
111111
112112 public async Task < NLUResult > ClassifyAsync ( string text , string context = null , int departmentId = 0 )
@@ -142,30 +142,60 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null, i
142142 } ;
143143 }
144144
145+ // Anthropic uses a different request/response schema and auth header than the OpenAI
146+ // chat-completions API. Department overrides carry no provider type, so detect Anthropic
147+ // from the endpoint URL; otherwise honour the system-level provider setting.
148+ var isAnthropic = departmentLlm != null
149+ ? ( ! string . IsNullOrWhiteSpace ( endpoint ) && endpoint . IndexOf ( "anthropic" , StringComparison . OrdinalIgnoreCase ) >= 0 )
150+ : ChatbotConfig . CloudNluProvider == CloudNluProviderType . Anthropic ;
151+
145152 var systemPrompt = ! string . IsNullOrWhiteSpace ( ChatbotConfig . CloudNluSystemPrompt )
146153 ? ChatbotConfig . CloudNluSystemPrompt
147154 : IntentSystemPrompt ;
148155
149- var messages = new List < object >
150- {
151- new { role = "system" , content = systemPrompt }
152- } ;
156+ var maxTokens = ChatbotConfig . CloudNluMaxTokens > 0 ? ChatbotConfig . CloudNluMaxTokens : 256 ;
153157
154- if ( ! string . IsNullOrWhiteSpace ( context ) )
158+ object requestBody ;
159+ if ( isAnthropic )
155160 {
156- messages . Add ( new { role = "system" , content = $ "Conversation context: { context } " } ) ;
161+ // Anthropic /v1/messages: the system prompt is a top-level field, messages contain
162+ // only user/assistant turns, and there is no response_format option.
163+ var anthropicSystem = string . IsNullOrWhiteSpace ( context )
164+ ? systemPrompt
165+ : $ "{ systemPrompt } \n \n Conversation context: { context } ";
166+
167+ requestBody = new
168+ {
169+ model ,
170+ max_tokens = maxTokens ,
171+ temperature = ChatbotConfig . CloudNluTemperature ,
172+ system = anthropicSystem ,
173+ messages = new [ ] { new { role = "user" , content = text } }
174+ } ;
157175 }
176+ else
177+ {
178+ var messages = new List < object >
179+ {
180+ new { role = "system" , content = systemPrompt }
181+ } ;
158182
159- messages . Add ( new { role = "user" , content = text } ) ;
183+ if ( ! string . IsNullOrWhiteSpace ( context ) )
184+ {
185+ messages . Add ( new { role = "system" , content = $ "Conversation context: { context } " } ) ;
186+ }
160187
161- var requestBody = new
162- {
163- model ,
164- messages ,
165- temperature = ChatbotConfig . CloudNluTemperature ,
166- max_tokens = ChatbotConfig . CloudNluMaxTokens > 0 ? ChatbotConfig . CloudNluMaxTokens : 256 ,
167- response_format = new { type = "json_object" }
168- } ;
188+ messages . Add ( new { role = "user" , content = text } ) ;
189+
190+ requestBody = new
191+ {
192+ model ,
193+ messages ,
194+ temperature = ChatbotConfig . CloudNluTemperature ,
195+ max_tokens = maxTokens ,
196+ response_format = new { type = "json_object" }
197+ } ;
198+ }
169199
170200 var json = JsonConvert . SerializeObject ( requestBody ) ;
171201 var content = new StringContent ( json , Encoding . UTF8 , "application/json" ) ;
@@ -174,23 +204,37 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null, i
174204 {
175205 Content = content
176206 } ;
177- request . Headers . Add ( "Authorization" , $ "Bearer { apiKey } ") ;
178207
179- // Azure OpenAI uses api-key header instead (system config only; a department override
180- // is assumed OpenAI-compatible with Bearer auth).
181- if ( departmentLlm == null && ChatbotConfig . CloudNluProvider == CloudNluProviderType . AzureOpenAI )
208+ if ( isAnthropic )
182209 {
183- request . Headers . Remove ( "Authorization" ) ;
210+ // Anthropic authenticates with x-api-key and requires an API version header.
211+ request . Headers . Add ( "x-api-key" , apiKey ) ;
212+ request . Headers . Add ( "anthropic-version" , "2023-06-01" ) ;
213+ }
214+ else if ( departmentLlm == null && ChatbotConfig . CloudNluProvider == CloudNluProviderType . AzureOpenAI )
215+ {
216+ // Azure OpenAI uses an api-key header instead of Bearer auth (system config only;
217+ // a department override is assumed OpenAI-compatible with Bearer auth).
184218 request . Headers . Add ( "api-key" , apiKey ) ;
185219 }
220+ else
221+ {
222+ request . Headers . Add ( "Authorization" , $ "Bearer { apiKey } ") ;
223+ }
224+
225+ // Enforce the configured timeout per request via a CancellationToken rather than the
226+ // shared client's Timeout.
227+ using var cts = new CancellationTokenSource ( TimeSpan . FromSeconds (
228+ ChatbotConfig . CloudNluTimeoutSeconds > 0 ? ChatbotConfig . CloudNluTimeoutSeconds : 10 ) ) ;
186229
187- var response = await _httpClient . SendAsync ( request ) ;
230+ var response = await _httpClient . SendAsync ( request , cts . Token ) ;
188231 var responseBody = await response . Content . ReadAsStringAsync ( ) ;
189232
190233 sw . Stop ( ) ;
191234
192235 if ( ! response . IsSuccessStatusCode )
193236 {
237+ Logging . LogError ( $ "Cloud NLU error from { ProviderName } (HTTP { ( int ) response . StatusCode } ): { responseBody ? . Truncate ( 500 ) } ") ;
194238 return new NLUResult
195239 {
196240 IntentName = "unknown" ,
@@ -202,12 +246,13 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null, i
202246 } ;
203247 }
204248
205- var parsed = ParseOpenAiResponse ( responseBody , model , sw . ElapsedMilliseconds ) ;
249+ var parsed = ParseOpenAiResponse ( responseBody , model , sw . ElapsedMilliseconds , isAnthropic ) ;
206250 return parsed ;
207251 }
208- catch ( TaskCanceledException )
252+ catch ( TaskCanceledException ex )
209253 {
210254 sw . Stop ( ) ;
255+ Logging . LogError ( $ "Cloud NLU ({ ProviderName } ) timed out after { sw . ElapsedMilliseconds } ms: { ex . Message } ") ;
211256 return new NLUResult
212257 {
213258 IntentName = "unknown" ,
@@ -220,6 +265,7 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null, i
220265 catch ( Exception ex )
221266 {
222267 sw . Stop ( ) ;
268+ Logging . LogException ( ex , "Cloud NLU classification failed." ) ;
223269 return new NLUResult
224270 {
225271 IntentName = "unknown" ,
@@ -286,40 +332,61 @@ private string ResolveModel()
286332 CloudNluProviderType . OpenAI => "gpt-4o" ,
287333 CloudNluProviderType . OpenAiCompatible => "gpt-4o" ,
288334 CloudNluProviderType . AzureOpenAI => "gpt-4" ,
289- CloudNluProviderType . Anthropic => "claude-3-5-sonnet" ,
335+ CloudNluProviderType . Anthropic => "claude-3-5-sonnet-latest " ,
290336 _ => "gpt-4o"
291337 } ;
292338 }
293339
294- private NLUResult ParseOpenAiResponse ( string responseBody , string model , long latencyMs )
340+ private NLUResult ParseOpenAiResponse ( string responseBody , string model , long latencyMs , bool isAnthropic = false )
295341 {
296342 try
297343 {
298344 var root = JObject . Parse ( responseBody ) ;
299- var choices = root [ "choices" ] as JArray ;
300- if ( choices == null || choices . Count == 0 )
345+
346+ string contentText ;
347+ int ? totalTokens ;
348+
349+ if ( isAnthropic )
301350 {
302- return new NLUResult
351+ // Anthropic returns content as an array of blocks and reports input/output tokens
352+ // separately rather than a single total_tokens value.
353+ var contentBlocks = root [ "content" ] as JArray ;
354+ contentText = contentBlocks != null && contentBlocks . Count > 0
355+ ? contentBlocks [ 0 ] ? [ "text" ] ? . ToString ( )
356+ : null ;
357+
358+ var usage = root [ "usage" ] ;
359+ totalTokens = usage != null
360+ ? ( usage [ "input_tokens" ] ? . Value < int > ( ) ?? 0 ) + ( usage [ "output_tokens" ] ? . Value < int > ( ) ?? 0 )
361+ : ( int ? ) null ;
362+ }
363+ else
364+ {
365+ var choices = root [ "choices" ] as JArray ;
366+ if ( choices == null || choices . Count == 0 )
303367 {
304- IntentName = "unknown" ,
305- Confidence = 0 ,
306- ProviderName = ProviderName ,
307- RawResponse = "Cloud NLU returned no choices." ,
308- LatencyMs = latencyMs ,
309- ModelName = model
310- } ;
368+ Logging . LogError ( $ "Cloud NLU ({ ProviderName } ) returned no choices.") ;
369+ return new NLUResult
370+ {
371+ IntentName = "unknown" ,
372+ Confidence = 0 ,
373+ ProviderName = ProviderName ,
374+ RawResponse = "Cloud NLU returned no choices." ,
375+ LatencyMs = latencyMs ,
376+ ModelName = model
377+ } ;
378+ }
379+
380+ var message = choices [ 0 ] [ "message" ] ;
381+ contentText = message ? [ "content" ] ? . ToString ( ) ;
382+
383+ var usage = root [ "usage" ] ;
384+ totalTokens = usage != null ? usage [ "total_tokens" ] ? . Value < int > ( ) : null ;
311385 }
312386
313- var message = choices [ 0 ] [ "message" ] ;
314- var contentText = message ? [ "content" ] ? . ToString ( ) ;
315-
316- int ? totalTokens = null ;
317- var usage = root [ "usage" ] ;
318- if ( usage != null )
319- totalTokens = usage [ "total_tokens" ] ? . Value < int > ( ) ;
320-
321387 if ( string . IsNullOrWhiteSpace ( contentText ) )
322388 {
389+ Logging . LogError ( $ "Cloud NLU ({ ProviderName } ) returned empty content.") ;
323390 return new NLUResult
324391 {
325392 IntentName = "unknown" ,
@@ -356,8 +423,9 @@ private NLUResult ParseOpenAiResponse(string responseBody, string model, long la
356423 TotalTokens = totalTokens
357424 } ;
358425 }
359- catch ( JsonException )
426+ catch ( JsonException ex )
360427 {
428+ Logging . LogException ( ex , "Cloud NLU returned unparseable JSON." ) ;
361429 return new NLUResult
362430 {
363431 IntentName = "unknown" ,
0 commit comments