1414use Netresearch \NrLlm \Domain \Repository \LlmConfigurationRepository ;
1515use Netresearch \NrLlm \Provider \Exception \ProviderException ;
1616use Netresearch \NrLlm \Service \LlmServiceManagerInterface ;
17- use Netresearch \NrLlm \Service \Option \ChatOptions ;
1817use Netresearch \T3Cowriter \Domain \DTO \CompleteRequest ;
1918use Netresearch \T3Cowriter \Domain \DTO \CompleteResponse ;
2019use Netresearch \T3Cowriter \Service \RateLimiterInterface ;
5554 */
5655 private const ALLOWED_ROLES = ['user ' , 'assistant ' ];
5756
58- /**
59- * Maximum tokens upper bound to prevent denial-of-wallet attacks.
60- */
61- private const MAX_TOKENS_UPPER_BOUND = 16384 ;
62-
63- /**
64- * Maximum number of stop sequences allowed.
65- */
66- private const MAX_STOP_SEQUENCES = 10 ;
67-
6857 /**
6958 * System prompt for the cowriter assistant.
7059 */
@@ -88,7 +77,7 @@ public function __construct(
8877 *
8978 * Expects JSON body with:
9079 * - messages: array of {role: string, content: string}
91- * - options : optional array with temperature, maxTokens, etc.
80+ * - configuration : optional configuration identifier
9281 */
9382 public function chatAction (ServerRequestInterface $ request ): ResponseInterface
9483 {
@@ -114,9 +103,6 @@ public function chatAction(ServerRequestInterface $request): ResponseInterface
114103 }
115104
116105 $ rawMessages = isset ($ body ['messages ' ]) && is_array ($ body ['messages ' ]) ? $ body ['messages ' ] : [];
117- /** @var array<string, mixed> $optionsData */
118- $ optionsData = isset ($ body ['options ' ]) && is_array ($ body ['options ' ]) ? $ body ['options ' ] : [];
119- $ options = $ this ->createChatOptions ($ optionsData );
120106
121107 if ($ rawMessages === []) {
122108 return new JsonResponse (['success ' => false , 'error ' => 'Messages array is required ' ], 400 );
@@ -131,8 +117,19 @@ public function chatAction(ServerRequestInterface $request): ResponseInterface
131117 );
132118 }
133119
120+ // Resolve configuration from request or fall back to default
121+ $ configIdentifier = isset ($ body ['configuration ' ]) && is_string ($ body ['configuration ' ]) ? $ body ['configuration ' ] : null ;
122+ $ configuration = $ this ->resolveConfiguration ($ configIdentifier );
123+ if (!$ configuration instanceof LlmConfiguration) {
124+ return $ this ->jsonResponseWithRateLimitHeaders (
125+ ['success ' => false , 'error ' => 'No LLM configuration available. Please configure the nr_llm extension. ' ],
126+ $ rateLimitResult ,
127+ 404 ,
128+ );
129+ }
130+
134131 try {
135- $ response = $ this ->llmServiceManager ->chat ($ messages , $ options );
132+ $ response = $ this ->llmServiceManager ->chatWithConfiguration ($ messages , $ configuration );
136133
137134 // Escape HTML to prevent XSS attacks (defense in depth for all string values)
138135 return $ this ->jsonResponseWithRateLimitHeaders ([
@@ -227,14 +224,7 @@ private function executeCompletion(
227224 ['role ' => 'user ' , 'content ' => $ dto ->prompt ],
228225 ];
229226
230- $ options = $ configuration ->toChatOptions ();
231-
232- // Apply model override if specified via #cw:model-name prefix
233- if ($ dto ->modelOverride !== null ) {
234- $ options = $ options ->withModel ($ dto ->modelOverride );
235- }
236-
237- $ response = $ this ->llmServiceManager ->chat ($ messages , $ options );
227+ $ response = $ this ->llmServiceManager ->chatWithConfiguration ($ messages , $ configuration );
238228
239229 // CompleteResponse.success() handles HTML escaping
240230 return $ this ->jsonResponseWithRateLimitHeaders (
@@ -304,31 +294,18 @@ public function streamAction(ServerRequestInterface $request): ResponseInterface
304294 );
305295 }
306296
307- // Check if streaming is supported
308- if (!$ this ->llmServiceManager ->supportsFeature ('streaming ' )) {
309- // Fall back to non-streaming completion (reuse already-parsed DTO and rate limit)
310- return $ this ->executeCompletion ($ dto , $ configuration , $ rateLimitResult );
311- }
312-
313297 // Build the streaming response using a generator
314298 $ messages = [
315299 ['role ' => 'system ' , 'content ' => self ::SYSTEM_PROMPT ],
316300 ['role ' => 'user ' , 'content ' => $ dto ->prompt ],
317301 ];
318302
319- $ options = $ configuration ->toChatOptions ();
320-
321- // Apply model override if specified via #cw:model-name prefix
322- if ($ dto ->modelOverride !== null ) {
323- $ options = $ options ->withModel ($ dto ->modelOverride );
324- }
325-
326303 // Collect all chunks and return as SSE-formatted response
327304 // Note: True streaming requires output buffering disabled which isn't always possible in TYPO3
328305 // This implementation collects chunks and returns them in SSE format for compatibility
329306 try {
330307 $ chunks = [];
331- $ generator = $ this ->llmServiceManager ->streamChat ($ messages , $ options );
308+ $ generator = $ this ->llmServiceManager ->streamChatWithConfiguration ($ messages , $ configuration );
332309
333310 foreach ($ generator as $ chunk ) {
334311 $ sanitizedChunk = htmlspecialchars ($ chunk , ENT_QUOTES | ENT_HTML5 , 'UTF-8 ' );
@@ -338,7 +315,7 @@ public function streamAction(ServerRequestInterface $request): ResponseInterface
338315 // Add final "done" event
339316 $ chunks [] = 'data: ' . json_encode ([
340317 'done ' => true ,
341- 'model ' => htmlspecialchars ($ options -> getModel () ?? '' , ENT_QUOTES | ENT_HTML5 , 'UTF-8 ' ),
318+ 'model ' => htmlspecialchars ($ configuration -> getModelId () , ENT_QUOTES | ENT_HTML5 , 'UTF-8 ' ),
342319 ], JSON_THROW_ON_ERROR ) . "\n\n" ;
343320
344321 $ body = implode ('' , $ chunks );
@@ -416,74 +393,6 @@ private function resolveConfiguration(?string $identifier): ?LlmConfiguration
416393 return $ this ->configurationRepository ->findDefault ();
417394 }
418395
419- /**
420- * Create ChatOptions from array of options with range validation.
421- *
422- * Validates and clamps numeric parameters to their valid ranges:
423- * - temperature: 0.0 to 2.0
424- * - topP: 0.0 to 1.0
425- * - frequencyPenalty: -2.0 to 2.0
426- * - presencePenalty: -2.0 to 2.0
427- * - maxTokens: 1 to 16384
428- *
429- * Security: provider, model, and systemPrompt overrides from the client
430- * are intentionally ignored. These are controlled server-side via
431- * LlmConfiguration records in the nr-llm extension.
432- *
433- * @param array<string, mixed> $options
434- */
435- private function createChatOptions (array $ options ): ?ChatOptions
436- {
437- if ($ options === []) {
438- return null ;
439- }
440-
441- $ temperature = isset ($ options ['temperature ' ]) && is_numeric ($ options ['temperature ' ])
442- ? $ this ->clampFloat ((float ) $ options ['temperature ' ], 0.0 , 2.0 )
443- : null ;
444- $ maxTokens = isset ($ options ['maxTokens ' ]) && is_numeric ($ options ['maxTokens ' ])
445- ? $ this ->clampInt ((int ) $ options ['maxTokens ' ], 1 , self ::MAX_TOKENS_UPPER_BOUND )
446- : null ;
447- $ topP = isset ($ options ['topP ' ]) && is_numeric ($ options ['topP ' ])
448- ? $ this ->clampFloat ((float ) $ options ['topP ' ], 0.0 , 1.0 )
449- : null ;
450- $ frequencyPenalty = isset ($ options ['frequencyPenalty ' ]) && is_numeric ($ options ['frequencyPenalty ' ])
451- ? $ this ->clampFloat ((float ) $ options ['frequencyPenalty ' ], -2.0 , 2.0 )
452- : null ;
453- $ presencePenalty = isset ($ options ['presencePenalty ' ]) && is_numeric ($ options ['presencePenalty ' ])
454- ? $ this ->clampFloat ((float ) $ options ['presencePenalty ' ], -2.0 , 2.0 )
455- : null ;
456- $ responseFormat = isset ($ options ['responseFormat ' ]) && is_string ($ options ['responseFormat ' ])
457- && in_array ($ options ['responseFormat ' ], ['text ' , 'json ' , 'markdown ' ], true )
458- ? $ options ['responseFormat ' ]
459- : null ;
460- $ stopSequences = null ;
461- if (isset ($ options ['stopSequences ' ]) && is_array ($ options ['stopSequences ' ])) {
462- $ filtered = array_values (array_filter (
463- array_slice ($ options ['stopSequences ' ], 0 , self ::MAX_STOP_SEQUENCES ),
464- is_string (...),
465- ));
466- $ stopSequences = $ filtered !== [] ? $ filtered : null ;
467- }
468-
469- // If none of the recognized options had values, treat as "no options"
470- if ($ temperature === null && $ maxTokens === null && $ topP === null
471- && $ frequencyPenalty === null && $ presencePenalty === null
472- && $ responseFormat === null && $ stopSequences === null ) {
473- return null ;
474- }
475-
476- return new ChatOptions (
477- temperature: $ temperature ,
478- maxTokens: $ maxTokens ,
479- topP: $ topP ,
480- frequencyPenalty: $ frequencyPenalty ,
481- presencePenalty: $ presencePenalty ,
482- responseFormat: $ responseFormat ,
483- stopSequences: $ stopSequences ,
484- );
485- }
486-
487396 /**
488397 * Validate and sanitize chat messages.
489398 *
@@ -526,22 +435,6 @@ private function validateMessages(array $rawMessages): ?array
526435 return $ validated ;
527436 }
528437
529- /**
530- * Clamp a float value to a range.
531- */
532- private function clampFloat (float $ value , float $ min , float $ max ): float
533- {
534- return max ($ min , min ($ max , $ value ));
535- }
536-
537- /**
538- * Clamp an integer value to a range.
539- */
540- private function clampInt (int $ value , int $ min , int $ max ): int
541- {
542- return max ($ min , min ($ max , $ value ));
543- }
544-
545438 /**
546439 * Check rate limit for current backend user.
547440 */
0 commit comments