Update LLM Classification (#482)

* Add better logging with API finish_reason
* Option for disabling allow_thinking
This commit is contained in:
Alexandre Alapetite authored and GitHub committed 2026-06-29 15:01:38 +02:00
1 parent 93fa005ad6
commit b7a8fa3366
5 files changed
+54 -29

No files matched your search

@@ -132,6 +132,15 @@
</div>
</div>
<div class="form-group">
<label class="group-name" for="allow_thinking"><?= _t('ext.llm_classification.api.allow_thinking') ?></label>
<div class="group-controls">
<input type="checkbox" name="allow_thinking" id="allow_thinking" value="1" <?=
$this->getUserConfigurationBool('allow_thinking') ? 'checked' : '' ?> />
<p class="help"><?= _t('ext.llm_classification.api.allow_thinking_help') ?></p>
</div>
</div>
<div class="form-group form-actions">
<div class="group-controls">
<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
+38 -26
View File
@@ -57,6 +57,9 @@ final class LlmClassificationExtension extends Minz_Extension {
if ($this->getUserConfigurationString('search_filter') === null) {
$this->setUserConfigurationValue('search_filter', '');
}
if ($this->getUserConfigurationBool('allow_thinking') === null) {
$this->setUserConfigurationValue('allow_thinking', true);
}
}
#[\Override]
@@ -67,28 +70,20 @@ final class LlmClassificationExtension extends Minz_Extension {
$apiUrl = trim(Minz_Request::paramString('api_url', plaintext: true));
$apiUrl = preg_replace('#/chat/completions/?$#i', '', $apiUrl) ?? $apiUrl;
$this->setUserConfigurationValue('api_url', $apiUrl);
$this->setUserConfigurationValue('api_key',
trim(Minz_Request::paramString('api_key', plaintext: true)));
$this->setUserConfigurationValue('api_key', trim(Minz_Request::paramString('api_key', plaintext: true)));
$this->setUserConfigurationValue('model', trim(Minz_Request::paramString('model', plaintext: true)));
$userPrompt = trim(Minz_Request::paramString('user_prompt', plaintext: true))
?: _t('ext.llm_classification.default_prompt');
$this->saveFile(self::PROMPT_FILENAME, $userPrompt);
$this->setUserConfigurationValue('max_content_length', Minz_Request::paramInt('max_content_length'));
$this->setUserConfigurationValue('timeout',
Minz_Request::paramInt('timeout') ?: self::DEFAULT_TIMEOUT);
$this->setUserConfigurationValue('timeout', Minz_Request::paramInt('timeout') ?: self::DEFAULT_TIMEOUT);
$this->setUserConfigurationValue('max_tokens', Minz_Request::paramInt('max_tokens'));
$this->setUserConfigurationValue('max_retries',
max(0, min(5, Minz_Request::paramInt('max_retries'))));
$this->setUserConfigurationValue('enable_tags',
Minz_Request::paramBoolean('enable_tags'));
$this->setUserConfigurationValue('tag_prefix',
trim(Minz_Request::paramString('tag_prefix', plaintext: true)));
$this->setUserConfigurationValue('allowed_tags',
trim(Minz_Request::paramString('allowed_tags', plaintext: true)));
$this->setUserConfigurationValue('search_filter',
trim(Minz_Request::paramString('search_filter', plaintext: true)));
$this->setUserConfigurationValue('max_retries', max(0, min(5, Minz_Request::paramInt('max_retries'))));
$this->setUserConfigurationValue('enable_tags', Minz_Request::paramBoolean('enable_tags'));
$this->setUserConfigurationValue('tag_prefix', trim(Minz_Request::paramString('tag_prefix', plaintext: true)));
$this->setUserConfigurationValue('allowed_tags', trim(Minz_Request::paramString('allowed_tags', plaintext: true)));
$this->setUserConfigurationValue('search_filter', trim(Minz_Request::paramString('search_filter', plaintext: true)));
$this->setUserConfigurationValue('allow_thinking', Minz_Request::paramBoolean('allow_thinking'));
}
$this->user_prompt = '';
@@ -254,6 +249,13 @@ final class LlmClassificationExtension extends Minz_Extension {
$body['max_completion_tokens'] = $maxTokens;
}
$allowThinking = $this->getUserConfigurationBool('allow_thinking') ?? true;
if (!$allowThinking) {
$body['chat_template_kwargs'] = [
'enable_thinking' => false,
];
}
$requestBody = json_encode($body, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($requestBody === false) {
@@ -269,10 +271,11 @@ final class LlmClassificationExtension extends Minz_Extension {
$headers[] = 'Authorization: Bearer ' . $apiKey;
}
$cachePath = CACHE_PATH . '/llm_classification_' . sha1($apiUrl . $requestBody) . '.json';
$cachePath = CACHE_PATH . '/llm_classification_' . sha1($apiUrl . $apiKey . $model . $requestBody) . '.json';
$maxRetries = $this->getUserConfigurationInt('max_retries') ?? self::DEFAULT_MAX_RETRIES;
$response = null;
$httpStatus = 0;
for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
$response = FreshRSS_http_Util::httpGet($url, $cachePath, type: 'json', curl_options: [
@@ -283,13 +286,16 @@ final class LlmClassificationExtension extends Minz_Extension {
CURLOPT_TIMEOUT => $timeout,
]);
$httpStatus = $response['status'] ?? null;
$httpStatus = is_numeric($httpStatus) ? (int)$httpStatus : 0;
if (!($response['fail'] ?? false) && ($response['body'] ?? '') !== '') {
break; // Success
}
if ($attempt < $maxRetries && self::isRetryableFailure($response)) {
$delay = (int)pow(2, $attempt); // Exponential backoff: 1s, 2s, 4s...
Minz_Log::warning('LlmClassification: API call failed (HTTP ' . ($response['status'] ?? 0)
Minz_Log::warning('LlmClassification: API call failed (HTTP ' . $httpStatus
. (($response['error'] ?? '') !== '' ? '; ' . ($response['error'] ?? '') : '')
. '), retry ' . ($attempt + 1) . '/' . $maxRetries . ' after ' . $delay . 's');
sleep($delay);
@@ -298,32 +304,38 @@ final class LlmClassificationExtension extends Minz_Extension {
}
Minz_Log::warning('LlmClassification: API call failed for ' . $url
. ' (HTTP ' . ($response['status'] ?? 0)
. ' (HTTP ' . $httpStatus
. (($response['error'] ?? '') !== '' ? '; ' . ($response['error'] ?? '') : '')
. '), not retrying');
return null;
}
if ($response === null || ($response['fail'] ?? false) || !is_string($response['body'] ?? null) || ($response['body'] ?? '') === '') {
Minz_Log::warning('LlmClassification: API call failed after ' . (1 + $maxRetries) . ' attempt(s) for ' . $url);
Minz_Log::warning('LlmClassification: API call failed after ' .
(1 + $maxRetries) . ' attempt(s) for ' . $url . ' (HTTP ' . $httpStatus . ')');
return null;
}
$responseData = json_decode($response['body'], true);
if (!is_array($responseData)) {
Minz_Log::warning('LlmClassification: Invalid JSON response from API');
Minz_Log::warning('LlmClassification: Invalid JSON response from API! (HTTP ' . $httpStatus . ')');
return null;
}
$choices = $responseData['choices'] ?? null;
$content = is_array($choices) && is_array($choices[0] ?? null) && is_array($choices[0]['message'] ?? null)
? ($choices[0]['message']['content'] ?? null)
: null;
$choice = is_array($choices) && is_array($choices[0] ?? null) ? $choices[0] : null;
$content = is_array($choice) && is_array($choice['message'] ?? null) ? ($choice['message']['content'] ?? null) : null;
if (!is_string($content)) {
Minz_Log::warning('LlmClassification: Missing choices[0].message.content in API response');
Minz_Log::warning('LlmClassification: Missing `choices[0].message.content` in API response! (HTTP ' . $httpStatus . ')');
return null;
}
$finishReason = is_array($choice) ? ($choice['finish_reason'] ?? 'stop') : null;
if ($finishReason !== 'stop') {
$finishReason = is_scalar($finishReason) ? (string)$finishReason : '?';
Minz_Log::warning('LlmClassification: API terminated prematurely with finish_reason “' . $finishReason . '”! (HTTP ' . $httpStatus . ')');
}
$classification = json_decode($content, true);
if (is_array($classification) && is_array($classification['tags'] ?? null)) {
return [
@@ -331,7 +343,7 @@ final class LlmClassificationExtension extends Minz_Extension {
];
}
Minz_Log::warning('LlmClassification: LLM returned invalid JSON: ' . $content);
Minz_Log::warning('LlmClassification: LLM returned invalid JSON structure! `' . ($response['body'] ?? '') . '` (HTTP ' . $httpStatus . ')');
return null;
}
@@ -15,6 +15,8 @@ return array(
'max_tokens_help' => 'Maximum number of tokens the LLM may generate (0 = provider default)',
'max_retries' => 'Max retries',
'max_retries_help' => 'Number of retry attempts on transient errors (timeouts, invalid response, 500). 0 = no retry.',
'allow_thinking' => 'Allow thinking mode<br />(if available)',
'allow_thinking_help' => 'Allow thinking mode (<i>reasoning</i>) for models that support it. Uncheck to disable, e.g. to make output length shorter.',
),
'prompts' => array(
'title' => 'Prompts',
+4 -2
View File
@@ -11,10 +11,12 @@ return array(
'model' => 'Modèle',
'model_help' => 'Nom du modèle (ex. gpt-4o-mini)',
'timeout' => 'Délai d’attente (secondes)',
'max_tokens' => 'Tokens max. de complétion',
'max_tokens' => 'Limite de tokens',
'max_tokens_help' => 'Nombre maximum de tokens que le LLM peut générer (0 = valeur par défaut du fournisseur)',
'max_retries' => 'Tentatives max.',
'max_retries' => 'Tentatives',
'max_retries_help' => 'Nombre de nouvelles tentatives en cas d’erreur transitoire (délai dépassé, réponse invalide, 500). 0 = pas de nouvelle tentative.',
'allow_thinking' => 'Autoriser le mode raisonnement<br />(si disponible)',
'allow_thinking_help' => 'Autoriser le mode raisonnement (<i>thinking, reasoning</i>) pour les modèles qui le supportent. Décocher pour désactiver, par ex. pour réduire la longueur de la réponse.',
),
'prompts' => array(
'title' => 'Invites',
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "LLM Classification",
"author": "Alkarex",
"description": "Tag incoming articles by calling an OpenAI-compatible LLM API",
"version": "0.1",
"version": "0.2",
"entrypoint": "LlmClassification",
"type": "user",
"compatibility": "1.28.2"