Compare commits
12 Commits
claude/v0.
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b399023291 | ||
|
|
382f2a0a6f | ||
|
|
ef85100501 | ||
|
|
97129ed457 | ||
|
|
21430ce3bf | ||
|
|
1c3d9b50c6 | ||
|
|
8bedecd2dd | ||
|
|
2cacaa3512 | ||
|
|
80a7e11a2b | ||
|
|
33d3ba4ea1 | ||
|
|
188e6603dd | ||
|
|
5c7736bf4e |
@@ -171,17 +171,17 @@ prautoblogger/
|
|||||||
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
|
│ │ ├── 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-config.php # API base URL (direct vs AI Gateway)
|
||||||
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
|
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
|
||||||
│ │ ├── class-cloudflare-image-provider.php # Cloudflare Workers AI (sequential batch fallback)
|
│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
|
||||||
│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation
|
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver
|
||||||
│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check
|
│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap
|
||||||
│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for CF provider
|
│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher
|
||||||
│ │ └── (new providers go here — see CONVENTIONS.md)
|
│ │ └── (new providers go here — see CONVENTIONS.md)
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
|
│ │ ├── 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-registry.php # Fetches, caches, queries the OpenRouter model list
|
||||||
│ │ ├── class-open-router-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
|
│ │ ├── 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/
|
│ ├── frontend/
|
||||||
│ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
|
│ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
|
||||||
@@ -435,8 +435,6 @@ All prefixed with `prautoblogger_`:
|
|||||||
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
|
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
|
||||||
| `prautoblogger_db_version` | Schema version for migrations |
|
| `prautoblogger_db_version` | Schema version for migrations |
|
||||||
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
| `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 slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
| `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_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_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
|
||||||
@@ -478,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 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` |
|
| 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` |
|
| 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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -521,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.
|
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
|
### #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)
|
### #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.
|
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.
|
||||||
@@ -530,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.
|
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)
|
### #17: Runware as default image backend (v0.9.0, Apr 2026)
|
||||||
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.
|
|
||||||
|
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
|
### #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.
|
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.
|
||||||
|
|||||||
172
CHANGELOG.md
172
CHANGELOG.md
@@ -5,6 +5,178 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
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
|
## [0.8.0] — 2026-04-21
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
$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 ) ) {
|
if ( in_array( $option_name, $encrypted, true ) ) {
|
||||||
// Empty value means password field wasn't touched — keep existing.
|
// Empty value means password field wasn't touched — keep existing.
|
||||||
if ( '' === $value ) {
|
if ( '' === $value ) {
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* What: Returns a hardcoded list of image models for the admin model picker.
|
* What: Returns a hardcoded list of image models for the admin model picker.
|
||||||
* No API discovery — updated manually when providers add/remove models.
|
* No API discovery — updated manually when providers add/remove models.
|
||||||
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI) and
|
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
|
||||||
* PRAutoBlogger_Image_Pipeline (model validation).
|
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
|
||||||
* Dependencies: None.
|
* Dependencies: None.
|
||||||
*
|
*
|
||||||
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
|
* @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 {
|
class PRAutoBlogger_Image_Model_Registry {
|
||||||
|
|
||||||
@@ -21,22 +22,41 @@ class PRAutoBlogger_Image_Model_Registry {
|
|||||||
* Each entry contains:
|
* Each entry contains:
|
||||||
* - id: Model identifier used in API calls.
|
* - id: Model identifier used in API calls.
|
||||||
* - name: Human-readable display name.
|
* - name: Human-readable display name.
|
||||||
* - provider: 'openrouter' or 'cloudflare'.
|
* - provider: 'runware' | 'openrouter'.
|
||||||
* - cost_per_image: Estimated USD cost per generation.
|
* - cost_per_image: Estimated USD cost per generation.
|
||||||
* - capabilities: Array of capability tags.
|
* - capabilities: Array of capability tags.
|
||||||
* - description: Short description for the admin UI.
|
* - 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>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public static function get_models(): array {
|
public static function get_models(): array {
|
||||||
return [
|
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',
|
'id' => 'google/gemini-2.5-flash-image',
|
||||||
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
|
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
|
||||||
'provider' => 'openrouter',
|
'provider' => 'openrouter',
|
||||||
'cost_per_image' => 0.005,
|
'cost_per_image' => 0.005,
|
||||||
'capabilities' => [ 'image_generation' ],
|
'capabilities' => [ 'image_generation' ],
|
||||||
'description' => __( 'Good quality, low cost. Recommended.', 'prautoblogger' ),
|
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'google/gemini-3.1-flash-image-preview',
|
'id' => 'google/gemini-3.1-flash-image-preview',
|
||||||
@@ -70,14 +90,6 @@ class PRAutoBlogger_Image_Model_Registry {
|
|||||||
'capabilities' => [ 'image_generation' ],
|
'capabilities' => [ 'image_generation' ],
|
||||||
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
|
'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' ),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +98,7 @@ class PRAutoBlogger_Image_Model_Registry {
|
|||||||
* model id is not in the registry.
|
* model id is not in the registry.
|
||||||
*
|
*
|
||||||
* @param string $model_id Model slug from the admin UI.
|
* @param string $model_id Model slug from the admin UI.
|
||||||
* @return string Provider id ('openrouter' | 'cloudflare' | '').
|
* @return string Provider id ('runware' | 'openrouter' | '').
|
||||||
*/
|
*/
|
||||||
public static function provider_for( string $model_id ): string {
|
public static function provider_for( string $model_id ): string {
|
||||||
foreach ( self::get_models() as $model ) {
|
foreach ( self::get_models() as $model ) {
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'type' => 'time',
|
'type' => 'time',
|
||||||
'section' => 'prautoblogger_schedule',
|
'section' => 'prautoblogger_schedule',
|
||||||
'default' => '03:00',
|
'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',
|
'id' => 'prautoblogger_monthly_budget_usd',
|
||||||
@@ -154,7 +158,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||||
'type' => 'text',
|
'type' => 'text',
|
||||||
'section' => 'prautoblogger_analytics',
|
'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',
|
'id' => 'prautoblogger_ga4_credentials_json',
|
||||||
@@ -217,25 +221,17 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'section' => 'prautoblogger_images',
|
'section' => 'prautoblogger_images',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
||||||
'capability' => 'image_generation',
|
'capability' => 'image_generation',
|
||||||
'description' => __( 'Pick an image model. The provider (OpenRouter or Cloudflare Workers AI) is derived from the model registry on save, so mismatched pairs are no longer possible.', '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' ),
|
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'prautoblogger_cloudflare_ai_token',
|
'id' => 'prautoblogger_runware_api_key',
|
||||||
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
|
'label' => __( 'Runware API Key', 'prautoblogger' ),
|
||||||
'type' => 'password',
|
'type' => 'password',
|
||||||
'section' => 'prautoblogger_images',
|
'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' => '🔑',
|
'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',
|
'id' => 'prautoblogger_image_style_suffix',
|
||||||
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
||||||
@@ -258,7 +254,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'type' => 'toggle',
|
'type' => 'toggle',
|
||||||
'section' => 'prautoblogger_images',
|
'section' => 'prautoblogger_images',
|
||||||
'default' => '1',
|
'default' => '1',
|
||||||
'description' => __( 'When the provider rejects an image prompt as NSFW (e.g. Cloudflare code 3030), retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
'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' ),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,121 +28,15 @@ class PRAutoBlogger_Activator {
|
|||||||
self::create_tables();
|
self::create_tables();
|
||||||
self::set_default_options();
|
self::set_default_options();
|
||||||
self::schedule_cron();
|
self::schedule_cron();
|
||||||
|
self::backpopulate_run_id_meta_v081();
|
||||||
|
|
||||||
// Store the DB version so we can run migrations on future updates.
|
// Store the DB version so we can run migrations on future updates.
|
||||||
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */
|
||||||
* Create all custom database tables using dbDelta.
|
|
||||||
*
|
|
||||||
* Side effects: creates/updates four database tables.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function create_tables(): void {
|
private static function create_tables(): void {
|
||||||
global $wpdb;
|
PRAutoBlogger_Schema_Installer::install();
|
||||||
|
|
||||||
$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 );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,6 +68,7 @@ class PRAutoBlogger_Activator {
|
|||||||
'prautoblogger_table_borders' => '1',
|
'prautoblogger_table_borders' => '1',
|
||||||
'prautoblogger_schedule_time' => '03:00',
|
'prautoblogger_schedule_time' => '03:00',
|
||||||
'prautoblogger_log_level' => 'info',
|
'prautoblogger_log_level' => 'info',
|
||||||
|
'prautoblogger_image_nsfw_retry' => '1',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ( $defaults as $key => $value ) {
|
foreach ( $defaults as $key => $value ) {
|
||||||
@@ -195,14 +90,8 @@ class PRAutoBlogger_Activator {
|
|||||||
*/
|
*/
|
||||||
private static function schedule_cron(): void {
|
private static function schedule_cron(): void {
|
||||||
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
|
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
|
||||||
// Schedule for the configured time tomorrow.
|
$timestamp = self::next_daily_generation_timestamp();
|
||||||
$time_str = get_option( 'prautoblogger_schedule_time', '03:00' );
|
if ( $timestamp > 0 ) {
|
||||||
$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 ) {
|
|
||||||
wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' );
|
wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,5 +114,156 @@ class PRAutoBlogger_Activator {
|
|||||||
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
||||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', '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' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class PRAutoBlogger_Ajax_Handlers {
|
|||||||
return;
|
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
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
@set_time_limit( 120 );
|
@set_time_limit( 120 );
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class PRAutoBlogger_Deactivator {
|
|||||||
$hooks = [
|
$hooks = [
|
||||||
'prautoblogger_daily_generation',
|
'prautoblogger_daily_generation',
|
||||||
'prautoblogger_collect_metrics',
|
'prautoblogger_collect_metrics',
|
||||||
|
'prautoblogger_reap_orphan_research_rows',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ( $hooks as $hook ) {
|
foreach ( $hooks as $hook ) {
|
||||||
|
|||||||
99
includes/class-migrate-remove-cloudflare-v0100.php
Normal file
99
includes/class-migrate-remove-cloudflare-v0100.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,24 @@ class PRAutoBlogger {
|
|||||||
|
|
||||||
// Single-idea generation from the Ideas browser page.
|
// Single-idea generation from the Ideas browser page.
|
||||||
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
|
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. */
|
/** Register AJAX handlers for admin actions. */
|
||||||
@@ -174,6 +192,13 @@ class PRAutoBlogger {
|
|||||||
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
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.
|
// One-time migration (v3): switch to single-panel newspaper comic style.
|
||||||
// Replaces both the old infomercial pastiche and the short-lived premium
|
// Replaces both the old infomercial pastiche and the short-lived premium
|
||||||
// photography style. Force-update unless the user has a truly custom value.
|
// photography style. Force-update unless the user has a truly custom value.
|
||||||
@@ -195,7 +220,6 @@ class PRAutoBlogger {
|
|||||||
}
|
}
|
||||||
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
// 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.
|
// Captions are now inserted as HTML below the image, not baked into the image.
|
||||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||||
@@ -207,9 +231,13 @@ class PRAutoBlogger {
|
|||||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
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.
|
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||||
if ( ! get_option( 'prautoblogger_migrated_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 ) {
|
foreach ( $enc_options as $opt ) {
|
||||||
$val = get_option( $opt, '' );
|
$val = get_option( $opt, '' );
|
||||||
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
||||||
|
|||||||
136
includes/class-schema-installer.php
Normal file
136
includes/class-schema-installer.php
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Single-shot retry of NSFW-blocked image slots with a sanitized prompt.
|
* Single-shot retry of NSFW-blocked image slots with a sanitized prompt.
|
||||||
*
|
*
|
||||||
* When an image provider (currently only Cloudflare Workers AI) rejects a
|
* When an image provider rejects a prompt via its content filter, its
|
||||||
* prompt via its content filter, its batch result carries
|
* batch result carries
|
||||||
* `['error_type' => 'nsfw_blocked']`. This helper reruns just the blocked
|
* `['error_type' => 'nsfw_blocked']`. This helper reruns just the blocked
|
||||||
* slot once with the rule-based fallback prompt (article title + style
|
* slot once with the rule-based fallback prompt (article title + style
|
||||||
* suffix, no LLM rewrite) and merges the new result back into the batch.
|
* suffix, no LLM rewrite) and merges the new result back into the batch.
|
||||||
|
|||||||
@@ -17,22 +17,19 @@ declare(strict_types=1);
|
|||||||
class PRAutoBlogger_Image_Pipeline {
|
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_WIDTH = 1200;
|
||||||
private const DEFAULT_HEIGHT = 632;
|
private const DEFAULT_HEIGHT = 632;
|
||||||
|
|
||||||
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
|
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
|
||||||
private PRAutoBlogger_Image_Provider_Interface $provider;
|
private PRAutoBlogger_Image_Provider_Interface $provider;
|
||||||
|
|
||||||
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
|
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
|
||||||
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
||||||
|
|
||||||
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
|
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
|
||||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||||
|
|
||||||
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
||||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||||
|
|
||||||
@@ -41,7 +38,7 @@ class PRAutoBlogger_Image_Pipeline {
|
|||||||
*
|
*
|
||||||
* When no provider is injected, the constructor reads the
|
* When no provider is injected, the constructor reads the
|
||||||
* `prautoblogger_image_provider` setting and instantiates 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_Image_Provider_Interface|null $provider Optional provider override.
|
||||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
* @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
|
* @return PRAutoBlogger_Image_Provider_Interface
|
||||||
*/
|
*/
|
||||||
@@ -66,7 +64,7 @@ class PRAutoBlogger_Image_Pipeline {
|
|||||||
if ( 'openrouter' === $provider_id ) {
|
if ( 'openrouter' === $provider_id ) {
|
||||||
return new PRAutoBlogger_OpenRouter_Image_Provider();
|
return new PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
}
|
}
|
||||||
return new PRAutoBlogger_Cloudflare_Image_Provider();
|
return new PRAutoBlogger_Runware_Image_Provider();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ You are a comedy writer and single-panel cartoon creator, like Gary Larson (The
|
|||||||
|
|
||||||
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:
|
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.
|
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:
|
Rules:
|
||||||
- The joke MUST relate to the article's actual subject matter, not generic science humor
|
- 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)
|
- 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
|
- 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
|
- The caption should work as a standalone joke even without the image
|
||||||
@@ -44,9 +45,9 @@ Rules:
|
|||||||
- Output ONLY the scene and caption. No preamble, no explanation.
|
- Output ONLY the scene and caption. No preamble, no explanation.
|
||||||
|
|
||||||
Example output format:
|
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;
|
PROMPT;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class PRAutoBlogger_Publisher {
|
|||||||
'post_status' => $post_status,
|
'post_status' => $post_status,
|
||||||
'post_type' => 'post',
|
'post_type' => 'post',
|
||||||
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
|
'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. */
|
/** @see class-prautoblogger.php — listeners registered in main loader. */
|
||||||
@@ -120,15 +120,22 @@ class PRAutoBlogger_Publisher {
|
|||||||
/**
|
/**
|
||||||
* Build post_meta array for generation metadata.
|
* 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_Article_Idea $idea
|
||||||
* @param PRAutoBlogger_Editorial_Review $review
|
* @param PRAutoBlogger_Editorial_Review $review
|
||||||
|
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function build_meta(
|
private function build_meta(
|
||||||
PRAutoBlogger_Article_Idea $idea,
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
PRAutoBlogger_Editorial_Review $review
|
PRAutoBlogger_Editorial_Review $review,
|
||||||
|
?string $run_id = null
|
||||||
): array {
|
): array {
|
||||||
return [
|
$meta = [
|
||||||
'_prautoblogger_generated' => '1',
|
'_prautoblogger_generated' => '1',
|
||||||
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
||||||
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
|
'_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_article_type' => $idea->get_article_type(),
|
||||||
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
|
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
|
||||||
];
|
];
|
||||||
|
if ( null !== $run_id && '' !== $run_id ) {
|
||||||
|
$meta['_prautoblogger_run_id'] = $run_id;
|
||||||
|
}
|
||||||
|
return $meta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
183
includes/core/class-research-reaper.php
Normal file
183
includes/core/class-research-reaper.php
Normal 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 ) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,296 +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 );
|
|
||||||
$this->support->throw_if_nsfw( $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 ( PRAutoBlogger_Image_NSFW_Blocked $e ) {
|
|
||||||
// Tag NSFW so the pipeline's retry layer can rebuild the prompt.
|
|
||||||
$results[ $key ] = [ 'error' => $e->getMessage(), 'error_type' => 'nsfw_blocked' ];
|
|
||||||
} 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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,172 +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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cloudflare Workers AI error code for NSFW content rejection.
|
|
||||||
*
|
|
||||||
* Observed on FLUX.1 schnell on 2026-04-20 when backfilling the post
|
|
||||||
* "Semaglutide vs Tirzepatide". Documented upstream as
|
|
||||||
* `AiError: Input prompt contains NSFW content.`
|
|
||||||
*/
|
|
||||||
public const ERROR_CODE_NSFW = 3030;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return true when the Cloudflare response body contains the NSFW
|
|
||||||
* content-filter error code (3030).
|
|
||||||
*
|
|
||||||
* The body shape is `{"errors":[{"code":3030,"message":"..."}],...}`.
|
|
||||||
* Parsing is tolerant: any JSON decode failure or missing key returns
|
|
||||||
* false, so a malformed 4xx never masquerades as NSFW.
|
|
||||||
*
|
|
||||||
* @param string $raw Raw response body from Cloudflare.
|
|
||||||
* @return bool True when at least one error entry carries code 3030.
|
|
||||||
*/
|
|
||||||
public function is_nsfw_error( string $raw ): bool {
|
|
||||||
$decoded = json_decode( $raw, true );
|
|
||||||
if ( ! is_array( $decoded ) || empty( $decoded['errors'] ) || ! is_array( $decoded['errors'] ) ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
foreach ( $decoded['errors'] as $err ) {
|
|
||||||
if ( is_array( $err ) && (int) ( $err['code'] ?? 0 ) === self::ERROR_CODE_NSFW ) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throw a typed NSFW exception when the response body signals CF's
|
|
||||||
* content-filter rejection (code 3030). No-op otherwise.
|
|
||||||
*
|
|
||||||
* Centralised so callers stay at one line at the call site and so the
|
|
||||||
* exception-construction shape can't drift between call sites.
|
|
||||||
*
|
|
||||||
* @param string $raw Raw response body.
|
|
||||||
* @return void
|
|
||||||
* @throws PRAutoBlogger_Image_NSFW_Blocked When the body contains code 3030.
|
|
||||||
*/
|
|
||||||
public function throw_if_nsfw( string $raw ): void {
|
|
||||||
if ( ! $this->is_nsfw_error( $raw ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new PRAutoBlogger_Image_NSFW_Blocked(
|
|
||||||
sprintf(
|
|
||||||
/* translators: %s: truncated error body. */
|
|
||||||
esc_html__( 'Cloudflare Workers AI rejected the prompt as NSFW: %s', 'prautoblogger' ),
|
|
||||||
esc_html( substr( $raw, 0, 300 ) )
|
|
||||||
),
|
|
||||||
$raw
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,18 +4,18 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Exception raised when an image provider rejects a prompt as NSFW.
|
* Exception raised when an image provider rejects a prompt as NSFW.
|
||||||
*
|
*
|
||||||
* Cloudflare Workers AI returns HTTP 400 with an `errors[].code === 3030`
|
* Image providers (Runware, OpenRouter) return provider-specific error
|
||||||
* body when its content filter blocks a prompt. We catch that shape in
|
* shapes when their content filters block a prompt. We catch those shapes
|
||||||
* the provider and raise this typed exception so the image pipeline can
|
* in each provider and raise this typed exception so the image pipeline
|
||||||
* distinguish a content-filter rejection from a generic 4xx and retry
|
* can distinguish a content-filter rejection from a generic 4xx and
|
||||||
* once with a sanitized fallback prompt.
|
* retry once with a sanitized fallback prompt.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider on NSFW 4xx.
|
* Triggered by: image providers on NSFW-filter rejection.
|
||||||
* Dependencies: None.
|
* Dependencies: None.
|
||||||
*
|
*
|
||||||
* @see providers/class-cloudflare-image-provider.php — Raises this.
|
* @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.
|
* @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt.
|
||||||
* @see https://developers.cloudflare.com/workers-ai/models/flux-1-schnell/ — Upstream error codes.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException {
|
class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException {
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Pricing + model-id resolution helpers for the OpenRouter image provider.
|
* 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).
|
* is a flat rate sourced from OpenRouter's pricing page (April 2026).
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider (both generate_image()
|
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider (both generate_image()
|
||||||
@@ -12,7 +12,7 @@ declare(strict_types=1);
|
|||||||
* Dependencies: None — pure calculation + option reads.
|
* Dependencies: None — pure calculation + option reads.
|
||||||
*
|
*
|
||||||
* @see class-openrouter-image-provider.php — Caller.
|
* @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 {
|
class PRAutoBlogger_OpenRouter_Image_Pricing {
|
||||||
|
|
||||||
|
|||||||
294
includes/providers/class-runware-image-batch.php
Normal file
294
includes/providers/class-runware-image-batch.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
118
includes/providers/class-runware-image-pricing.php
Normal file
118
includes/providers/class-runware-image-pricing.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
294
includes/providers/class-runware-image-provider.php
Normal file
294
includes/providers/class-runware-image-provider.php
Normal 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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
includes/providers/class-runware-image-support.php
Normal file
219
includes/providers/class-runware-image-support.php
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
declare(strict_types=1);
|
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
|
* Consumers depend on this interface, never on a concrete provider — swapping
|
||||||
* providers means writing one new class, not refactoring the pipeline. This
|
* 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.
|
* content generation run, after editorial review, before publish.
|
||||||
* Dependencies: None at interface level — implementations wrap a specific HTTP API.
|
* 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 interface-llm-provider.php — Parallel pattern for LLM providers.
|
||||||
* @see ARCHITECTURE.md — External API Integrations table.
|
* @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.
|
* 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;
|
public function get_provider_name(): string;
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
|||||||
* Contract for a provider-specific AI model registry.
|
* Contract for a provider-specific AI model registry.
|
||||||
*
|
*
|
||||||
* Phase 1 (v1): single implementation for OpenRouter (`class-open-router-model-registry.php`).
|
* 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
|
* Phase 3: a second implementation wraps Runware model data. A higher-level
|
||||||
* higher-level dispatcher iterates registered registries, calls each with the same
|
* dispatcher iterates registered registries, calls each with the same capability,
|
||||||
* capability, and unions the results — the per-registry interface stays unchanged.
|
* and unions the results — the per-registry interface stays unchanged.
|
||||||
*
|
*
|
||||||
* Implementations MUST:
|
* Implementations MUST:
|
||||||
* - Store the normalized registry as a WP option + transient cache layer.
|
* - 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;
|
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
|
* @return string
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/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.
|
* 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.8.0
|
* Version: 0.10.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| and limits without magic strings.
|
| and limits without magic strings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.8.0' );
|
define( 'PRAUTOBLOGGER_VERSION', '0.10.0' );
|
||||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __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' );
|
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
||||||
|
|
||||||
// Image generation defaults.
|
// Image generation defaults.
|
||||||
// Default provider is OpenRouter; Cloudflare is the fallback.
|
// Default provider is Runware; OpenRouter (Gemini) remains as the alternate.
|
||||||
// Default model is Gemini 2.5 Flash Image ($0.005/image, good for comics).
|
// Default model is FLUX.1 schnell via Runware (~$0.0006/image, ~2s). CEO
|
||||||
// User can override via Settings → Images → Image Model.
|
// decision 2026-04-21 after comic A/B: schnell's looser fidelity reads as
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'openrouter' );
|
// editorial-cartoon style and is 65× cheaper than Nano Banana. Users can
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'google/gemini-2.5-flash-image' );
|
// 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
|
// Default style suffix appended to every image-gen prompt. Updated
|
||||||
// 2026-04-18 v4: single-panel newspaper comic style. Caption is now
|
// 2026-04-18 v4: single-panel newspaper comic style. Caption is now
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
|
|||||||
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
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.
|
// Default image style suffix used by ImagePromptBuilder and settings UI.
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
||||||
|
|||||||
133
tests/unit/Core/ActivatorMigrationTest.php
Normal file
133
tests/unit/Core/ActivatorMigrationTest.php
Normal 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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
tests/unit/Core/ActivatorRemoveCloudflareTest.php
Normal file
163
tests/unit/Core/ActivatorRemoveCloudflareTest.php
Normal 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'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/unit/Core/ActivatorScheduleTest.php
Normal file
91
tests/unit/Core/ActivatorScheduleTest.php
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,17 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test build_article_prompt() generates a prompt from article title and content.
|
* 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 {
|
public function test_build_article_prompt_from_content(): void {
|
||||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||||
@@ -55,13 +65,13 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
'post_content' => '<p>Dragons are fascinating creatures. Learn the secrets.</p>',
|
'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( 'How to Train Your Dragon', $scene );
|
||||||
$this->assertStringContainsString( 'Dragons are fascinating', $prompt );
|
|
||||||
// Style suffix should be appended.
|
// Style suffix should be appended.
|
||||||
$this->assertNotEmpty( $prompt );
|
$this->assertNotEmpty( $scene );
|
||||||
$this->assertGreaterThan( 50, strlen( $prompt ) );
|
$this->assertGreaterThan( 50, strlen( $scene ) );
|
||||||
|
$this->assertIsString( $caption );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,10 +84,11 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
'post_title' => 'Minimal Article',
|
'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->assertStringContainsString( 'Minimal Article', $scene );
|
||||||
$this->assertNotEmpty( $prompt );
|
$this->assertNotEmpty( $scene );
|
||||||
|
$this->assertIsString( $caption );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,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( 'Robot', $scene );
|
||||||
$this->assertStringContainsString( 'Dancing', $prompt );
|
$this->assertStringContainsString( 'Dancing', $scene );
|
||||||
$this->assertNotEmpty( $prompt );
|
$this->assertNotEmpty( $scene );
|
||||||
|
$this->assertIsString( $caption );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,14 +123,18 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
'title' => 'Standalone Title',
|
'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->assertStringContainsString( 'Standalone Title', $scene );
|
||||||
$this->assertNotEmpty( $prompt );
|
$this->assertNotEmpty( $scene );
|
||||||
|
$this->assertIsString( $caption );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test that prompts don't exceed reasonable length.
|
* 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 {
|
public function test_prompts_are_reasonably_sized(): void {
|
||||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||||
@@ -129,10 +145,10 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
'post_content' => $long_content,
|
'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).
|
// Scene + style suffix should be under 500 chars.
|
||||||
$this->assertLessThan( 500, strlen( $prompt ) );
|
$this->assertLessThan( 500, strlen( $scene ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
286
tests/unit/Core/ResearchReaperTest.php
Normal file
286
tests/unit/Core/ResearchReaperTest.php
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,483 +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() );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A 400 carrying Cloudflare's NSFW code (3030) must throw the typed
|
|
||||||
* PRAutoBlogger_Image_NSFW_Blocked exception, not a generic
|
|
||||||
* RuntimeException. Image_NSFW_Retry keys off the typed exception to
|
|
||||||
* decide whether to rebuild the prompt and retry.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_throws_nsfw_blocked_on_code_3030(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
$nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}';
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
||||||
'body' => $nsfw_body,
|
|
||||||
'response' => [ 'code' => 400 ],
|
|
||||||
'headers' => [],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body );
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$this->expectException( \PRAutoBlogger_Image_NSFW_Blocked::class );
|
|
||||||
$provider->generate_image( 'some peptide prompt', 1080, 1080 );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A non-3030 4xx (e.g. 401 bad token) must NOT be tagged NSFW — the
|
|
||||||
* retry path would be wasted work there.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_batch_tags_nsfw_errors_distinctly(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
$nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}';
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
||||||
'body' => $nsfw_body,
|
|
||||||
'response' => [ 'code' => 400 ],
|
|
||||||
'headers' => [],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body );
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$results = $provider->generate_image_batch( [
|
|
||||||
'image_a' => [ 'prompt' => 'some prompt', 'width' => 1080, 'height' => 1080 ],
|
|
||||||
] );
|
|
||||||
|
|
||||||
$this->assertArrayHasKey( 'image_a', $results );
|
|
||||||
$this->assertSame( 'nsfw_blocked', $results['image_a']['error_type'] ?? null );
|
|
||||||
$this->assertArrayHasKey( 'error', $results['image_a'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
91
tests/unit/Providers/RunwareImagePricingTest.php
Normal file
91
tests/unit/Providers/RunwareImagePricingTest.php
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
239
tests/unit/Providers/RunwareImageProviderTest.php
Normal file
239
tests/unit/Providers/RunwareImageProviderTest.php
Normal 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'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
182
tests/unit/Providers/RunwareImageSupportTest.php
Normal file
182
tests/unit/Providers/RunwareImageSupportTest.php
Normal 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() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ class ImageModelRegistryTest extends BaseTestCase {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Every entry returned by get_models() must carry a non-empty id and
|
* Every entry returned by get_models() must carry a non-empty id and
|
||||||
* a provider of 'openrouter' or 'cloudflare'. If this breaks, the
|
* 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.
|
* admin save flow will silently drop the provider derivation.
|
||||||
*/
|
*/
|
||||||
public function test_every_registry_entry_has_id_and_known_provider(): void {
|
public function test_every_registry_entry_has_id_and_known_provider(): void {
|
||||||
@@ -31,7 +32,7 @@ class ImageModelRegistryTest extends BaseTestCase {
|
|||||||
$this->assertArrayHasKey( 'provider', $model );
|
$this->assertArrayHasKey( 'provider', $model );
|
||||||
$this->assertContains(
|
$this->assertContains(
|
||||||
$model['provider'],
|
$model['provider'],
|
||||||
[ 'openrouter', 'cloudflare' ],
|
[ 'runware', 'openrouter' ],
|
||||||
sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] )
|
sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,14 +43,46 @@ class ImageModelRegistryTest extends BaseTestCase {
|
|||||||
* This is the core of the v0.8.0 collapsed-dropdown contract.
|
* This is the core of the v0.8.0 collapsed-dropdown contract.
|
||||||
*/
|
*/
|
||||||
public function test_provider_for_returns_paired_provider_for_known_model(): void {
|
public function test_provider_for_returns_paired_provider_for_known_model(): void {
|
||||||
$this->assertSame(
|
|
||||||
'cloudflare',
|
|
||||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' )
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'openrouter',
|
'openrouter',
|
||||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'google/gemini-2.5-flash-image' )
|
\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' )
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user