diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aebc5ae
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+vendor/
+composer.lock
+tests/fixtures/current_response.json
diff --git a/.phpunit.result.cache b/.phpunit.result.cache
new file mode 100644
index 0000000..e373aa6
--- /dev/null
+++ b/.phpunit.result.cache
@@ -0,0 +1 @@
+{"version":2,"defects":{"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDisabled":5,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDescriptionEmpty":5},"times":{"OpenAICompatibleTest::testGetInfoReturnsRequiredKeys":0.001,"OpenAICompatibleTest::testGetConfParamsReturnsAllFields":0,"OpenAICompatibleTest::testGetConfFieldTypes":0,"OpenAICompatibleTest::testGenerateTagsMissingEndpointThrowsException":0,"OpenAICompatibleTest::testGenerateTagsMissingModelThrowsException":0,"OpenAICompatibleTest::testGenerateTagsThrowsWhenImageFileUnreadable":0.001,"OpenAICompatibleTest::testGenerateTagsWithValidJsonResponse":0.001,"OpenAICompatibleTest::testGenerateTagsWithMarkdownFencedJson":0,"OpenAICompatibleTest::testGenerateTagsWithUnlabelledMarkdownFence":0,"OpenAICompatibleTest::testGenerateTagsReturnsEmptyArrayWhenTagsKeyMissing":0,"OpenAICompatibleTest::testGenerateTagsRespectsLimit":0,"OpenAICompatibleTest::testGenerateTagsWritesDescriptionToDatabase":0.001,"OpenAICompatibleTest::testGenerateTagsDescriptionUsesCorrectImageId":0,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDisabled":0,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDescriptionEmpty":0,"OpenAICompatibleTest::testGenerateTagsWithFreeTextFallback":0,"OpenAICompatibleTest::testFreeTextFallbackFiltersLongSentences":0,"OpenAICompatibleTest::testFreeTextFallbackFiltersEntryLongerThan50Chars":0,"OpenAICompatibleTest::testFreeTextFallbackRespectsLimit":0,"OpenAICompatibleTest::testFreeTextFallbackWritesRawContentAsDescription":0,"OpenAICompatibleTest::testGenerateTagsThrowsOnMalformedApiResponse":0,"OpenAICompatibleTest::testGenerateTagsThrowsOnConnectionError":0}}
\ No newline at end of file
diff --git a/README.md b/README.md
index 8e0074b..12cbfdb 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,50 @@ Generate and automatically apply tags to a bunch of image on the batch manager.
## Available APIs
- [Imagga](https://imagga.com)
-- **New** [Microsft Azure](https://azure.microsoft.com/fr-fr/services/cognitive-services/computer-vision/) (you'll have to create an application on Azure)
+- [Microsoft Azure Computer Vision](https://azure.microsoft.com/fr-fr/services/cognitive-services/computer-vision/) (you'll have to create an application on Azure)
+- **New** OpenAI-compatible self-hosted endpoint (llama.cpp, Ollama, vLLM, LiteLLM, OpenAI, etc.)
## Usage
* Install the plugin on your Piwigo
-* Create an Imagga Account on https://imagga.com/auth/signup (or [here](https://azure.microsoft.com/fr-fr/free/cognitive-services/) for Microsoft Azure)
-* Enter your api token and api secret on the plugin configuration page
+* Create an Imagga Account on https://imagga.com/auth/signup (or [here](https://azure.microsoft.com/fr-fr/free/cognitive-services/) for Microsoft Azure), or set up a self-hosted vision model (see below)
+* Enter your API credentials on the plugin configuration page
* Go to an image modification page and click on the little robot next to the tags input
* Generate tags, select the ones you want and apply them
+### Self-hosted / OpenAI-compatible backend
+
+The **OpenAI-compatible** provider works with any server that implements the `/v1/chat/completions` endpoint with vision support. Common options:
+
+| Server | Example base URL |
+|--------|-----------------|
+| [Ollama](https://ollama.com) | `http://localhost:11434` |
+| [llama.cpp](https://github.com/ggerganov/llama.cpp) | `http://localhost:8080` |
+| [vLLM](https://github.com/vllm-project/vllm) | `http://localhost:8000` |
+| [LiteLLM](https://github.com/BerriAI/litellm) | `http://localhost:4000` |
+| OpenAI | `https://api.openai.com` |
+
+**Configuration fields:**
+
+| Field | Description |
+|-------|-------------|
+| API Base URL | Base URL of the server (the plugin appends `/v1/chat/completions`) |
+| API Key | Bearer token — leave empty or set to `none` for local servers that don't require auth |
+| Model Name | The vision-capable model to use (e.g. `llava`, `gpt-4o`, `llava-llama3`) |
+| Max Tokens | Maximum tokens in the model's response (default 300) |
+| Custom Prompt | Override the default JSON prompt. Leave empty to use the built-in prompt. |
+| Write description as photo comment | When checked, the model's image description is also saved to the photo's comment field. |
+
+The plugin sends the image as a base64-encoded `image_url` content block, which is standard across all OpenAI-compatible vision APIs.
+
+**Example with Ollama:**
+```
+# Pull a vision model
+ollama pull llava
+
+# Start Ollama (it listens on port 11434 by default)
+ollama serve
+```
+Then set API Base URL to `http://localhost:11434` and Model Name to `llava`.
+
### Warning
-As this plugin use an external API, we cannot assure you that your data will not be used or sold. I recommend you to check the data policy of each external API you use with this plugin.
\ No newline at end of file
+As this plugin uses an external API, we cannot assure you that your data will not be used or sold. I recommend you to check the data policy of each external API you use with this plugin. Using a self-hosted model keeps all image data on your own infrastructure.
\ No newline at end of file
diff --git a/admin.php b/admin.php
index 360ffea..4ad7b1a 100644
--- a/admin.php
+++ b/admin.php
@@ -24,35 +24,44 @@
$newConf->setSelectedAPI($_POST['api']);
}
- $apiObject = tr_getAPI($_POST['api']);
+ $apiObject = tr_getAPI($_POST['api']);
+ $fieldTypes = $apiObject->getConfFieldTypes();
foreach ($apiObject->getConfParams() as $key => $value) {
- $newConf->setParam($_POST['api'], $key, trim($_POST[$key]));
+ $type = isset($fieldTypes[$key]) ? $fieldTypes[$key] : 'text';
+ if ($type === 'checkbox') {
+ $newConf->setParam($_POST['api'], $key, isset($_POST[$key]) ? '1' : '0');
+ } else {
+ $newConf->setParam($_POST['api'], $key, trim($_POST[$key] ?? ''));
+ }
}
tr_setConf($newConf);
}
}
-$tr_api_info = [];
-$tr_api_params = [];
-$tr_api_conf = [];
+$tr_api_info = [];
+$tr_api_params = [];
+$tr_api_conf = [];
+$tr_api_field_types = [];
foreach (TR_API_LIST as $apiName) {
$apiObject = tr_getAPI($apiName);
- $tr_api_info[$apiName] = $apiObject->getInfo();
- $tr_api_params[$apiName] = $apiObject->getConfParams();
- $tr_api_conf[$apiName] = tr_getConf()->getConf($apiName);
+ $tr_api_info[$apiName] = $apiObject->getInfo();
+ $tr_api_params[$apiName] = $apiObject->getConfParams();
+ $tr_api_conf[$apiName] = tr_getConf()->getConf($apiName);
+ $tr_api_field_types[$apiName] = $apiObject->getConfFieldTypes();
}
$template->assign(array(
- 'TR_PATH' => TR_PATH,
- 'TR_API_LIST' => TR_API_LIST,
- 'TR_API_INFO' => $tr_api_info,
- 'TR_API_PARAMS' => $tr_api_params,
- 'TR_API_CONF' => $tr_api_conf,
- 'TR_API_SELECTED' => tr_getConf()->getSelectedAPI(),
- 'PWG_TOKEN' => get_pwg_token()
+ 'TR_PATH' => TR_PATH,
+ 'TR_API_LIST' => TR_API_LIST,
+ 'TR_API_INFO' => $tr_api_info,
+ 'TR_API_PARAMS' => $tr_api_params,
+ 'TR_API_CONF' => $tr_api_conf,
+ 'TR_API_FIELD_TYPES' => $tr_api_field_types,
+ 'TR_API_SELECTED' => tr_getConf()->getSelectedAPI(),
+ 'PWG_TOKEN' => get_pwg_token()
));
$template->set_filename('plugin_admin_content', realpath(TR_PATH . 'template/admin.tpl'));
diff --git a/api_classes/OpenAICompatible.php b/api_classes/OpenAICompatible.php
new file mode 100644
index 0000000..7a31c4a
--- /dev/null
+++ b/api_classes/OpenAICompatible.php
@@ -0,0 +1,174 @@
+ '',
+ "site" => 'https://platform.openai.com/docs/api-reference/chat',
+ "info" => 'Self-hosted OpenAI-compatible vision API. Works with any server that implements the /v1/chat/completions endpoint, including llama.cpp, Ollama, vLLM, LiteLLM, and OpenAI itself. Requires a vision-capable model.',
+ ];
+ }
+
+ function getConfParams() : array
+ {
+ return [
+ 'ENDPOINT' => 'API Base URL',
+ 'API_KEY' => 'API Key (optional)',
+ 'MODEL' => 'Model Name',
+ 'MAX_TOKENS' => 'Max Tokens',
+ 'PROMPT' => 'Custom Prompt (optional)',
+ 'WRITE_DESCRIPTION' => 'Write description as photo comment',
+ ];
+ }
+
+ function getConfFieldTypes() : array
+ {
+ return [
+ 'PROMPT' => 'textarea',
+ 'WRITE_DESCRIPTION' => 'checkbox',
+ ];
+ }
+
+ function generateTags($conf, $params) : array
+ {
+ $file_path = $this->getFileName($params['imageId']);
+
+ if (empty($conf['ENDPOINT']) || empty($conf['MODEL']))
+ throw new Exception('API parameters are not set');
+
+ // getFileName() returns a derivative URL or path. Try to resolve it to a
+ // filesystem path so file_get_contents() works without an HTTP round-trip.
+ $fs_path = $file_path;
+ if (defined('PHPWG_ROOT_PATH') && !preg_match('/^https?:\/\//', $file_path)) {
+ $candidate = realpath(PHPWG_ROOT_PATH . $file_path);
+ if ($candidate !== false && file_exists($candidate)) {
+ $fs_path = $candidate;
+ }
+ }
+
+ $pathinfo = pathinfo($fs_path);
+ $mime_types = [
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'webp' => 'image/webp',
+ 'gif' => 'image/gif',
+ ];
+ $ext = strtolower($pathinfo['extension'] ?? 'jpg');
+ $mime_content_type = $mime_types[$ext] ?? 'image/jpeg';
+
+ $data = @file_get_contents($fs_path);
+ if ($data === false)
+ throw new Exception('Cannot read image file: ' . $fs_path);
+
+ $base64 = base64_encode($data);
+ $dataUri = 'data:' . $mime_content_type . ';base64,' . $base64;
+
+ $limit = (int)($params['limit'] ?? 20);
+ $prompt = !empty($conf['PROMPT'])
+ ? $conf['PROMPT']
+ : 'Analyze this image and respond with a JSON object containing two keys: "description" (a 2-3 sentence description of the image) and "tags" (an array of up to ' . $limit . ' relevant keyword tags). Respond with only the JSON object, no markdown or extra text.';
+
+ $max_tokens = !empty($conf['MAX_TOKENS']) ? (int)$conf['MAX_TOKENS'] : 300;
+
+ $payload = [
+ 'model' => $conf['MODEL'],
+ 'max_tokens' => $max_tokens,
+ 'messages' => [
+ [
+ 'role' => 'user',
+ 'content' => [
+ [
+ 'type' => 'text',
+ 'text' => $prompt,
+ ],
+ [
+ 'type' => 'image_url',
+ 'image_url' => ['url' => $dataUri],
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $endpoint = rtrim($conf['ENDPOINT'], '/') . '/v1/chat/completions';
+
+ $api_key = !empty($conf['API_KEY']) ? $conf['API_KEY'] : 'none';
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $endpoint);
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
+ // 120s is enough for most local models; increase for large models on slow hardware
+ curl_setopt($ch, CURLOPT_TIMEOUT, 120);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ 'Content-Type: application/json',
+ 'Authorization: Bearer ' . $api_key,
+ ]);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
+
+ $response = curl_exec($ch);
+
+ if (curl_errno($ch)) {
+ $error = curl_error($ch);
+ curl_close($ch);
+ throw new Exception('Connection error: ' . $error);
+ }
+ curl_close($ch);
+
+ $json_response = json_decode($response);
+
+ if (!isset($json_response->choices[0]->message->content))
+ throw new Exception('API Error: ' . $response);
+
+ $content = $json_response->choices[0]->message->content;
+
+ // Strip markdown code fences that some models add
+ $content = preg_replace('/^```(?:json)?\s*/m', '', $content);
+ $content = preg_replace('/\s*```\s*$/m', '', $content);
+ $content = trim($content);
+
+ $parsed = json_decode($content, true);
+
+ $tags = [];
+ $description = '';
+
+ if (is_array($parsed)) {
+ if (!empty($parsed['tags']) && is_array($parsed['tags'])) {
+ $tags = array_slice(array_values($parsed['tags']), 0, $limit);
+ }
+ if (!empty($parsed['description']) && is_string($parsed['description'])) {
+ $description = $parsed['description'];
+ }
+ } else {
+ // Fallback for models that return free text instead of JSON.
+ // Use the raw content as the description and try to split it into tags.
+ $description = $content;
+
+ $candidates = preg_split('/[\n,]+/', $content);
+ foreach ($candidates as $candidate) {
+ $candidate = trim($candidate);
+ // Skip empty strings, sentences (> 5 words), and overly long entries
+ if ($candidate === '') continue;
+ if (str_word_count($candidate) > 5) continue;
+ if (strlen($candidate) > 50) continue;
+ $tags[] = $candidate;
+ if (count($tags) >= $limit) break;
+ }
+ }
+
+ if (!empty($conf['WRITE_DESCRIPTION']) && $conf['WRITE_DESCRIPTION'] === '1' && !empty($description)) {
+ $query = '
+UPDATE ' . IMAGES_TABLE . '
+ SET comment = \'' . pwg_db_real_escape_string($description) . '\'
+ WHERE id = ' . ((int)$params['imageId']) . '
+;';
+ pwg_query($query);
+ }
+
+ return $tags;
+ }
+}
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..edeec3b
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,14 @@
+{
+ "name": "piwigo/tag-recognition",
+ "description": "Piwigo plugin to suggest tags on images using external APIs",
+ "type": "piwigo-plugin",
+ "license": "MIT",
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "config": {
+ "platform": {
+ "php": "8.3"
+ }
+ }
+}
diff --git a/include/api_types.php b/include/api_types.php
index df64123..bbb7315 100644
--- a/include/api_types.php
+++ b/include/api_types.php
@@ -1,6 +1,6 @@
+
+
+
+ tests
+
+
+
diff --git a/template/admin.tpl b/template/admin.tpl
index f04717d..f5eb846 100644
--- a/template/admin.tpl
+++ b/template/admin.tpl
@@ -26,9 +26,23 @@
{foreach from=$TR_API_PARAMS[$apiName] item=label key=param}
+ {assign var=field_type value='text'}
+ {if isset($TR_API_FIELD_TYPES[$apiName][$param])}
+ {assign var=field_type value=$TR_API_FIELD_TYPES[$apiName][$param]}
+ {/if}
-
-
+ {if $field_type eq 'checkbox'}
+
+ {elseif $field_type eq 'textarea'}
+
+
+ {else}
+
+
+ {/if}
{/foreach}
diff --git a/tests/OpenAICompatibleTest.php b/tests/OpenAICompatibleTest.php
new file mode 100644
index 0000000..484bf03
--- /dev/null
+++ b/tests/OpenAICompatibleTest.php
@@ -0,0 +1,481 @@
+testFilePath;
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+class OpenAICompatibleTest extends TestCase
+{
+ private TestableOpenAICompatible $api;
+ private string $fixtureFile;
+ private string $serverBase;
+
+ protected function setUp(): void
+ {
+ $GLOBALS['pwg_queries'] = [];
+
+ $this->api = new TestableOpenAICompatible();
+ $this->api->testFilePath = __DIR__ . '/fixtures/test_image.jpg';
+ $this->fixtureFile = __DIR__ . '/fixtures/current_response.json';
+ $this->serverBase = 'http://127.0.0.1:' . MOCK_SERVER_PORT;
+ }
+
+ protected function tearDown(): void
+ {
+ if (file_exists($this->fixtureFile)) {
+ unlink($this->fixtureFile);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Internal helpers
+ // -----------------------------------------------------------------------
+
+ /** Skip the test if the mock HTTP server failed to start. */
+ private function requireServer(): void
+ {
+ if (empty($GLOBALS['mock_server_available'])) {
+ $this->markTestSkipped('Mock HTTP server could not be started on port ' . MOCK_SERVER_PORT . '.');
+ }
+ }
+
+ /**
+ * Build a minimal valid OpenAI chat-completion response envelope.
+ * $content is the raw string placed in choices[0].message.content.
+ */
+ private function openAIEnvelope(string $content): array
+ {
+ return [
+ 'id' => 'chatcmpl-test',
+ 'object' => 'chat.completion',
+ 'choices' => [
+ [
+ 'index' => 0,
+ 'message' => [
+ 'role' => 'assistant',
+ 'content' => $content,
+ ],
+ 'finish_reason' => 'stop',
+ ],
+ ],
+ ];
+ }
+
+ /** Write $data as JSON to the fixture file the mock server will return. */
+ private function setMockResponse(array $data): void
+ {
+ file_put_contents($this->fixtureFile, json_encode($data));
+ }
+
+ /**
+ * Build a full conf array, overriding individual keys as needed.
+ * Defaults point at the local mock server.
+ */
+ private function conf(array $overrides = []): array
+ {
+ return array_merge([
+ 'ENDPOINT' => $this->serverBase,
+ 'API_KEY' => 'test-key',
+ 'MODEL' => 'test-model',
+ 'MAX_TOKENS' => '100',
+ 'PROMPT' => '',
+ 'WRITE_DESCRIPTION' => '0',
+ ], $overrides);
+ }
+
+ /** Build a params array, overriding individual keys as needed. */
+ private function params(array $overrides = []): array
+ {
+ return array_merge([
+ 'imageId' => 42,
+ 'language' => 'en',
+ 'limit' => 20,
+ ], $overrides);
+ }
+
+ // -----------------------------------------------------------------------
+ // Metadata tests — no HTTP call, no curl needed
+ // -----------------------------------------------------------------------
+
+ public function testGetInfoReturnsRequiredKeys(): void
+ {
+ $info = (new OpenAICompatible())->getInfo();
+
+ $this->assertIsArray($info);
+ $this->assertArrayHasKey('icon', $info);
+ $this->assertArrayHasKey('site', $info);
+ $this->assertArrayHasKey('info', $info);
+
+ // site must be a non-empty string (used as href in the template)
+ $this->assertIsString($info['site']);
+ $this->assertNotEmpty($info['site']);
+
+ // info must be a plain string (not null, not a shell execution result)
+ $this->assertIsString($info['info']);
+ $this->assertNotEmpty($info['info']);
+ }
+
+ public function testGetConfParamsReturnsAllFields(): void
+ {
+ $params = (new OpenAICompatible())->getConfParams();
+
+ $this->assertIsArray($params);
+
+ $expected = ['ENDPOINT', 'API_KEY', 'MODEL', 'MAX_TOKENS', 'PROMPT', 'WRITE_DESCRIPTION'];
+ foreach ($expected as $key) {
+ $this->assertArrayHasKey($key, $params, "Missing config param: $key");
+ $this->assertIsString($params[$key], "Label for $key must be a string");
+ $this->assertNotEmpty($params[$key], "Label for $key must not be empty");
+ }
+
+ $this->assertCount(6, $params);
+ }
+
+ public function testGetConfFieldTypes(): void
+ {
+ $types = (new OpenAICompatible())->getConfFieldTypes();
+
+ $this->assertIsArray($types);
+
+ $this->assertArrayHasKey('PROMPT', $types);
+ $this->assertArrayHasKey('WRITE_DESCRIPTION', $types);
+ $this->assertSame('textarea', $types['PROMPT']);
+ $this->assertSame('checkbox', $types['WRITE_DESCRIPTION']);
+
+ // Fields that are plain text inputs must NOT appear in the type map
+ $this->assertArrayNotHasKey('ENDPOINT', $types);
+ $this->assertArrayNotHasKey('API_KEY', $types);
+ $this->assertArrayNotHasKey('MODEL', $types);
+ $this->assertArrayNotHasKey('MAX_TOKENS', $types);
+ }
+
+ // -----------------------------------------------------------------------
+ // Config-validation exceptions — fired before any HTTP call
+ // -----------------------------------------------------------------------
+
+ public function testGenerateTagsMissingEndpointThrowsException(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('API parameters are not set');
+
+ $this->api->generateTags($this->conf(['ENDPOINT' => '']), $this->params());
+ }
+
+ public function testGenerateTagsMissingModelThrowsException(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('API parameters are not set');
+
+ $this->api->generateTags($this->conf(['MODEL' => '']), $this->params());
+ }
+
+ public function testGenerateTagsThrowsWhenImageFileUnreadable(): void
+ {
+ $this->api->testFilePath = '/nonexistent/path/no_such_image.jpg';
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Cannot read image file');
+
+ $this->api->generateTags($this->conf(), $this->params());
+ }
+
+ // -----------------------------------------------------------------------
+ // Happy-path tag parsing — requires mock server
+ // -----------------------------------------------------------------------
+
+ public function testGenerateTagsWithValidJsonResponse(): void
+ {
+ $this->requireServer();
+
+ $this->setMockResponse($this->openAIEnvelope(json_encode([
+ 'description' => 'A beach scene at sunset.',
+ 'tags' => ['beach', 'ocean', 'sunset'],
+ ])));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params());
+
+ $this->assertSame(['beach', 'ocean', 'sunset'], $tags);
+ }
+
+ public function testGenerateTagsWithMarkdownFencedJson(): void
+ {
+ $this->requireServer();
+
+ $fenced = "```json\n" . json_encode([
+ 'description' => 'A mountain landscape.',
+ 'tags' => ['mountain', 'landscape', 'snow', 'sky'],
+ ]) . "\n```";
+
+ $this->setMockResponse($this->openAIEnvelope($fenced));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params());
+
+ $this->assertSame(['mountain', 'landscape', 'snow', 'sky'], $tags);
+ }
+
+ public function testGenerateTagsWithUnlabelledMarkdownFence(): void
+ {
+ $this->requireServer();
+
+ // Some models omit the "json" hint after the backticks
+ $fenced = "```\n" . json_encode([
+ 'description' => 'A city skyline.',
+ 'tags' => ['city', 'skyline', 'night'],
+ ]) . "\n```";
+
+ $this->setMockResponse($this->openAIEnvelope($fenced));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params());
+
+ $this->assertSame(['city', 'skyline', 'night'], $tags);
+ }
+
+ public function testGenerateTagsReturnsEmptyArrayWhenTagsKeyMissing(): void
+ {
+ $this->requireServer();
+
+ // Valid JSON but no "tags" key — should return [] without throwing
+ $this->setMockResponse($this->openAIEnvelope(json_encode([
+ 'description' => 'No tags provided.',
+ ])));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params());
+
+ $this->assertSame([], $tags);
+ }
+
+ // -----------------------------------------------------------------------
+ // limit enforcement
+ // -----------------------------------------------------------------------
+
+ public function testGenerateTagsRespectsLimit(): void
+ {
+ $this->requireServer();
+
+ $this->setMockResponse($this->openAIEnvelope(json_encode([
+ 'description' => 'Many tags.',
+ 'tags' => ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'],
+ ])));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params(['limit' => 3]));
+
+ $this->assertCount(3, $tags);
+ $this->assertSame(['a', 'b', 'c'], $tags);
+ }
+
+ // -----------------------------------------------------------------------
+ // WRITE_DESCRIPTION feature
+ // -----------------------------------------------------------------------
+
+ public function testGenerateTagsWritesDescriptionToDatabase(): void
+ {
+ $this->requireServer();
+
+ $this->setMockResponse($this->openAIEnvelope(json_encode([
+ 'description' => 'A serene forest in autumn.',
+ 'tags' => ['forest', 'autumn'],
+ ])));
+
+ $this->api->generateTags(
+ $this->conf(['WRITE_DESCRIPTION' => '1']),
+ $this->params(['imageId' => 99])
+ );
+
+ $this->assertNotEmpty($GLOBALS['pwg_queries'], 'Expected an UPDATE query to be executed');
+
+ $lastQuery = end($GLOBALS['pwg_queries']);
+ $this->assertStringContainsString('UPDATE', $lastQuery);
+ $this->assertStringContainsString('comment', $lastQuery);
+ $this->assertStringContainsString('A serene forest in autumn.', $lastQuery);
+ $this->assertStringContainsString('99', $lastQuery);
+ }
+
+ public function testGenerateTagsDescriptionUsesCorrectImageId(): void
+ {
+ $this->requireServer();
+
+ $this->setMockResponse($this->openAIEnvelope(json_encode([
+ 'description' => 'Test.',
+ 'tags' => ['test'],
+ ])));
+
+ $this->api->generateTags(
+ $this->conf(['WRITE_DESCRIPTION' => '1']),
+ $this->params(['imageId' => 777])
+ );
+
+ $updateQuery = end($GLOBALS['pwg_queries']);
+ $this->assertStringContainsString('WHERE id = 777', $updateQuery);
+ }
+
+ public function testGenerateTagsDoesNotWriteDescriptionWhenDisabled(): void
+ {
+ $this->requireServer();
+
+ $this->setMockResponse($this->openAIEnvelope(json_encode([
+ 'description' => 'This should not be saved.',
+ 'tags' => ['tag1'],
+ ])));
+
+ $this->api->generateTags(
+ $this->conf(['WRITE_DESCRIPTION' => '0']),
+ $this->params()
+ );
+
+ $updateQueries = array_filter(
+ $GLOBALS['pwg_queries'],
+ static fn(string $q): bool => str_contains(strtoupper($q), 'UPDATE')
+ );
+ $this->assertCount(0, $updateQueries, 'No UPDATE should be issued when WRITE_DESCRIPTION is disabled');
+ }
+
+ public function testGenerateTagsDoesNotWriteDescriptionWhenDescriptionEmpty(): void
+ {
+ $this->requireServer();
+
+ // JSON with no "description" key → description stays '' → no UPDATE
+ $this->setMockResponse($this->openAIEnvelope(json_encode([
+ 'tags' => ['only', 'tags'],
+ ])));
+
+ $this->api->generateTags(
+ $this->conf(['WRITE_DESCRIPTION' => '1']),
+ $this->params()
+ );
+
+ $updateQueries = array_filter(
+ $GLOBALS['pwg_queries'],
+ static fn(string $q): bool => str_contains(strtoupper($q), 'UPDATE')
+ );
+ $this->assertCount(0, $updateQueries, 'No UPDATE should fire when description is empty');
+ }
+
+ // -----------------------------------------------------------------------
+ // Free-text fallback (model ignores JSON instructions)
+ // -----------------------------------------------------------------------
+
+ public function testGenerateTagsWithFreeTextFallback(): void
+ {
+ $this->requireServer();
+
+ // Model returns plain comma-separated keywords instead of JSON
+ $this->setMockResponse($this->openAIEnvelope('cat, dog, pet, animal'));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params());
+
+ $this->assertNotEmpty($tags);
+ $this->assertContains('cat', $tags);
+ $this->assertContains('dog', $tags);
+ $this->assertContains('pet', $tags);
+ $this->assertContains('animal', $tags);
+ }
+
+ public function testFreeTextFallbackFiltersLongSentences(): void
+ {
+ $this->requireServer();
+
+ $this->setMockResponse($this->openAIEnvelope(
+ "sunset\nThis is a very long sentence that should be filtered out\nocean"
+ ));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params());
+
+ $this->assertContains('sunset', $tags);
+ $this->assertContains('ocean', $tags);
+ $this->assertNotContains(
+ 'This is a very long sentence that should be filtered out',
+ $tags
+ );
+ }
+
+ public function testFreeTextFallbackFiltersEntryLongerThan50Chars(): void
+ {
+ $this->requireServer();
+
+ $long = str_repeat('x', 51); // 51 chars, single word
+ $this->setMockResponse($this->openAIEnvelope("valid_tag\n{$long}\nanother_tag"));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params());
+
+ $this->assertContains('valid_tag', $tags);
+ $this->assertContains('another_tag', $tags);
+ $this->assertNotContains($long, $tags);
+ }
+
+ public function testFreeTextFallbackRespectsLimit(): void
+ {
+ $this->requireServer();
+
+ $this->setMockResponse($this->openAIEnvelope("alpha\nbeta\ngamma\ndelta\nepsilon"));
+
+ $tags = $this->api->generateTags($this->conf(), $this->params(['limit' => 2]));
+
+ $this->assertCount(2, $tags);
+ }
+
+ public function testFreeTextFallbackWritesRawContentAsDescription(): void
+ {
+ $this->requireServer();
+
+ $rawText = 'cat, dog, pet';
+ $this->setMockResponse($this->openAIEnvelope($rawText));
+
+ $this->api->generateTags(
+ $this->conf(['WRITE_DESCRIPTION' => '1']),
+ $this->params(['imageId' => 7])
+ );
+
+ $lastQuery = end($GLOBALS['pwg_queries']);
+ $this->assertNotFalse($lastQuery, 'Expected an UPDATE query for free-text description');
+ $this->assertStringContainsString('cat, dog, pet', $lastQuery);
+ }
+
+ // -----------------------------------------------------------------------
+ // Error-handling
+ // -----------------------------------------------------------------------
+
+ public function testGenerateTagsThrowsOnMalformedApiResponse(): void
+ {
+ $this->requireServer();
+
+ // Server returns a valid HTTP 200 but an error body (no choices key)
+ $this->setMockResponse([
+ 'error' => [
+ 'message' => 'model not found',
+ 'type' => 'invalid_request_error',
+ ],
+ ]);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('API Error');
+
+ $this->api->generateTags($this->conf(), $this->params());
+ }
+
+ public function testGenerateTagsThrowsOnConnectionError(): void
+ {
+ // Port 17891 has nothing listening → immediate ECONNREFUSED
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Connection error');
+
+ $this->api->generateTags(
+ $this->conf(['ENDPOINT' => 'http://127.0.0.1:17891']),
+ $this->params()
+ );
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..cd823f8
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,166 @@
+ 1, 'path' => 'test.jpg', 'representative_ext' => null];
+}
+
+function pwg_db_real_escape_string(string $str): string
+{
+ // Mirrors MySQL escaping closely enough for assertion purposes
+ return addslashes($str);
+}
+
+function query2array(string $query, $key = null, $col = null): array
+{
+ return [];
+}
+
+function conf_update_param(string $key, $value, bool $update = false): void
+{
+ $GLOBALS['pwg_conf'][$key] = $value;
+}
+
+function safe_unserialize(string $str)
+{
+ return unserialize($str);
+}
+
+function set_make_full_url(): void {}
+function unset_make_full_url(): void {}
+function fetchRemote(string $url, &$dest): void { $dest = ''; }
+
+// ---------------------------------------------------------------------------
+// 4. Piwigo class stubs required by API::getFileName()
+// TestableOpenAICompatible overrides getFileName() entirely, so these
+// stubs only need to satisfy the abstract class at load time.
+// ---------------------------------------------------------------------------
+
+class SrcImage
+{
+ public function __construct(array $info) {}
+}
+
+class DerivativeImage
+{
+ public static function url(string $type, SrcImage $src): string
+ {
+ // Must NOT start with "i" to avoid the cache-generation branch
+ return '_data/i/stub_derivative.jpg';
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 5. Tiny test-image fixture
+// Any readable file works; content is base64-encoded and sent to the mock
+// server which ignores it. The .jpg extension drives MIME-type selection.
+// ---------------------------------------------------------------------------
+
+$fixturesDir = __DIR__ . '/fixtures';
+$testImagePath = $fixturesDir . '/test_image.jpg';
+
+if (!is_dir($fixturesDir)) {
+ mkdir($fixturesDir, 0755, true);
+}
+
+if (!file_exists($testImagePath)) {
+ file_put_contents($testImagePath, 'FAKE_JPEG_CONTENT_FOR_TESTS');
+}
+
+// ---------------------------------------------------------------------------
+// 6. Start the mock HTTP server (PHP built-in server)
+// ---------------------------------------------------------------------------
+
+define('MOCK_SERVER_PORT', 17890);
+
+$GLOBALS['mock_server_proc'] = null;
+$GLOBALS['mock_server_available'] = false;
+
+$serverScript = __DIR__ . '/mock_api_server.php';
+
+$descriptors = [
+ 0 => ['pipe', 'r'],
+ 1 => ['file', '/dev/null', 'w'],
+ 2 => ['file', '/dev/null', 'w'],
+];
+
+$proc = proc_open(
+ PHP_BINARY . ' -S 127.0.0.1:' . MOCK_SERVER_PORT . ' ' . escapeshellarg($serverScript),
+ $descriptors,
+ $pipes
+);
+
+if (is_resource($proc)) {
+ $GLOBALS['mock_server_proc'] = $proc;
+ fclose($pipes[0]);
+
+ // Wait up to 3 s for the server to accept connections
+ $deadline = microtime(true) + 3.0;
+ while (microtime(true) < $deadline) {
+ $sock = @fsockopen('127.0.0.1', MOCK_SERVER_PORT, $errno, $errstr, 0.1);
+ if ($sock !== false) {
+ fclose($sock);
+ $GLOBALS['mock_server_available'] = true;
+ break;
+ }
+ usleep(50_000); // 50 ms
+ }
+}
+
+register_shutdown_function(static function (): void {
+ if (isset($GLOBALS['mock_server_proc']) && is_resource($GLOBALS['mock_server_proc'])) {
+ proc_terminate($GLOBALS['mock_server_proc']);
+ proc_close($GLOBALS['mock_server_proc']);
+ }
+ // Clean up the fixture file written per-test
+ $f = __DIR__ . '/fixtures/current_response.json';
+ if (file_exists($f)) {
+ unlink($f);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// 7. Load plugin source
+// api_types.php defines TR_API_LIST, the abstract API class, and
+// auto-includes all three API class files via TR_PATH.
+// ---------------------------------------------------------------------------
+
+include_once dirname(__DIR__) . '/include/api_types.php';
diff --git a/tests/fixtures/test_image.jpg b/tests/fixtures/test_image.jpg
new file mode 100644
index 0000000..b56eb66
--- /dev/null
+++ b/tests/fixtures/test_image.jpg
@@ -0,0 +1 @@
+FAKE_JPEG_CONTENT_FOR_TESTS
\ No newline at end of file
diff --git a/tests/live_test.php b/tests/live_test.php
new file mode 100644
index 0000000..cb84ae0
--- /dev/null
+++ b/tests/live_test.php
@@ -0,0 +1,485 @@
+tmpFiles as $f) {
+ if (file_exists($f)) {
+ unlink($f);
+ }
+ }
+ }
+
+ public function getFileName($imageId): string
+ {
+ $query = '
+SELECT path
+ FROM ' . IMAGES_TABLE . '
+ WHERE id = ' . ((int)$imageId) . '
+;';
+ $row = pwg_db_fetch_assoc(pwg_query($query));
+ if (!$row) {
+ throw new Exception("Image {$imageId} not found in database");
+ }
+
+ // DB path is like "./galleries/…" — resolve to absolute filesystem path
+ $abs = realpath(PHPWG_ROOT_PATH . ltrim($row['path'], './'));
+ if ($abs === false || !file_exists($abs)) {
+ throw new Exception('Image file not found on disk: ' . $row['path']);
+ }
+
+ return $this->maybeScale($abs);
+ }
+
+ /**
+ * If the image is larger than MAX_DIM on either axis, create a
+ * proportionally-scaled JPEG in /tmp and return its path.
+ * This mirrors what the plugin normally receives via the medium derivative.
+ */
+ private function maybeScale(string $path): string
+ {
+ $size = @getimagesize($path);
+ if ($size === false) {
+ return $path; // not an image we can inspect; pass through
+ }
+ [$w, $h] = $size;
+
+ if ($w <= self::MAX_DIM && $h <= self::MAX_DIM) {
+ return $path; // already small enough
+ }
+
+ $scale = self::MAX_DIM / max($w, $h);
+ $newW = (int)round($w * $scale);
+ $newH = (int)round($h * $scale);
+
+ $src = match($size[2]) {
+ IMAGETYPE_JPEG => imagecreatefromjpeg($path),
+ IMAGETYPE_PNG => imagecreatefrompng($path),
+ IMAGETYPE_WEBP => imagecreatefromwebp($path),
+ default => false,
+ };
+ if ($src === false) {
+ return $path; // unsupported type; pass through as-is
+ }
+
+ $dst = imagecreatetruecolor($newW, $newH);
+ imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $w, $h);
+ imagedestroy($src);
+
+ $tmp = tempnam(sys_get_temp_dir(), 'piwigo_live_') . '.jpg';
+ imagejpeg($dst, $tmp, 85);
+ imagedestroy($dst);
+
+ $this->tmpFiles[] = $tmp;
+ return $tmp;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 3. Pick a real image from the DB
+// ---------------------------------------------------------------------------
+
+hr('1 / 5 Selecting test image from Piwigo database');
+
+$query = '
+SELECT id, path, comment
+ FROM ' . IMAGES_TABLE . '
+ WHERE path IS NOT NULL
+ AND path != ""
+ ORDER BY id
+ LIMIT 20
+;';
+$result = pwg_query($query);
+
+$testImage = null;
+while ($row = pwg_db_fetch_assoc($result)) {
+ $abs = realpath(PHPWG_ROOT_PATH . ltrim($row['path'], './'));
+ if ($abs !== false && file_exists($abs)) {
+ $testImage = $row;
+ break;
+ }
+}
+
+if (!$testImage) {
+ fail('No usable image found in the database. Aborting.');
+ exit(1);
+}
+
+$imageId = (int) $testImage['id'];
+$imagePath = realpath(PHPWG_ROOT_PATH . ltrim($testImage['path'], './'));
+$originalComment = $testImage['comment'] ?? '';
+
+label('Image ID', (string)$imageId);
+label('File path', $imagePath);
+label('File size', number_format(filesize($imagePath)) . ' bytes');
+label('Original comment', $originalComment !== '' ? "\"$originalComment\"" : '(empty)');
+
+// ---------------------------------------------------------------------------
+// 4. Shared configuration
+// ---------------------------------------------------------------------------
+
+$MODEL = 'Qwen3-VL-30B-A3B-Instruct-UD-Q4_K_XL.gguf';
+$ENDPOINT = 'http://localhost:8082';
+
+$baseConf = [
+ 'ENDPOINT' => $ENDPOINT,
+ 'API_KEY' => '',
+ 'MODEL' => $MODEL,
+ 'MAX_TOKENS' => '500',
+ 'PROMPT' => '',
+ 'WRITE_DESCRIPTION' => '0',
+];
+
+$params = [
+ 'imageId' => $imageId,
+ 'language' => 'en',
+ 'limit' => 10,
+];
+
+$api = new LiveTestOpenAICompatible();
+
+// ---------------------------------------------------------------------------
+// Helper: make a raw curl call and return the model's message content string,
+// so we can display it before any parsing happens.
+// ---------------------------------------------------------------------------
+/**
+ * Scale $imagePath to at most $maxDim px on the longest axis (using GD) and
+ * return the binary JPEG. Falls back to the original file if GD can't decode
+ * it (e.g. unsupported format).
+ */
+function scaleImageData(string $imagePath, int $maxDim = 1024): string
+{
+ $data = @file_get_contents($imagePath);
+ if ($data === false) { return ''; }
+
+ $size = @getimagesize($imagePath);
+ if ($size === false) { return $data; }
+
+ [$w, $h] = $size;
+ if ($w <= $maxDim && $h <= $maxDim) { return $data; }
+
+ $scale = $maxDim / max($w, $h);
+ $nw = (int)round($w * $scale);
+ $nh = (int)round($h * $scale);
+
+ $src = match($size[2]) {
+ IMAGETYPE_JPEG => imagecreatefromjpeg($imagePath),
+ IMAGETYPE_PNG => imagecreatefrompng($imagePath),
+ IMAGETYPE_WEBP => imagecreatefromwebp($imagePath),
+ default => false,
+ };
+ if ($src === false) { return $data; }
+
+ $dst = imagecreatetruecolor($nw, $nh);
+ imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
+ imagedestroy($src);
+
+ ob_start();
+ imagejpeg($dst, null, 85);
+ imagedestroy($dst);
+ return ob_get_clean();
+}
+
+function rawModelCall(string $endpoint, string $model, int $maxTokens,
+ string $prompt, string $imagePath): string
+{
+ $data = scaleImageData($imagePath);
+ if ($data === '') { return '(could not read image file)'; }
+
+ // scaleImageData() always returns JPEG bytes (or the original if no scaling
+ // was needed). Use the original extension's MIME for the data URI.
+ $ext = strtolower(pathinfo($imagePath, PATHINFO_EXTENSION));
+ $mimeMap = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png',
+ 'webp'=>'image/webp','gif'=>'image/gif'];
+ $mime = $mimeMap[$ext] ?? 'image/jpeg';
+ // If we scaled (GD always outputs JPEG), override to image/jpeg
+ $size = @getimagesize($imagePath);
+ if ($size && max($size[0], $size[1]) > 1024) {
+ $mime = 'image/jpeg';
+ }
+ $dataUri = 'data:' . $mime . ';base64,' . base64_encode($data);
+
+ $payload = [
+ 'model' => $model,
+ 'max_tokens' => $maxTokens,
+ 'messages' => [[
+ 'role' => 'user',
+ 'content' => [
+ ['type' => 'text', 'text' => $prompt],
+ ['type' => 'image_url', 'image_url' => ['url' => $dataUri]],
+ ],
+ ]],
+ ];
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, rtrim($endpoint, '/') . '/v1/chat/completions');
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 180);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json',
+ 'Authorization: Bearer none']);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
+ $response = curl_exec($ch);
+ if (curl_errno($ch)) { $e = curl_error($ch); curl_close($ch); return "(curl error: $e)"; }
+ curl_close($ch);
+
+ $decoded = json_decode($response, true);
+ return $decoded['choices'][0]['message']['content'] ?? "(unexpected response: $response)";
+}
+
+// ---------------------------------------------------------------------------
+// 5. TEST A — Default JSON prompt, WRITE_DESCRIPTION = 1
+// ---------------------------------------------------------------------------
+
+hr('2 / 5 Test A: default JSON prompt (WRITE_DESCRIPTION = 1)');
+
+$confA = array_merge($baseConf, ['WRITE_DESCRIPTION' => '1']);
+
+// --- 5a. Raw model output (separate call for display) ----------------------
+$defaultPrompt = 'Analyze this image and respond with a JSON object containing two keys: '
+ . '"description" (a 2-3 sentence description of the image) and "tags" '
+ . '(an array of up to 10 relevant keyword tags). Respond with only the '
+ . 'JSON object, no markdown or extra text.';
+
+info('Sending image to ' . $ENDPOINT . ' (model: ' . $MODEL . ') …');
+$t0 = microtime(true);
+$rawContent = rawModelCall($ENDPOINT, $MODEL, 500, $defaultPrompt, $imagePath);
+$elapsed = round(microtime(true) - $t0, 1);
+
+echo "\n";
+echo " \e[33mRaw model response content\e[0m ({$elapsed}s):\n";
+echo " " . str_replace("\n", "\n ", trim($rawContent)) . "\n\n";
+
+// --- 5b. Run generateTags() (second API call through the plugin code) -------
+info('Running generateTags() via plugin code …');
+$t0 = microtime(true);
+try {
+ $tags = $api->generateTags($confA, $params);
+ $elapsed = round(microtime(true) - $t0, 1);
+ ok("generateTags() returned in {$elapsed}s");
+} catch (Exception $e) {
+ fail('generateTags() threw: ' . $e->getMessage());
+ exit(1);
+}
+
+echo "\n \e[33mExtracted tags\e[0m:\n";
+if (empty($tags)) {
+ fail('No tags returned');
+} else {
+ foreach ($tags as $i => $tag) {
+ echo ' ' . ($i + 1) . '. ' . $tag . "\n";
+ }
+}
+
+// --- 5c. Verify the description was written to the DB ----------------------
+echo "\n";
+$row = pwg_db_fetch_assoc(pwg_query(
+ 'SELECT comment FROM ' . IMAGES_TABLE . ' WHERE id = ' . $imageId . ';'
+));
+$writtenComment = $row['comment'] ?? '';
+
+echo " \e[33mDescription written to DB\e[0m:\n";
+if ($writtenComment !== '' && $writtenComment !== $originalComment) {
+ ok("comment field updated");
+ echo " " . wordwrap('"' . $writtenComment . '"', 66, "\n ", true) . "\n";
+} elseif ($writtenComment === '' ) {
+ fail("comment field is empty — description was not written");
+} else {
+ fail("comment field unchanged from original value");
+}
+
+// ---------------------------------------------------------------------------
+// 6. TEST B — Custom plain-text prompt (free-text fallback path)
+// ---------------------------------------------------------------------------
+
+hr('3 / 5 Test B: custom prompt — free-text fallback path');
+
+$customPrompt = 'List 5 keywords for this image, separated by commas. No JSON, just the keywords.';
+$confB = array_merge($baseConf, [
+ 'PROMPT' => $customPrompt,
+ 'WRITE_DESCRIPTION' => '1',
+]);
+
+info('Prompt: "' . $customPrompt . '"');
+info('Calling ' . $ENDPOINT . ' …');
+
+// Raw output
+$t0 = microtime(true);
+$rawFreeText = rawModelCall($ENDPOINT, $MODEL, 500, $customPrompt, $imagePath);
+$elapsed = round(microtime(true) - $t0, 1);
+
+echo "\n";
+echo " \e[33mRaw model response content\e[0m ({$elapsed}s):\n";
+echo " " . str_replace("\n", "\n ", trim($rawFreeText)) . "\n\n";
+
+// Through the plugin
+info('Running generateTags() via plugin code …');
+$t0 = microtime(true);
+try {
+ $tagsFallback = $api->generateTags($confB, $params);
+ $elapsed = round(microtime(true) - $t0, 1);
+ ok("generateTags() returned in {$elapsed}s");
+} catch (Exception $e) {
+ fail('generateTags() threw: ' . $e->getMessage());
+ exit(1);
+}
+
+echo "\n \e[33mFallback-extracted tags\e[0m:\n";
+if (empty($tagsFallback)) {
+ fail("Fallback returned no tags — model may have returned a sentence; "
+ . "check the raw output above and tune the prompt if needed");
+} else {
+ foreach ($tagsFallback as $i => $tag) {
+ echo ' ' . ($i + 1) . '. ' . $tag . "\n";
+ }
+}
+
+// Verify description written
+$row = pwg_db_fetch_assoc(pwg_query(
+ 'SELECT comment FROM ' . IMAGES_TABLE . ' WHERE id = ' . $imageId . ';'
+));
+$commentB = $row['comment'] ?? '';
+echo "\n";
+if ($commentB !== '' && $commentB !== $writtenComment) {
+ ok('DB comment updated with free-text content');
+ echo " " . wordwrap('"' . $commentB . '"', 66, "\n ", true) . "\n";
+} elseif ($commentB === $writtenComment) {
+ fail('DB comment was NOT overwritten by Test B (still has Test A value)');
+} else {
+ fail('DB comment is empty after Test B');
+}
+
+// ---------------------------------------------------------------------------
+// 7. Cleanup — restore original comment
+// ---------------------------------------------------------------------------
+
+hr('4 / 5 Cleanup — restoring original comment');
+
+$escapedOriginal = pwg_db_real_escape_string($originalComment);
+pwg_query(
+ 'UPDATE ' . IMAGES_TABLE . '
+ SET comment = \'' . $escapedOriginal . '\'
+ WHERE id = ' . $imageId . ';'
+);
+
+$row = pwg_db_fetch_assoc(pwg_query(
+ 'SELECT comment FROM ' . IMAGES_TABLE . ' WHERE id = ' . $imageId . ';'
+));
+$restored = $row['comment'] ?? '';
+
+if ($restored === $originalComment) {
+ ok('Original comment restored: ' . ($originalComment !== '' ? '"' . $originalComment . '"' : '(empty)'));
+} else {
+ fail('Comment restore mismatch — got: "' . $restored . '"');
+}
+
+// ---------------------------------------------------------------------------
+// 8. Summary
+// ---------------------------------------------------------------------------
+
+hr('5 / 5 Summary');
+
+label('Image tested', "ID {$imageId} ({$imagePath})");
+label('Model', $MODEL);
+label('Endpoint', $ENDPOINT);
+label('Test A tags', implode(', ', $tags));
+label('Test B tags', implode(', ', $tagsFallback));
+label('DB comment restored', $restored === $originalComment ? 'yes' : 'NO — check manually');
+
+echo "\n";
+ok('Live integration test complete');
+echo "\n";
diff --git a/tests/mock_api_server.php b/tests/mock_api_server.php
new file mode 100644
index 0000000..34a7d48
--- /dev/null
+++ b/tests/mock_api_server.php
@@ -0,0 +1,33 @@
+ 'mock_api_server: no fixture file at ' . $fixture]);
+ exit;
+}
+
+$body = file_get_contents($fixture);
+if ($body === false) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ echo json_encode(['error' => 'mock_api_server: cannot read fixture file']);
+ exit;
+}
+
+http_response_code(200);
+header('Content-Type: application/json');
+echo $body;