From 52a8ffbd9e463384c1093e216ee310f7d8fbb9f5 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 01:16:30 +0700 Subject: [PATCH] feat(images): collapse Image Provider + Image Model into a single dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now, Image Provider and Image Model were two independent selects in the admin UI. That let the CEO set Model=flux-1-schnell while Provider was still openrouter — OpenRouter has no such model so every image gen on posts 650/657 on 2026-04-20 returned HTTP 400, and the article published without a featured image. v0.8.0 removes the Image Provider field from the admin UI entirely. The provider is now derived from the model's entry in the registry every time the model is saved. A new PRAutoBlogger_Image_Model_Registry::provider_for() helper centralises the mapping, so admin save, the on-upgrade migration, and future callers all read from one place. On save, PRAutoBlogger_Admin_Page::sanitize_image_model() validates the submitted slug against the registry: - Known slug: persist it, and update `prautoblogger_image_provider` to the paired provider as a side-effect. - Unknown slug: add_settings_error with a clear message, and return the previously-saved value so the site stays on a working model. A one-shot v0.8.0 migration in PRAutoBlogger::on_check_db_version() derives the provider from the currently-saved model on first admin_init after the plugin is updated. This auto-heals sites that were running mismatched pairs before the upgrade, without requiring the admin to re-open settings. `prautoblogger_image_provider` option key is preserved — all existing reads in the pipeline, cost tracker, and validators continue to work unchanged. Tests (Services/ImageModelRegistryTest): - Every registry entry has an id and a provider in {openrouter, cloudflare}. - provider_for() returns the paired provider for a known slug. - provider_for() returns '' for unknown/empty slugs. Cost impact: none. Migration idempotent via the `prautoblogger_migrated_image_provider_v080` flag. Uninstall sweeps it along with every other plugin option. Part of the v0.8.0 image bundle (thread image-mime-bug, Issue 1). Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/admin/class-admin-page.php | 16 ++++- includes/admin/class-image-model-registry.php | 16 +++++ .../admin/class-settings-fields-extended.php | 14 +--- includes/class-prautoblogger.php | 14 ++++ .../unit/Services/ImageModelRegistryTest.php | 69 +++++++++++++++++++ 5 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 tests/unit/Services/ImageModelRegistryTest.php diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index 61881cb..f954999 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -279,7 +279,21 @@ class PRAutoBlogger_Admin_Page { if ( in_array( $option_name, $numeric, true ) ) { return is_numeric( $value ) ? $value : 0; } - + if ( 'prautoblogger_image_model' === $option_name ) { + return $this->sanitize_image_model( (string) $value ); + } return sanitize_text_field( (string) $value ); } + + /** Validate model id against the registry and persist the derived provider. */ + private function sanitize_image_model( string $value ): string { + $candidate = sanitize_text_field( $value ); + $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate ); + if ( '' !== $provider ) { + update_option( 'prautoblogger_image_provider', $provider ); + return $candidate; + } + add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) ); + return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL ); + } } diff --git a/includes/admin/class-image-model-registry.php b/includes/admin/class-image-model-registry.php index 92401e2..cd37bd8 100644 --- a/includes/admin/class-image-model-registry.php +++ b/includes/admin/class-image-model-registry.php @@ -80,4 +80,20 @@ class PRAutoBlogger_Image_Model_Registry { ], ]; } + + /** + * Return the provider id for a known model, or empty string if the + * model id is not in the registry. + * + * @param string $model_id Model slug from the admin UI. + * @return string Provider id ('openrouter' | 'cloudflare' | ''). + */ + public static function provider_for( string $model_id ): string { + foreach ( self::get_models() as $model ) { + if ( ( $model['id'] ?? '' ) === $model_id ) { + return (string) ( $model['provider'] ?? '' ); + } + } + return ''; + } } diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 390bfce..8431457 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -210,18 +210,6 @@ class PRAutoBlogger_Settings_Fields_Extended { 'default' => '1', 'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ), ], - [ - 'id' => 'prautoblogger_image_provider', - 'label' => __( 'Image Provider', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_images', - 'default' => 'openrouter', - 'options' => [ - 'openrouter' => __( 'OpenRouter (multiple models available)', 'prautoblogger' ), - 'cloudflare' => __( 'Cloudflare Workers AI', 'prautoblogger' ), - ], - 'description' => __( 'OpenRouter reuses your existing API key and offers higher-quality models. Cloudflare is cheaper but lower quality.', 'prautoblogger' ), - ], [ 'id' => 'prautoblogger_image_model', 'label' => __( 'Image Model', 'prautoblogger' ), @@ -229,7 +217,7 @@ class PRAutoBlogger_Settings_Fields_Extended { 'section' => 'prautoblogger_images', 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, 'capability' => 'image_generation', - 'description' => __( 'Pick a model from your selected provider.', 'prautoblogger' ), + 'description' => __( 'Pick an image model. The provider (OpenRouter or Cloudflare Workers AI) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ), 'badge' => __( 'Quality', 'prautoblogger' ), ], [ diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 8a9a98a..2a26c16 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -160,6 +160,20 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' ); } + // One-time migration (v0.8.0): the admin no longer has an independent + // Image Provider dropdown; provider is derived from the image model on + // save. Auto-heal any existing site where the saved provider doesn't + // match the saved model's provider (the root cause of posts 650/657 + // silently missing their featured image on 2026-04-20). Runs once. + if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) { + $saved_model = (string) get_option( 'prautoblogger_image_model', '' ); + $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model ); + if ( '' !== $provider ) { + update_option( 'prautoblogger_image_provider', $provider ); + } + update_option( 'prautoblogger_migrated_image_provider_v080', '1' ); + } + // One-time migration (v3): switch to single-panel newspaper comic style. // Replaces both the old infomercial pastiche and the short-lived premium // photography style. Force-update unless the user has a truly custom value. diff --git a/tests/unit/Services/ImageModelRegistryTest.php b/tests/unit/Services/ImageModelRegistryTest.php new file mode 100644 index 0000000..f6235c1 --- /dev/null +++ b/tests/unit/Services/ImageModelRegistryTest.php @@ -0,0 +1,69 @@ +assertNotEmpty( $models ); + + foreach ( $models as $model ) { + $this->assertArrayHasKey( 'id', $model ); + $this->assertNotEmpty( $model['id'] ); + $this->assertArrayHasKey( 'provider', $model ); + $this->assertContains( + $model['provider'], + [ 'openrouter', 'cloudflare' ], + sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] ) + ); + } + } + + /** + * provider_for() returns the paired provider for a known model id. + * This is the core of the v0.8.0 collapsed-dropdown contract. + */ + public function test_provider_for_returns_paired_provider_for_known_model(): void { + $this->assertSame( + 'cloudflare', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' ) + ); + $this->assertSame( + 'openrouter', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'google/gemini-2.5-flash-image' ) + ); + } + + /** + * Unknown model ids return an empty string so callers can distinguish + * "not in registry" from a valid provider and show a settings error. + */ + public function test_provider_for_returns_empty_for_unknown_model(): void { + $this->assertSame( + '', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'nonexistent/model' ) + ); + $this->assertSame( + '', + \PRAutoBlogger_Image_Model_Registry::provider_for( '' ) + ); + } +}