Skip to content

fix: send images one at a time using existing sizes or public URLs - #4

Merged
kevinchappell merged 1 commit into
mainfrom
fix/tag-generation-memory
Sep 13, 2026
Merged

kevinchappell merged 1 commit into
mainfrom
fix/tag-generation-memory

Conversation

@kevinchappell

@kevinchappell kevinchappell commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Clicking Generate AI Tags returned a 500 in production while working locally. The production wp-admin/error_log showed:

PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 7655424 bytes)
in wp-includes/functions.php on line 4444

Tag generation base64-encoded every original upload into a single /chat/completions request. On a post with seven 1MB+ photos that is a 10.7MB JSON body and ~40MB of peak memory on top of the WordPress baseline. admin-ajax.php never calls wp_raise_memory_limit(), so on a host with memory_limit = 128M the request dies inside wp_json_encode(). Locally the container allows 512M, which is why it passed there.

A second issue hid behind the first: providers that accept only one image per prompt (vLLM-style servers by default) rejected the multi-image request with a 400, so image tags never worked and "success" came from the text fallback alone.

Changes

  • One request per image. Per-image tag lists are merged by how many images each tag appears in (case-insensitive, ties keep first-seen order), capped by KWIK_AI_MAX_IMAGE_TAGS. Analysis is capped at KWIK_AI_MAX_IMAGES (10) per post.
  • Use existing intermediate sizes. For media library items the plugin picks the first available of 1536x1536, large, medium_large, medium instead of the original upload. For the test post that is ~210KB instead of ~1.2MB per image. No resizing at request time.
  • Send public URLs, fall back to base64. When the image is on a public host (not .test/.local/localhost/private IP) the URL is sent for the provider to fetch, so WordPress downloads and encodes nothing. If the provider returns nothing for the URL, the image is fetched and sent as base64, and the remaining images go straight to base64. Native Ollama's /api/generate path refuses URL entries, which triggers the same fallback.
  • Reasoning-model budget memo. Once a model returns empty with finish_reason=length and the retry at the larger token budget succeeds, later requests in the same run start with the larger budget instead of paying for a doomed first attempt on every image.
  • Filters: kwik_ai_analysis_image_sizes, kwik_ai_analysis_image_url, kwik_ai_send_image_urls, kwik_ai_max_images_per_post.

Verification

Same 7-image post, memory_limit forced to 128M:

Scenario Requests Body per request Peak memory Result
Before 1 image request 10.7 MB 119M locally, fatal in production text-only tags
Base64 fallback (.test host) 7 + 1 budget retry 0.3 to 0.4 MB 80M image tags
Public URLs 7 + 1 budget retry under 1 KB 78M image tags
Simulated provider rejecting URLs 1 URL attempt, then 8 base64 mixed 80M image tags

PHPUnit: 54 tests, 160 assertions passing (14 new). PHPCS clean.

Notes

Runtime now scales with image count, roughly 3 seconds per image against a vLLM server. The public-host check is a name test, not a network test: a site behind basic auth will pass it, hit the one-time fallback, and continue in base64.

Generating tags base64-encoded every original upload into a single request.
On a post with seven 1MB+ photos that is a 10.7MB JSON body and ~40MB of
peak memory, which exhausts the 128M memory_limit admin-ajax runs under on
shared hosts and returns a 500. Providers that accept only one image per
prompt also rejected the request with a 400.

Each image is now analyzed in its own request. When the image is a media
library item, an already-generated intermediate size (1536x1536, large,
medium_large, medium) is used instead of the original. When the URL is on a
public host it is sent as-is for the provider to fetch, with an automatic
base64 fallback for development hosts, private addresses, native Ollama,
or providers that reject URLs. Per-image tag lists are merged by how many
images each tag appears in. Once a reasoning model needs the larger token
budget, later requests in the same run start with it instead of retrying.

New filters: kwik_ai_analysis_image_sizes, kwik_ai_analysis_image_url,
kwik_ai_send_image_urls, kwik_ai_max_images_per_post.

Claude-Session: https://claude.ai/code/session_01QcttG1fpMj2rg6axwhTvFA
Copilot AI lite review requested due to automatic review settings September 13, 2026 23:44
@kevinchappell
kevinchappell merged commit daab44e into main Sep 13, 2026
2 checks passed
@kevinchappell
kevinchappell deleted the fix/tag-generation-memory branch September 13, 2026 23:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Five unresolved moderate comments address URL handling and avoidable repeated API requests.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR reduces image-tagging memory usage and supports providers that accept one image per request.

Changes:

  • Analyzes bounded images individually and merges tags.
  • Uses existing image sizes, public URLs, and base64 fallback.
  • Adds image/tag limits, URL handling, retry budgeting, and tests.
File summaries
File Summary
tests/unit/KwikAiImageAnalysisTest.php Tests image selection, URL validation, and tag merging.
tests/bootstrap.php Adds WordPress attachment and API mocks.
includes/tag-generation.php Implements per-image analysis, limits, and tag merging.
includes/ollama-api.php Adds URL payloads, fallback handling, and reasoning-budget retries. Moderate comments: cache native endpoint detection (1 vote); memoize the accepted token field (1 vote); only memoize larger budgets after a successful non-empty retry (2 votes).
includes/image-processing.php Selects image sizes and validates public URLs. Moderate comments: apply the final URL filter to unresolved/external URLs (2 votes); normalize trailing-dot hosts before validation (1 vote).
core/constants.php Adds image and tag limits.
Review details

Suppressed comments (3)

includes/image-processing.php:455

  • A trailing-dot host such as 127.0.0.1. or mysite.local. is not accepted by FILTER_VALIDATE_IP, then has an empty TLD and passes this check as public. Normalize the trailing dot before the IP and development-TLD checks, otherwise local URLs can take the provider-URL path instead of the intended base64 fallback.
  $host = strtolower(trim($parts['host'], '[]'));

includes/ollama-api.php:386

  • For a custom native Ollama endpoint, every base64 image reaches this branch after the /chat/completions probe has already returned 404/405 for the previous image. Because that endpoint capability is not cached, the new per-image loop performs an extra failed HTTP request per image, adding latency and load and making the documented one-request-per-image behavior inaccurate. Cache the detected native endpoint, or otherwise skip the compatibility probe for the remainder of the request.
    // Native Ollama only accepts base64 images; it cannot fetch URLs. Tell the
    // caller so it can retry with image data instead.
    foreach ($images as $image) {
      if (kwik_ai_tags_image_is_url($image)) {
        kwik_ai_log('Kwik AI: /api/generate cannot fetch image URLs; caller must supply base64');
        return null;

includes/ollama-api.php:217

  • This memo records only that a larger budget is needed; it does not remember that the model required max_completion_tokens. For models that reject max_tokens, every subsequent per-image call still sends a guaranteed 400 with the legacy field before retrying with the larger field, so the new loop retains an avoidable extra request for every image. Store the accepted token field along with the budget and skip the legacy attempt once it is known to be unsupported.
  static $needs_large_budget = array();
  $model_key = isset($payload['model']) ? (string) $payload['model'] : '';
  $large_budget = max($max_tokens * 8, 2048);
  if ($model_key !== '' && !empty($needs_large_budget[$model_key])) {
    $max_tokens = $large_budget;
  }

  // Most models accept the legacy 'max_tokens'; start there for compatibility.
  $token_field = 'max_tokens';
  $payload[$token_field] = $max_tokens;
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +395 to +397
$attachment_id = attachment_url_to_postid($original_url);
if (!$attachment_id) {
return $url;
Comment thread includes/ollama-api.php
Comment on lines +245 to 249
if ($model_key !== '') {
$needs_large_budget[$model_key] = true;
}
$payload[$token_field] = $large_budget;
$response = $post($payload);
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.1.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants