diff --git a/README.de.md b/README.de.md
index 38e8687..66f0b89 100644
--- a/README.de.md
+++ b/README.de.md
@@ -3,8 +3,8 @@



-
-
+
+
🇬🇧 [English version → README.md](README.md)
@@ -26,7 +26,7 @@ Die KI-Welle hat es schlimmer gemacht. Plugins fingen an, „KI-gestützte" Feat
BreznGEO verfolgt einen anderen Ansatz:
-- **Direkter API-Zugriff.** Du hinterlegst deinen eigenen Key von OpenAI, Anthropic, Google oder xAI. BreznGEO ruft die API direkt auf. Kein Mittelsmann, keine Marge, keine Daten über Server Dritter.
+- **Direkter API-Zugriff.** Du hinterlegst deinen eigenen Key von OpenAI, Anthropic, Google, xAI oder OpenRouter (600+ Modelle über einen Key). BreznGEO ruft die API direkt auf. Kein Mittelsmann, keine Marge, keine Daten über Server Dritter.
- **Klarer Output, kein Lärm.** Metabeschreibungen, Strukturdaten, KI-Inhaltsblöcke für GEO, Bot-Steuerung. Keine Lesbarkeits-Scores, keine Keyword-Dichte-Meter, keine Upsell-Banner.
- **Keine Subscription.** GPL-2.0. Kostenlos auf beliebig vielen Sites nutzbar. Die einzigen Kosten sind die API-Nutzung — typischerweise Bruchteile eines Cents pro Beitrag.
- **Keine Telemetrie.** BreznGEO sendet keine Daten nach Hause. Kein Usage-Tracking, kein Remote-Logging, keine Analytics, die den eigenen Server verlassen.
@@ -104,12 +104,13 @@ brezngeo/
│ │ ├── KeywordVariants.php # Locale-basierte Keyword-Varianten (EN/DE)
│ │ └── TokenEstimator.php # Grobe Token-Schätzung für Kostenvorschau im Bulk
│ └── Providers/
-│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
-│ ├── ProviderRegistry.php # Registry-Pattern: Provider registrieren und abrufen
-│ ├── AnthropicProvider.php # Claude API (Messages API)
-│ ├── GeminiProvider.php # Google Gemini (generateContent API)
-│ ├── GrokProvider.php # xAI Grok (OpenAI-kompatibler Endpunkt)
-│ └── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
+│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
+│ ├── ProviderRegistry.php # Registry-Pattern: Provider registrieren und abrufen
+│ ├── AnthropicProvider.php # Claude API (Messages API)
+│ ├── GeminiProvider.php # Google Gemini (generateContent API)
+│ ├── GrokProvider.php # xAI Grok (OpenAI-kompatibler Endpunkt)
+│ ├── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
+│ └── OpenRouterProvider.php # OpenRouter (600+ Modelle über eine OpenAI-kompatible API)
└── vendor/ # Composer-Abhängigkeiten (nur Produktionsstand)
```
@@ -336,6 +337,9 @@ CrawlerLog speichert IPs ausschließlich als SHA-256-Hash. Originalwert wird nie
| Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` |
| Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` |
| xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` |
+| OpenRouter | `OpenRouterProvider` | `https://openrouter.ai/api/v1/chat/completions` |
+
+**Zu OpenRouter:** Ein einziger API-Key öffnet den Zugang zu 600+ Modellen von OpenAI, Anthropic, Google, Meta, Mistral, xAI, DeepSeek u.v.m. Die kuratierte Marketing/SEO-Auswahl wird on demand geladen ("Modelle laden"-Button) und 12 Stunden im Transient gecached. Preise werden automatisch aus OpenRouter übernommen. Eigene Modell-IDs (z. B. `anthropic/claude-opus-4.7`) werden unterstützt.
Neuen Provider hinzufügen: `ProviderInterface` implementieren, in `Core.php` via `$registry->register()` eintragen — erscheint automatisch in allen Dropdowns.
@@ -371,6 +375,7 @@ Alle Endpunkte erfordern `manage_options` (kein `nopriv`).
| `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Meta-Beschreibung für einzelnen Post neu generieren |
| `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | API-Key und Verbindung testen |
| `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Standard-Prompt zurücksetzen |
+| `brezngeo_openrouter_load_models` | `ProviderPage::ajax_openrouter_load_models` | Kuratierte OpenRouter-Marketing/SEO-Modell-Liste laden (12 h Transient-Cache) |
| `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Link-Analyse ausführen |
| `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Top-10 interne Link-Vorschläge für aktuellen Beitrag zurückgeben |
| `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | GEO Block generieren |
@@ -421,7 +426,7 @@ Kein JavaScript-Build-Step. Alle Assets unter `assets/` sind direkte JS/CSS-Date
| Caching | WordPress Transients |
| Frontend | Vanilla JS + jQuery (WordPress-integriert), kein Build-Step |
| I18n | `.pot`-File, Text-Domain `brezngeo` |
-| Tests | PHPUnit (158 Tests, 301 Assertions) |
+| Tests | PHPUnit (163 Tests, 311 Assertions) |
| Coding Standard | WordPress PHPCS |
| Lizenz | GPL-2.0-or-later |
diff --git a/README.md b/README.md
index fefc1f3..7edf5a4 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,8 @@



-
-
+
+
🇩🇪 [Deutsche Version → README.de.md](README.de.md)
@@ -26,7 +26,7 @@ The AI wave made it worse. Plugins started offering "AI-powered" features — bu
BreznGEO takes a different approach:
-- **Direct API access.** You store your own key from OpenAI, Anthropic, Google, or xAI. BreznGEO calls the API directly. No middleman, no margin, no data passing through third-party servers.
+- **Direct API access.** You store your own key from OpenAI, Anthropic, Google, xAI, or OpenRouter (600+ models through one key). BreznGEO calls the API directly. No middleman, no margin, no data passing through third-party servers.
- **Clear output, not noise.** Meta descriptions, structured data, GEO content blocks, bot management. No readability scores, no keyword density meters, no upsell banners.
- **No subscription.** GPL-2.0. Free to use on any number of sites. The only costs are API usage — typically fractions of a cent per post.
- **No telemetry.** BreznGEO sends no data home. No usage tracking, no remote logging, no analytics leaving your server.
@@ -104,12 +104,13 @@ brezngeo/
│ │ ├── KeywordVariants.php # Locale-aware keyword variant generation (EN/DE)
│ │ └── TokenEstimator.php # Rough token estimate for cost preview in bulk
│ └── Providers/
-│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
-│ ├── ProviderRegistry.php # Registry pattern: register and retrieve providers
-│ ├── AnthropicProvider.php # Claude API (Messages API)
-│ ├── GeminiProvider.php # Google Gemini (generateContent API)
-│ ├── GrokProvider.php # xAI Grok (OpenAI-compatible endpoint)
-│ └── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
+│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
+│ ├── ProviderRegistry.php # Registry pattern: register and retrieve providers
+│ ├── AnthropicProvider.php # Claude API (Messages API)
+│ ├── GeminiProvider.php # Google Gemini (generateContent API)
+│ ├── GrokProvider.php # xAI Grok (OpenAI-compatible endpoint)
+│ ├── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
+│ └── OpenRouterProvider.php # OpenRouter (600+ models via one OpenAI-compatible API)
└── vendor/ # Composer dependencies (production only)
```
@@ -444,7 +445,7 @@ The crawler log stores IP addresses exclusively as SHA-256 hashes. The original
## AI Providers
-BreznGEO supports four providers, all implementing the same `ProviderInterface`:
+BreznGEO supports five providers, all implementing the same `ProviderInterface`:
| Provider | Class | API Base URL |
|---|---|---|
@@ -452,6 +453,9 @@ BreznGEO supports four providers, all implementing the same `ProviderInterface`:
| Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` |
| Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` |
| xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` |
+| OpenRouter | `OpenRouterProvider` | `https://openrouter.ai/api/v1/chat/completions` |
+
+**About OpenRouter:** A single API key opens access to 600+ models from OpenAI, Anthropic, Google, Meta, Mistral, xAI, DeepSeek and others. The curated Marketing/SEO selection is fetched on demand ("Load models" button) and cached for 12 hours. Pricing is read from OpenRouter automatically. Custom model IDs (e.g. `anthropic/claude-opus-4.7`) are supported.
### Adding a New Provider
@@ -525,6 +529,7 @@ All endpoints are exclusively accessible to logged-in users with `manage_options
| `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Regenerate meta description for a single post |
| `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | Test API key and connection |
| `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Reset to default prompt |
+| `brezngeo_openrouter_load_models` | `ProviderPage::ajax_openrouter_load_models` | Fetch curated OpenRouter marketing/SEO model list (12 h transient cache) |
| `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Run link analysis for the dashboard |
| `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Return top-10 internal link suggestions for current post |
| `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | Generate GEO block for a single post |
@@ -576,7 +581,7 @@ The plugin has no JavaScript build step. All assets under `assets/` are direct J
| Caching | WordPress transients (llms.txt, link analysis, bulk lock) |
| Frontend | Vanilla JS + jQuery (WordPress-bundled), no build step |
| i18n | `.pot` file, text domain `brezngeo` |
-| Tests | PHPUnit (158 tests, 301 assertions) |
+| Tests | PHPUnit (163 tests, 311 assertions) |
| Coding standard | WordPress PHPCS |
| License | GPL-2.0-or-later |
diff --git a/brezngeo/assets/admin.js b/brezngeo/assets/admin.js
index 7d6787b..ec05fb1 100644
--- a/brezngeo/assets/admin.js
+++ b/brezngeo/assets/admin.js
@@ -33,6 +33,68 @@ jQuery( function ( $ ) {
} );
} );
+ // OpenRouter: toggle custom-model field when dropdown value is __custom__
+ function updateOpenRouterCustom() {
+ var val = $( '#brezngeo-openrouter-model' ).val();
+ $( '.brezngeo-openrouter-custom-wrap' ).toggle( val === '__custom__' );
+ }
+ $( document ).on( 'change', '#brezngeo-openrouter-model', function () {
+ updateOpenRouterCustom();
+ var $opt = $( this ).find( 'option:selected' );
+ var inVal = $opt.data( 'input' );
+ var outVal = $opt.data( 'output' );
+ if ( inVal !== undefined && inVal !== '' ) {
+ $( '#brezngeo-openrouter-pricing' ).html(
+ 'Input $' + Number( inVal ).toFixed( 4 ) +
+ ' / 1M \u00b7 Output $' + Number( outVal ).toFixed( 4 ) +
+ ' / 1M'
+ );
+ }
+ } );
+ if ( $( '#brezngeo-openrouter-model' ).length ) updateOpenRouterCustom();
+
+ // OpenRouter: "Load models" button — fetches curated Marketing/SEO list from OpenRouter
+ $( document ).on( 'click', '.brezngeo-openrouter-load-btn', function () {
+ var btn = $( this );
+ var status = $( '.brezngeo-openrouter-load-status' );
+ var select = $( '#brezngeo-openrouter-model' );
+
+ btn.prop( 'disabled', true );
+ status.removeClass( 'success error' ).text( brezngeoAdmin.testing );
+
+ $.post( brezngeoAdmin.ajaxUrl, {
+ action: 'brezngeo_openrouter_load_models',
+ nonce: brezngeoAdmin.nonce,
+ } ).done( function ( res ) {
+ if ( ! res.success ) {
+ status.addClass( 'error' ).text( '\u2717 ' + res.data );
+ return;
+ }
+ var models = res.data;
+ var previous = select.val();
+ // Preserve the __custom__ option at the bottom
+ select.find( 'option' ).not( '[value="__custom__"]' ).remove();
+ select.find( 'option[value=""]' ).remove();
+ $.each( models, function ( id, meta ) {
+ var opt = $( '' )
+ .val( id )
+ .text( meta.label || id )
+ .attr( 'data-input', meta.input_cost )
+ .attr( 'data-output', meta.output_cost );
+ select.find( 'option[value="__custom__"]' ).before( opt );
+ } );
+ if ( previous && select.find( 'option[value="' + previous.replace( /"/g, '\\"' ) + '"]' ).length ) {
+ select.val( previous );
+ }
+ select.trigger( 'change' );
+ status.addClass( 'success' ).text( '\u2713 ' + Object.keys( models ).length );
+ } ).fail( function () {
+ status.addClass( 'error' ).text( '\u2717 ' + brezngeoAdmin.networkError );
+ } ).always( function () {
+ btn.prop( 'disabled', false );
+ } );
+ } );
+
$( '#brezngeo-reset-prompt' ).on( 'click', function () {
if ( ! confirm( brezngeoAdmin.resetConfirm ) ) return;
$.post( brezngeoAdmin.ajaxUrl, {
diff --git a/brezngeo/brezngeo.php b/brezngeo/brezngeo.php
index 938b947..4d28209 100644
--- a/brezngeo/brezngeo.php
+++ b/brezngeo/brezngeo.php
@@ -3,7 +3,7 @@
* Plugin Name: BreznGEO
* Plugin URI: https://brezngeo.com/
* Description: AI-powered meta descriptions, GEO structured data, and llms.txt for WordPress.
- * Version: 1.2.2
+ * Version: 1.3.0
* Requires at least: 6.0
* Requires PHP: 8.0
* Author: NoSchmarrn.dev
@@ -18,7 +18,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
-define( 'BREZNGEO_VERSION', '1.2.2' );
+define( 'BREZNGEO_VERSION', '1.3.0' );
define( 'BREZNGEO_FILE', __FILE__ );
define( 'BREZNGEO_DIR', plugin_dir_path( __FILE__ ) );
define( 'BREZNGEO_URL', plugin_dir_url( __FILE__ ) );
diff --git a/brezngeo/includes/Admin/AdminMenu.php b/brezngeo/includes/Admin/AdminMenu.php
index 335a0bd..be4a0c2 100644
--- a/brezngeo/includes/Admin/AdminMenu.php
+++ b/brezngeo/includes/Admin/AdminMenu.php
@@ -51,7 +51,7 @@ class AdminMenu {
wp_safe_redirect(
add_query_arg(
array(
- 'page' => 'brezngeo',
+ 'page' => 'brezngeo',
'brezngeo-saved' => '1',
),
admin_url( 'admin.php' )
@@ -207,8 +207,8 @@ class AdminMenu {
$provider = $prov_obj ? $prov_obj->getName() : $provider_key;
}
- $post_types = $settings['meta_post_types'] ?? array( 'post', 'page' );
- $meta_stats = $this->get_meta_stats( $post_types );
+ $post_types = $settings['meta_post_types'] ?? array( 'post', 'page' );
+ $meta_stats = $this->get_meta_stats( $post_types );
$brezngeo_compat = $this->get_compat_info();
$brezngeo_show_welcome = $this->should_show_welcome();
diff --git a/brezngeo/includes/Admin/ProviderPage.php b/brezngeo/includes/Admin/ProviderPage.php
index 543679d..37b46cb 100644
--- a/brezngeo/includes/Admin/ProviderPage.php
+++ b/brezngeo/includes/Admin/ProviderPage.php
@@ -7,13 +7,15 @@ if ( ! defined( 'ABSPATH' ) ) {
use BreznGEO\ProviderRegistry;
use BreznGEO\Helpers\KeyVault;
+use BreznGEO\Providers\OpenRouterProvider;
class ProviderPage {
private const PRICING_URLS = array(
- 'openai' => 'https://openai.com/de-DE/api/pricing',
- 'anthropic' => 'https://platform.claude.com/docs/en/about-claude/pricing',
- 'gemini' => 'https://ai.google.dev/gemini-api/docs/pricing?hl=de',
- 'grok' => 'https://docs.x.ai/developers/models',
+ 'openai' => 'https://openai.com/de-DE/api/pricing',
+ 'anthropic' => 'https://platform.claude.com/docs/en/about-claude/pricing',
+ 'gemini' => 'https://ai.google.dev/gemini-api/docs/pricing?hl=de',
+ 'grok' => 'https://docs.x.ai/developers/models',
+ 'openrouter' => 'https://openrouter.ai/models',
);
public function register(): void {
@@ -21,6 +23,7 @@ class ProviderPage {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'wp_ajax_brezngeo_test_connection', array( $this, 'ajax_test_connection' ) );
add_action( 'wp_ajax_brezngeo_get_default_prompt', array( $this, 'ajax_get_default_prompt' ) );
+ add_action( 'wp_ajax_brezngeo_openrouter_load_models', array( $this, 'ajax_openrouter_load_models' ) );
}
public function register_settings(): void {
@@ -74,7 +77,13 @@ class ProviderPage {
$clean['models'] = array();
foreach ( ( $input['models'] ?? array() ) as $provider_id => $model ) {
- $clean['models'][ sanitize_key( $provider_id ) ] = sanitize_text_field( $model );
+ $pid = sanitize_key( $provider_id );
+ $value = sanitize_text_field( $model );
+ if ( $pid === 'openrouter' && $value === '__custom__' ) {
+ $custom_raw = (string) ( $input['openrouter_custom_model'] ?? '' );
+ $value = sanitize_text_field( $custom_raw );
+ }
+ $clean['models'][ $pid ] = $value;
}
$clean['costs'] = array();
@@ -92,6 +101,18 @@ class ProviderPage {
}
}
+ $selected_openrouter = $clean['models']['openrouter'] ?? '';
+ if ( $selected_openrouter !== '' ) {
+ $cached = get_transient( \BreznGEO\Providers\OpenRouterProvider::MODELS_CACHE );
+ if ( is_array( $cached ) && isset( $cached[ $selected_openrouter ] ) ) {
+ $meta = $cached[ $selected_openrouter ];
+ $clean['costs']['openrouter'][ $selected_openrouter ] = array(
+ 'input' => (float) ( $meta['input_cost'] ?? 0 ),
+ 'output' => (float) ( $meta['output_cost'] ?? 0 ),
+ );
+ }
+ }
+
return $clean;
}
@@ -126,6 +147,59 @@ class ProviderPage {
wp_send_json_success( SettingsPage::getDefaultPrompt() );
}
+ public function ajax_openrouter_load_models(): void {
+ check_ajax_referer( 'brezngeo_admin', 'nonce' );
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
+ }
+
+ $response = wp_remote_get(
+ OpenRouterProvider::MODELS_URL . '?category=marketing',
+ array(
+ 'timeout' => 15,
+ 'headers' => array(
+ 'Accept' => 'application/json',
+ 'HTTP-Referer' => home_url( '/' ),
+ 'X-Title' => 'BreznGEO',
+ ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ wp_send_json_error( $response->get_error_message() );
+ }
+
+ $code = wp_remote_retrieve_response_code( $response );
+ $body = json_decode( wp_remote_retrieve_body( $response ), true );
+ if ( $code !== 200 || ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
+ $msg = $body['error']['message'] ?? "HTTP $code";
+ wp_send_json_error( $msg );
+ }
+
+ $normalized = array();
+ foreach ( $body['data'] as $model ) {
+ if ( ! is_array( $model ) || empty( $model['id'] ) ) {
+ continue;
+ }
+ $id = (string) $model['id'];
+ $label = isset( $model['name'] ) && is_string( $model['name'] ) && $model['name'] !== '' ? (string) $model['name'] : $id;
+ $input_per_token = isset( $model['pricing']['prompt'] ) ? (float) $model['pricing']['prompt'] : 0.0;
+ $output_per_token = isset( $model['pricing']['completion'] ) ? (float) $model['pricing']['completion'] : 0.0;
+ $normalized[ $id ] = array(
+ 'label' => $label,
+ 'input_cost' => round( $input_per_token * 1_000_000, 4 ),
+ 'output_cost' => round( $output_per_token * 1_000_000, 4 ),
+ );
+ }
+
+ if ( empty( $normalized ) ) {
+ wp_send_json_error( __( 'No models returned by OpenRouter.', 'brezngeo' ) );
+ }
+
+ set_transient( OpenRouterProvider::MODELS_CACHE, $normalized, 12 * HOUR_IN_SECONDS );
+ wp_send_json_success( $normalized );
+ }
+
public function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
diff --git a/brezngeo/includes/Admin/views/partials/openrouter-model-field.php b/brezngeo/includes/Admin/views/partials/openrouter-model-field.php
new file mode 100644
index 0000000..d6fe1a9
--- /dev/null
+++ b/brezngeo/includes/Admin/views/partials/openrouter-model-field.php
@@ -0,0 +1,81 @@
+getModels();
+$or_saved_model = $settings['models']['openrouter'] ?? '';
+$or_is_custom = $or_saved_model !== '' && ! array_key_exists( $or_saved_model, $or_models );
+$or_cached_pricing = get_transient( \BreznGEO\Providers\OpenRouterProvider::MODELS_CACHE );
+$or_cache_is_array = is_array( $or_cached_pricing );
+$or_selected_pricing = ( $or_cache_is_array && isset( $or_cached_pricing[ $or_saved_model ] ) )
+ ? $or_cached_pricing[ $or_saved_model ]
+ : null;
+?>
+
+
+
+
+
+
+
+ + + +
+ + ++
- getModels() as $model_id => $model_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound - $saved_costs = $settings['costs'][ $id ][ $model_id ] ?? array(); - ?> + getModels() as $model_id => $model_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound + $saved_costs = $settings['costs'][ $id ][ $model_id ] ?? array(); + ?>