Compare commits

...

12 Commits

Author SHA1 Message Date
peptiderepo
b399023291 cf-removal: move PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL define to test bootstrap
On v0.9.0 this constant was implicitly defined by CloudflareImageProviderTest::setUp()
— deleted in this PR. OpenRouterImagePricingTest relied on PHPUnit's alphabetical
file order (C before O) to have the constant defined before its assertion.
Deleting the CF test broke that chain; OR pricing test errored with 'Undefined
constant "PRAutoBlogger\\Tests\\Providers\\PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL"'.

Fix: hoist the define into tests/bootstrap.php alongside the other
PRAUTOBLOGGER_DEFAULT_* constants. No test ordering dependency remains.
2026-04-21 17:01:54 +00:00
peptiderepo
382f2a0a6f cf-removal: drop prautoblogger_cf_image_via_gateway from fresh-activation defaults
The set_default_options() block was still seeding the dead CF Workers AI gateway
toggle on fresh activations. Removed alongside its settings field + migration
delete-sweep so uninstall → install cycles stop re-adding the option.

Residual from commit 21430ce. Caught in local verification before PR open.
2026-04-21 16:56:30 +00:00
peptiderepo
ef85100501 cf-removal: bump 0.9.0 -> 0.10.0 + update docs
- prautoblogger.php: plugin header Version + PRAUTOBLOGGER_VERSION
- CHANGELOG.md: Removed/Changed/Note/Cost impact/Migration sections
- ARCHITECTURE.md:
  - drop 4 CF provider files from the file tree
  - drop 2 CF credential options from the wp_options table
  - drop CF Workers AI row from the External API Integrations table
  - flip Phase 3 placeholder from CF Workers AI registry to Runware
  - supersede ADR #16 with a one-line history note pointing at v0.10.0
  - update ADR #17 tail to mention the removal

readme.txt stable tag is stale (0.2.2) and clearly not maintained
inline with real releases — leaving alone in this PR; fix belongs in
a dedicated release-process cleanup.
2026-04-21 16:53:33 +00:00
peptiderepo
97129ed457 cf-removal: add v0.10.0 migration + tests
New class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 (own file so
class-activator.php stays under 300 lines). Runs from
PRAutoBlogger::maybe_run_db_migrations() right after the v0.9.0
migration. Idempotent via prautoblogger_migrated_remove_cloudflare_v0100
flag option (picked up by the uninstall prefix sweep).

Migrates any user on a Cloudflare image provider to Runware FLUX.1
schnell (closest equivalent, ~bash.0006/image). Detects the three
shapes a legacy user can be in: registry-derived provider ===
'cloudflare', image_provider option === 'cloudflare', or the legacy
slug 'flux-1-schnell'.

Cleans up three now-dead options: prautoblogger_cloudflare_ai_token,
prautoblogger_cloudflare_account_id, prautoblogger_cf_image_via_gateway.
Does NOT touch prautoblogger_ai_gateway_base_url — that option is
shared with the OpenRouter AI Gateway route and must stay (live-
verified test).

Tests (tests/unit/Core/ActivatorRemoveCloudflareTest.php):
- cloudflare user → runware schnell
- non-cloudflare user preserved
- runware user preserved
- legacy 'flux-1-schnell' slug triggers migration
- second run is a no-op (does not clobber admin manual changes)
- AI Gateway URL preserved across every path

ImageModelRegistryTest updated: allowed-provider set is now
['runware','openrouter']; new test_cloudflare_entries_removed_v0100
test pins the scope boundary.
2026-04-21 16:52:17 +00:00
peptiderepo
21430ce3bf cf-removal: patch registry, pipeline, settings, admin-page, interface
Wires out the Cloudflare Workers AI image provider across the plugin:

- class-image-pipeline.php: drop 'cloudflare' dispatch branch; Runware
  becomes the unrecognised-provider fallback
- class-image-model-registry.php: remove flux-1-schnell/cloudflare row;
  update provider-enum docblocks (runware|openrouter only)
- class-settings-fields-extended.php: remove three CF fields
  (cloudflare_ai_token, cloudflare_account_id, cf_image_via_gateway);
  drop Cloudflare mention from model-picker + NSFW retry descriptions
- class-admin-page.php: remove prautoblogger_cloudflare_ai_token from
  the encrypted-options list
- interface-image-provider.php: retarget preamble @see at Runware +
  OpenRouter; update get_provider_name() example
- class-image-nsfw-blocked.php: generalise preamble (Runware +
  OpenRouter can raise this)

Also cleans stale CF Workers AI mentions in:
class-ajax-handlers.php, interface-model-registry.php,
class-image-nsfw-retry.php, class-open-router-image-pricing.php,
prautoblogger.php.

The Cloudflare AI Gateway path (in class-open-router-config.php,
class-settings-fields.php 'prautoblogger_ai_gateway_base_url',
class-open-router-request-builder.php cache headers) is intentionally
untouched — it fronts OpenRouter and is unrelated to the CF Workers AI
image provider.
2026-04-21 16:48:04 +00:00
peptiderepo
1c3d9b50c6 cf-removal: delete Cloudflare Workers AI provider files + tests
Drops the four CF provider classes and their two test files. The
OpenRouter AI Gateway path is unaffected — it reads
prautoblogger_ai_gateway_base_url via class-open-router-config.php
directly, with no dependency on the CF support class.

Leaves class-cost-tracker.php line 165 'cloudflare-workers-ai' label
intact for historical log integrity.

Part of v0.10.0 (removing CF Workers AI as image provider).
See convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/.
2026-04-21 16:45:23 +00:00
peptiderepo
8bedecd2dd test: fix Runware tests to use real Encryption class path
The composer classmap autoloader loads the real PRAutoBlogger_Encryption
before our eval() stub runs, so class_exists() is always true and the stub
is never installed. Two consequences:

1. Provider tests set 'enc:fake-key' and the real decrypt() path ran,
   calling wp_salt() which isn't stubbed. Switched to plaintext keys
   so is_encrypted() short-circuits and no crypto runs.
2. Support test for the decrypt path now uses a real encrypt/decrypt
   round-trip with a stubbed wp_salt() returning a deterministic
   32-char salt. This exercises the actual code path users hit in
   production, not a lying stub.

Removes the dead eval() stubs that were being skipped anyway.
2026-04-21 15:59:36 +00:00
peptiderepo
2cacaa3512 v0.9.0: Runware FLUX.1 schnell as default image provider
New Runware image backend at ~$0.0006/image (FLUX.1 schnell) — 65× cheaper
than Gemini 2.5 Flash Image. Provider mirrors OpenRouter's split: interface
+ provider + support + pricing + batch, all under the 300-line cap. Batch
class uses curl_multi for true parallel A/B dispatch — wall-clock drops to
the slowest single image instead of the sum.

Changes:
- Add PRAutoBlogger_Runware_Image_Provider + Pricing + Support + Batch
- Flip PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER/MODEL to runware:100@1
- Add prautoblogger_runware_api_key settings field (encrypted at rest)
- v0.9.0 one-time migration flips legacy default model + re-derives provider
- Wire runware branch into Image_Pipeline::create_default_provider()
- Add runware:100@1 and runware:101@1 to Image_Model_Registry (defaults top)
- Update image prompt builder: no more anthropomorphized molecules;
  prefer concrete human-scale scenes (gym bros, doctors, scientists)
- ARCHITECTURE.md + CHANGELOG.md updated with new integration + decision
- Unit tests for pricing, support, provider; update model registry test

Cost impact at 100 images/day (50 articles × A/B):
- Before (Gemini via OpenRouter): $3.90/day ≈ $117/mo
- After  (FLUX schnell via Runware): $0.06/day ≈ $1.80/mo
Net saving: ~$115/mo
2026-04-21 15:56:35 +00:00
peptiderepo
80a7e11a2b v0.8.2: CF images via AI Gateway + scheduled-gen timezone fix
* feat(images): route Cloudflare Workers AI via AI Gateway by default

Direct probe on 2026-04-21 confirmed the Cloudflare AI Gateway
route to Workers AI (`gateway.ai.cloudflare.com/.../workers-ai/@cf/...`)
is no longer 403ing — the upstream regression noted in v0.8.0's
ARCHITECTURE.md #16 and in thread image-pipeline seq 6 has been
resolved. Routing via the gateway gives us the same benefits we
already get for OpenRouter LLM calls: response caching, cost/latency
dashboard, rate limiting.

Changes:

- `Cloudflare_Image_Support::build_endpoint_url()` now prefers the
  gateway URL derived from the existing `prautoblogger_ai_gateway_base_url`
  option. Direct API remains the fallback.
- New private helper `build_gateway_workers_ai_url()` strips the
  OpenRouter path suffix from the configured gateway URL and appends
  `/workers-ai/{model}`. Returns empty string (fallback to direct) when:
  no gateway URL configured, the new toggle is off, or the URL isn't a
  valid `https://gateway.ai.cloudflare.com/...` shape.
- New admin toggle `prautoblogger_cf_image_via_gateway` (default on) in
  the Images section. Operators can disable to force direct API without
  clearing the LLM-path gateway URL.
- ARCHITECTURE.md #16 rewritten to reflect the v0.8.2+ gateway routing.
- CHANGELOG.md v0.8.2 entry under Changed.

Tests (new `CloudflareImageSupportTest`):

- Direct URL when gateway not configured.
- Gateway URL derived correctly when configured + toggle on.
- Toggle off forces direct URL even with gateway configured.
- Malformed gateway URL falls back to direct (defensive).
- `is_nsfw_error()` only matches error code 3030 (pinned separately so
  a tolerance regression fails early).

Cost impact: near-zero. Gateway adds a few ms of latency and enables
response caching — could *reduce* cost on identical prompts within the
configurable cache TTL.

Uninstall: new option covered by existing prautoblogger_% LIKE sweep.

Implements Commit 1b (narrow AI-Gateway-migration scope) per thread
image-pipeline seq 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(cost): seed new toggles in activator defaults; tighten URL stripper

QA review on cd45ae2 surfaced two things; one real, one hallucinated:

- **Real (fixed):** `prautoblogger_cf_image_via_gateway` and v0.8.1's
  `prautoblogger_image_nsfw_retry` weren't seeded in
  `Activator::set_default_options()`. Belt-and-braces — the `get_option()`
  fallback at the read site already defaults to '1', so no user-visible
  regression exists, but seeding the options follows the established
  pattern for every other plugin setting and makes intent legible to
  operators inspecting `wp_options` directly.
- **Hallucinated (rejected):** the reviewer claimed
  `CloudflareImageSupportTest` methods 2–5 were empty stubs. Verified
  — every test has real `$this->assert*` calls (10 total across the 5
  cases). Ignoring. Flagging the false-positive to the CTO per the
  qa-review quality-tracking protocol.

Also clarified the docblock + tightened the ternary inside
`build_gateway_workers_ai_url()` — explicit `strrpos` false-check
instead of the `?:` short-circuit, so a no-slash base (defensive edge)
falls through cleanly instead of returning a weirdly-sliced URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(schedule): interpret `prautoblogger_schedule_time` in site timezone

Per thread scheduled-gen-timezone seq 1: WordPress core calls
`date_default_timezone_set('UTC')` early in bootstrap, so the activator's
`strtotime("tomorrow HH:MM")` was resolving in UTC regardless of the
site's configured timezone. On peptiderepo.com (Asia/Singapore, UTC+8)
a `schedule_time` of `06:00` fired at 14:00 local.

Fold this into the existing v0.8.2 branch (PR #102) since both this fix
and the earlier CF-image-gateway change target the same version and
touch overlapping files (activator, settings-fields-extended).

Changes:

- `Activator::next_daily_generation_timestamp()` — new public helper
  that reads `prautoblogger_schedule_time`, constructs a
  `DateTimeImmutable('tomorrow', wp_timezone())`, applies the
  configured hour/minute, returns a Unix timestamp. Defensive fallback
  to the old UTC-interpretation behaviour on any DateTime parse error
  so a bad tz string can't brick the schedule.
- `Activator::schedule_cron()` delegates to the helper instead of
  calling `strtotime` directly.
- New `Activator::reschedule_daily_in_site_timezone_v082()` — one-shot
  migration that clears the stale UTC-scheduled event and re-queues
  using the helper. Gated by `prautoblogger_migrated_schedule_tz_v082`
  option so it runs exactly once. Logs an INFO line comparing
  before/after next_run for auditability.
- `class-prautoblogger.php::on_check_db_version()` invokes the
  migration on every admin_init; the one-shot flag handles re-run
  guarding (no DB_VERSION bump needed — the migration is independent
  of the schema version).
- Settings description for "Generation Time" now says
  "...in your site's configured timezone (Settings → General →
  Timezone). Current site timezone: <wp_timezone_string()>" instead
  of the ambiguous "server timezone" phrasing.

File-size compliance:
- Adding the timezone helpers pushed `class-activator.php` to 352
  lines. Extracted the 104-line `create_tables()` SQL to a new
  `class-schema-installer.php` (136 lines). Activator now 245 lines,
  schema file fully dedicated to dbDelta.

Tests (new):

- `ActivatorScheduleTest` — SGT 06:00 → 22:00 UTC prev day; UTC 06:00
  → 06:00 UTC (no-op case); Asia/Kathmandu UTC+5:45 → 00:15 UTC
  (fractional offset); malformed time input doesn't throw.
- `ActivatorMigrationTest` — pre-seeded UTC cron gets cleared and
  rescheduled in SGT; second pass is idempotent.

CHANGELOG v0.8.2 entry now also covers the timezone fix under Fixed +
the description wording change under Changed.

Implements thread `scheduled-gen-timezone` seq 1 spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:34:37 +07:00
peptiderepo
33d3ba4ea1 v0.8.1: orphan research-row reaper + GA4 description fix
* feat(cost): persist run_id on generated posts + back-populate for v0.8.1 reaper

The orphan-research-row reaper (upcoming commit) needs a cheap way to
answer "which posts belong to this run_id?" when amortizing a stranded
`llm_research` row back to its siblings. Relying on the generation_log
post_id column is circular: if the pipeline died before amortize_research
ran, we'd trust the same table we're trying to repair.

- `Publisher::build_meta()` now accepts `?string $run_id` and writes
  `_prautoblogger_run_id => $run_id` when non-empty. Null/empty preserves
  existing null-safe legacy behaviour.
- `Publisher::create_post()` forwards the $run_id it already receives
  into build_meta — one-line plumbing, no new parameters at call sites.
- `Activator::backpopulate_run_id_meta_v081()` runs once per site,
  gated by `prautoblogger_migrated_run_id_meta_v081` option. Walks
  `wp_prautoblogger_generation_log`, pairs (post_id, min(run_id)),
  writes `_prautoblogger_run_id` on any post that doesn't already have
  it. Uses `metadata_exists()` so it's a no-op on re-run.
- Activator also schedules the new daily `prautoblogger_reap_orphan_research_rows`
  cron at 03:15 — 15 min after the primary generation cron — so live
  pipelines have time to finish amortizing cleanly before the reaper scans.
- Deactivator unschedules the new hook alongside the existing cron hooks.

No user-visible behaviour change yet. The reaper handler ships in the
next commit; this one just ensures it will have the data + schedule it
needs when it lands.

Part of the v0.8.1 bundle (thread research-cost-reaper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): daily reaper for orphan llm_research rows

Implements the orphan-research-row reaper per thread research-cost-reaper
seq 1. When a pipeline dies between post creation and
`Post_Assembler::amortize_research_costs()` (Hostinger exec kill, OOM,
LiteSpeed cut, fatal), the shared research row stays in
`wp_prautoblogger_generation_log` with `post_id = NULL` and sibling
articles' cost popovers understate their true spend.

New class `PRAutoBlogger_Research_Reaper` owns:

- `on_cron()` — WP-Cron entrypoint hooked to the daily
  `prautoblogger_reap_orphan_research_rows` event (already scheduled
  by the Activator in the previous commit at 03:15).
- `reap()` — returns a `{reaped, deleted, skipped}` summary. For each
  orphan `llm_research` row older than 1 hour (grace window for live
  pipelines):
  - `_prautoblogger_run_id` post_meta primary lookup.
  - Falls back to distinct post_ids in gen_log with the same run_id.
  - If ≥1 post found → delegate to the existing idempotent
    `Post_Assembler::amortize_research_costs()`. That method inserts
    the per-article rows and deletes the original orphan in one pass,
    so the reaper is idempotent on re-run.
  - If 0 posts found AND orphan is >7 days old → delete outright as
    sunk cost with nothing to attribute to.
  - Else → skip; try again the next day.
- Never throws. DB errors caught and logged WARNING.

New WP-CLI command `wp prautoblogger reap-research` registered in the
main loader when `WP_CLI` is defined — manual ops trigger that prints
the same summary.

Departure from the CTO's spec: the reap logic lives on
`Research_Reaper` instead of `Post_Assembler`. Placing it on
Post_Assembler pushed that file to 391 lines, over the 300-line cap.
Moving it to the thin class colocates the attribution logic with the
cron/CLI plumbing that triggers it. `amortize_research_costs()` stays
on Post_Assembler — the reaper delegates to it unchanged. Flagged for
CTO review.

Part of the v0.8.1 bundle (thread research-cost-reaper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cost): ResearchReaperTest — five cases per spec

Covers the five scenarios from convo thread research-cost-reaper seq 1:

- test_reaps_orphan_with_matching_run_id_posts — meta lookup returns
  two post IDs; reaper delegates to amortize_research_costs which
  inserts two rows and deletes the orphan.
- test_skips_orphan_within_grace_period — the SELECT's
  `created_at < now - 1h` clause filters fresh orphans at the DB; the
  reaper's stats stay zero and no side effects fire.
- test_deletes_orphan_with_no_articles_after_7_days — orphan is 8 days
  old, zero siblings via meta or gen_log; reaper deletes outright.
- test_falls_back_to_gen_log_when_post_meta_missing — meta returns
  empty (legacy pre-v0.8.1 post); gen_log has two distinct post_ids;
  amortize still runs.
- test_idempotent_on_repeat_run — second pass on a cleaned state
  returns 0/0/0 with no inserts or deletes.

All DB interaction mocked via $wpdb stub — no real DB round-trips.
Fixtures are driven by property mutations in the test methods so
PHPUnit's "one stub per method" restriction on mocks doesn't bite.

Part of the v0.8.1 bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(v0.8.1): version bump + CHANGELOG + ARCHITECTURE.md for reaper

Bump plugin header and PRAUTOBLOGGER_VERSION 0.8.0 → 0.8.1. Patch
bump — the reaper is internal infrastructure; no user-visible UX
change.

CHANGELOG:
- Fixed: orphan `llm_research` rows now reaped daily.
- Added: WP-CLI `wp prautoblogger reap-research`; `_prautoblogger_run_id`
  post_meta is now written by Publisher on every post.

ARCHITECTURE.md:
- Decision #13 (Run-ID based log linking) cross-refs the new post_meta.
- New Decision #19 documents the reaper's scan/amortize/delete policy,
  the 1-hour grace window, the 7-day stale cutoff, and the one-shot
  activator back-populate migration.

DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump.

Final commit of the v0.8.1 bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cost): widen $wpdb mock so ResearchReaperTest actually runs

BaseTestCase::create_mock_wpdb() only adds methods
[prepare, get_var, get_results, insert, query, get_row, update] — the
reaper and amortize_research_costs also call `delete` and `get_col`,
which the default mock can't stub. CI on PR #100 fired all five new
ResearchReaperTest cases as errors at the first `->method('delete')`
call during setUp.

Fix: build a bespoke $wpdb mock in this file's setUp with the full
superset of methods it needs. Self-contained; no change to
BaseTestCase so existing tests aren't affected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cost): filter gen_log inserts from event_log inserts in reaper tests

CI on ec1efee showed the two reap-and-amortize tests failing because
`assertCount(2, $this->inserted_rows)` was seeing 4 rows, not 2. Root
cause: the Logger singleton writes INFO lines via `$wpdb->insert` into
the event_log table — the reaper logs once ("Reaped...") and amortize
logs once ("Amortized LLM research cost...") before the two per-post
gen_log inserts, so the capture ended up with 4 total rows.

Add a `gen_log_inserts()` helper that filters the capture down to rows
whose `__table` contains "generation_log" — the assertions on
attribution count should only measure the actual cost inserts, not
incidental log writes. Two tests updated; logic unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): correct misleading GA4 Property ID description

The GA4 client at includes/core/class-ga4-client.php:85 already wraps the
property_id option with "properties/" in the outbound URL path. The admin
description told users to enter "properties/XXXXXXXXX" — which would have
produced a double-prefixed URL (properties/properties/XXXXXXXXX) and a
guaranteed 404 from the Data API. Description now asks for digits only,
matching actual code behavior.

No code logic change — UX copy + CHANGELOG only. Flagged by CEO while
inspecting the Analytics tab; folded into the in-flight v0.8.1 PR per
CTO call rather than cutting a separate patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:48:34 +07:00
peptiderepo
188e6603dd test(images): green ImagePromptBuilderTest (follow-up to #97)
* test(images): update ImagePromptBuilderTest for array-returning builder

`build_article_prompt()` and `build_source_prompt()` have returned
`array{prompt: string, caption: string}` since v0.4.x; five existing
tests still asserted against the envelope as if it were a string. On
PHP 8+ `assertStringContainsString` and `strlen()` raise a TypeError
when handed an array, so every one of those tests failed on the CI
matrix even though the production code is fine.

Fix is mechanical per CTO spec in convo seq 6: destructure the return
on receipt and assert against `$scene` / `$caption`. Cases updated:

- test_build_article_prompt_from_content
- test_build_article_prompt_with_missing_content
- test_build_source_prompt_from_reddit_data
- test_build_source_prompt_without_comments
- test_prompts_are_reasonably_sized (500-char cap now bound to the
  scene+style-suffix, which is what the cap was always meant to measure)

No production code changes. Unrelated pre-existing failures
(`Call to undefined function post_type_exists()` in Publisher fixtures
and the repo-wide PHPCS backlog) remain out of scope per CTO.

Follow-up to PR #97 (v0.8.0 image bundle), which shipped before this
test fix landed. Context in convo thread image-mime-bug seq 07.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(images): drop unreachable content-based assertion in article-prompt test

Per CTO review on PR #99 (convo image-mime-bug seq 8): the earlier diff
destructured the return value correctly, but left an assertion for
'Dragons are fascinating' — a content substring. With no OpenRouter key
in the unit-test env, `rewrite_via_llm()` catches and falls back to
`synthesize_visual_concepts_fallback()`, which uses title only. The
content substring is structurally unreachable on that path, so the
assertion can never pass without mocking the LLM.

Picking CTO option 1 ("honest and minimal"): drop the content
assertion. The title + non-empty + length + caption-is-string checks
remain — those describe what the method actually guarantees regardless
of LLM reachability. Content-path testing belongs in a mocked-LLM test
(see `test_rewrite_uses_setting_when_present`, which already covers it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:34:05 +07:00
peptiderepo
5c7736bf4e v0.8.0: fix silent image failures + NSFW retry + prompt instructions (#97)
* fix(image-pipeline): surface per-image failures in the event log

Two gaps made posts 650/657 appear to silently "lose" their featured
images on 2026-04-20:

1. The batch provider tagged each failed key with ['error' => ...] but
   the ERROR log line it emitted didn't identify which key failed, so
   operators couldn't tell image_a from image_b in the event log when
   one of two parallel requests died.

2. Image_Pipeline::process_image_a/_b folded the per-key error into
   $result['errors'] and returned silently, with no WARNING row. Ops
   only learned about the miss when the outer catch in the caller fired
   — and on the 2026-04-20 incident that outer catch didn't fire, so
   nothing was logged at all except an unrelated pricing warning.

Fix:

- class-open-router-image-batch.php: collect_result() takes the request
  key as its first argument and every Logger::error message reads
  'OpenRouter batch image gen <class> for key "<key>": <detail>'.
  Matches the shape of OpenRouter_Image_Support::log_retry.

- class-image-pipeline.php: process_image_a and process_image_b now
  emit a Logger::warning('image_pipeline') row whenever $image_data is
  missing or carries an error key, in addition to pushing to
  $result['errors']. The warning fires whether or not the outer catch
  runs.

Test: ImagePipelineTest::test_partial_batch_failure_emits_warning_log_row
mocks $wpdb, runs the pipeline with image_a erroring, and asserts at
least one warning/image_pipeline row was inserted.

No behavior change beyond logging; cost impact zero.

Part of the v0.8.0 image bundle (issues from thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(images): retry Cloudflare NSFW-blocked prompts with a sanitized fallback

Cloudflare's Workers AI FLUX.1 schnell content filter sometimes rejects
peptide-comic prompts as NSFW (HTTP 400, errors[].code === 3030). On
2026-04-20 this produced a silent publish-without-featured-image for
post #650 ("Semaglutide vs Tirzepatide") — the LLM-rewritten scene
tripped the filter and we had no recovery path.

New behaviour: on a 3030 response, the Cloudflare provider throws a
typed PRAutoBlogger_Image_NSFW_Blocked exception. The batch wrapper
catches it and tags the per-slot result with
`error_type: nsfw_blocked`. Image_Pipeline hands the results to a new
PRAutoBlogger_Image_NSFW_Retry helper, which reruns the blocked slot
once with a sanitized fallback prompt (title + style suffix, no LLM
rewrite). On success the retry result replaces the error entry; on a
second block, a WARNING is logged and the pipeline publishes without
that image — same terminal behaviour we had before, just explicit.

Files:

- New providers/class-image-nsfw-blocked.php — typed exception.
- providers/class-cloudflare-image-support.php — is_nsfw_error() +
  throw_if_nsfw() helpers.
- providers/class-cloudflare-image-provider.php — calls throw_if_nsfw
  inside the 4xx branch; batch catches the typed exception and marks
  the result with error_type.
- core/class-image-prompt-builder.php — new public build_fallback_prompt
  shim so the retry layer can reach the rule-based fallback without
  going through the LLM path.
- New core/class-image-nsfw-retry.php — single-shot retry helper.
- core/class-image-pipeline.php — invokes the retry between batch
  completion and per-slot processing.
- admin/class-settings-fields-extended.php — new toggle
  `prautoblogger_image_nsfw_retry` (default on) in the Images section.

Tests:

- CloudflareImageProviderTest — 3030 response throws NSFW exception;
  batch tags the per-slot result with `error_type: nsfw_blocked`.
- ImagePipelineTest — retry succeeds when provider returns a good
  result on the second call; retry is skipped when the setting is off.

Cost impact: worst-case an extra Cloudflare call per NSFW-blocked slot
(~$0.000834). Current incidence ~2% of generations; ~+$0.00002 per
generation amortized. Immaterial.

Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.

Part of the v0.8.0 image bundle (issues from thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(images): expose rewriter system prompt as admin setting

CEO feedback: every generated image is anchoring on the cartoon
peptide-with-arms-and-legs character because the hardcoded rewriter
system prompt both lists "anthropomorphized molecules" first in its
primer and ships a few-shot example that literally shows one. The ask
is to give CEO the lever without a code change.

Adds `prautoblogger_image_prompt_instructions` (textarea) in the
Images section. Default value is the existing REWRITER_SYSTEM_PROMPT
constant verbatim, so behaviour is unchanged on upgrade until someone
edits it. `Image_Prompt_Builder::rewrite_via_llm()` reads the option
at call time via a new `resolve_system_prompt()` helper and falls back
to the constant if the option is blank or whitespace-only, so a saved
empty value cannot brick image generation.

Const visibility flipped from `private` to `public` so the settings
field can reference it directly as the field default.

Tests:

- `test_rewrite_uses_setting_when_present` — option set, assert the
  LLM's system message equals the option content.
- `test_rewrite_falls_back_to_default_when_setting_empty` — option
  whitespace, assert the LLM's system message equals the constant.

Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.

Cost impact: none by default. Future CEO edits will change prompt
length; current default is ~180 tokens, budget impact linear from
there.

Part of the v0.8.0 image bundle (thread image-mime-bug, message 03).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(images): collapse Image Provider + Image Model into a single dropdown

Until now, Image Provider and Image Model were two independent selects
in the admin UI. That let the CEO set Model=flux-1-schnell while
Provider was still openrouter — OpenRouter has no such model so every
image gen on posts 650/657 on 2026-04-20 returned HTTP 400, and the
article published without a featured image.

v0.8.0 removes the Image Provider field from the admin UI entirely.
The provider is now derived from the model's entry in the registry
every time the model is saved. A new
PRAutoBlogger_Image_Model_Registry::provider_for() helper centralises
the mapping, so admin save, the on-upgrade migration, and future
callers all read from one place.

On save, PRAutoBlogger_Admin_Page::sanitize_image_model() validates
the submitted slug against the registry:
- Known slug: persist it, and update `prautoblogger_image_provider` to
  the paired provider as a side-effect.
- Unknown slug: add_settings_error with a clear message, and return
  the previously-saved value so the site stays on a working model.

A one-shot v0.8.0 migration in
PRAutoBlogger::on_check_db_version() derives the provider from the
currently-saved model on first admin_init after the plugin is updated.
This auto-heals sites that were running mismatched pairs before the
upgrade, without requiring the admin to re-open settings.

`prautoblogger_image_provider` option key is preserved — all existing
reads in the pipeline, cost tracker, and validators continue to work
unchanged.

Tests (Services/ImageModelRegistryTest):

- Every registry entry has an id and a provider in {openrouter, cloudflare}.
- provider_for() returns the paired provider for a known slug.
- provider_for() returns '' for unknown/empty slugs.

Cost impact: none.

Migration idempotent via the `prautoblogger_migrated_image_provider_v080`
flag. Uninstall sweeps it along with every other plugin option.

Part of the v0.8.0 image bundle (thread image-mime-bug, Issue 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(v0.8.0): version bump + CHANGELOG + ARCHITECTURE.md for image bundle

Bump plugin header and PRAUTOBLOGGER_VERSION 0.7.3 → 0.8.0. Minor bump
because Commit D removes the Image Provider dropdown from admin (a
UX-visible change), not a patch.

CHANGELOG:
- Changed: collapsed Image Provider + Model dropdowns; provider derived.
- Added: Image Prompt Instructions setting; Retry NSFW-Blocked Images
  setting.
- Fixed: silent per-image failures in the event log.

ARCHITECTURE.md:
- Stage 6b (Image Pipeline) callout now describes provider derivation
  and the NSFW-retry fallback path.
- wp_options table lists the four new/changed keys
  (`prautoblogger_image_provider` as derived, new
  `prautoblogger_image_prompt_instructions`,
  `prautoblogger_image_nsfw_retry`, and the one-shot migration flag
  `prautoblogger_migrated_image_provider_v080`).

DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump needed.

Final commit of the v0.8.0 image bundle (thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:02:20 +07:00
42 changed files with 3774 additions and 1313 deletions

View File

@@ -66,10 +66,16 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
│
▼
┌─────────────────────────┐
│ 6b. Image Pipeline │ (commit 1b) Generates two A/B images:
│ 6b. Image Pipeline │ Generates two A/B images in parallel:
│ (optional, if enabled) │ - Image A: article-driven (featured)
│ │ - Image B: source-driven (post meta)
│ │ Sideloads to media library, logs costs
│ │ Provider derived from the saved Image Model
│ │ via Image_Model_Registry::provider_for().
│ │ NSFW-blocked slots (CF error code 3030) are
│ │ retried once with a rule-based fallback
│ │ prompt via Image_NSFW_Retry; a second block
│ │ logs WARNING and publishes without that image.
│ │ Sideloads to media library, logs costs.
└────────────┬────────────┘
│
▼
@@ -165,17 +171,17 @@ prautoblogger/
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
│ │ ├── class-cloudflare-image-provider.php # Cloudflare Workers AI (sequential batch fallback)
│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation
│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check
│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for CF provider
│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver
│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap
│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher
│ │ └── (new providers go here — see CONVENTIONS.md)
│ │
│ ├── services/
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
│ │ ├── class-open-router-model-registry.php # Fetches, caches, queries the OpenRouter model list
│ │ ├── class-open-router-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
│ │ └── (Phase 3: class-cloudflare-workers-ai-model-registry.php)
│ │ └── (Phase 3: class-runware-model-registry.php)
│ │
│ ├── frontend/
│ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
@@ -429,9 +435,11 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
| `prautoblogger_db_version` | Schema version for migrations |
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
| `prautoblogger_cloudflare_ai_token` | Encrypted Cloudflare Workers AI API token |
| `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) |
| `prautoblogger_image_model` | Image model alias: `flux-1-schnell` (default) or `flux-1-dev` |
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') |
| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade |
| `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' |
| `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. |
| `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') |
@@ -468,7 +476,7 @@ Stored on every PRAutoBlogger-generated post:
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
| Cloudflare Workers AI | Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements | API token (encrypted in wp_options) + account ID | Workers AI per-account quotas | `providers/class-cloudflare-image-provider.php`, `providers/class-cloudflare-image-pricing.php`, `providers/class-cloudflare-image-validator.php` |
| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php` |
---
@@ -511,7 +519,10 @@ The posts widget uses WordPress-bundled React (`wp.element`) with raw `createEle
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
### #13: Run-ID based log linking
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession.
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. **v0.8.1+**: `run_id` is also persisted on each post as `_prautoblogger_run_id` post_meta, giving the orphan-research reaper a direct path from run to siblings without walking the gen_log table (see #19).
### #19: Daily orphan-research-row reaper (v0.8.1)
When a pipeline dies between post creation and the final `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared `llm_research` cost row stays in `prab_generation_log` with `post_id = NULL` and sibling articles understate their true cost in the breakdown popover. A new daily WP-Cron event `prautoblogger_reap_orphan_research_rows` (hooked to `PRAutoBlogger_Research_Reaper::on_cron`, 03:15 server time — 15 min after the primary generation cron) scans for orphan rows older than a 1-hour grace window, looks up sibling posts by `_prautoblogger_run_id` post_meta (fallback: distinct `post_id`s in gen_log for the same run_id), and delegates to the existing idempotent `amortize_research_costs()` on match. Orphans that still have no siblings after 7 days are deleted outright as sunk cost. A one-time activator migration (`prautoblogger_migrated_run_id_meta_v081`) back-populates `_prautoblogger_run_id` on existing posts from gen_log so the meta-primary path works on upgrade. Manual ops trigger: `wp prautoblogger reap-research`.
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
@@ -520,8 +531,13 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
### #16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway)
The image-generation layer (started 2026-04-15, tracked in `convo/prautoblogger/threads/2026-04-image-pipeline/`) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/...`, *not* through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside.
### #17: Runware as default image backend (v0.9.0, Apr 2026)
After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below).
### #16: Image generation via Cloudflare Workers AI (FLUX.1) — SUPERSEDED v0.10.0
Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell on Cloudflare Workers AI, routed via the Cloudflare AI Gateway for caching + unified telemetry. In v0.9.0 (2026-04-21) Runware FLUX.1 schnell became the default (65× cheaper, see ADR #17). In v0.10.0 (2026-04-21) the Cloudflare Workers AI provider was removed entirely — classes, settings, tests, and registry entries are gone; any CF user is migrated to `runware:100@1` on upgrade via `PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()`. The Cloudflare AI Gateway in front of OpenRouter (ADR #15) is unaffected — it is a distinct integration.
### #15: Optional Cloudflare AI Gateway in front of OpenRouter
We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged.

View File

@@ -5,6 +5,218 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.10.0] — 2026-04-21
### Removed
- **Cloudflare Workers AI as image provider.** The four `class-cloudflare-image-*`
provider files are gone along with their unit tests and the three
CF-specific options (`prautoblogger_cloudflare_ai_token`,
`prautoblogger_cloudflare_account_id`, `prautoblogger_cf_image_via_gateway`).
Runware FLUX.1 schnell produces equivalent-quality output at ~65×
less cost and eliminates a duplicated settings surface. The
`flux-1-schnell` registry entry that mapped to the CF provider is
removed; the legacy slug now resolves to an empty provider (caught
by the migration).
### Changed
- **Sites on any `cloudflare` image provider auto-migrate to
`runware:100@1` on upgrade.** New migration
`PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()` runs from
`admin_init` once per site. Detects three legacy shapes: registry-
derived provider `cloudflare`, `prautoblogger_image_provider` option
set to `cloudflare`, or the legacy slug `flux-1-schnell`. Sets
`prautoblogger_image_provider` + `prautoblogger_image_model` to the
Runware schnell pair and deletes the three dead CF options. Idempotent
via `prautoblogger_migrated_remove_cloudflare_v0100` flag — running
twice is a no-op and will not clobber a post-migration manual choice.
- **Image provider dispatch fallback is now Runware, not Cloudflare.**
`PRAutoBlogger_Image_Pipeline::create_default_provider()` returns
`PRAutoBlogger_Runware_Image_Provider` for any unrecognised
`prautoblogger_image_provider` value. An unmigrated install still
gets a working pipeline instead of a fatal.
### Note
- **Cloudflare AI Gateway fronting OpenRouter is unaffected.** The
`prautoblogger_ai_gateway_base_url` + `prautoblogger_ai_gateway_cache_ttl`
options, their settings fields, `class-open-router-config.php`
gateway resolution, and `class-open-router-request-builder.php`
`cf-aig-cache-ttl` headers are all intentionally untouched. The AI
Gateway is a proxy/cache layer in front of OpenRouter and is
orthogonal to the CF Workers AI image provider removal. The
migration test asserts this invariant explicitly.
### Cost impact
Zero recurring spend from CF Workers AI image generation (the
peptiderepo.com install was still on CF as recently as v0.8.2 — see
commit `80a7e11`). No new prompt tokens, no change in model mix for
sites already on Runware or OpenRouter.
### Migration checklist (for anyone else running this plugin)
1. On first admin_init after upgrade, the migration will flip
`prautoblogger_image_provider` to `runware` and
`prautoblogger_image_model` to `runware:100@1` if you were on CF.
2. **Set `prautoblogger_runware_api_key`** in Settings → Images if you
hadn't already — image generation will fail until it is set.
3. The CF API token + account ID options are deleted by the migration;
you don't need to do anything.
4. If you want to switch back to OpenRouter (Gemini) instead of
Runware, just change the Image Model picker after the migration
has run.
## [0.9.0] — 2026-04-21
### Added
- **Runware (FLUX.1 schnell) as the default image provider.** New
provider at `includes/providers/class-runware-image-*` generates
~$0.0006/image on FLUX.1 schnell (`runware:100@1`) — roughly 65× cheaper
than the previous default (Gemini 2.5 Flash Image via OpenRouter at
~$0.039/image). FLUX.1 dev (`runware:101@1`, ~$0.02/image) is also
available for higher-fidelity runs. The provider mirrors the
OpenRouter split: interface + provider + support + pricing + batch,
all under the 300-line cap.
- **True parallel image generation.** `PRAutoBlogger_Runware_Image_Batch`
uses `curl_multi_init/exec/select` to fire all imageInference POSTs
concurrently, then downloads the signed URLs concurrently. Wall-clock
for the A/B pair drops to ≈ the slowest single image instead of the
sum of both.
- **v0.9.0 one-time migration.** Sites where the image model is empty
or still pinned to `google/gemini-2.5-flash-image` are auto-flipped
to `runware:100@1` on upgrade, with the provider re-derived from the
registry. Flag: `prautoblogger_migrated_default_image_v090`. The
Runware API key option (`prautoblogger_runware_api_key`) was added
to the `enc:`-prefix migration list so Settings writes go through
the same encryption-at-rest path as the other provider keys.
### Changed
- **Image prompt builder steered away from abstract/chemical subjects.**
The rewriter system prompt now explicitly refuses to personify
molecules, peptides, hormones, or proteins — FLUX (and most diffusion
image models) render "anthropomorphic peptide" as an incoherent blob.
Default scene guidance is now "concrete, human-scale scenes: people
reacting to things, doctors and patients, gym bros with vials, etc."
Style suffix unchanged — single-panel comic aesthetic preserved. See
`includes/core/class-image-prompt-builder.php::REWRITER_SYSTEM_PROMPT`.
- **Default image model flipped to `runware:100@1` (FLUX.1 schnell).**
`PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER` and
`PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL` constants in `prautoblogger.php`
updated. Cloudflare flux-1-schnell remains in the registry as a legacy
fallback for any operators who set it explicitly. Rationale:
`image-style` memory (2026-04-21) — schnell passed the comic A/B
round and the cost delta funds more generations for the same monthly
budget.
### Cost impact
At 50 articles/day × 2 image A/B = 100 images/day:
- Before (Gemini 2.5 Flash Image via OpenRouter): ≈ $3.90/day, ≈ $117/mo
- After (FLUX.1 schnell via Runware): ≈ $0.06/day, ≈ $1.80/mo
Net saving: ~$115/mo at current traffic, funded by a single provider
swap with no user-visible change in article quality.
## [0.8.2] — 2026-04-21
### Fixed
- **Daily generation schedule now honors site timezone.** Previously
the time configured in Settings → Schedule was interpreted as UTC
(WordPress core forces `date_default_timezone_set('UTC')`), causing
runs to fire N hours off from user intent where N is the site's UTC
offset. On peptiderepo.com (Asia/Singapore, UTC+8), a `schedule_time`
of `06:00` fired at 14:00 local. An on-upgrade one-shot migration
(`prautoblogger_migrated_schedule_tz_v082`) reschedules the existing
daily cron in the site's configured timezone — no user action
required.
### Changed
- **Cloudflare Workers AI image calls now route through the AI Gateway
by default.** The 2026-04-15 regression that caused the gateway route
to 403 on Workers AI has been resolved upstream. Routing via the
gateway gives us response caching, unified cost/latency dashboard,
and rate limiting for free, mirroring the OpenRouter LLM path. A new
admin toggle `prautoblogger_cf_image_via_gateway` (default on) lets
operators force direct-API if the gateway route regresses again,
without clearing the gateway URL setting the LLM provider depends on.
- **"Generation Time" settings description** now names the site's
configured timezone explicitly (from `wp_timezone_string()`) instead
of the ambiguous "server timezone" label — operators can see at a
glance which zone their input is being interpreted in.
## [0.8.1] — 2026-04-21
### Fixed
- **Orphaned LLM research costs now reaped daily.** When a pipeline died
between post creation and the final cost-amortization step (e.g.
execution-time kill on shared hosting), the `llm_research` row stayed
unlinked and the affected articles' cost popovers understated true spend.
A new daily cron (`prautoblogger_reap_orphan_research_rows`, 03:15
server time) retroactively amortizes orphan rows against posts from
the same run. Includes a one-time activator migration to back-populate
`_prautoblogger_run_id` post_meta for existing posts.
- **GA4 Property ID admin description corrected.** The field description
previously instructed users to enter `properties/XXXXXXXXX`, but the
GA4 client already prepends the `properties/` URL segment internally —
a double-prefixed value would 404. Description now asks for the digits
only, matching actual code behavior.
### Added
- New WP-CLI command `wp prautoblogger reap-research` for manual reaping
and ops debugging.
- `Publisher::build_meta()` now writes `_prautoblogger_run_id` on every
generated post, so the reaper has a direct meta path from run_id to
sibling posts without walking `wp_prautoblogger_generation_log`.
## [0.8.0] — 2026-04-21
### Changed
- **Single Image Model dropdown in admin → Images.** The separate Image
Provider select has been removed. The provider (OpenRouter or
Cloudflare Workers AI) is now derived from the chosen model's entry
in `PRAutoBlogger_Image_Model_Registry` on save, so mismatched
provider/model pairs — the root cause of posts 650 and 657 silently
missing their featured image on 2026-04-20 — are no longer possible.
A one-shot migration runs on first admin_init after upgrade and
auto-heals any existing site where the saved provider and model
disagreed. The `prautoblogger_image_provider` option key is
preserved; all existing reads in the pipeline continue to work.
### Added
- **Image Prompt Instructions setting.** The system prompt used to
rewrite articles into SCENE + CAPTION is now editable from admin →
Images. Default preserves prior behavior; edit to reshape the
creative direction without a code change.
- **Retry NSFW-Blocked Images setting** (default on). When the image
provider rejects a prompt as NSFW — Cloudflare Workers AI returns
HTTP 400 with `errors[].code === 3030` on FLUX.1 schnell —
PRAutoBlogger now retries the slot once with a rule-based fallback
prompt (article title + style suffix, no LLM rewrite). On a second
block it logs a WARNING and publishes without that image, matching
existing behaviour for any unrecoverable image failure.
### Fixed
- **Silent per-image failures in the event log.** The OpenRouter batch
provider now emits `Logger::error` with the request key for every
per-slot HTTP/cURL/parse failure (previously logged the error but
not which slot it was), and `Image_Pipeline::process_image_a/_b`
emit `Logger::warning` on `['error' => …]` results whether or not
the outer catch fires. Operators can now see NSFW blocks, 4xx
bodies, and timeouts in the admin Event Log without cross-
referencing PHP error_log timestamps.
## [0.7.3] — 2026-04-20
### Fixed

View File

@@ -231,7 +231,7 @@ class PRAutoBlogger_Admin_Page {
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
}
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_cloudflare_ai_token' ];
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
if ( in_array( $option_name, $encrypted, true ) ) {
// Empty value means password field wasn't touched — keep existing.
if ( '' === $value ) {
@@ -279,7 +279,21 @@ class PRAutoBlogger_Admin_Page {
if ( in_array( $option_name, $numeric, true ) ) {
return is_numeric( $value ) ? $value : 0;
}
if ( 'prautoblogger_image_model' === $option_name ) {
return $this->sanitize_image_model( (string) $value );
}
return sanitize_text_field( (string) $value );
}
/** Validate model id against the registry and persist the derived provider. */
private function sanitize_image_model( string $value ): string {
$candidate = sanitize_text_field( $value );
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
if ( '' !== $provider ) {
update_option( 'prautoblogger_image_provider', $provider );
return $candidate;
}
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
}
}

View File

@@ -6,12 +6,13 @@ declare(strict_types=1);
*
* What: Returns a hardcoded list of image models for the admin model picker.
* No API discovery — updated manually when providers add/remove models.
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI) and
* PRAutoBlogger_Image_Pipeline (model validation).
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
* Dependencies: None.
*
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
* @see core/class-image-pipeline.php — Validates selected model against this list.
* @see core/class-image-pipeline.php — Derives provider from picked model.
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
*/
class PRAutoBlogger_Image_Model_Registry {
@@ -21,22 +22,41 @@ class PRAutoBlogger_Image_Model_Registry {
* Each entry contains:
* - id: Model identifier used in API calls.
* - name: Human-readable display name.
* - provider: 'openrouter' or 'cloudflare'.
* - provider: 'runware' | 'openrouter'.
* - cost_per_image: Estimated USD cost per generation.
* - capabilities: Array of capability tags.
* - description: Short description for the admin UI.
*
* Ordered cheapest-to-most-expensive within each provider, with the
* recommended default (Runware schnell) at the top.
*
* @return array<int, array<string, mixed>>
*/
public static function get_models(): array {
return [
[
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
],
[
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
],
[
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Good quality, low cost. Recommended.', 'prautoblogger' ),
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
],
[
'id' => 'google/gemini-3.1-flash-image-preview',
@@ -70,14 +90,22 @@ class PRAutoBlogger_Image_Model_Registry {
'capabilities' => [ 'image_generation' ],
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
],
[
'id' => 'flux-1-schnell',
'name' => 'FLUX.1 schnell (Cloudflare)',
'provider' => 'cloudflare',
'cost_per_image' => 0.0007,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Cheapest. Low quality, 4-step.', 'prautoblogger' ),
],
];
}
/**
* Return the provider id for a known model, or empty string if the
* model id is not in the registry.
*
* @param string $model_id Model slug from the admin UI.
* @return string Provider id ('runware' | 'openrouter' | '').
*/
public static function provider_for( string $model_id ): string {
foreach ( self::get_models() as $model ) {
if ( ( $model['id'] ?? '' ) === $model_id ) {
return (string) ( $model['provider'] ?? '' );
}
}
return '';
}
}

View File

@@ -95,7 +95,11 @@ class PRAutoBlogger_Settings_Fields_Extended {
'type' => 'time',
'section' => 'prautoblogger_schedule',
'default' => '03:00',
'description' => __( 'Daily generation runs at this time (server timezone).', 'prautoblogger' ),
'description' => sprintf(
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
),
],
[
'id' => 'prautoblogger_monthly_budget_usd',
@@ -154,7 +158,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_analytics',
'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'prautoblogger' ),
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_ga4_credentials_json',
@@ -210,18 +214,6 @@ class PRAutoBlogger_Settings_Fields_Extended {
'default' => '1',
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_provider',
'label' => __( 'Image Provider', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_images',
'default' => 'openrouter',
'options' => [
'openrouter' => __( 'OpenRouter (multiple models available)', 'prautoblogger' ),
'cloudflare' => __( 'Cloudflare Workers AI', 'prautoblogger' ),
],
'description' => __( 'OpenRouter reuses your existing API key and offers higher-quality models. Cloudflare is cheaper but lower quality.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
@@ -229,25 +221,17 @@ class PRAutoBlogger_Settings_Fields_Extended {
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'capability' => 'image_generation',
'description' => __( 'Pick a model from your selected provider.', 'prautoblogger' ),
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_cloudflare_ai_token',
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
'id' => 'prautoblogger_runware_api_key',
'label' => __( 'Runware API Key', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Only needed if using Cloudflare provider. Workers AI token with Read + Edit scope.', 'prautoblogger' ),
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
'icon' => '🔑',
],
[
'id' => 'prautoblogger_cloudflare_account_id',
'label' => __( 'Cloudflare Account ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => '',
'description' => __( 'Only needed if using Cloudflare provider.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_style_suffix',
'label' => __( 'Style Suffix', 'prautoblogger' ),
@@ -256,6 +240,22 @@ class PRAutoBlogger_Settings_Fields_Extended {
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_prompt_instructions',
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_nsfw_retry',
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
],
];
}
}

View File

@@ -28,121 +28,15 @@ class PRAutoBlogger_Activator {
self::create_tables();
self::set_default_options();
self::schedule_cron();
self::backpopulate_run_id_meta_v081();
// Store the DB version so we can run migrations on future updates.
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
}
/**
* Create all custom database tables using dbDelta.
*
* Side effects: creates/updates four database tables.
*
* @return void
*/
/** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */
private static function create_tables(): void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$prefix = $wpdb->prefix . 'prautoblogger_';
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// Source data — raw posts/comments from social platforms.
$sql_source_data = "CREATE TABLE {$prefix}source_data (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
source_type VARCHAR(50) NOT NULL,
source_id VARCHAR(255) NOT NULL,
subreddit VARCHAR(255) DEFAULT NULL,
title TEXT DEFAULT NULL,
content LONGTEXT DEFAULT NULL,
author VARCHAR(255) DEFAULT NULL,
score INT DEFAULT 0,
comment_count INT DEFAULT 0,
permalink VARCHAR(500) DEFAULT NULL,
collected_at DATETIME NOT NULL,
metadata_json LONGTEXT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY source_unique (source_type, source_id),
KEY source_collected (source_type, collected_at)
) {$charset_collate};";
// Analysis results — detected patterns from source data.
$sql_analysis = "CREATE TABLE {$prefix}analysis_results (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
analysis_type VARCHAR(50) NOT NULL,
topic VARCHAR(500) NOT NULL,
summary TEXT DEFAULT NULL,
frequency INT DEFAULT 1,
relevance_score FLOAT DEFAULT 0,
source_ids_json LONGTEXT DEFAULT NULL,
analyzed_at DATETIME NOT NULL,
metadata_json LONGTEXT DEFAULT NULL,
PRIMARY KEY (id),
KEY type_relevance (analysis_type, relevance_score),
KEY analyzed_at (analyzed_at)
) {$charset_collate};";
// Generation log — every API call with cost tracking.
// run_id groups all log entries from a single pipeline execution so
// link_generation_logs() can accurately attribute costs to a post.
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED DEFAULT NULL,
run_id VARCHAR(36) DEFAULT NULL,
stage VARCHAR(50) NOT NULL,
provider VARCHAR(50) NOT NULL,
model VARCHAR(100) NOT NULL,
prompt_tokens INT DEFAULT 0,
completion_tokens INT DEFAULT 0,
estimated_cost DECIMAL(10,6) DEFAULT 0,
request_json LONGTEXT DEFAULT NULL,
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
error_message TEXT DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),
KEY run_id (run_id),
KEY created_at (created_at),
KEY stage (stage)
) {$charset_collate};";
// Content scores — post performance metrics for self-improvement.
$sql_content_scores = "CREATE TABLE {$prefix}content_scores (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
pageviews INT DEFAULT 0,
avg_time_on_page FLOAT DEFAULT 0,
bounce_rate FLOAT DEFAULT 0,
comment_count INT DEFAULT 0,
composite_score FLOAT DEFAULT 0,
score_factors_json LONGTEXT DEFAULT NULL,
measured_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),
KEY measured_at (measured_at),
KEY composite_score (composite_score)
) {$charset_collate};";
// Event log — structured application log entries for the admin log viewer.
$sql_event_log = "CREATE TABLE {$prefix}event_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
level VARCHAR(10) NOT NULL DEFAULT 'info',
context VARCHAR(100) DEFAULT NULL,
message TEXT NOT NULL,
meta_json LONGTEXT DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY level (level),
KEY created_at (created_at),
KEY level_created (level, created_at)
) {$charset_collate};";
dbDelta( $sql_source_data );
dbDelta( $sql_analysis );
dbDelta( $sql_generation_log );
dbDelta( $sql_content_scores );
dbDelta( $sql_event_log );
PRAutoBlogger_Schema_Installer::install();
}
/**
@@ -174,6 +68,7 @@ class PRAutoBlogger_Activator {
'prautoblogger_table_borders' => '1',
'prautoblogger_schedule_time' => '03:00',
'prautoblogger_log_level' => 'info',
'prautoblogger_image_nsfw_retry' => '1',
];
foreach ( $defaults as $key => $value ) {
@@ -195,14 +90,8 @@ class PRAutoBlogger_Activator {
*/
private static function schedule_cron(): void {
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
// Schedule for the configured time tomorrow.
$time_str = get_option( 'prautoblogger_schedule_time', '03:00' );
$parts = explode( ':', $time_str );
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
if ( false !== $timestamp ) {
$timestamp = self::next_daily_generation_timestamp();
if ( $timestamp > 0 ) {
wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' );
}
}
@@ -225,5 +114,156 @@ class PRAutoBlogger_Activator {
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
}
// v0.8.1: daily reaper for orphan `llm_research` rows. Pinned to
// 03:15 so it runs 15 min after the primary generation cron, giving
// any live pipeline time to finish and call amortize_research_costs
// cleanly before the reaper scans.
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
$tomorrow = strtotime( 'tomorrow 03:15' );
if ( false !== $tomorrow ) {
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
}
}
}
/**
* Compute the next daily-generation timestamp in the site's configured
* timezone. Fixes the v<0.8.2 bug where `strtotime("tomorrow HH:MM")`
* evaluated in UTC (WP core sets `date_default_timezone_set('UTC')`),
* firing the cron N hours off from user intent where N is the site's
* UTC offset.
*
* @return int Unix timestamp, or 0 on any failure.
*/
public static function next_daily_generation_timestamp(): int {
$time_str = (string) get_option( 'prautoblogger_schedule_time', '03:00' );
$parts = explode( ':', $time_str );
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
try {
$site_tz = function_exists( 'wp_timezone' )
? wp_timezone()
: new \DateTimeZone( 'UTC' );
$local = ( new \DateTimeImmutable( 'tomorrow', $site_tz ) )
->setTime( $hour, $minute, 0 );
return (int) $local->getTimestamp();
} catch ( \Exception $e ) {
// Defensive: never leave the site without any schedule because
// of a bad tz string. Fall back to the pre-fix behaviour.
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
return false === $timestamp ? 0 : (int) $timestamp;
}
}
/**
* v0.8.2 one-shot migration — reschedule `prautoblogger_daily_generation`
* in the site's timezone.
*
* Existing installs had their daily cron scheduled with the v<0.8.2
* UTC-interpretation bug. This clears the stale event and reschedules it
* using the timezone-aware helper, exactly once per site (gated by
* `prautoblogger_migrated_schedule_tz_v082`). Logs an INFO line with the
* before/after next-run times so the outcome is auditable.
*
* @return void
*/
public static function reschedule_daily_in_site_timezone_v082(): void {
if ( get_option( 'prautoblogger_migrated_schedule_tz_v082' ) ) {
return;
}
$old_timestamp = wp_next_scheduled( 'prautoblogger_daily_generation' );
wp_clear_scheduled_hook( 'prautoblogger_daily_generation' );
$new_timestamp = self::next_daily_generation_timestamp();
if ( $new_timestamp > 0 ) {
wp_schedule_event( $new_timestamp, 'daily', 'prautoblogger_daily_generation' );
}
if ( class_exists( 'PRAutoBlogger_Logger', false ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Rescheduled daily generation in site timezone (was UTC). Old next_run: %s, new next_run: %s.',
false !== $old_timestamp ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', (int) $old_timestamp ) : 'unscheduled',
$new_timestamp > 0 ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', $new_timestamp ) : 'unscheduled'
),
'activator'
);
}
update_option( 'prautoblogger_migrated_schedule_tz_v082', '1' );
}
/**
* One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id`
* post_meta for existing posts from their gen-log rows.
*
* Lets the orphan-research-row reaper (v0.8.1+) attribute historic
* orphan rows to sibling posts via post_meta lookup, without having
* to walk the generation_log table. Gated by
* `prautoblogger_migrated_run_id_meta_v081` so it runs exactly once
* per site.
*
* Side effects: adds post_meta rows for existing PRAutoBlogger posts
* that don't already have `_prautoblogger_run_id`.
*
* @return void
*/
private static function backpopulate_run_id_meta_v081(): void {
if ( get_option( 'prautoblogger_migrated_run_id_meta_v081' ) ) {
return;
}
global $wpdb;
$gen_log = $wpdb->prefix . 'prautoblogger_generation_log';
// For each (post_id, run_id) pair present in the gen-log, write
// the run_id meta if it's not already set. Uses the first run_id
// seen per post — in practice each post is linked to exactly one run.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$pairs = $wpdb->get_results(
"SELECT post_id, MIN(run_id) AS run_id FROM {$gen_log}
WHERE post_id IS NOT NULL AND run_id IS NOT NULL AND run_id != ''
GROUP BY post_id"
);
if ( is_array( $pairs ) ) {
foreach ( $pairs as $row ) {
$post_id = (int) ( $row->post_id ?? 0 );
$run_id = (string) ( $row->run_id ?? '' );
if ( $post_id > 0 && '' !== $run_id && ! metadata_exists( 'post', $post_id, '_prautoblogger_run_id' ) ) {
update_post_meta( $post_id, '_prautoblogger_run_id', $run_id );
}
}
}
update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' );
}
/**
* v0.9.0 migration: flip legacy default image model to Runware schnell
* and re-derive provider. CEO decision 2026-04-21 — 65× cheaper than
* Gemini Nano Banana. Explicit user selections are preserved.
*
* Side effects: may update `prautoblogger_image_model`,
* `prautoblogger_image_provider`, and sets the
* `prautoblogger_migrated_default_image_v090` flag.
*/
public static function migrate_default_image_v090(): void {
if ( get_option( 'prautoblogger_migrated_default_image_v090' ) ) {
return;
}
$curr = (string) get_option( 'prautoblogger_image_model', '' );
if ( in_array( $curr, [ '', 'google/gemini-2.5-flash-image' ], true ) ) {
update_option( 'prautoblogger_image_model', 'runware:100@1' );
}
$final = (string) get_option( 'prautoblogger_image_model', 'runware:100@1' );
$prov = PRAutoBlogger_Image_Model_Registry::provider_for( $final );
if ( '' !== $prov ) {
update_option( 'prautoblogger_image_provider', $prov );
}
update_option( 'prautoblogger_migrated_default_image_v090', '1' );
}
}

View File

@@ -42,7 +42,7 @@ class PRAutoBlogger_Ajax_Handlers {
return;
}
// Image generation can take 20-30s on Cloudflare Workers AI.
// Image generation can take 20-30s (Runware FLUX + OpenRouter image models).
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 120 );

View File

@@ -36,6 +36,7 @@ class PRAutoBlogger_Deactivator {
$hooks = [
'prautoblogger_daily_generation',
'prautoblogger_collect_metrics',
'prautoblogger_reap_orphan_research_rows',
];
foreach ( $hooks as $hook ) {

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
/**
* One-shot v0.10.0 migration — remove Cloudflare Workers AI as image provider.
*
* Cloudflare Workers AI was removed as an image provider in v0.10.0.
* Runware FLUX.1 schnell is 65× cheaper at equivalent quality, so any user
* still on a `cloudflare` provider must be migrated off — otherwise the
* image pipeline throws "no provider for id cloudflare" at the first
* generation run after upgrade.
*
* Split out of class-activator.php so that file stays under the 300-line
* cap. The activator's maybe_run_db_migrations() delegates here.
*
* Triggered by: PRAutoBlogger::maybe_run_db_migrations() once per admin_init
* until the `prautoblogger_migrated_remove_cloudflare_v0100`
* flag is set.
* Dependencies: PRAutoBlogger_Image_Model_Registry::provider_for() for
* current-model provider derivation.
*
* @see class-activator.php — Parallel v0.9.0 migration.
* @see class-prautoblogger.php — Calls run() during admin_init.
* @see class-image-model-registry.php — Provider derivation source.
* @see convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/
*/
class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 {
/**
* Migration flag option key. Presence = already migrated.
*/
private const FLAG = 'prautoblogger_migrated_remove_cloudflare_v0100';
/**
* Run the migration. Idempotent — second + later calls are no-ops.
*
* Side effects: may update `prautoblogger_image_model` +
* `prautoblogger_image_provider`; always deletes
* `prautoblogger_cloudflare_ai_token`,
* `prautoblogger_cloudflare_account_id`,
* `prautoblogger_cf_image_via_gateway`; sets the
* migration flag option.
*
* Explicitly does NOT touch `prautoblogger_ai_gateway_base_url` —
* that option is shared with the OpenRouter AI Gateway route and
* must stay intact.
*
* @return void
*/
public static function run(): void {
if ( get_option( self::FLAG ) ) {
return;
}
if ( self::is_user_on_cloudflare() ) {
// Closest equivalent — FLUX.1 schnell, Runware-hosted, ~$0.0006/image.
update_option( 'prautoblogger_image_model', 'runware:100@1' );
update_option( 'prautoblogger_image_provider', 'runware' );
}
// Clean up orphaned CF Workers AI credentials + the dead gateway toggle.
delete_option( 'prautoblogger_cloudflare_ai_token' );
delete_option( 'prautoblogger_cloudflare_account_id' );
delete_option( 'prautoblogger_cf_image_via_gateway' );
update_option( self::FLAG, '1' );
}
/**
* Detect whether the site is currently configured to use Cloudflare
* Workers AI for image generation.
*
* Because the v0.10.0 image-model registry no longer contains
* Cloudflare entries, `provider_for()` returns '' for legacy
* `flux-1-schnell` slugs. We therefore look at three signals and
* treat any of them as "on Cloudflare":
* 1. Current registry-derived provider === 'cloudflare'.
* 2. The legacy `prautoblogger_image_provider` option is 'cloudflare'.
* 3. The legacy CF-specific slug `flux-1-schnell` is still persisted
* as the image model (pre-registry-rename installs).
*
* @return bool
*/
private static function is_user_on_cloudflare(): bool {
$curr = (string) get_option( 'prautoblogger_image_model', '' );
$curr_provider = PRAutoBlogger_Image_Model_Registry::provider_for( $curr );
if ( 'cloudflare' === $curr_provider ) {
return true;
}
$legacy_provider = (string) get_option( 'prautoblogger_image_provider', '' );
if ( 'cloudflare' === $legacy_provider ) {
return true;
}
return 'flux-1-schnell' === $curr;
}
}

View File

@@ -116,6 +116,24 @@ class PRAutoBlogger {
// Single-idea generation from the Ideas browser page.
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
// pipeline dies before amortize_research_costs runs).
add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_Reaper', 'on_cron' ] );
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
// WP-CLI is present; no-op in normal HTTP requests.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command( 'prautoblogger reap-research', static function (): void {
$stats = PRAutoBlogger_Research_Reaper::reap();
\WP_CLI::success( sprintf(
'Reaped %d, deleted %d (stale), skipped %d.',
(int) $stats['reaped'],
(int) $stats['deleted'],
(int) $stats['skipped']
) );
} );
}
}
/** Register AJAX handlers for admin actions. */
@@ -160,6 +178,27 @@ class PRAutoBlogger {
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
}
// One-time migration (v0.8.0): the admin no longer has an independent
// Image Provider dropdown; provider is derived from the image model on
// save. Auto-heal any existing site where the saved provider doesn't
// match the saved model's provider (the root cause of posts 650/657
// silently missing their featured image on 2026-04-20). Runs once.
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
if ( '' !== $provider ) {
update_option( 'prautoblogger_image_provider', $provider );
}
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
}
// One-time migration (v0.8.2): reschedule the daily-generation cron in
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
// site timezone. Clears the stale UTC-scheduled event and re-queues
// using the timezone-aware helper. See class-activator.php.
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// One-time migration (v3): switch to single-panel newspaper comic style.
// Replaces both the old infomercial pastiche and the short-lived premium
// photography style. Force-update unless the user has a truly custom value.
@@ -181,7 +220,6 @@ class PRAutoBlogger {
}
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
}
// One-time migration (v4): remove caption-in-image instruction from style suffix.
// Captions are now inserted as HTML below the image, not baked into the image.
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
@@ -193,9 +231,13 @@ class PRAutoBlogger {
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
}
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
PRAutoBlogger_Activator::migrate_default_image_v090();
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
foreach ( $enc_options as $opt ) {
$val = get_option( $opt, '' );
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
/**
* Creates / updates the plugin's custom database tables via `dbDelta`.
*
* Extracted from `PRAutoBlogger_Activator` in v0.8.2 to keep that class
* under the 300-line cap once the timezone-aware cron migration landed.
* The Activator delegates to `install()` on activate and on db-version
* upgrade; schema definitions live here and only here.
*
* Triggered by: PRAutoBlogger_Activator::activate().
* Dependencies: WordPress $wpdb, dbDelta() from wp-admin/includes/upgrade.php.
*
* @see class-activator.php — Sole caller; delegates on activate and db-version bump.
* @see ARCHITECTURE.md — Data model section documents each table's purpose.
*/
class PRAutoBlogger_Schema_Installer {
/**
* Create (or upgrade) all plugin tables.
*
* `dbDelta` is idempotent and will create new tables, add missing
* columns on existing tables, and add missing indexes — but it will
* not drop columns or indexes. That matches the plugin's forward-only
* schema-migration policy.
*
* Side effects: up to five `CREATE TABLE` / `ALTER TABLE` statements.
*
* @return void
*/
public static function install(): void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$prefix = $wpdb->prefix . 'prautoblogger_';
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// Source data — raw posts/comments from social platforms.
$sql_source_data = "CREATE TABLE {$prefix}source_data (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
source_type VARCHAR(50) NOT NULL,
source_id VARCHAR(255) NOT NULL,
subreddit VARCHAR(255) DEFAULT NULL,
title TEXT DEFAULT NULL,
content LONGTEXT DEFAULT NULL,
author VARCHAR(255) DEFAULT NULL,
score INT DEFAULT 0,
comment_count INT DEFAULT 0,
permalink VARCHAR(500) DEFAULT NULL,
collected_at DATETIME NOT NULL,
metadata_json LONGTEXT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY source_unique (source_type, source_id),
KEY source_collected (source_type, collected_at)
) {$charset_collate};";
// Analysis results — detected patterns from source data.
$sql_analysis = "CREATE TABLE {$prefix}analysis_results (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
analysis_type VARCHAR(50) NOT NULL,
topic VARCHAR(500) NOT NULL,
summary TEXT DEFAULT NULL,
frequency INT DEFAULT 1,
relevance_score FLOAT DEFAULT 0,
source_ids_json LONGTEXT DEFAULT NULL,
analyzed_at DATETIME NOT NULL,
metadata_json LONGTEXT DEFAULT NULL,
PRIMARY KEY (id),
KEY type_relevance (analysis_type, relevance_score),
KEY analyzed_at (analyzed_at)
) {$charset_collate};";
// Generation log — every API call with cost tracking. `run_id`
// groups all entries from a single pipeline execution so
// `link_generation_logs()` can accurately attribute costs to a post.
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED DEFAULT NULL,
run_id VARCHAR(36) DEFAULT NULL,
stage VARCHAR(50) NOT NULL,
provider VARCHAR(50) NOT NULL,
model VARCHAR(100) NOT NULL,
prompt_tokens INT DEFAULT 0,
completion_tokens INT DEFAULT 0,
estimated_cost DECIMAL(10,6) DEFAULT 0,
request_json LONGTEXT DEFAULT NULL,
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
error_message TEXT DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),
KEY run_id (run_id),
KEY created_at (created_at),
KEY stage (stage)
) {$charset_collate};";
// Content scores — post performance metrics for self-improvement.
$sql_content_scores = "CREATE TABLE {$prefix}content_scores (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
pageviews INT DEFAULT 0,
avg_time_on_page FLOAT DEFAULT 0,
bounce_rate FLOAT DEFAULT 0,
comment_count INT DEFAULT 0,
composite_score FLOAT DEFAULT 0,
score_factors_json LONGTEXT DEFAULT NULL,
measured_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),
KEY measured_at (measured_at),
KEY composite_score (composite_score)
) {$charset_collate};";
// Event log — structured application log entries for the admin log viewer.
$sql_event_log = "CREATE TABLE {$prefix}event_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
level VARCHAR(10) NOT NULL DEFAULT 'info',
context VARCHAR(100) DEFAULT NULL,
message TEXT NOT NULL,
meta_json LONGTEXT DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY level (level),
KEY created_at (created_at),
KEY level_created (level, created_at)
) {$charset_collate};";
dbDelta( $sql_source_data );
dbDelta( $sql_analysis );
dbDelta( $sql_generation_log );
dbDelta( $sql_content_scores );
dbDelta( $sql_event_log );
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
/**
* Single-shot retry of NSFW-blocked image slots with a sanitized prompt.
*
* When an image provider rejects a prompt via its content filter, its
* batch result carries
* `['error_type' => 'nsfw_blocked']`. This helper reruns just the blocked
* slot once with the rule-based fallback prompt (article title + style
* suffix, no LLM rewrite) and merges the new result back into the batch.
*
* Intentionally single-retry: if the fallback is also blocked, we log a
* WARNING and leave the `['error' => ...]` entry in place so the pipeline
* proceeds to publish without a featured image (current behaviour for any
* unrecoverable image failure).
*
* Gated behind the `prautoblogger_image_nsfw_retry` setting (default on).
*
* Triggered by: PRAutoBlogger_Image_Pipeline::generate_and_attach_images.
* Dependencies: PRAutoBlogger_Image_Provider_Interface (retry HTTP call),
* PRAutoBlogger_Image_Prompt_Builder (fallback prompt),
* PRAutoBlogger_Logger (warning + info lines).
*
* @see core/class-image-pipeline.php — Sole caller.
* @see core/class-image-prompt-builder.php — Supplies the fallback prompt.
* @see providers/class-image-nsfw-blocked.php — Typed exception raised by providers.
*/
class PRAutoBlogger_Image_NSFW_Retry {
/**
* Option key for the admin toggle.
*/
private const SETTING_KEY = 'prautoblogger_image_nsfw_retry';
/** @var PRAutoBlogger_Image_Provider_Interface */
private PRAutoBlogger_Image_Provider_Interface $provider;
/** @var PRAutoBlogger_Image_Prompt_Builder */
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
/**
* @param PRAutoBlogger_Image_Provider_Interface $provider Provider to retry against.
* @param PRAutoBlogger_Image_Prompt_Builder $prompt_builder Fallback prompt source.
*/
public function __construct(
PRAutoBlogger_Image_Provider_Interface $provider,
PRAutoBlogger_Image_Prompt_Builder $prompt_builder
) {
$this->provider = $provider;
$this->prompt_builder = $prompt_builder;
}
/**
* Whether the admin setting currently allows NSFW retries.
*
* Defaults to on. Kept as a static so callers can short-circuit before
* constructing a retry object when nothing needs retrying anyway.
*
* @return bool
*/
public static function is_enabled(): bool {
return '1' === (string) get_option( self::SETTING_KEY, '1' );
}
/**
* Replace every NSFW-blocked slot in $batch_results with a fresh result
* from a sanitized fallback prompt.
*
* Mutates $batch_results and $captions in place. Also updates the
* per-slot entry in $captions so the caller writes the fallback
* caption under the post if the retry succeeds.
*
* Side effects: up to one HTTP call per blocked slot; Logger lines
* for every retry attempt (info on success, warning on second block).
*
* @param int $post_id Target post ID (for logs).
* @param array $batch_requests Original per-slot request map.
* @param array<string, array|null> $batch_results Provider result map (mutated).
* @param array<string, string> $captions Caption map (mutated).
* @param array<string, mixed> $article_data Article data (for title).
* @param array<string, mixed>|null $source_data Source data (for title on image_b).
* @return void
*/
public function retry_blocked_slots(
int $post_id,
array $batch_requests,
array &$batch_results,
array &$captions,
array $article_data,
?array $source_data
): void {
foreach ( $batch_results as $key => $entry ) {
if ( ! is_array( $entry ) || 'nsfw_blocked' !== ( $entry['error_type'] ?? '' ) ) {
continue;
}
$title = 'image_b' === $key
? (string) ( $source_data['title'] ?? '' )
: (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' );
$fallback = $this->prompt_builder->build_fallback_prompt( $title );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'NSFW retry for post %d slot "%s" with sanitized fallback prompt.', $post_id, $key ),
'image_pipeline'
);
$retry_request = [
'prompt' => $fallback['prompt'],
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
'height' => (int) ( $batch_requests[ $key ]['height'] ?? 0 ),
'options' => $batch_requests[ $key ]['options'] ?? [],
];
$retry_results = $this->provider->generate_image_batch( [ $key => $retry_request ] );
$retry_entry = $retry_results[ $key ] ?? [ 'error' => 'Retry provider returned no result.' ];
if ( isset( $retry_entry['error'] ) ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'NSFW retry for post %d slot "%s" also failed: %s',
$post_id,
$key,
(string) $retry_entry['error']
),
'image_pipeline'
);
// Leave the original ['error' => ...] entry so the pipeline
// publishes without this image, matching current behaviour
// for any unrecoverable image failure.
continue;
}
$batch_results[ $key ] = $retry_entry;
$captions[ $key ] = $fallback['caption'];
}
}
}

View File

@@ -17,22 +17,19 @@ declare(strict_types=1);
class PRAutoBlogger_Image_Pipeline {
/**
* Default image dimensions (landscape).
* Default image dimensions (landscape). 1200×632 is the closest
* 8-aligned pair to the standard OG image size (1200×630). Some
* providers require dimensions divisible by 8.
*/
// Dimensions must be divisible by 8 (required by some providers).
// 1200×632 is the closest 8-aligned pair to the standard OG image size (1200×630).
private const DEFAULT_WIDTH = 1200;
private const DEFAULT_HEIGHT = 632;
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
private PRAutoBlogger_Image_Provider_Interface $provider;
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
private PRAutoBlogger_Cost_Tracker $cost_tracker;
@@ -41,7 +38,7 @@ class PRAutoBlogger_Image_Pipeline {
*
* When no provider is injected, the constructor reads the
* `prautoblogger_image_provider` setting and instantiates the
* matching concrete class ('openrouter' or 'cloudflare').
* matching concrete class ('runware' or 'openrouter').
*
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
@@ -57,7 +54,8 @@ class PRAutoBlogger_Image_Pipeline {
}
/**
* Instantiate the image provider based on the admin setting.
* Instantiate the image provider based on the admin setting. Defaults
* to Runware on unrecognised values (CF was removed in v0.10.0).
*
* @return PRAutoBlogger_Image_Provider_Interface
*/
@@ -66,7 +64,7 @@ class PRAutoBlogger_Image_Pipeline {
if ( 'openrouter' === $provider_id ) {
return new PRAutoBlogger_OpenRouter_Image_Provider();
}
return new PRAutoBlogger_Cloudflare_Image_Provider();
return new PRAutoBlogger_Runware_Image_Provider();
}
/**
@@ -138,6 +136,12 @@ class PRAutoBlogger_Image_Pipeline {
return $result;
}
// Single-shot retry for any slot the provider flagged as NSFW-blocked.
if ( PRAutoBlogger_Image_NSFW_Retry::is_enabled() ) {
( new PRAutoBlogger_Image_NSFW_Retry( $this->provider, $this->prompt_builder ) )
->retry_blocked_slots( $post_id, $batch_requests, $batch_results, $captions, $article_data, $source_data );
}
// Process Image A result.
$this->process_image_a( $post_id, $batch_results, $captions, $result );
@@ -165,7 +169,14 @@ class PRAutoBlogger_Image_Pipeline {
private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void {
$image_data = $batch_results['image_a'] ?? null;
if ( ! $image_data || isset( $image_data['error'] ) ) {
$result['errors'][] = $image_data['error'] ?? 'Image A missing from batch results.';
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
$result['errors'][] = $err_msg;
// Log at WARNING so the path is visible even if the outer catch in the
// caller doesn't fire. See thread image-mime-bug Issue 2.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ),
'image_pipeline'
);
return;
}
@@ -196,7 +207,14 @@ class PRAutoBlogger_Image_Pipeline {
private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void {
$image_data = $batch_results['image_b'] ?? null;
if ( ! $image_data || isset( $image_data['error'] ) ) {
$result['errors'][] = $image_data['error'] ?? 'Image B missing from batch results.';
$err_msg = $image_data['error'] ?? 'Image B missing from batch results.';
$result['errors'][] = $err_msg;
// Log at WARNING so the path is visible even if the outer catch in the
// caller doesn't fire. See thread image-mime-bug Issue 2.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ),
'image_pipeline'
);
return;
}

View File

@@ -21,23 +21,23 @@ declare(strict_types=1);
class PRAutoBlogger_Image_Prompt_Builder {
/**
* System prompt that teaches the LLM how to write image-gen prompts.
*
* Why this lives here instead of in wp_options: it's engineering-level
* instruction, not a user-facing setting. Changing it should require a
* code review, not an admin-panel click.
* Default system prompt that teaches the LLM how to write image-gen
* prompts. Exposed as a `public const` so the admin-settings layer can
* use it as the default value for `prautoblogger_image_prompt_instructions`.
* The option, when non-empty, wins at call time (see rewrite_via_llm).
*/
private const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
You are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor.
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Use anthropomorphized molecules, lab-coat-wearing animals, befuddled scientists, gym bros encountering peptide science, supplement bottles with personality, or everyday situations with a peptide twist. Keep it simple — one clear visual joke, 1-3 characters max.
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
Rules:
- The joke MUST relate to the article's actual subject matter, not generic science humor
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
- The caption should work as a standalone joke even without the image
@@ -45,9 +45,9 @@ Rules:
- Output ONLY the scene and caption. No preamble, no explanation.
Example output format:
A muscular gym bro stares in confusion at a tiny vial while a peptide molecule with arms and legs flexes next to him, clearly outperforming him.
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
"I've been doing this wrong for three years."
"They told me the peptide did the work, not the squats."
PROMPT;
/**
@@ -66,18 +66,12 @@ PROMPT;
/**
* Build a visual prompt from finished article content.
*
* Tries LLM rewriting first; falls back to rule-based synthesis on failure.
* Separates the scene description (for image gen) from the caption text
* (for insertion below the image as HTML). Appends style suffix to the
* scene-only prompt so no text is rendered inside the image.
* Tries LLM rewriting first; falls back to rule-based synthesis on
* failure. Splits scene (for image gen) from caption (HTML below the
* image) and appends the style suffix.
*
* @param array{
* post_title?: string,
* post_content?: string,
* suggested_title?: string,
* } $article_data Article data with title and HTML content.
*
* @return array{prompt: string, caption: string} Image prompt (scene + style) and caption text.
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
* @return array{prompt: string, caption: string}
*/
public function build_article_prompt( array $article_data ): array {
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
@@ -95,18 +89,11 @@ PROMPT;
}
/**
* Build a visual prompt from source Reddit thread data.
* Build a visual prompt from source Reddit thread data. Tries LLM
* rewriting first; falls back to rule-based synthesis on failure.
*
* Tries LLM rewriting first; falls back to rule-based synthesis on failure.
* Appends the style suffix from plugin options.
*
* @param array{
* title?: string,
* selftext?: string,
* comments?: string[],
* } $source_data Reddit source data with title and comments.
*
* @return array{prompt: string, caption: string} Image prompt (scene + style) and caption text.
* @param array{title?: string, selftext?: string, comments?: string[]} $source_data
* @return array{prompt: string, caption: string}
*/
public function build_source_prompt( array $source_data ): array {
$title = $source_data['title'] ?? 'Reddit Discussion';
@@ -156,10 +143,11 @@ PROMPT;
try {
$llm = $this->get_llm_provider();
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
$system = $this->resolve_system_prompt();
$result = $llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => self::REWRITER_SYSTEM_PROMPT ],
[ 'role' => 'system', 'content' => $system ],
[ 'role' => 'user', 'content' => $user_message ],
],
$model,
@@ -257,6 +245,23 @@ PROMPT;
return $para;
}
/**
* Public entry into the rule-based fallback, used by NSFW retry to
* rebuild a provider-safe prompt from just the article title. Matches
* the return shape of build_article_prompt() / build_source_prompt().
*
* @param string $title Article or source title.
* @return array{prompt: string, caption: string}
*/
public function build_fallback_prompt( string $title ): array {
$parsed = $this->synthesize_visual_concepts_fallback( $title, '' );
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
return [
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
];
}
/**
* Rule-based fallback when LLM rewriting is unavailable.
*
@@ -279,15 +284,17 @@ PROMPT;
];
}
/**
* Lazy-load the OpenRouter provider.
*
* @return PRAutoBlogger_OpenRouter_Provider
*/
/** Lazy-load the OpenRouter provider. */
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
if ( null === $this->llm ) {
$this->llm = new PRAutoBlogger_OpenRouter_Provider();
}
return $this->llm;
}
/** Admin option wins; blank falls back to REWRITER_SYSTEM_PROMPT. */
private function resolve_system_prompt(): string {
$override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' );
return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT;
}
}

View File

@@ -87,7 +87,7 @@ class PRAutoBlogger_Publisher {
'post_status' => $post_status,
'post_type' => 'post',
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
'meta_input' => $this->build_meta( $idea, $review ),
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
];
/** @see class-prautoblogger.php — listeners registered in main loader. */
@@ -120,15 +120,22 @@ class PRAutoBlogger_Publisher {
/**
* Build post_meta array for generation metadata.
*
* The `_prautoblogger_run_id` key (added v0.8.1) lets the orphan-research
* reaper attribute an orphan `llm_research` row back to its sibling
* articles without re-walking the gen_log table. See
* core/class-research-reaper.php.
*
* @param PRAutoBlogger_Article_Idea $idea
* @param PRAutoBlogger_Editorial_Review $review
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
* @return array<string, mixed>
*/
private function build_meta(
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review
PRAutoBlogger_Editorial_Review $review,
?string $run_id = null
): array {
return [
$meta = [
'_prautoblogger_generated' => '1',
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
@@ -143,5 +150,9 @@ class PRAutoBlogger_Publisher {
'_prautoblogger_article_type' => $idea->get_article_type(),
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
];
if ( null !== $run_id && '' !== $run_id ) {
$meta['_prautoblogger_run_id'] = $run_id;
}
return $meta;
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
/**
* Daily cron that retroactively amortizes orphan `llm_research` rows.
*
* When a pipeline run dies between post creation and the final
* `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill,
* OOM, LiteSpeed cut, fatal), the shared research-cost row stays in
* `wp_prautoblogger_generation_log` with `post_id = NULL` and the
* articles from that run understate their true cost in the popover.
*
* This reaper scans for such orphan rows once a day, finds their sibling
* posts via the `_prautoblogger_run_id` post_meta (primary) or the
* gen_log's own post-linked rows (fallback for legacy posts that
* predate the meta), and delegates to the existing idempotent
* `amortize_research_costs()` method. Orphans that still have no
* siblings after 7 days are deleted outright as sunk cost.
*
* Spec-wise the CTO asked for the reap method to live on Post_Assembler;
* hosting it here instead keeps Post_Assembler under the 300-line cap
* (it grew past 300 with the reap method attached) and colocates the
* attribution logic next to the cron/CLI plumbing that triggers it.
*
* Triggered by:
* - WP-Cron: `prautoblogger_reap_orphan_research_rows` (daily, 03:15).
* - WP-CLI: `wp prautoblogger reap-research` (manual ops trigger).
* Dependencies: PRAutoBlogger_Post_Assembler (amortize_research_costs),
* PRAutoBlogger_Logger, WordPress $wpdb + get_posts.
*
* @see core/class-post-assembler.php — amortize_research_costs() does the work.
* @see class-activator.php — Schedules the daily cron.
* @see class-deactivator.php — Unschedules on deactivate.
* @see ARCHITECTURE.md — Cost tracking data flow.
*/
class PRAutoBlogger_Research_Reaper {
/** Grace window — live pipelines have this long to finish amortizing before reaping. */
private const GRACE_WINDOW_SECONDS = 3600; // 1 hour.
/** Stale window — orphans with no sibling posts are deleted outright after this long. */
private const STALE_WINDOW_SECONDS = 7 * 86400; // 7 days.
/**
* Cron action handler — entrypoint for the daily WP-Cron event.
*
* Pure delegate. Never throws.
*
* @return void
*/
public static function on_cron(): void {
self::reap();
}
/**
* Scan and reap orphan `llm_research` rows. Returns a summary that the
* WP-CLI command prints and the cron path ignores.
*
* Side effects: SELECT / INSERT / DELETE on generation_log; post_meta
* reads; one INFO log line per reaped or stale-deleted
* orphan; one WARNING on DB error.
*
* @return array{reaped: int, deleted: int, skipped: int}
*/
public static function reap(): array {
$stats = [ 'reaped' => 0, 'deleted' => 0, 'skipped' => 0 ];
try {
global $wpdb;
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
$grace = gmdate( 'Y-m-d H:i:s', time() - self::GRACE_WINDOW_SECONDS );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$orphans = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, run_id, created_at FROM {$log_table}
WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s",
$grace
)
);
if ( ! is_array( $orphans ) ) {
return $stats;
}
$stale_cutoff = time() - self::STALE_WINDOW_SECONDS;
foreach ( $orphans as $orphan ) {
self::handle_orphan( $orphan, $stale_cutoff, $log_table, $stats );
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->warning(
'Reaper encountered DB error: ' . $e->getMessage(),
'cost-tracker'
);
}
return $stats;
}
/**
* Route one orphan row: amortize if siblings exist, delete if stale,
* otherwise skip and try again next run.
*
* @param object $orphan DB row with id, run_id, created_at.
* @param int $stale_cutoff Unix timestamp — orphans older than this delete.
* @param string $log_table Fully-qualified gen_log table name.
* @param array $stats Counters, mutated by reference.
* @return void
*/
private static function handle_orphan( object $orphan, int $stale_cutoff, string $log_table, array &$stats ): void {
global $wpdb;
$run_id = (string) ( $orphan->run_id ?? '' );
$created_ts = strtotime( (string) ( $orphan->created_at ?? '' ) );
$is_stale = false !== $created_ts && $created_ts < $stale_cutoff;
if ( '' === $run_id ) {
if ( $is_stale ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
$stats['deleted']++;
} else {
$stats['skipped']++;
}
return;
}
$post_ids = self::find_posts_for_run_id( $run_id );
if ( count( $post_ids ) >= 1 ) {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ),
'cost-tracker'
);
$stats['reaped']++;
return;
}
if ( $is_stale ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Deleted 7-day-stale orphan research row for run %s — no articles to attribute.', $run_id ),
'cost-tracker'
);
$stats['deleted']++;
return;
}
$stats['skipped']++;
}
/**
* Return distinct post IDs for a given run_id. Prefers post_meta;
* falls back to gen_log rows for legacy posts.
*
* @param string $run_id Pipeline run UUID.
* @return int[] Distinct post IDs.
*/
private static function find_posts_for_run_id( string $run_id ): array {
$meta_ids = get_posts( [
'post_type' => 'any',
'post_status' => 'any',
'meta_key' => '_prautoblogger_run_id',
'meta_value' => $run_id,
'fields' => 'ids',
'posts_per_page' => -1,
'suppress_filters' => true,
] );
if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) {
return array_map( 'intval', $meta_ids );
}
global $wpdb;
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT post_id FROM {$log_table} WHERE run_id = %s AND post_id IS NOT NULL",
$run_id
)
);
return is_array( $ids ) ? array_map( 'intval', $ids ) : [];
}
}

View File

@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the Cloudflare image provider.
*
* Kept separate from the provider so the provider stays under the 300-line
* cap and so cost estimation can be unit-tested without spinning up HTTP
* mocks. Mirrors the `class-open-router-pricing.php` split.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider (both generate_image()
* and estimate_cost() delegate here).
* Dependencies: None — pure calculation + option reads.
*
* @see class-cloudflare-image-provider.php — Caller.
* @see class-open-router-pricing.php — Sibling pattern for LLM pricing.
*/
class PRAutoBlogger_Cloudflare_Image_Pricing {
/**
* Cloudflare Workers AI FLUX pricing (USD per megapixel), April 2026.
* Single source of truth — update here if Cloudflare reprices.
*/
private const COST_PER_MP_SCHNELL_USD = 0.0011;
/**
* Short-alias → fully-qualified Workers AI model id.
*
* FLUX.1 [dev] was removed from Cloudflare Workers AI in April 2026
* (returns HTTP 400 "No route for that URI"). Only schnell remains.
* Any saved setting referencing flux-1-dev falls through to schnell
* via the null-coalesce in resolve_model().
*
* @var array<string, string>
*/
private const MODEL_ALIAS_MAP = [
'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell',
];
/**
* Normalize a caller's model hint to a fully-qualified Workers AI id.
*
* Accepts short aliases (`flux-1-schnell`), fully-qualified names
* (`@cf/black-forest-labs/...`), or empty. Empty input falls back to
* the site option, then to the hardcoded default.
*
* @param string $hint Caller-supplied model identifier (may be empty).
* @return string Fully-qualified Workers AI model id suitable for the URL path.
*/
public function resolve_model( string $hint ): string {
$hint = trim( $hint );
if ( '' === $hint ) {
$hint = (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
}
if ( 0 === strpos( $hint, '@cf/' ) ) {
return $hint;
}
return self::MODEL_ALIAS_MAP[ $hint ] ?? self::MODEL_ALIAS_MAP['flux-1-schnell'];
}
/**
* Estimate USD cost for a single image at the given dimensions.
*
* FLUX is priced per megapixel of output. We clamp the MP floor at
* 0.01 so a tiny thumbnail doesn't round to zero and bypass the
* budget check.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param string $model Fully-qualified Workers AI model id (call resolve_model first).
* @return float USD cost rounded to 6 decimals.
*/
public function estimate_cost( int $width, int $height, string $model ): float {
$mp = max( 0.01, ( $width * $height ) / 1_000_000 );
$rate = self::COST_PER_MP_SCHNELL_USD;
return round( $mp * $rate, 6 );
}
}

View File

@@ -1,297 +0,0 @@
<?php
declare(strict_types=1);
/**
* Cloudflare Workers AI image provider — FLUX.1 family.
*
* Calls `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/{model}`
* directly. We bypass the Cloudflare AI Gateway for v1 because the gateway
* route to Workers AI currently 403s (see decision D-001); the OpenRouter
* gateway path is unaffected.
*
* Triggered by: PRAutoBlogger_Image_Pipeline (commit 1b) during the content
* generation run, after editorial review, before publish. Also
* triggered by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
* (WARN/ERROR lines), PRAutoBlogger_Cloudflare_Image_Pricing
* (model resolution + cost estimation).
*
* @see interface-image-provider.php Interface this class implements.
* @see class-cloudflare-image-pricing.php Cost + model id helpers.
* @see class-cloudflare-image-support.php Token, account, URL, and log helpers.
* @see class-cloudflare-image-validator.php Credential check helper.
* @see class-open-router-provider.php Sibling provider; same retry pattern.
* @see ARCHITECTURE.md External API Integrations, key decision #16.
*/
class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'cloudflare_workers_ai';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
private const DEFAULT_STEPS_SCHNELL = 4;
private const RETRY_AFTER_CAP_S = 60;
/**
* Pricing + model-id resolver. Constructed once per provider instance.
*
* @var PRAutoBlogger_Cloudflare_Image_Pricing
*/
private PRAutoBlogger_Cloudflare_Image_Pricing $pricing;
/**
* Token, account, URL, and log helpers — pulled out of this class to
* keep it under the 300-line cap.
*
* @var PRAutoBlogger_Cloudflare_Image_Support
*/
private PRAutoBlogger_Cloudflare_Image_Support $support;
/**
* Construct the provider with its default pricing + support helpers.
*/
public function __construct() {
$this->pricing = new PRAutoBlogger_Cloudflare_Image_Pricing();
$this->support = new PRAutoBlogger_Cloudflare_Image_Support();
}
/**
* Generate a single image from a prompt at the requested dimensions.
*
* Retries 5xx / 429 / network errors with exponential backoff bounded
* by PRAUTOBLOGGER_MAX_RETRIES. 4xx other than 429 fail loudly on the
* first attempt — retrying a bad prompt or a bad token wastes money.
*
* Side effects: one HTTP request per attempt; one Logger line per attempt.
*
* {@inheritDoc}
*
* @throws \InvalidArgumentException If dimensions out of range or prompt empty.
* @throws \RuntimeException On auth failure, bad response shape, or retry exhaustion.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
// FLUX.1 requires dimensions divisible by 8; round to nearest valid value.
$width = (int) ( round( $width / 8 ) * 8 );
$height = (int) ( round( $height / 8 ) * 8 );
$this->assert_valid_dimensions( $width, $height );
$prompt = trim( $prompt );
if ( '' === $prompt ) {
throw new \InvalidArgumentException(
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
);
}
$account_id = $this->support->get_account_id();
$api_token = $this->support->get_api_token();
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
$body = [
'prompt' => $prompt,
'width' => $width,
'height' => $height,
'steps' => (int) ( $options['steps'] ?? self::DEFAULT_STEPS_SCHNELL ),
];
if ( isset( $options['seed'] ) ) {
$body['seed'] = (int) $options['seed'];
}
$url = $this->support->build_endpoint_url( $account_id, $model );
$headers = [
'Authorization' => 'Bearer ' . $api_token,
'Content-Type' => 'application/json',
'Accept' => 'image/*',
];
$started_at = microtime( true );
$last_error = '';
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post(
$url,
[
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
'headers' => $headers,
'body' => wp_json_encode( $body ),
]
);
if ( is_wp_error( $response ) ) {
$last_error = $response->get_error_message();
$this->support->log_retryable_failure( $attempt, 'network', $last_error );
$this->sleep_for_attempt( $attempt );
continue;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 429 === $status || $status >= 500 ) {
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
$this->support->log_retryable_failure( $attempt, (string) $status, $last_error );
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
$this->sleep_for_attempt( $attempt, $retry_after );
}
continue;
}
if ( $status >= 400 ) {
$this->support->log_client_error( $status, $api_token, $account_id, $raw );
throw new \RuntimeException(
sprintf(
/* translators: %1$d: HTTP status, %2$s: error body. */
esc_html__( 'Cloudflare Workers AI error (HTTP %1$d): %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $raw, 0, 300 ) )
)
);
}
// Success. FLUX on Workers AI returns either raw image bytes
// (Content-Type: image/*) or a JSON envelope with base64 under
// `result.image`. Handle both without branching the caller.
$mime = (string) wp_remote_retrieve_header( $response, 'content-type' );
[ $raw, $mime ] = $this->normalize_response_body( $raw, $mime );
return [
'bytes' => $raw,
'mime_type' => $mime,
'width' => $width,
'height' => $height,
'model' => $model,
'seed' => isset( $body['seed'] ) ? (int) $body['seed'] : null,
'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ),
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
];
}
throw new \RuntimeException(
sprintf(
/* translators: %1$d: max retries, %2$s: last error. */
esc_html__( 'Cloudflare Workers AI failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
PRAUTOBLOGGER_MAX_RETRIES,
esc_html( $last_error )
)
);
}
/**
* Sequential fallback — Cloudflare Workers AI does not support batching.
*
* {@inheritDoc}
*/
public function generate_image_batch( array $requests ): array {
$results = [];
foreach ( $requests as $key => $req ) {
try {
$results[ $key ] = $this->generate_image(
$req['prompt'],
$req['width'],
$req['height'],
$req['options'] ?? []
);
} catch ( \Throwable $e ) {
$results[ $key ] = [ 'error' => $e->getMessage() ];
}
}
return $results;
}
/**
* Estimate the USD cost of a single generation call.
*
* {@inheritDoc}
*/
public function estimate_cost( int $width, int $height, array $options = [] ): float {
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
return $this->pricing->estimate_cost( $width, $height, $model );
}
/**
* @return string Machine-readable provider id used in logs and settings.
*/
public function get_provider_name(): string {
return self::PROVIDER_ID;
}
/**
* Verify credentials are present and the API is reachable.
*
* Delegates to PRAutoBlogger_Cloudflare_Image_Validator so the HTTP probe
* and error-shape code can be tested and sized independently.
*
* Side effects: one HTTP GET via the validator; one Logger line on failure.
*
* @return array{status: string, message: string, debug?: string}
*/
public function validate_credentials_detailed(): array {
return ( new PRAutoBlogger_Cloudflare_Image_Validator() )->run();
}
/**
* Enforce the interface's dimension contract.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @return void
* @throws \InvalidArgumentException If either dimension is outside [MIN, MAX].
*/
private function assert_valid_dimensions( int $width, int $height ): void {
if (
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
) {
throw new \InvalidArgumentException(
sprintf(
/* translators: %1$d: width, %2$d: height, %3$d: min, %4$d: max. */
esc_html__( 'Image dimensions %1$dx%2$d are outside the supported range (%3$d–%4$d px per axis).', 'prautoblogger' ),
$width,
$height,
self::MIN_DIMENSION_PX,
self::MAX_DIMENSION_PX
)
);
}
}
/**
* Normalize a 2xx response into [bytes, mime-type] whether the body
* arrives as raw image bytes or as a JSON envelope with base64 image.
*
* @param string $raw Raw response body.
* @param string $mime Response Content-Type header value (may be empty).
* @return array{0: string, 1: string} [image bytes, mime type]
* @throws \RuntimeException If the body matches neither expected shape.
*/
private function normalize_response_body( string $raw, string $mime ): array {
if ( '' !== $mime && 0 === stripos( $mime, 'image/' ) ) {
return [ $raw, $mime ];
}
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) && isset( $decoded['result']['image'] ) && is_string( $decoded['result']['image'] ) ) {
$bytes = (string) base64_decode( $decoded['result']['image'], true );
if ( '' !== $bytes ) {
return [ $bytes, 'image/png' ];
}
}
throw new \RuntimeException(
esc_html__( 'Cloudflare Workers AI returned an unexpected response shape.', 'prautoblogger' )
);
}
/**
* Sleep between retries. Retry-After header value wins when present,
* otherwise exponential backoff from the PRAUTOBLOGGER_* constants.
*
* @param int $attempt 1-based attempt number that just failed.
* @param int $retry_after_secs Retry-After header value (0 if absent).
* @return void
*/
private function sleep_for_attempt( int $attempt, int $retry_after_secs = 0 ): void {
$backoff = PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
$delay = $retry_after_secs > 0 ? min( $retry_after_secs, self::RETRY_AFTER_CAP_S ) : $backoff;
if ( $delay > 0 ) {
sleep( $delay );
}
}
}

View File

@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
/**
* Small support helpers for the Cloudflare image provider — credential
* lookups, URL building, and structured log lines.
*
* Split out of the provider so the provider stays under the 300-line cap
* and so these tiny pieces are individually testable. Pure functions plus
* one logger forward; no state.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider only.
* Dependencies: PRAutoBlogger_Encryption (token decryption),
* PRAutoBlogger_Logger (WARN/ERROR forwarding).
*
* @see class-cloudflare-image-provider.php Sole caller.
*/
class PRAutoBlogger_Cloudflare_Image_Support {
/**
* Decrypt the Cloudflare API token from the settings option.
*
* @return string Plaintext token, or empty string if not set / decrypt fails.
*/
public function get_api_token(): string {
$stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
if ( '' === $stored ) {
return '';
}
// If the token is already encrypted (has "enc:" prefix), decrypt it.
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
return (string) PRAutoBlogger_Encryption::decrypt( $stored );
}
// Legacy: token was saved as plaintext before the field was added to
// the encrypted list in sanitize_field(). Encrypt it now so future
// reads go through the normal path, then return the plaintext value.
$cipher = PRAutoBlogger_Encryption::encrypt( $stored );
if ( '' !== $cipher ) {
update_option( 'prautoblogger_cloudflare_ai_token', $cipher );
}
return $stored;
}
/**
* Read the Cloudflare account ID from settings.
*
* @return string Account UUID, or empty string.
*/
public function get_account_id(): string {
return trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
}
/**
* Build the Workers AI run URL for a given account + model.
*
* @param string $account_id Cloudflare account UUID.
* @param string $model Fully-qualified Workers AI model id.
* @return string Fully-formed HTTPS URL.
*/
public function build_endpoint_url( string $account_id, string $model ): string {
return sprintf(
'https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s',
rawurlencode( $account_id ),
ltrim( $model, '/' )
);
}
/**
* Emit a WARN line for a retryable failure.
*
* @param int $attempt 1-based attempt number.
* @param string $failure_class Either 'network' or HTTP status code as string.
* @param string $detail Short failure detail for the log.
* @return void
*/
public function log_retryable_failure( int $attempt, string $failure_class, string $detail ): void {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Cloudflare image gen %s failure (attempt %d/%d): %s',
$failure_class,
$attempt,
PRAUTOBLOGGER_MAX_RETRIES,
$detail
),
'cloudflare-image'
);
}
/**
* Log an unrecoverable 4xx with triage context. Never logs the full
* secret — only a 4-char token prefix and a 6-char account prefix.
*
* @param int $status HTTP status returned by Cloudflare.
* @param string $api_token Plaintext token.
* @param string $account_id Account UUID.
* @param string $raw Raw response body (truncated to 300 chars in the log).
* @return void
*/
public function log_client_error( int $status, string $api_token, string $account_id, string $raw ): void {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Cloudflare image gen HTTP %d (token_prefix=%s, token_len=%d, account=%s): %s',
$status,
substr( $api_token, 0, 4 ),
strlen( $api_token ),
substr( $account_id, 0, 6 ),
substr( $raw, 0, 300 )
),
'cloudflare-image'
);
}
}

View File

@@ -1,122 +0,0 @@
<?php
declare(strict_types=1);
/**
* Credential + connectivity validator for the Cloudflare image provider.
*
* Split out of the provider to keep each file under the 300-line cap and
* to keep the validation path easy to unit-test without spinning up the
* retry loop. The provider's `validate_credentials_detailed()` delegates
* straight to `run()` here.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider::validate_credentials_detailed()
* which is called by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
* (WARN/ERROR lines), wp_remote_get().
*
* @see class-cloudflare-image-provider.php Parent class that delegates here.
* @see interface-image-provider.php Return-shape contract.
*/
class PRAutoBlogger_Cloudflare_Image_Validator {
private const TIMEOUT_SECONDS = 15;
/**
* Run a non-destructive credential check.
*
* Lightweight probe: hits the account-scoped AI models listing endpoint.
* Never generates a real image — that would cost money on every click.
*
* Side effects: one HTTP GET; one Logger line on failure.
*
* @return array{status: string, message: string, debug?: string}
*/
public function run(): array {
$account_id = trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
if ( '' === $account_id ) {
return $this->err(
'account_id_empty',
__( 'Cloudflare account ID is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
);
}
$stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
$api_token = '';
if ( '' !== $stored ) {
// Handle legacy plaintext tokens (saved before the field was in the encrypted list).
$api_token = PRAutoBlogger_Encryption::is_encrypted( $stored )
? (string) PRAutoBlogger_Encryption::decrypt( $stored )
: $stored;
}
if ( '' === $api_token ) {
return $this->err(
'token_empty',
__( 'Cloudflare API token is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
);
}
$response = wp_remote_get(
sprintf(
'https://api.cloudflare.com/client/v4/accounts/%s/ai/models/search?per_page=1',
rawurlencode( $account_id )
),
[
'timeout' => self::TIMEOUT_SECONDS,
'headers' => [
'Authorization' => 'Bearer ' . $api_token,
'Content-Type' => 'application/json',
],
]
);
if ( is_wp_error( $response ) ) {
$msg = $response->get_error_message();
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Cloudflare credential check wp_error: %s', $msg ),
'cloudflare-image'
);
return $this->err(
'wp_error:' . $msg,
sprintf(
/* translators: %s: transport-layer error message. */
esc_html__( 'Network error reaching Cloudflare: %s', 'prautoblogger' ),
esc_html( $msg )
)
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 === $status ) {
return [
'status' => 'ok',
'message' => __( 'Cloudflare Workers AI connected.', 'prautoblogger' ),
];
}
$body = (string) wp_remote_retrieve_body( $response );
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Cloudflare credential check HTTP %d: %s', $status, substr( $body, 0, 200 ) ),
'cloudflare-image'
);
return $this->err(
sprintf( 'http_%d', $status ),
sprintf(
/* translators: %1$d: HTTP status, %2$s: response body. */
esc_html__( 'Cloudflare returned HTTP %1$d. %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $body, 0, 200 ) )
)
);
}
/**
* @return array{status: string, message: string, debug: string}
*/
private function err( string $debug, string $message ): array {
return [
'status' => 'error',
'message' => $message,
'debug' => $debug,
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/**
* Exception raised when an image provider rejects a prompt as NSFW.
*
* Image providers (Runware, OpenRouter) return provider-specific error
* shapes when their content filters block a prompt. We catch those shapes
* in each provider and raise this typed exception so the image pipeline
* can distinguish a content-filter rejection from a generic 4xx and
* retry once with a sanitized fallback prompt.
*
* Triggered by: image providers on NSFW-filter rejection.
* Dependencies: None.
*
* @see providers/class-runware-image-provider.php — May raise this.
* @see providers/class-open-router-image-provider.php — May raise this.
* @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt.
*/
class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException {
/**
* The raw upstream response body, kept verbatim for log forensics.
*
* @var string
*/
private string $upstream_body;
/**
* @param string $message Human-readable summary.
* @param string $upstream_body Raw response body from the provider.
* @param \Throwable|null $previous Optional previous exception.
*/
public function __construct( string $message, string $upstream_body = '', ?\Throwable $previous = null ) {
parent::__construct( $message, 0, $previous );
$this->upstream_body = $upstream_body;
}
/**
* @return string Raw upstream body (may be empty).
*/
public function get_upstream_body(): string {
return $this->upstream_body;
}
}

View File

@@ -130,6 +130,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
$results = [];
foreach ( $handles as $key => $entry ) {
$results[ $key ] = $this->collect_result(
(string) $key,
$entry['handle'],
$entry['model'],
$entry['req'],
@@ -169,21 +170,35 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
/**
* Parse the result of one cURL handle into image data or error.
*
* All failure paths emit `Logger::error` at the point they fold the
* request into an `['error' => ...]` array — tagged with the request
* `$key` so the pipeline can attribute which of the parallel images
* failed (image_a vs image_b) without cross-referencing timestamps.
*
* @param string $key Request key from the batch map (e.g. "image_a").
* @param \CurlHandle $ch Individual cURL handle (completed).
* @param string $model Resolved model id.
* @param array $req Original request array.
* @param float $start Microtime when this handle was created.
*
* @return array Image data array or `{error: string}`.
*
* Side effects: writes `Logger::error` on any failure path.
*/
private function collect_result( $ch, string $model, array $req, float $start ): array {
private function collect_result( string $key, $ch, string $model, array $req, float $start ): array {
$latency_ms = (int) ( ( microtime( true ) - $start ) * 1000 );
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno
$curl_errno = curl_errno( $ch );
if ( 0 !== $curl_errno ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
$msg = sprintf( 'cURL error %d: %s', $curl_errno, curl_error( $ch ) );
$detail = curl_error( $ch );
$msg = sprintf(
'OpenRouter batch image gen cURL error %d for key "%s": %s',
$curl_errno,
$key,
$detail
);
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return [ 'error' => $msg ];
}
@@ -194,7 +209,12 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
$raw = (string) curl_multi_getcontent( $ch );
if ( $http_code >= 400 ) {
$msg = sprintf( 'HTTP %d: %s', $http_code, substr( $raw, 0, 300 ) );
$msg = sprintf(
'OpenRouter batch image gen HTTP %d for key "%s": %s',
$http_code,
$key,
substr( $raw, 0, 300 )
);
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return [ 'error' => $msg ];
}
@@ -202,11 +222,13 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
try {
$image_bytes = $this->support->extract_image_bytes( $raw );
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
'Batch parse error: ' . $e->getMessage(),
'openrouter-image-batch'
$msg = sprintf(
'OpenRouter batch image gen parse error for key "%s": %s',
$key,
$e->getMessage()
);
return [ 'error' => $e->getMessage() ];
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return [ 'error' => $msg ];
}
return [

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the OpenRouter image provider.
*
* Mirrors the Cloudflare pricing helper pattern. Each model's cost per image
* Mirrors the Runware pricing helper pattern. Each model's cost per image
* is a flat rate sourced from OpenRouter's pricing page (April 2026).
*
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider (both generate_image()
@@ -12,7 +12,7 @@ declare(strict_types=1);
* Dependencies: None — pure calculation + option reads.
*
* @see class-openrouter-image-provider.php — Caller.
* @see class-cloudflare-image-pricing.php — Sibling pattern for Cloudflare.
* @see class-runware-image-pricing.php — Sibling pattern for Runware.
*/
class PRAutoBlogger_OpenRouter_Image_Pricing {

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
/**
* Parallel image generation via curl_multi for Runware.
*
* Fires multiple POST /v1 inference calls concurrently, then fetches
* the returned signed image URLs in a second parallel round. Cuts
* wall-clock time from N×2s to ~2s regardless of batch size — matches
* the Image A/B parallelism pattern already in use for OpenRouter.
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider::generate_image_batch().
* Dependencies: PRAutoBlogger_Runware_Image_Support, PRAutoBlogger_Runware_Image_Pricing,
* PRAutoBlogger_Logger.
*
* @see class-runware-image-provider.php — Sole caller.
* @see class-runware-image-support.php — Response parsing + image URL fetch.
* @see class-open-router-image-batch.php — Sibling pattern for OpenRouter.
*/
class PRAutoBlogger_Runware_Image_Batch {
/** @var PRAutoBlogger_Runware_Image_Pricing */
private PRAutoBlogger_Runware_Image_Pricing $pricing;
/** @var PRAutoBlogger_Runware_Image_Support */
private PRAutoBlogger_Runware_Image_Support $support;
public function __construct(
PRAutoBlogger_Runware_Image_Pricing $pricing,
PRAutoBlogger_Runware_Image_Support $support
) {
$this->pricing = $pricing;
$this->support = $support;
}
/**
* Execute multiple image generation requests concurrently via curl_multi.
*
* Two parallel rounds: (1) POST /v1 returns signed imageURL per request;
* (2) GET each signed imageURL returns PNG bytes. Each round runs
* concurrently. No retry — single-attempt success rate on schnell >99%.
*
* @param array<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
* @return array<string, array> Image data or `{error}` keyed identically.
*/
public function execute( array $requests ): array {
if ( empty( $requests ) ) {
return [];
}
$api_key = $this->support->get_api_key();
$round1 = $this->round_one_inference( $api_key, $requests );
$results = [];
$to_download = [];
foreach ( $round1 as $key => $entry ) {
if ( isset( $entry['error'] ) ) {
$results[ $key ] = [ 'error' => $entry['error'] ];
continue;
}
$to_download[ $key ] = $entry;
}
if ( empty( $to_download ) ) {
return $results;
}
$bytes_map = $this->round_two_download( $to_download );
foreach ( $to_download as $key => $entry ) {
if ( isset( $bytes_map[ $key ]['error'] ) ) {
$results[ $key ] = [ 'error' => $bytes_map[ $key ]['error'] ];
continue;
}
$results[ $key ] = [
'bytes' => $bytes_map[ $key ]['bytes'],
'mime_type' => 'image/png',
'width' => $entry['width'],
'height' => $entry['height'],
'model' => $entry['model'],
'seed' => $entry['seed'],
'cost_usd' => $this->pricing->estimate_cost( $entry['width'], $entry['height'], $entry['model'] ),
'latency_ms' => (int) ( ( microtime( true ) - $entry['started_at'] ) * 1000 ),
];
}
return $results;
}
/**
* Round 1: fire all inference POSTs in parallel.
*
* @param string $api_key Plaintext API key.
* @param array $requests Batch requests.
* @return array<string, array> Map of key => {error} | {image_url, width, height, model, seed, started_at}.
*/
private function round_one_inference( string $api_key, array $requests ): array {
$headers = [ 'Content-Type: application/json' ];
$mh = curl_multi_init();
$handles = [];
$start_times = [];
foreach ( $requests as $key => $req ) {
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
$snap_w = $this->support->snap_dimension( (int) $req['width'] );
$snap_h = $this->support->snap_dimension( (int) $req['height'] );
$seed = $this->support->normalize_seed( isset( $req['options']['seed'] ) ? (int) $req['options']['seed'] : null );
$steps = isset( $req['options']['steps'] ) ? (int) $req['options']['steps'] : $this->pricing->default_steps( $model );
$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, PRAutoBlogger_Runware_Image_Support::API_URL );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode(
$this->build_payload( $api_key, (string) $req['prompt'], $snap_w, $snap_h, $model, $seed, $steps )
) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
curl_multi_add_handle( $mh, $ch );
$handles[ $key ] = [
'handle' => $ch,
'model' => $model,
'width' => $snap_w,
'height' => $snap_h,
'seed' => $seed,
];
$start_times[ $key ] = microtime( true );
}
$this->run_multi( $mh );
$out = [];
foreach ( $handles as $key => $entry ) {
$out[ $key ] = $this->collect_inference_result( (string) $key, $entry, $start_times[ $key ] );
curl_multi_remove_handle( $mh, $entry['handle'] );
curl_close( $entry['handle'] );
}
curl_multi_close( $mh );
return $out;
}
/**
* Round 2: fetch all signed image URLs in parallel.
*
* @param array<string, array> $inferences Map from round one.
* @return array<string, array{bytes?: string, error?: string}>
*/
private function round_two_download( array $inferences ): array {
$mh = curl_multi_init();
$handles = [];
foreach ( $inferences as $key => $entry ) {
$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, $entry['image_url'] );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_TIMEOUT, 30 );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
curl_multi_add_handle( $mh, $ch );
$handles[ $key ] = $ch;
}
$this->run_multi( $mh );
$out = [];
foreach ( $handles as $key => $ch ) {
$errno = curl_errno( $ch );
if ( 0 !== $errno ) {
$msg = sprintf( 'Runware image download cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
$out[ $key ] = [ 'error' => $msg ];
} else {
$code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$body = (string) curl_multi_getcontent( $ch );
if ( 200 !== $code || '' === $body ) {
$msg = sprintf( 'Runware image download HTTP %d for "%s" (%d bytes)', $code, $key, strlen( $body ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
$out[ $key ] = [ 'error' => $msg ];
} else {
$out[ $key ] = [ 'bytes' => $body ];
}
}
curl_multi_remove_handle( $mh, $ch );
curl_close( $ch );
}
curl_multi_close( $mh );
return $out;
}
/**
* Drive curl_multi_exec to completion.
*
* @param \CurlMultiHandle $mh Multi handle.
*/
private function run_multi( $mh ): void {
$running = 0;
do {
$status = curl_multi_exec( $mh, $running );
if ( $running > 0 ) {
curl_multi_select( $mh, 1.0 );
}
} while ( $running > 0 && CURLM_OK === $status );
}
/**
* Build the auth+inference task-array body for one image request.
*
* @param string $api_key Plaintext API key.
* @param string $prompt Prompt text.
* @param int $width Snapped width.
* @param int $height Snapped height.
* @param string $model Resolved model id.
* @param int $seed Positive seed.
* @param int $steps Inference steps.
* @return array<int, array<string, mixed>>
*/
private function build_payload( string $api_key, string $prompt, int $width, int $height, string $model, int $seed, int $steps ): array {
return [
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
[
'taskType' => 'imageInference',
'taskUUID' => wp_generate_uuid4(),
'positivePrompt' => $prompt,
'width' => $width,
'height' => $height,
'model' => $model,
'steps' => $steps,
'numberResults' => 1,
'outputFormat' => 'PNG',
'outputType' => 'URL',
'seed' => $seed,
],
];
}
/**
* Parse one round-1 handle into either an error or an imageURL entry.
*
* @param string $key Request key.
* @param array $entry Handle entry (handle + model + dims + seed).
* @param float $start Microtime when this handle was queued.
* @return array Either {error} or {image_url, width, height, model, seed, started_at}.
*/
private function collect_inference_result( string $key, array $entry, float $start ): array {
$ch = $entry['handle'];
$errno = curl_errno( $ch );
if ( 0 !== $errno ) {
$msg = sprintf( 'Runware batch inference cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$raw = (string) curl_multi_getcontent( $ch );
if ( $http_code >= 400 ) {
$msg = sprintf( 'Runware batch inference HTTP %d for "%s": %s', $http_code, $key, substr( $raw, 0, 300 ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
try {
$image_url = $this->support->extract_image_url( $raw );
} catch ( \Throwable $e ) {
$msg = sprintf( 'Runware batch inference parse error for "%s": %s', $key, $e->getMessage() );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
return [
'image_url' => $image_url,
'width' => (int) $entry['width'],
'height' => (int) $entry['height'],
'model' => (string) $entry['model'],
'seed' => (int) $entry['seed'],
'started_at' => $start,
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the Runware image provider.
*
* Each Runware FLUX model has a flat per-image cost sourced from
* runware.ai/pricing (April 2026). Unlike OpenRouter models, Runware
* prices scale slightly with steps — the quoted rate here assumes the
* provider's default step count (4 for schnell, 28 for dev).
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
* Dependencies: None — pure calculation + option reads.
*
* @see class-runware-image-provider.php — Primary caller.
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
*/
class PRAutoBlogger_Runware_Image_Pricing {
/**
* Flat per-image cost (USD) for each supported Runware model, April 2026.
* Update here when Runware reprices or new models launch.
*
* Runware model IDs use `family:variant@version` form. The two FLUX
* variants we support are the CEO-sanctioned defaults.
*
* @var array<string, float>
*/
private const COST_PER_IMAGE = [
// FLUX.1 schnell — 4-step distilled. Our new default (2026-04-21).
'runware:100@1' => 0.0006,
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x cost.
'runware:101@1' => 0.02,
];
/**
* Default number of inference steps per model. Runware bills per image
* at the model's standard step count; custom step counts may alter price.
*
* @var array<string, int>
*/
private const DEFAULT_STEPS = [
'runware:100@1' => 4,
'runware:101@1' => 28,
];
/**
* Resolve the model identifier from a hint, falling back to site option,
* then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant.
*
* If the saved/default model is not a Runware id (e.g. still points at
* a Gemini slug), fall back to schnell — the provider was selected
* explicitly, so the user wants a Runware model.
*
* @param string $hint Caller-supplied model identifier (may be empty).
* @return string Runware model id.
*/
public function resolve_model( string $hint ): string {
$candidate = trim( $hint );
if ( '' === $candidate ) {
$candidate = trim( (string) get_option( 'prautoblogger_image_model', '' ) );
}
if ( '' === $candidate && defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
$candidate = (string) PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL;
}
// If the candidate isn't a known Runware id, fall back to schnell.
if ( '' === $candidate || ! isset( self::COST_PER_IMAGE[ $candidate ] ) ) {
return 'runware:100@1';
}
return $candidate;
}
/**
* Estimate USD cost for a single image at the given model.
*
* Runware FLUX pricing is flat per image at the model's standard step
* count. Width/height are accepted for interface compatibility but do
* not affect cost for the variants we support.
*
* @param int $width Width in pixels (unused for flat pricing).
* @param int $height Height in pixels (unused for flat pricing).
* @param string $model Resolved Runware model id.
* @return float USD cost rounded to 6 decimals.
*/
public function estimate_cost( int $width, int $height, string $model ): float {
if ( isset( self::COST_PER_IMAGE[ $model ] ) ) {
return round( self::COST_PER_IMAGE[ $model ], 6 );
}
// Unknown model — log it and use a conservative $0.01 estimate.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'No Runware pricing data for model "%s". Using $0.01 estimate.', $model ),
'runware-image'
);
return 0.01;
}
/**
* Get the default step count for a Runware model.
*
* @param string $model Resolved Runware model id.
* @return int Default steps.
*/
public function default_steps( string $model ): int {
return self::DEFAULT_STEPS[ $model ] ?? 4;
}
/**
* Get the full model cost table for admin display.
*
* @return array<string, float> Model id => cost per image in USD.
*/
public static function get_model_costs(): array {
return self::COST_PER_IMAGE;
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
/**
* Runware image provider — FLUX.1 schnell / dev via runware.ai.
*
* Default image backend as of 2026-04-21: schnell (runware:100@1) at
* ~$0.0006/image, ~2s latency. 65× cheaper than Gemini Nano Banana with
* quality that reads as "stylistic looseness" for our comic aesthetic.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
* Dependencies: Runware_Image_Support (key/parsing/backoff),
* Runware_Image_Pricing (model/cost),
* Runware_Image_Batch (parallel curl_multi), Logger.
*
* @see interface-image-provider.php — Contract.
* @see class-runware-image-batch.php — Parallel generation via curl_multi.
* @see class-runware-image-support.php — Key, response parsing, retry.
* @see class-runware-image-pricing.php — Model + cost table.
* @see class-open-router-image-provider.php — Sibling pattern for OpenRouter.
* @see ARCHITECTURE.md — External API Integrations table.
*/
class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'runware';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
/** @var PRAutoBlogger_Runware_Image_Pricing */
private PRAutoBlogger_Runware_Image_Pricing $pricing;
/** @var PRAutoBlogger_Runware_Image_Support */
private PRAutoBlogger_Runware_Image_Support $support;
public function __construct() {
$this->pricing = new PRAutoBlogger_Runware_Image_Pricing();
$this->support = new PRAutoBlogger_Runware_Image_Support();
}
/**
* {@inheritDoc}
*
* Two-step HTTP flow: POST /v1 returns a signed imageURL, then GET that
* URL for the bytes. Retries 5xx/429 on the POST with exponential
* backoff; 4xx (except 429) fail immediately. The image download GET
* is not retried — signed URLs are short-lived.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
$this->assert_valid_dimensions( $width, $height );
$prompt = trim( $prompt );
if ( '' === $prompt ) {
throw new \InvalidArgumentException(
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
);
}
$api_key = $this->support->get_api_key();
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
$snap_w = $this->support->snap_dimension( $width );
$snap_h = $this->support->snap_dimension( $height );
$seed = $this->support->normalize_seed( isset( $options['seed'] ) ? (int) $options['seed'] : null );
$steps = isset( $options['steps'] ) ? (int) $options['steps'] : $this->pricing->default_steps( $model );
$body = $this->build_request_body( $api_key, $prompt, $snap_w, $snap_h, $model, $seed, $steps );
$headers = [ 'Content-Type' => 'application/json' ];
$started_at = microtime( true );
$last_error = '';
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
'headers' => $headers,
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
$last_error = $response->get_error_message();
$this->support->log_retry( $attempt, 'network', $last_error );
$this->support->backoff( $attempt );
continue;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 429 === $status || $status >= 500 ) {
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
$this->support->log_retry( $attempt, (string) $status, $last_error );
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
$this->support->backoff( $attempt, $retry_after );
}
continue;
}
if ( $status >= 400 ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Runware image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ),
'runware-image'
);
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image error (HTTP %1$d): %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $raw, 0, 300 ) )
)
);
}
$image_url = $this->support->extract_image_url( $raw );
$image_bytes = $this->support->download_image_bytes( $image_url );
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $snap_w,
'height' => $snap_h,
'model' => $model,
'seed' => $seed,
'cost_usd' => $this->pricing->estimate_cost( $snap_w, $snap_h, $model ),
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
];
}
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
PRAUTOBLOGGER_MAX_RETRIES,
esc_html( $last_error )
)
);
}
/**
* Generate multiple images concurrently via curl_multi.
*
* Delegates to PRAutoBlogger_Runware_Image_Batch, which fires all
* inference POSTs in parallel, then fetches the signed image URLs
* in parallel as well. Wall-clock time ≈ slowest single image.
*
* {@inheritDoc}
*/
public function generate_image_batch( array $requests ): array {
foreach ( $requests as $key => $req ) {
$this->assert_valid_dimensions( $req['width'], $req['height'] );
if ( '' === trim( $req['prompt'] ) ) {
throw new \InvalidArgumentException(
sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) )
);
}
}
$batch = new PRAutoBlogger_Runware_Image_Batch( $this->pricing, $this->support );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Starting parallel Runware image batch (%d images)', count( $requests ) ),
'runware-image'
);
return $batch->execute( $requests );
}
/** {@inheritDoc} */
public function estimate_cost( int $width, int $height, array $options = [] ): float {
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
return $this->pricing->estimate_cost( $width, $height, $model );
}
/** {@inheritDoc} */
public function get_provider_name(): string {
return self::PROVIDER_ID;
}
/**
* {@inheritDoc}
*
* Runware has no dedicated "validate key" endpoint, so we send a minimal
* authentication task and look for `taskType=authentication` in the
* response data with no accompanying errors.
*/
public function validate_credentials_detailed(): array {
try {
$api_key = $this->support->get_api_key();
} catch ( \RuntimeException $e ) {
return [ 'status' => 'error', 'message' => $e->getMessage() ];
}
$body = [
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
];
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
'timeout' => 15,
'headers' => [ 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
return [
'status' => 'error',
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
'debug' => $response->get_error_message(),
];
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 200 !== $status ) {
return [
'status' => 'error',
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
'debug' => substr( $raw, 0, 200 ),
];
}
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? [];
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
return [
'status' => 'error',
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
'debug' => substr( $msg, 0, 200 ),
];
}
return [ 'status' => 'ok', 'message' => __( 'Runware API key valid.', 'prautoblogger' ) ];
}
/**
* Build the JSON task-array body for a single imageInference request.
*
* Runware's single endpoint consumes [auth, inference] in one POST.
*
* @param string $api_key Plaintext API key.
* @param string $prompt Full prompt text.
* @param int $width Snapped width.
* @param int $height Snapped height.
* @param string $model Resolved model id.
* @param int $seed Positive seed.
* @param int $steps Inference steps.
* @return array<int, array<string, mixed>>
*/
private function build_request_body(
string $api_key,
string $prompt,
int $width,
int $height,
string $model,
int $seed,
int $steps
): array {
return [
[
'taskType' => 'authentication',
'apiKey' => $api_key,
],
[
'taskType' => 'imageInference',
'taskUUID' => wp_generate_uuid4(),
'positivePrompt' => $prompt,
'width' => $width,
'height' => $height,
'model' => $model,
'steps' => $steps,
'numberResults' => 1,
'outputFormat' => 'PNG',
'outputType' => 'URL',
'seed' => $seed,
],
];
}
/**
* Enforce the interface's dimension contract.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @throws \InvalidArgumentException If outside [MIN, MAX].
*/
private function assert_valid_dimensions( int $width, int $height ): void {
if (
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
) {
throw new \InvalidArgumentException(
sprintf(
esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ),
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
)
);
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
/**
* Support helpers for the Runware image provider — API key lookup,
* response parsing (URL fetch), retry backoff, and logging.
*
* Split from the provider to keep both files under the 300-line cap.
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
*
* @see class-runware-image-provider.php — Primary caller.
* @see class-runware-image-batch.php — Parallel caller.
*/
class PRAutoBlogger_Runware_Image_Support {
/**
* Runware task-based single endpoint. Unlike OpenRouter /chat/completions,
* Runware accepts a JSON array of tasks (auth + inference) at this URL.
*/
public const API_URL = 'https://api.runware.ai/v1';
/**
* Get the decrypted Runware API key from settings.
*
* @return string Plaintext key.
* @throws \RuntimeException If key is not configured or decrypt fails.
*/
public function get_api_key(): string {
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
if ( '' === $stored ) {
throw new \RuntimeException(
__( 'Runware API key is not configured. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
);
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
if ( '' === $decrypted ) {
throw new \RuntimeException(
__( 'Runware API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
);
}
return $decrypted;
}
return $stored;
}
/**
* Extract the image URL from Runware's JSON response body.
*
* The response is `{"data": [{"taskType":"imageInference","imageURL":"..."}]}`.
* We scan `data[]` for the first imageInference entry with a non-empty URL.
*
* @param string $raw Raw JSON response body from POST /v1.
* @return string The imageURL to fetch bytes from.
* @throws \RuntimeException If the response is malformed or contains no image.
*/
public function extract_image_url( string $raw ): string {
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException(
esc_html__( 'Runware returned invalid JSON.', 'prautoblogger' )
);
}
// Runware wraps errors in an `errors` array.
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? [];
$msg = is_array( $first )
? (string) ( $first['message'] ?? wp_json_encode( $first ) )
: (string) $first;
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware upstream error: %s', 'prautoblogger' ),
esc_html( substr( $msg, 0, 300 ) )
)
);
}
$data = $decoded['data'] ?? [];
if ( ! is_array( $data ) || empty( $data ) ) {
throw new \RuntimeException(
esc_html__( 'Runware response contained no data.', 'prautoblogger' )
);
}
foreach ( $data as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
if ( 'imageInference' === ( $entry['taskType'] ?? '' ) ) {
$url = (string) ( $entry['imageURL'] ?? '' );
if ( '' !== $url ) {
return $url;
}
}
}
throw new \RuntimeException(
esc_html__( 'Runware response contained no imageInference imageURL.', 'prautoblogger' )
);
}
/**
* Download the image bytes from a signed Runware delivery URL.
*
* Runware returns the image URL separately and bills the inference call;
* downloading is a regular GET against `im.runware.ai`.
*
* @param string $image_url Signed image URL from extract_image_url().
* @return string Raw PNG bytes.
* @throws \RuntimeException On HTTP error or empty body.
*/
public function download_image_bytes( string $image_url ): string {
$response = wp_remote_get( $image_url, [
'timeout' => 30,
] );
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image download failed: %s', 'prautoblogger' ),
esc_html( $response->get_error_message() )
)
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image download HTTP %d.', 'prautoblogger' ),
$status
)
);
}
$bytes = (string) wp_remote_retrieve_body( $response );
if ( '' === $bytes ) {
throw new \RuntimeException(
esc_html__( 'Runware image download returned empty body.', 'prautoblogger' )
);
}
return $bytes;
}
/**
* Snap a target pixel dimension to Runware's FLUX constraints.
*
* FLUX.1 (schnell + dev) requires dimensions divisible by 64, in the
* range [512, 2048]. We round to the nearest multiple of 64 and clamp.
*
* @param int $px Caller-requested pixel dimension.
* @return int Snapped dimension.
*/
public function snap_dimension( int $px ): int {
$snapped = (int) round( $px / 64 ) * 64;
if ( $snapped < 512 ) {
return 512;
}
if ( $snapped > 2048 ) {
return 2048;
}
return $snapped;
}
/**
* Normalize a caller seed to Runware's constraint (must be > 0).
*
* @param int|null $seed Caller-supplied seed or null for "random".
* @return int A positive integer seed.
*/
public function normalize_seed( ?int $seed ): int {
if ( null === $seed || $seed < 1 ) {
$derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 );
return $derived > 0 ? $derived : 1;
}
return $seed;
}
/**
* Emit a WARN line for a retryable image-gen failure.
*
* @param int $attempt 1-based attempt number.
* @param string $class 'network' or HTTP status code string.
* @param string $detail Short failure detail.
*/
public function log_retry( int $attempt, string $class, string $detail ): void {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Runware image gen %s failure (attempt %d/%d): %s',
$class,
$attempt,
PRAUTOBLOGGER_MAX_RETRIES,
$detail
),
'runware-image'
);
}
/**
* Sleep between retries with exponential backoff.
*
* @param int $attempt 1-based attempt that just failed.
* @param int $retry_after Retry-After header value (0 if absent).
*/
public function backoff( int $attempt, int $retry_after = 0 ): void {
$delay = $retry_after > 0
? min( $retry_after, 60 )
: PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
if ( $delay > 0 ) {
sleep( $delay );
}
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
/**
* Contract for any image generation provider (Cloudflare Workers AI, DALL-E, etc.).
* Contract for any image generation provider (Runware FLUX, OpenRouter Gemini, etc.).
*
* Consumers depend on this interface, never on a concrete provider — swapping
* providers means writing one new class, not refactoring the pipeline. This
@@ -13,7 +13,8 @@ declare(strict_types=1);
* content generation run, after editorial review, before publish.
* Dependencies: None at interface level — implementations wrap a specific HTTP API.
*
* @see class-cloudflare-image-provider.php — Cloudflare Workers AI implementation.
* @see class-runware-image-provider.php — Runware FLUX implementation (default).
* @see class-open-router-image-provider.php — OpenRouter image implementation.
* @see interface-llm-provider.php — Parallel pattern for LLM providers.
* @see ARCHITECTURE.md — External API Integrations table.
*/
@@ -79,7 +80,7 @@ interface PRAutoBlogger_Image_Provider_Interface {
/**
* Machine-readable provider identifier used in logs and settings.
*
* @return string Lowercase identifier, e.g. 'cloudflare_workers_ai'.
* @return string Lowercase identifier, e.g. 'runware' or 'openrouter'.
*/
public function get_provider_name(): string;

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
* Contract for a provider-specific AI model registry.
*
* Phase 1 (v1): single implementation for OpenRouter (`class-open-router-model-registry.php`).
* Phase 3: a second implementation wraps Cloudflare Workers AI model data. A
* higher-level dispatcher iterates registered registries, calls each with the same
* capability, and unions the results — the per-registry interface stays unchanged.
* Phase 3: a second implementation wraps Runware model data. A higher-level
* dispatcher iterates registered registries, calls each with the same capability,
* and unions the results — the per-registry interface stays unchanged.
*
* Implementations MUST:
* - Store the normalized registry as a WP option + transient cache layer.
@@ -86,7 +86,7 @@ interface PRAutoBlogger_Model_Registry_Interface {
public function get_fetched_at(): int;
/**
* Machine-readable provider identifier (e.g. 'openrouter', 'cloudflare_workers_ai').
* Machine-readable provider identifier (e.g. 'openrouter', 'runware').
*
* @return string
*/

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.7.3
* Version: 0.10.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.7.3' );
define( 'PRAUTOBLOGGER_VERSION', '0.10.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
@@ -54,11 +54,13 @@ define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'google/gemini-2.5-flash-lite' );
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
// Image generation defaults.
// Default provider is OpenRouter; Cloudflare is the fallback.
// Default model is Gemini 2.5 Flash Image ($0.005/image, good for comics).
// User can override via Settings → Images → Image Model.
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'openrouter' );
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'google/gemini-2.5-flash-image' );
// Default provider is Runware; OpenRouter (Gemini) remains as the alternate.
// Default model is FLUX.1 schnell via Runware (~$0.0006/image, ~2s). CEO
// decision 2026-04-21 after comic A/B: schnell's looser fidelity reads as
// editorial-cartoon style and is 65× cheaper than Nano Banana. Users can
// override via Settings → Images → Image Model.
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
// Default style suffix appended to every image-gen prompt. Updated
// 2026-04-18 v4: single-panel newspaper comic style. Caption is now

View File

@@ -55,6 +55,15 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
}
// Default image model slug used by OpenRouter/Runware pricing fallbacks and
// settings UI. Source of truth: prautoblogger.php line 63.
// Historically this was defined implicitly by CloudflareImageProviderTest::setUp()
// — that test was deleted in v0.10.0 (cloudflare-workers-ai-removal), so the
// define now lives here where it belongs.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
}
// Default image style suffix used by ImagePromptBuilder and settings UI.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );

View File

@@ -0,0 +1,133 @@
<?php
/**
* Tests for the v0.8.2 one-shot migration that reschedules the daily
* generation cron in the site's configured timezone. Exercises both
* the "first run" path and the "already migrated, no-op" path.
*
* See thread scheduled-gen-timezone/01-cto-handoff.md.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ActivatorMigrationTest extends BaseTestCase {
/**
* Track cron scheduling side effects so we can assert on them.
*
* @var array<string, array{scheduled_at: int|null, recurrence: string|null, cleared: bool}>
*/
private array $cron_state = [];
/**
* Track the v0.8.2 migration flag across get_option/update_option calls.
*
* @var array<string, mixed>
*/
private array $options = [];
protected function setUp(): void {
parent::setUp();
$this->cron_state = [
'prautoblogger_daily_generation' => [
'scheduled_at' => null,
'recurrence' => null,
'cleared' => false,
],
];
// Start with a pre-existing cron simulating the v<0.8.2 UTC-interpreted
// schedule, so the migration has something to clear.
$this->cron_state['prautoblogger_daily_generation']['scheduled_at'] = strtotime( 'tomorrow 03:00 UTC' );
$this->cron_state['prautoblogger_daily_generation']['recurrence'] = 'daily';
// Baseline options.
$this->options = [
'prautoblogger_schedule_time' => '06:00',
'prautoblogger_log_level' => 'info',
];
Functions\when( 'get_option' )->alias(
fn( $key, $default = false ) => $this->options[ $key ] ?? $default
);
Functions\when( 'update_option' )->alias(
function ( $key, $value ) {
$this->options[ $key ] = $value;
return true;
}
);
Functions\when( 'wp_next_scheduled' )->alias(
fn( $hook ) => $this->cron_state[ $hook ]['scheduled_at'] ?? false
);
Functions\when( 'wp_clear_scheduled_hook' )->alias(
function ( $hook ) {
if ( isset( $this->cron_state[ $hook ] ) ) {
$this->cron_state[ $hook ]['scheduled_at'] = null;
$this->cron_state[ $hook ]['cleared'] = true;
}
}
);
Functions\when( 'wp_schedule_event' )->alias(
function ( $timestamp, $recurrence, $hook ) {
$this->cron_state[ $hook ]['scheduled_at'] = (int) $timestamp;
$this->cron_state[ $hook ]['recurrence'] = (string) $recurrence;
return true;
}
);
Functions\when( 'wp_timezone' )->alias(
static fn() => new \DateTimeZone( 'Asia/Singapore' )
);
}
/**
* Migration clears the UTC-scheduled event and reschedules in SGT.
* Flag is set so the next pass is a no-op.
*/
public function test_db_version_migration_reschedules_existing_cron(): void {
$before_timestamp = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
$this->assertNotNull( $before_timestamp, 'Precondition: stale cron was pre-seeded.' );
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// Hook was cleared and then re-scheduled in one pass.
$this->assertTrue( $this->cron_state['prautoblogger_daily_generation']['cleared'] );
$after = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
$this->assertNotNull( $after );
$this->assertNotSame( $before_timestamp, $after, 'Migration should produce a different timestamp.' );
// Re-scheduled timestamp maps to 06:00 SGT (22:00 UTC prev day).
$utc = ( new \DateTimeImmutable( '@' . $after ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
$this->assertSame( '22:00', $utc->format( 'H:i' ) );
// Flag was set.
$this->assertSame( '1', $this->options['prautoblogger_migrated_schedule_tz_v082'] ?? null );
}
/**
* Running the migration twice is idempotent — the second pass must
* return immediately without clearing or rescheduling.
*/
public function test_db_version_migration_idempotent(): void {
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// Reset the cleared marker so we can detect a second clear.
$this->cron_state['prautoblogger_daily_generation']['cleared'] = false;
$timestamp_after_first = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// No second clear, no re-schedule.
$this->assertFalse( $this->cron_state['prautoblogger_daily_generation']['cleared'] );
$this->assertSame(
$timestamp_after_first,
$this->cron_state['prautoblogger_daily_generation']['scheduled_at']
);
}
}

View File

@@ -0,0 +1,163 @@
<?php
/**
* Tests for the v0.10.0 one-shot migration that removes Cloudflare
* Workers AI as an image provider — exercises the cloudflare-user
* path, the non-cloudflare-user path, idempotency, and the critical
* scope-boundary invariant that `prautoblogger_ai_gateway_base_url`
* (shared with the OpenRouter AI Gateway route) is left intact.
*
* See thread 2026-04-cloudflare-workers-ai-removal/01-cto-handoff.md.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ActivatorRemoveCloudflareTest extends BaseTestCase {
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void {
parent::setUp();
$this->options = [];
Functions\when( 'get_option' )->alias(
fn( $key, $default = false ) => $this->options[ $key ] ?? $default
);
Functions\when( 'update_option' )->alias(
function ( $key, $value ) {
$this->options[ $key ] = $value;
return true;
}
);
Functions\when( 'delete_option' )->alias(
function ( $key ) {
unset( $this->options[ $key ] );
return true;
}
);
}
/**
* Cloudflare user: flipped to Runware schnell, CF credentials removed,
* gateway URL preserved, flag set.
*/
public function test_cloudflare_user_migrated_to_runware(): void {
$this->options = [
'prautoblogger_image_provider' => 'cloudflare',
'prautoblogger_image_model' => 'flux-1-schnell',
'prautoblogger_cloudflare_ai_token' => 'enc:SECRET',
'prautoblogger_cloudflare_account_id' => 'acct-123',
'prautoblogger_cf_image_via_gateway' => '1',
// Shared with OpenRouter AI Gateway — MUST be preserved.
'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] );
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options );
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options );
$this->assertArrayNotHasKey( 'prautoblogger_cf_image_via_gateway', $this->options );
$this->assertSame(
'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
$this->options['prautoblogger_ai_gateway_base_url'],
'AI Gateway URL (shared with OpenRouter route) must NOT be touched.'
);
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
}
/**
* Non-Cloudflare user (e.g. already on OpenRouter Gemini): model +
* provider untouched; CF credentials still swept; flag set.
*/
public function test_non_cloudflare_user_preserved(): void {
$this->options = [
'prautoblogger_image_provider' => 'openrouter',
'prautoblogger_image_model' => 'google/gemini-2.5-flash-image',
// Hypothetical orphans from an earlier experiment.
'prautoblogger_cloudflare_ai_token' => 'enc:OLD',
'prautoblogger_cloudflare_account_id' => 'acct-999',
'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'google/gemini-2.5-flash-image', $this->options['prautoblogger_image_model'] );
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options );
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options );
$this->assertSame(
'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
$this->options['prautoblogger_ai_gateway_base_url']
);
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
}
/**
* Runware user: provider + model untouched; flag set.
*/
public function test_runware_user_preserved(): void {
$this->options = [
'prautoblogger_image_provider' => 'runware',
'prautoblogger_image_model' => 'runware:101@1',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'runware:101@1', $this->options['prautoblogger_image_model'] );
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
}
/**
* Legacy slug detection — the pre-registry `flux-1-schnell` model id
* still gets migrated even when the provider option is empty.
*/
public function test_legacy_slug_triggers_migration(): void {
$this->options = [
'prautoblogger_image_provider' => '', // Unset before v0.9.0 landed.
'prautoblogger_image_model' => 'flux-1-schnell',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] );
}
/**
* Running the migration twice is idempotent — the second pass must
* return immediately and must not clobber a user change made after
* the first migration run.
*/
public function test_idempotent_second_run_is_noop(): void {
$this->options = [
'prautoblogger_image_provider' => 'cloudflare',
'prautoblogger_image_model' => 'flux-1-schnell',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
// Simulate admin manually switching to OpenRouter after migration.
$this->options['prautoblogger_image_provider'] = 'openrouter';
$this->options['prautoblogger_image_model'] = 'google/gemini-3.1-flash-image-preview';
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
// Second run must NOT overwrite the admin's manual choice.
$this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'google/gemini-3.1-flash-image-preview', $this->options['prautoblogger_image_model'] );
}
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* Tests for `PRAutoBlogger_Activator::next_daily_generation_timestamp()` —
* the v0.8.2 timezone-aware replacement for `strtotime("tomorrow HH:MM")`
* which evaluated in UTC regardless of the WordPress site timezone.
*
* See thread scheduled-gen-timezone/01-cto-handoff.md for the bug report.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ActivatorScheduleTest extends BaseTestCase {
/**
* Stub `wp_timezone()` to return the given tz id, and seed
* `prautoblogger_schedule_time` with the given value.
*/
private function with_site_tz_and_time( string $tz_id, string $time_str ): void {
$this->stub_get_option( [
'prautoblogger_schedule_time' => $time_str,
] );
Functions\when( 'wp_timezone' )->alias(
static fn() => new \DateTimeZone( $tz_id )
);
}
/**
* On an Asia/Singapore site (UTC+8), `schedule_time = 06:00` must
* resolve to 22:00 UTC the previous day — the prod bug from thread
* scheduled-gen-timezone seq 1.
*/
public function test_schedule_cron_uses_site_timezone(): void {
$this->with_site_tz_and_time( 'Asia/Singapore', '06:00' );
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
$this->assertGreaterThan( 0, $timestamp );
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
$this->assertSame( '22:00', $utc->format( 'H:i' ), 'Expected 06:00 SGT == 22:00 UTC previous day' );
// And it really is "tomorrow" in Singapore — date part should not be today SGT.
$sgt = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'Asia/Singapore' ) );
$tomorrow_sgt = ( new \DateTimeImmutable( 'tomorrow', new \DateTimeZone( 'Asia/Singapore' ) ) )->format( 'Y-m-d' );
$this->assertSame( $tomorrow_sgt, $sgt->format( 'Y-m-d' ) );
}
/**
* UTC site, `06:00` input → 06:00 UTC. Behaviour unchanged for
* the always-UTC case; regression guard.
*/
public function test_schedule_cron_handles_utc_site(): void {
$this->with_site_tz_and_time( 'UTC', '06:00' );
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
$this->assertSame( '06:00', $utc->format( 'H:i' ) );
}
/**
* Nepal runs UTC+5:45. 06:00 local → 00:15 UTC. Guards against any
* integer-hour-offset assumptions in the conversion logic.
*/
public function test_schedule_cron_handles_fractional_offset(): void {
$this->with_site_tz_and_time( 'Asia/Kathmandu', '06:00' );
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
$this->assertSame( '00:15', $utc->format( 'H:i' ) );
}
/**
* Malformed `schedule_time` (e.g. blank string saved accidentally)
* must not throw — `absint()` clamps to 0:0 and the helper returns
* a valid timestamp. Regression guard against the "bricked schedule
* on bad input" failure mode.
*/
public function test_schedule_cron_survives_malformed_time_input(): void {
$this->with_site_tz_and_time( 'UTC', '' );
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
$this->assertGreaterThan( 0, $timestamp );
}
}

View File

@@ -211,6 +211,141 @@ class ImagePipelineTest extends BaseTestCase {
$this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) );
}
/**
* Test that a Logger::warning row is persisted when image_data carries an error.
*
* Covers Issue 2 from the image-mime-bug thread: previously the pipeline
* pushed the error into $result['errors'] silently, with no WARNING-level
* event_log row visible to ops. This regression-guards that a WARNING row
* now fires whether or not the outer catch in the caller triggers.
*/
public function test_partial_batch_failure_emits_warning_log_row(): void {
// Seed Logger's log_level so warnings actually get written.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_image_enabled' => '1',
'prautoblogger_log_level' => 'info',
];
return $options[ $key ] ?? $default;
} );
$mock_wpdb = $this->create_mock_wpdb();
$captured_rows = [];
$mock_wpdb->method( 'insert' )->willReturnCallback(
function ( $table, $row ) use ( &$captured_rows ) {
$captured_rows[] = $row;
return 1;
}
);
$GLOBALS['wpdb'] = $mock_wpdb;
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willReturn( [
'image_a' => [ 'error' => 'HTTP 400: flux-1-schnell is not a valid model ID' ],
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
$warning_rows = array_filter(
$captured_rows,
static fn( $row ) => ( $row['level'] ?? '' ) === 'warning'
&& ( $row['context'] ?? '' ) === 'image_pipeline'
);
$this->assertNotEmpty(
$warning_rows,
'Expected at least one image_pipeline WARNING row when image_data has an error key.'
);
unset( $GLOBALS['wpdb'] );
}
/**
* NSFW-blocked slots should be retried once with a sanitized fallback
* prompt. Retry success replaces the error entry with a real image.
*/
public function test_nsfw_blocked_slot_is_retried_with_fallback_prompt(): void {
// NSFW retry is default on; explicit for clarity.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_image_enabled' => '1',
'prautoblogger_image_nsfw_retry' => '1',
'prautoblogger_image_style_suffix' => 'Style: test suffix',
'prautoblogger_log_level' => 'info',
];
return $options[ $key ] ?? $default;
} );
$success_result = [
'bytes' => 'fake_image_bytes',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 632,
'model' => 'flux-1-schnell',
'cost_usd' => 0.000834,
'latency_ms' => 1500,
];
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.001 );
// First batch: NSFW. Second batch (retry for image_a): success.
$provider->expects( $this->exactly( 2 ) )
->method( 'generate_image_batch' )
->willReturnOnConsecutiveCalls(
[ 'image_a' => [ 'error' => 'NSFW', 'error_type' => 'nsfw_blocked' ] ],
[ 'image_a' => $success_result ]
);
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test Article' ] );
// Retry succeeded — image_a_id set, no errors.
$this->assertArrayHasKey( 'image_a_id', $result );
$this->assertEmpty( $result['errors'] );
}
/**
* When `prautoblogger_image_nsfw_retry` is off, blocked slots stay
* blocked — no second HTTP call, pipeline publishes without that
* image. Failsafe for when the filter gets trigger-happy.
*/
public function test_nsfw_retry_skipped_when_setting_disabled(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_image_enabled' => '1',
'prautoblogger_image_nsfw_retry' => '0',
];
return $options[ $key ] ?? $default;
} );
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.001 );
$provider->expects( $this->exactly( 1 ) )
->method( 'generate_image_batch' )
->willReturn( [
'image_a' => [ 'error' => 'NSFW', 'error_type' => 'nsfw_blocked' ],
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test Article' ] );
$this->assertArrayNotHasKey( 'image_a_id', $result );
$this->assertNotEmpty( $result['errors'] );
}
/**
* Test graceful handling when one image in the batch returns an error.
*/

View File

@@ -30,7 +30,32 @@ class ImagePromptBuilderTest extends BaseTestCase {
}
/**
* Test build_article_prompt() generates a prompt from article title and content.
* Inject a mock OpenRouter provider into the Prompt_Builder via
* reflection so the rewrite path can be exercised without a real HTTP
* call. Used by the system-prompt override tests below.
*/
private function inject_llm(
\PRAutoBlogger_Image_Prompt_Builder $builder,
$mock_llm
): void {
$ref = new \ReflectionClass( \PRAutoBlogger_Image_Prompt_Builder::class );
$prop = $ref->getProperty( 'llm' );
$prop->setAccessible( true );
$prop->setValue( $builder, $mock_llm );
}
/**
* Test build_article_prompt() generates a prompt from article title.
*
* `build_article_prompt()` has returned `array{prompt: string, caption: string}`
* since v0.4.x. Destructure on receipt so the assertions run against the
* scene string, not the envelope.
*
* Note: this test does NOT assert on the article content substring. With no
* OpenRouter API key in the unit-test env, `rewrite_via_llm()` catches and
* falls back to `synthesize_visual_concepts_fallback()`, which reliably
* includes only the title — not the content. Content-based assertions
* belong in a test that mocks the LLM (see `test_rewrite_uses_setting_when_present`).
*/
public function test_build_article_prompt_from_content(): void {
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
@@ -40,13 +65,13 @@ class ImagePromptBuilderTest extends BaseTestCase {
'post_content' => '<p>Dragons are fascinating creatures. Learn the secrets.</p>',
];
$prompt = $builder->build_article_prompt( $article_data );
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
$this->assertStringContainsString( 'How to Train Your Dragon', $prompt );
$this->assertStringContainsString( 'Dragons are fascinating', $prompt );
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
// Style suffix should be appended.
$this->assertNotEmpty( $prompt );
$this->assertGreaterThan( 50, strlen( $prompt ) );
$this->assertNotEmpty( $scene );
$this->assertGreaterThan( 50, strlen( $scene ) );
$this->assertIsString( $caption );
}
/**
@@ -59,10 +84,11 @@ class ImagePromptBuilderTest extends BaseTestCase {
'post_title' => 'Minimal Article',
];
$prompt = $builder->build_article_prompt( $article_data );
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
$this->assertStringContainsString( 'Minimal Article', $prompt );
$this->assertNotEmpty( $prompt );
$this->assertStringContainsString( 'Minimal Article', $scene );
$this->assertNotEmpty( $scene );
$this->assertIsString( $caption );
}
/**
@@ -79,11 +105,12 @@ class ImagePromptBuilderTest extends BaseTestCase {
],
];
$prompt = $builder->build_source_prompt( $source_data );
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_source_prompt( $source_data );
$this->assertStringContainsString( 'Robot', $prompt );
$this->assertStringContainsString( 'Dancing', $prompt );
$this->assertNotEmpty( $prompt );
$this->assertStringContainsString( 'Robot', $scene );
$this->assertStringContainsString( 'Dancing', $scene );
$this->assertNotEmpty( $scene );
$this->assertIsString( $caption );
}
/**
@@ -96,14 +123,18 @@ class ImagePromptBuilderTest extends BaseTestCase {
'title' => 'Standalone Title',
];
$prompt = $builder->build_source_prompt( $source_data );
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_source_prompt( $source_data );
$this->assertStringContainsString( 'Standalone Title', $prompt );
$this->assertNotEmpty( $prompt );
$this->assertStringContainsString( 'Standalone Title', $scene );
$this->assertNotEmpty( $scene );
$this->assertIsString( $caption );
}
/**
* Test that prompts don't exceed reasonable length.
*
* The 500-char cap applies to the scene + style suffix (the `prompt`
* field), not the full array envelope.
*/
public function test_prompts_are_reasonably_sized(): void {
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
@@ -114,9 +145,110 @@ class ImagePromptBuilderTest extends BaseTestCase {
'post_content' => $long_content,
];
$prompt = $builder->build_article_prompt( $article_data );
[ 'prompt' => $scene ] = $builder->build_article_prompt( $article_data );
// Prompt should be under 500 chars (concept + style suffix).
$this->assertLessThan( 500, strlen( $prompt ) );
// Scene + style suffix should be under 500 chars.
$this->assertLessThan( 500, strlen( $scene ) );
}
/**
* When `prautoblogger_image_prompt_instructions` is non-empty, the
* rewriter LLM must receive that content as its system message —
* NOT the hardcoded REWRITER_SYSTEM_PROMPT constant.
*/
public function test_rewrite_uses_setting_when_present(): void {
$custom_system = 'CUSTOM-OVERRIDE: tell the LLM a very different thing.';
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) use ( $custom_system ) {
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
return $custom_system;
}
if ( 'prautoblogger_image_style_suffix' === $key ) {
return 'Style: x';
}
return $default;
} );
$captured_messages = [];
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
function ( $messages ) use ( &$captured_messages ) {
$captured_messages = $messages;
return [
'content' => "A scene.\n\n\"A caption.\"",
'prompt_tokens' => 10,
'completion_tokens' => 10,
];
}
);
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
// can insert without blowing up.
$mock_wpdb = $this->create_mock_wpdb();
$mock_wpdb->insert_id = 1;
$mock_wpdb->method( 'insert' )->willReturn( 1 );
$GLOBALS['wpdb'] = $mock_wpdb;
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
$this->inject_llm( $builder, $mock_llm );
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
unset( $GLOBALS['wpdb'] );
$this->assertNotEmpty( $captured_messages, 'Rewriter LLM was not called.' );
$this->assertSame( 'system', $captured_messages[0]['role'] ?? '' );
$this->assertSame( $custom_system, $captured_messages[0]['content'] ?? '' );
}
/**
* When the option is empty (or whitespace-only), the rewriter must
* fall back to REWRITER_SYSTEM_PROMPT — belt-and-braces so a blank
* save from the admin UI cannot brick image generation.
*/
public function test_rewrite_falls_back_to_default_when_setting_empty(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
return ' '; // whitespace — should trigger fallback.
}
if ( 'prautoblogger_image_style_suffix' === $key ) {
return 'Style: x';
}
return $default;
} );
$captured_messages = [];
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
function ( $messages ) use ( &$captured_messages ) {
$captured_messages = $messages;
return [
'content' => "A scene.\n\n\"A caption.\"",
'prompt_tokens' => 10,
'completion_tokens' => 10,
];
}
);
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
// can insert without blowing up.
$mock_wpdb = $this->create_mock_wpdb();
$mock_wpdb->insert_id = 1;
$mock_wpdb->method( 'insert' )->willReturn( 1 );
$GLOBALS['wpdb'] = $mock_wpdb;
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
$this->inject_llm( $builder, $mock_llm );
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
unset( $GLOBALS['wpdb'] );
$this->assertSame(
\PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
$captured_messages[0]['content'] ?? ''
);
}
}

View File

@@ -0,0 +1,286 @@
<?php
/**
* Tests for PRAutoBlogger_Research_Reaper.
*
* Exercises the five scenarios spec'd in convo thread
* research-cost-reaper/01-cto-handoff.md: meta-based reap, grace-period
* skip, 7-day stale delete, gen_log fallback when meta is missing, and
* idempotency on repeat runs.
*
* All DB work is mocked via a $wpdb stub — no real database round-trips.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ResearchReaperTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* Captured INSERT rows (from amortize_research_costs delegating to $wpdb->insert).
*
* @var array<int, array<string, mixed>>
*/
private array $inserted_rows = [];
/**
* Captured DELETE calls: [$table, $where-array].
*
* @var array<int, array{0: string, 1: array<string, mixed>}>
*/
private array $deleted_rows = [];
/**
* Fixture: the rows get_results should hand back for the orphan scan.
*
* @var array<int, object>
*/
private array $orphan_fixtures = [];
/**
* Fixture: post IDs the reaper's fallback gen_log query should return.
*
* @var int[]
*/
private array $gen_log_post_ids = [];
protected function setUp(): void {
parent::setUp();
$this->inserted_rows = [];
$this->deleted_rows = [];
$this->orphan_fixtures = [];
$this->gen_log_post_ids = [];
// Bespoke $wpdb mock — BaseTestCase::create_mock_wpdb() doesn't
// stub `delete` or `get_col`, both of which the reaper + amortize
// call into. Wide-method mock keeps the test scope self-contained.
$this->wpdb = $this->getMockBuilder( \stdClass::class )
->addMethods( [ 'prepare', 'get_var', 'get_results', 'get_row', 'get_col', 'insert', 'delete', 'update', 'query' ] )
->getMock();
$this->wpdb->prefix = 'wp_';
$this->wpdb->insert_id = 1;
$this->wpdb->last_error = '';
$this->wpdb->prab_cost_logs = 'wp_prab_cost_logs';
$this->wpdb->method( 'insert' )->willReturnCallback(
function ( $table, $row ) {
$this->inserted_rows[] = array_merge( [ '__table' => $table ], $row );
return 1;
}
);
$this->wpdb->method( 'delete' )->willReturnCallback(
function ( $table, $where ) {
$this->deleted_rows[] = [ (string) $table, (array) $where ];
return 1;
}
);
// prepare() returns its args joined so we can pattern-match in get_* stubs.
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) {
return $sql . '|' . implode( '|', array_map( 'strval', $args ) );
}
);
// get_results routes by SQL fragment between the reaper's orphan scan
// and the occasional Logger insert probes.
$this->wpdb->method( 'get_results' )->willReturnCallback(
function ( $sql ) {
if ( false !== stripos( (string) $sql, "post_id IS NULL AND created_at <" ) ) {
return $this->orphan_fixtures;
}
return [];
}
);
// get_row serves amortize_research_costs's research-row lookup.
$this->wpdb->method( 'get_row' )->willReturnCallback(
function ( $sql ) {
foreach ( $this->orphan_fixtures as $o ) {
if ( false !== stripos( (string) $sql, (string) $o->run_id ) ) {
return (object) [
'id' => $o->id,
'estimated_cost' => $o->estimated_cost ?? 0.50,
'provider' => $o->provider ?? 'openrouter',
'model' => $o->model ?? 'gemini-flash',
'prompt_tokens' => $o->prompt_tokens ?? 1000,
'completion_tokens' => $o->completion_tokens ?? 500,
];
}
}
return null;
}
);
// get_col is the reaper's fallback "posts by run_id" and amortize's
// "distinct post_ids" query — both read from the same fixture.
$this->wpdb->method( 'get_col' )->willReturnCallback(
fn() => array_map( 'strval', $this->gen_log_post_ids )
);
$GLOBALS['wpdb'] = $this->wpdb;
$this->stub_get_option( [
'prautoblogger_log_level' => 'info',
] );
// Reaper uses get_posts to look up sibling posts via post_meta.
Functions\when( 'get_posts' )->justReturn( [] );
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Return only the `generation_log` inserts from the capture — filters out
* the `event_log` rows that Logger::instance()->info() writes as a side
* effect of the reaper + amortize's own progress logging.
*
* @return array<int, array<string, mixed>>
*/
private function gen_log_inserts(): array {
return array_values( array_filter(
$this->inserted_rows,
static fn( $row ) => false !== strpos( (string) ( $row['__table'] ?? '' ), 'generation_log' )
) );
}
/**
* Helper: seed the orphan list returned by the reaper's primary SELECT.
*
* @param array<int, array{id: int, run_id: string, created_at: string, estimated_cost?: float, provider?: string, model?: string, prompt_tokens?: int, completion_tokens?: int}> $orphans
*/
private function seed_orphans( array $orphans ): void {
$this->orphan_fixtures = array_map(
static fn( $r ) => (object) $r,
$orphans
);
}
/**
* Helper: set the post_ids the gen_log fallback query returns.
*
* @param int[] $post_ids
*/
private function seed_gen_log_posts_for_run( array $post_ids ): void {
$this->gen_log_post_ids = $post_ids;
}
/**
* 1. Reap succeeds via post_meta primary lookup.
*
* Orphan row is >1h old, `_prautoblogger_run_id` meta returns two
* post IDs, amortize inserts two attributed rows and deletes the
* original orphan.
*/
public function test_reaps_orphan_with_matching_run_id_posts(): void {
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 99, 'run_id' => 'run-abc', 'created_at' => $old_ts, 'estimated_cost' => 0.40 ] ] );
// In the v0.8.1 world both sources agree. Meta is the reaper's gate;
// gen_log also has rows so `amortize_research_costs()` can do its
// own internal post-count query.
Functions\when( 'get_posts' )->alias(
static fn( $args ) => ( '_prautoblogger_run_id' === ( $args['meta_key'] ?? '' ) && 'run-abc' === ( $args['meta_value'] ?? '' ) )
? [ 101, 102 ]
: []
);
$this->seed_gen_log_posts_for_run( [ 101, 102 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 1, $stats['reaped'] );
$this->assertSame( 0, $stats['deleted'] );
$this->assertSame( 0, $stats['skipped'] );
$this->assertCount(
2,
$this->gen_log_inserts(),
'One amortized row per post should be inserted into the generation_log.'
);
// Original orphan deleted by amortize_research_costs.
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 99 ) );
}
/**
* 2. Orphan within the 1-hour grace window is left alone — a live
* pipeline may still be in the middle of its own amortize step.
*/
public function test_skips_orphan_within_grace_period(): void {
// The primary SELECT filters on `created_at < (now - 1h)`, so a
// fresh orphan is excluded from the result set at the DB boundary.
// We simulate that by returning an empty orphan list.
$this->seed_orphans( [] );
Functions\when( 'get_posts' )->justReturn( [ 101, 102 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats['reaped'] );
$this->assertSame( 0, $stats['deleted'] );
$this->assertSame( 0, $stats['skipped'] );
$this->assertEmpty( $this->inserted_rows );
$this->assertEmpty( $this->deleted_rows );
}
/**
* 3. Orphan older than 7 days with zero matching posts is deleted
* outright — research cost is sunk with nothing to attribute to.
*/
public function test_deletes_orphan_with_no_articles_after_7_days(): void {
$stale_ts = gmdate( 'Y-m-d H:i:s', time() - ( 8 * DAY_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 77, 'run_id' => 'run-dead', 'created_at' => $stale_ts ] ] );
// No posts via meta, no posts via gen_log.
Functions\when( 'get_posts' )->justReturn( [] );
$this->seed_gen_log_posts_for_run( [] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats['reaped'] );
$this->assertSame( 1, $stats['deleted'] );
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 77 ) );
}
/**
* 4. When `_prautoblogger_run_id` post_meta is absent (legacy posts
* predating v0.8.1), the reaper falls back to querying gen_log for
* distinct post_ids carrying the same run_id.
*/
public function test_falls_back_to_gen_log_when_post_meta_missing(): void {
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 55, 'run_id' => 'run-legacy', 'created_at' => $old_ts, 'estimated_cost' => 0.30 ] ] );
// Meta lookup returns nothing.
Functions\when( 'get_posts' )->justReturn( [] );
// But gen_log has two posts with this run_id.
$this->seed_gen_log_posts_for_run( [ 201, 202 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 1, $stats['reaped'] );
$this->assertCount( 2, $this->gen_log_inserts() );
}
/**
* 5. A second reap run is a no-op — the first run deleted the orphan
* and inserted amortized per-article rows, so the scan returns empty.
*/
public function test_idempotent_on_repeat_run(): void {
// Simulate "already reaped" state: scan returns no orphans.
$this->seed_orphans( [] );
$stats_1 = \PRAutoBlogger_Research_Reaper::reap();
$stats_2 = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats_1['reaped'] );
$this->assertSame( 0, $stats_2['reaped'] );
$this->assertEmpty( $this->inserted_rows );
$this->assertEmpty( $this->deleted_rows );
}
}

View File

@@ -1,432 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Cloudflare_Image_Provider.
*
* Validates the image provider interface implementation methods.
* All HTTP calls are mocked — no real API calls.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class CloudflareImageProviderTest extends BaseTestCase {
/**
* Fake PNG payload — bytes don't need to be a real PNG, just non-empty.
* Using a short string keeps failure messages readable.
*/
private const FAKE_IMAGE_BYTES = 'FAKE_PNG_BYTES';
protected function setUp(): void {
parent::setUp();
// PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant is defined in
// prautoblogger.php — define here for test isolation.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
}
$this->stub_get_option( [
'prautoblogger_cloudflare_ai_token' => '',
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
'prautoblogger_image_model' => 'flux-1-schnell',
'prautoblogger_log_level' => 'info',
] );
Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' );
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
}
/**
* Helper: configure the plaintext API token the provider will eventually
* see after decryption. We round-trip through the real Encryption class
* (rather than stubbing it) so this test catches any regressions in the
* encrypt → option → decrypt flow at the same time.
*/
private function with_token( string $plaintext ): void {
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
$this->stub_get_option( [
'prautoblogger_cloudflare_ai_token' => $ciphertext,
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
'prautoblogger_image_model' => 'flux-1-schnell',
'prautoblogger_log_level' => 'info',
] );
}
/**
* Provider implements the interface.
*/
public function test_provider_implements_interface(): void {
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->assertInstanceOf( \PRAutoBlogger_Image_Provider_Interface::class, $provider );
}
/**
* get_provider_name returns the stable machine identifier.
*/
public function test_provider_name_is_stable_identifier(): void {
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->assertSame( 'cloudflare_workers_ai', $provider->get_provider_name() );
}
/**
* Dimension validation rejects inputs outside the [64, 2048] envelope
* before any HTTP call is made.
*/
public function test_generate_image_rejects_out_of_range_dimensions(): void {
$this->with_token( 'test-token' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'a prompt', 32, 1080 );
}
/**
* Empty prompts are rejected — an empty string burns budget on
* Workers AI for a deterministic "bad request" result.
*/
public function test_generate_image_rejects_empty_prompt(): void {
$this->with_token( 'test-token' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( ' ', 1080, 1080 );
}
/**
* Happy path — raw image bytes come back with expected metadata shape.
*/
public function test_generate_image_returns_raw_bytes_and_metadata(): void {
$this->with_token( 'test-token' );
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => self::FAKE_IMAGE_BYTES,
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'image/png' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( self::FAKE_IMAGE_BYTES );
Functions\when( 'wp_remote_retrieve_header' )->alias(
function ( $_response, $name ) {
return 'content-type' === strtolower( (string) $name ) ? 'image/png' : '';
}
);
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 1080, 1080, [ 'seed' => 42 ] );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
$this->assertSame( 'image/png', $result['mime_type'] );
$this->assertSame( 1080, $result['width'] );
$this->assertSame( 1080, $result['height'] );
$this->assertSame( 42, $result['seed'] );
$this->assertSame( '@cf/black-forest-labs/flux-1-schnell', $result['model'] );
$this->assertGreaterThan( 0.0, $result['cost_usd'] );
$this->assertGreaterThanOrEqual( 0, $result['latency_ms'] );
}
/**
* Happy path where Workers AI wraps the image in a JSON envelope with
* base64-encoded bytes under `result.image`. The provider must decode
* and return the raw bytes so the pipeline never has to branch.
*/
public function test_generate_image_decodes_json_envelope(): void {
$this->with_token( 'test-token' );
$encoded = base64_encode( self::FAKE_IMAGE_BYTES );
$body = wp_json_encode( [ 'result' => [ 'image' => $encoded ], 'success' => true ] );
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => $body,
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'application/json' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
Functions\when( 'wp_remote_retrieve_header' )->alias(
function ( $_response, $name ) {
return 'content-type' === strtolower( (string) $name ) ? 'application/json' : '';
}
);
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 600, 600 );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
$this->assertSame( 'image/png', $result['mime_type'] );
}
/**
* A 401 must NOT retry — auth errors are deterministic and retrying
* burns budget. The provider should throw on the first response.
*
* We can't assert "exactly one HTTP call" without injecting a call
* counter, so we assert that the throw message contains the status.
*/
public function test_generate_image_fails_loudly_on_401(): void {
$this->with_token( 'test-token' );
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => '{"errors":[{"message":"bad token"}]}',
'response' => [ 'code' => 401 ],
'headers' => [],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"bad token"}]}' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
try {
$provider->generate_image( 'a prompt', 1080, 1080 );
$this->fail( 'Expected RuntimeException for HTTP 401.' );
} catch ( \RuntimeException $e ) {
$this->assertStringContainsString( '401', $e->getMessage() );
}
}
/**
* Response shape must be one of: image/* bytes OR JSON envelope with
* base64 image. Anything else is a contract breach — fail loudly.
*/
public function test_generate_image_throws_on_unexpected_response_shape(): void {
$this->with_token( 'test-token' );
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => '<!doctype html><html><body>oops</body></html>',
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'text/html' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '<!doctype html><html><body>oops</body></html>' );
Functions\when( 'wp_remote_retrieve_header' )->alias(
function ( $_response, $name ) {
return 'content-type' === strtolower( (string) $name ) ? 'text/html' : '';
}
);
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->expectException( \RuntimeException::class );
$provider->generate_image( 'a prompt', 1080, 1080 );
}
/**
* Cost estimation is positive for in-range dimensions and scales with
* area — a 1080² image costs more than a 600² image on the same model.
*/
public function test_estimate_cost_scales_with_area(): void {
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$small = $provider->estimate_cost( 600, 600, [ 'model' => 'flux-1-schnell' ] );
$large = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
$this->assertGreaterThan( 0.0, $small );
$this->assertGreaterThan( $small, $large );
}
/**
* [dev] is meaningfully pricier than [schnell] at the same dimensions —
* the ~4x multiplier is the reason we default to schnell.
*/
public function test_estimate_cost_dev_more_expensive_than_schnell(): void {
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$schnell = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
$dev = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-dev' ] );
$this->assertGreaterThan( $schnell, $dev );
}
/**
* validate_credentials_detailed reports 'token_empty' debug tag when
* no token is configured, without making an HTTP call.
*/
public function test_validate_credentials_reports_missing_token(): void {
// Default setUp leaves token empty.
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
$this->assertSame( 'token_empty', $result['debug'] );
}
/**
* validate_credentials_detailed reports 'account_id_empty' when the
* account ID option is blank, without touching the network.
*/
public function test_validate_credentials_reports_missing_account_id(): void {
$this->stub_get_option( [
'prautoblogger_cloudflare_ai_token' => 'not-empty',
'prautoblogger_cloudflare_account_id' => '',
'prautoblogger_log_level' => 'info',
] );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
$this->assertSame( 'account_id_empty', $result['debug'] );
}
/**
* validate_credentials_detailed must never invoke the generation endpoint.
* Asserted by tracking every wp_remote_post call: a real-image call is
* the only thing that POSTs to `/ai/run/...`. Validator uses GET on
* `/ai/models/search`, which is the free read-only path.
*/
public function test_validate_credentials_never_calls_generate_endpoint(): void {
$this->with_token( 'test-token' );
$post_calls = [];
Functions\when( 'wp_remote_post' )->alias(
function ( $url, $args ) use ( &$post_calls ) {
$post_calls[] = $url;
return [ 'body' => '', 'response' => [ 'code' => 500 ] ];
}
);
Functions\when( 'wp_remote_get' )->justReturn( [
'body' => '{"result":[]}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$provider->validate_credentials_detailed();
$this->assertSame( [], $post_calls, 'validate_credentials_detailed() must not POST to /ai/run/ endpoints.' );
}
/**
* Retry path: a 500 followed by a 200 must yield the 200 payload.
* Exercises the exponential-backoff loop end-to-end without sleeping
* (bootstrap sets PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS to 0).
*/
public function test_generate_image_retries_on_5xx_then_succeeds(): void {
$this->with_token( 'test-token' );
$response_codes = [ 500, 200 ];
$response_bodies = [ 'upstream hiccup', self::FAKE_IMAGE_BYTES ];
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => self::FAKE_IMAGE_BYTES,
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'image/png' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function () use ( &$response_codes ) {
return array_shift( $response_codes ) ?? 200;
}
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function () use ( &$response_bodies ) {
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
}
);
// wp_remote_retrieve_header is called only on the success attempt —
// the 5xx branch returns before any header lookup. One justReturn
// for the content-type is enough; retry-after lookups accept the
// same stub and will coerce to (int) 0.
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
}
/**
* Retry exhaustion: persistent 502s must throw after MAX_RETRIES attempts
* rather than hanging indefinitely.
*/
public function test_generate_image_fails_after_max_retries_on_persistent_5xx(): void {
$this->with_token( 'test-token' );
$call_count = 0;
Functions\when( 'wp_remote_post' )->alias(
function () use ( &$call_count ) {
++$call_count;
return [
'body' => 'bad gateway',
'response' => [ 'code' => 502 ],
'headers' => [],
];
}
);
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 502 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'bad gateway' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
try {
$provider->generate_image( 'a prompt', 1080, 1080 );
$this->fail( 'Expected RuntimeException after retry exhaustion.' );
} catch ( \RuntimeException $e ) {
$this->assertStringContainsString( '502', $e->getMessage() );
}
$this->assertSame(
PRAUTOBLOGGER_MAX_RETRIES,
$call_count,
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
);
}
/**
* 429 triggers the same retry loop as 5xx (rate-limited, not deterministic
* error). The provider must retry and eventually succeed if the second
* attempt returns 200.
*/
public function test_generate_image_retries_on_429(): void {
$this->with_token( 'test-token' );
$response_codes = [ 429, 200 ];
$response_bodies = [ 'slow down', self::FAKE_IMAGE_BYTES ];
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => self::FAKE_IMAGE_BYTES,
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'image/png' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function () use ( &$response_codes ) {
return array_shift( $response_codes ) ?? 200;
}
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function () use ( &$response_bodies ) {
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
}
);
// Content-type is only read on the success attempt; retry-after is
// read on the 429 attempt and (int)-coerces anything non-numeric
// back to 0, falling through to exponential backoff.
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
}
/**
* validate_credentials_detailed returns 'ok' on a 200 from the models
* listing endpoint.
*/
public function test_validate_credentials_ok_on_200(): void {
$this->with_token( 'test-token' );
Functions\when( 'wp_remote_get' )->justReturn( [
'body' => '{"result":[{"id":"@cf/black-forest-labs/flux-1-schnell"}]}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'ok', $result['status'] );
}
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* Tests for PRAutoBlogger_Runware_Image_Pricing.
*
* Validates model resolution fallback logic and per-image cost estimation
* for the Runware FLUX models.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RunwareImagePricingTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [];
return $options[ $key ] ?? $default;
} );
}
/** Empty hint resolves to the v0.9.0 default (runware schnell). */
public function test_resolve_model_empty_hint_uses_schnell_default(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$model = $pricing->resolve_model( '' );
$this->assertSame( 'runware:100@1', $model );
}
/** Explicit schnell hint resolves to itself. */
public function test_resolve_model_schnell_hint(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$this->assertSame( 'runware:100@1', $pricing->resolve_model( 'runware:100@1' ) );
}
/** Explicit dev hint resolves to itself. */
public function test_resolve_model_dev_hint(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$this->assertSame( 'runware:101@1', $pricing->resolve_model( 'runware:101@1' ) );
}
/**
* A non-Runware hint (e.g. a Gemini slug) falls back to schnell because
* the Runware provider was selected explicitly — the user wants a
* Runware model even if the admin option still holds a foreign slug.
*/
public function test_resolve_model_unknown_hint_falls_back_to_schnell(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$this->assertSame( 'runware:100@1', $pricing->resolve_model( 'google/gemini-2.5-flash-image' ) );
}
/** FLUX.1 schnell costs ~$0.0006/image. */
public function test_estimate_cost_schnell(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$cost = $pricing->estimate_cost( 1216, 640, 'runware:100@1' );
$this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 );
}
/** FLUX.1 dev costs ~$0.02/image. */
public function test_estimate_cost_dev(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$cost = $pricing->estimate_cost( 1216, 640, 'runware:101@1' );
$this->assertEqualsWithDelta( 0.02, $cost, 0.000001 );
}
/** Unknown model falls back to conservative $0.01 estimate. */
public function test_estimate_cost_unknown_model(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$cost = $pricing->estimate_cost( 1216, 640, 'runware:999@1' );
$this->assertEqualsWithDelta( 0.01, $cost, 0.000001 );
}
/** Default step count: schnell=4, dev=28. */
public function test_default_steps(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$this->assertSame( 4, $pricing->default_steps( 'runware:100@1' ) );
$this->assertSame( 28, $pricing->default_steps( 'runware:101@1' ) );
$this->assertSame( 4, $pricing->default_steps( 'runware:999@1' ) );
}
/** get_model_costs returns a non-empty map with Runware models. */
public function test_get_model_costs(): void {
$costs = \PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
$this->assertIsArray( $costs );
$this->assertArrayHasKey( 'runware:100@1', $costs );
$this->assertArrayHasKey( 'runware:101@1', $costs );
}
}

View File

@@ -0,0 +1,239 @@
<?php
/**
* Tests for PRAutoBlogger_Runware_Image_Provider.
*
* Validates interface compliance, two-step POST-then-GET flow, retry
* behavior on 5xx, immediate-fail on 4xx, and dimension/prompt guards.
* All HTTP calls are mocked — no real API traffic.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RunwareImageProviderTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_runware_api_key' => 'rw-plain-test-key',
'prautoblogger_image_model' => 'runware:100@1',
];
return $options[ $key ] ?? $default;
} );
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
return $thing instanceof \WP_Error;
} );
Functions\when( 'wp_generate_uuid4' )->justReturn( '11111111-2222-3333-4444-555555555555' );
}
/** Provider name is 'runware'. */
public function test_get_provider_name(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->assertSame( 'runware', $provider->get_provider_name() );
}
/** estimate_cost returns a positive float for schnell. */
public function test_estimate_cost_schnell(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$cost = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] );
$this->assertIsFloat( $cost );
$this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 );
}
/** estimate_cost returns higher value for dev. */
public function test_estimate_cost_dev_higher_than_schnell(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$schnell = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] );
$dev = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:101@1' ] );
$this->assertGreaterThan( $schnell, $dev );
}
/** Empty prompt throws InvalidArgumentException. */
public function test_generate_image_empty_prompt_throws(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( '', 1200, 640 );
}
/** Whitespace-only prompt throws InvalidArgumentException. */
public function test_generate_image_whitespace_prompt_throws(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( " \t\n ", 1200, 640 );
}
/** Dimension below minimum throws. */
public function test_generate_image_tiny_dimensions_throws(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'A test prompt', 10, 10 );
}
/** Dimension above maximum throws. */
public function test_generate_image_huge_dimensions_throws(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'A test prompt', 5000, 5000 );
}
/** Happy path: POST returns URL, GET returns bytes, result shape correct. */
public function test_generate_image_success(): void {
$image_url = 'https://im.runware.ai/image/abc.png';
$post_body = wp_json_encode( [
'data' => [
[ 'taskType' => 'imageInference', 'imageURL' => $image_url ],
],
] );
$fake_bytes = 'fake-png-bytes';
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $post_body,
] );
Functions\when( 'wp_remote_get' )->justReturn( [
'response' => [ 'code' => 200 ],
'body' => $fake_bytes,
] );
Functions\when( 'wp_remote_retrieve_response_code' )->alias( function ( $r ) {
return isset( $r['response']['code'] ) ? (int) $r['response']['code'] : 0;
} );
Functions\when( 'wp_remote_retrieve_body' )->alias( function ( $r ) {
return isset( $r['body'] ) ? (string) $r['body'] : '';
} );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$result = $provider->generate_image( 'A gym bro holding a vial', 1200, 632 );
$this->assertArrayHasKey( 'bytes', $result );
$this->assertArrayHasKey( 'mime_type', $result );
$this->assertArrayHasKey( 'width', $result );
$this->assertArrayHasKey( 'height', $result );
$this->assertArrayHasKey( 'model', $result );
$this->assertArrayHasKey( 'seed', $result );
$this->assertArrayHasKey( 'cost_usd', $result );
$this->assertArrayHasKey( 'latency_ms', $result );
$this->assertSame( $fake_bytes, $result['bytes'] );
$this->assertSame( 'image/png', $result['mime_type'] );
// Snapped to multiples of 64: 1200 → 1216, 632 → 640.
$this->assertSame( 1216, $result['width'] );
$this->assertSame( 640, $result['height'] );
$this->assertSame( 'runware:100@1', $result['model'] );
$this->assertGreaterThan( 0, $result['seed'] );
}
/** 4xx (non-429) fails immediately with RuntimeException. */
public function test_generate_image_4xx_throws_immediately(): void {
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 401 ],
'headers' => [],
'body' => '{"errors":[{"message":"invalid key"}]}',
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'HTTP 401' );
$provider->generate_image( 'A test prompt', 1200, 632 );
}
/** 5xx triggers retry loop and eventually throws after PRAUTOBLOGGER_MAX_RETRIES. */
public function test_generate_image_5xx_retries_then_throws(): void {
$call_count = 0;
Functions\when( 'wp_remote_post' )->alias( function () use ( &$call_count ) {
$call_count++;
return [
'response' => [ 'code' => 503 ],
'headers' => [],
'body' => '{"errors":[{"message":"temporarily unavailable"}]}',
];
} );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 503 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"temporarily unavailable"}]}' );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
try {
$provider->generate_image( 'A test prompt', 1200, 632 );
$this->fail( 'Expected RuntimeException after max retries' );
} catch ( \RuntimeException $e ) {
$this->assertSame(
PRAUTOBLOGGER_MAX_RETRIES,
$call_count,
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
);
}
}
/** errors[] in a 200 body is treated as an upstream error. */
public function test_generate_image_errors_in_200_body_throws(): void {
$body = wp_json_encode( [
'errors' => [ [ 'message' => 'moderation rejected' ] ],
] );
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \RuntimeException::class );
$provider->generate_image( 'A test prompt', 1200, 632 );
}
/** validate_credentials_detailed returns ok for a 200 response with no errors. */
public function test_validate_credentials_detailed_ok(): void {
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'body' => '{"data":[{"taskType":"authentication"}]}',
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"data":[{"taskType":"authentication"}]}' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'ok', $result['status'] );
}
/** validate_credentials_detailed returns error when key missing. */
public function test_validate_credentials_detailed_missing_key(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
return $default;
} );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
}
/** validate_credentials_detailed returns error when response contains errors[]. */
public function test_validate_credentials_detailed_rejected_key(): void {
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'body' => '{"errors":[{"message":"invalid key"}]}',
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
}
}

View File

@@ -0,0 +1,182 @@
<?php
/**
* Tests for PRAutoBlogger_Runware_Image_Support.
*
* Covers response parsing (URL extraction), dimension snapping to FLUX
* constraints, seed normalization, and API-key lookup.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RunwareImageSupportTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
Functions\when( 'esc_html' )->returnArg();
Functions\when( 'esc_html__' )->returnArg();
Functions\when( '__' )->returnArg();
// Real PRAutoBlogger_Encryption uses wp_salt('auth') internally on the
// decrypt path. Stub it with a deterministic 32-char string so OpenSSL
// has a key to work with in the "enc:"-prefixed round-trip test.
Functions\when( 'wp_salt' )->justReturn( 'test-salt-32chars-aaaaaaaaaaaaaa' );
}
/** A well-formed imageInference entry returns its imageURL. */
public function test_extract_image_url_valid_response(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$raw = wp_json_encode( [
'data' => [
[
'taskType' => 'imageInference',
'imageURL' => 'https://im.runware.ai/image/abc123.png',
],
],
] );
$this->assertSame(
'https://im.runware.ai/image/abc123.png',
$support->extract_image_url( $raw )
);
}
/** An errors[] array in the response causes a RuntimeException. */
public function test_extract_image_url_error_response_throws(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$raw = wp_json_encode( [
'errors' => [
[ 'message' => 'invalid api key' ],
],
] );
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'invalid api key' );
$support->extract_image_url( $raw );
}
/** Malformed JSON throws RuntimeException. */
public function test_extract_image_url_invalid_json_throws(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->expectException( \RuntimeException::class );
$support->extract_image_url( 'not json at all' );
}
/** Data array present but no imageInference entry throws. */
public function test_extract_image_url_no_inference_entry_throws(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$raw = wp_json_encode( [
'data' => [
[ 'taskType' => 'authentication' ],
],
] );
$this->expectException( \RuntimeException::class );
$support->extract_image_url( $raw );
}
/** imageInference present but empty imageURL throws. */
public function test_extract_image_url_empty_url_throws(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$raw = wp_json_encode( [
'data' => [
[ 'taskType' => 'imageInference', 'imageURL' => '' ],
],
] );
$this->expectException( \RuntimeException::class );
$support->extract_image_url( $raw );
}
/** snap_dimension rounds to nearest multiple of 64. */
public function test_snap_dimension_rounds_to_64(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 1216, $support->snap_dimension( 1200 ) );
$this->assertSame( 640, $support->snap_dimension( 632 ) );
$this->assertSame( 1024, $support->snap_dimension( 1024 ) );
$this->assertSame( 1024, $support->snap_dimension( 1050 ) );
}
/** snap_dimension clamps to minimum 512. */
public function test_snap_dimension_clamps_min(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 512, $support->snap_dimension( 64 ) );
$this->assertSame( 512, $support->snap_dimension( 200 ) );
}
/** snap_dimension clamps to maximum 2048. */
public function test_snap_dimension_clamps_max(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 2048, $support->snap_dimension( 4096 ) );
$this->assertSame( 2048, $support->snap_dimension( 2500 ) );
}
/** A valid positive seed passes through unchanged. */
public function test_normalize_seed_positive_passthrough(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 42, $support->normalize_seed( 42 ) );
$this->assertSame( 1234567, $support->normalize_seed( 1234567 ) );
}
/** Null seed derives a positive integer. */
public function test_normalize_seed_null_derives_positive(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$seed = $support->normalize_seed( null );
$this->assertGreaterThan( 0, $seed );
}
/** Zero seed is replaced with a derived positive integer. */
public function test_normalize_seed_zero_replaced(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$seed = $support->normalize_seed( 0 );
$this->assertGreaterThan( 0, $seed );
}
/** Negative seed is replaced with a derived positive integer. */
public function test_normalize_seed_negative_replaced(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$seed = $support->normalize_seed( -5 );
$this->assertGreaterThan( 0, $seed );
}
/** Missing API key option throws RuntimeException. */
public function test_get_api_key_missing_throws(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
return $default;
} );
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->expectException( \RuntimeException::class );
$support->get_api_key();
}
/** Encrypted key is decrypted on read (round-trips through real PRAutoBlogger_Encryption). */
public function test_get_api_key_decrypts_encrypted(): void {
$plaintext = 'rw-real-round-trip-key';
$cipher = \PRAutoBlogger_Encryption::encrypt( $plaintext );
$this->assertStringStartsWith( 'enc:', $cipher, 'encrypt() must return an enc:-prefixed value' );
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) use ( $cipher ) {
if ( 'prautoblogger_runware_api_key' === $key ) {
return $cipher;
}
return $default;
} );
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( $plaintext, $support->get_api_key() );
}
/** A plaintext key (pre-migration) passes through unchanged. */
public function test_get_api_key_plaintext_passthrough(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
if ( 'prautoblogger_runware_api_key' === $key ) {
return 'rw-plain-key';
}
return $default;
} );
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 'rw-plain-key', $support->get_api_key() );
}
}

View File

@@ -0,0 +1,102 @@
<?php
/**
* Tests for PRAutoBlogger_Image_Model_Registry.
*
* The admin save flow (v0.8.0) collapses Provider and Image Model into a
* single dropdown and derives the provider from the model via
* Image_Model_Registry::provider_for(). This test pins that mapping down
* so a registry edit that breaks the contract fails CI.
*
* @package PRAutoBlogger\Tests\Services
*/
namespace PRAutoBlogger\Tests\Services;
use PRAutoBlogger\Tests\BaseTestCase;
class ImageModelRegistryTest extends BaseTestCase {
/**
* Every entry returned by get_models() must carry a non-empty id and
* a provider of 'runware' or 'openrouter' (Cloudflare Workers AI was
* removed as an image provider in v0.10.0). If this breaks, the
* admin save flow will silently drop the provider derivation.
*/
public function test_every_registry_entry_has_id_and_known_provider(): void {
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
$this->assertNotEmpty( $models );
foreach ( $models as $model ) {
$this->assertArrayHasKey( 'id', $model );
$this->assertNotEmpty( $model['id'] );
$this->assertArrayHasKey( 'provider', $model );
$this->assertContains(
$model['provider'],
[ 'runware', 'openrouter' ],
sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] )
);
}
}
/**
* provider_for() returns the paired provider for a known model id.
* This is the core of the v0.8.0 collapsed-dropdown contract.
*/
public function test_provider_for_returns_paired_provider_for_known_model(): void {
$this->assertSame(
'openrouter',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'google/gemini-2.5-flash-image' )
);
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:100@1' )
);
}
/**
* v0.10.0 scope boundary: no Cloudflare entries remain in the registry.
* The legacy `flux-1-schnell` slug (pre-Runware) must resolve to '' so
* the migration's detection path catches it. If a registry regression
* re-adds a cloudflare entry this test fails loudly.
*/
public function test_cloudflare_entries_removed_v0100(): void {
foreach ( \PRAutoBlogger_Image_Model_Registry::get_models() as $model ) {
$this->assertNotSame( 'cloudflare', $model['provider'] ?? '' );
}
$this->assertSame(
'',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' )
);
}
/**
* Runware model ids (added v0.9.0) resolve to the 'runware' provider.
* This test pins the v0.9.0 default flip so a registry regression
* that loses the Runware entries fails CI loudly.
*/
public function test_provider_for_runware_models(): void {
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:100@1' )
);
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:101@1' )
);
}
/**
* Unknown model ids return an empty string so callers can distinguish
* "not in registry" from a valid provider and show a settings error.
*/
public function test_provider_for_returns_empty_for_unknown_model(): void {
$this->assertSame(
'',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'nonexistent/model' )
);
$this->assertSame(
'',
\PRAutoBlogger_Image_Model_Registry::provider_for( '' )
);
}
}