diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a1caf6..228d876 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -313,6 +313,8 @@ All prefixed with `prautoblogger_`: | Option Key | Description | |----------------------------------------|-------------------------------------------------------| | `prautoblogger_openrouter_api_key` | Encrypted OpenRouter API key | +| `prautoblogger_ai_gateway_base_url` | Optional Cloudflare AI Gateway URL (proxies OpenRouter); empty = direct | +| `prautoblogger_ai_gateway_cache_ttl` | Seconds Cloudflare may cache identical LLM responses (0 = off) | | `prautoblogger_ga4_property_id` | Google Analytics 4 property ID | | `prautoblogger_ga4_credentials_json` | Encrypted GA4 service account credentials | | `prautoblogger_analysis_model` | OpenRouter model for analysis (default: cheap) | @@ -360,6 +362,7 @@ Stored on every PRAutoBlogger-generated post: | Service | Purpose | Auth | Rate Limit | Code | |---------|---------|------|------------|------| | OpenRouter | All LLM calls (analysis, writing, editing) | API key (encrypted in wp_options) | Per-model | `providers/class-open-router-provider.php`, `providers/class-open-router-pricing.php` | +| Cloudflare AI Gateway (optional) | Transparent proxy in front of OpenRouter — adds response caching, cost logging, rate limiting, provider fallback | Same OpenRouter key; gateway URL in `prautoblogger_ai_gateway_base_url` | Gateway-side quotas | Same provider file; activated when gateway URL option is non-empty | | Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` | | Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` | | Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` | @@ -410,6 +413,9 @@ Each pipeline execution generates a UUID (`run_id`) that tags every `prab_genera ### #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. +### #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. + --- ## Cross-System LLM Budget Coordination diff --git a/includes/admin/class-settings-fields.php b/includes/admin/class-settings-fields.php index 52e6151..620adac 100755 --- a/includes/admin/class-settings-fields.php +++ b/includes/admin/class-settings-fields.php @@ -84,6 +84,25 @@ class PRAutoBlogger_Settings_Fields { 'description' => __( 'Uses Reddit RSS (primary) and .json (fallback). No API key required.', 'prautoblogger' ), 'icon' => '📡', ], + [ + 'id' => 'prautoblogger_ai_gateway_base_url', + 'label' => __( 'Cloudflare AI Gateway URL', 'prautoblogger' ), + 'type' => 'text', + 'section' => 'prautoblogger_api', + 'default' => '', + 'description' => __( 'Optional. Route OpenRouter calls through a Cloudflare AI Gateway for caching, cost logging, and rate limiting. Format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter — leave blank to call OpenRouter directly.', 'prautoblogger' ), + 'icon' => '☁️', + ], + [ + 'id' => 'prautoblogger_ai_gateway_cache_ttl', + 'label' => __( 'AI Gateway Cache TTL (seconds)', 'prautoblogger' ), + 'type' => 'number', + 'section' => 'prautoblogger_api', + 'default' => 0, + 'min' => 0, + 'max' => 2592000, + 'description' => __( 'How long Cloudflare may serve cached responses for identical LLM calls. 0 disables caching. Only used when a gateway URL is set above. Safe values: 0 for article generation (always fresh), 3600+ for repeated classification/scoring calls.', 'prautoblogger' ), + ], // ── AI Models ─────────────────────────────────────────────── [ diff --git a/includes/providers/class-open-router-provider.php b/includes/providers/class-open-router-provider.php index ce9b162..88faa10 100644 --- a/includes/providers/class-open-router-provider.php +++ b/includes/providers/class-open-router-provider.php @@ -18,7 +18,52 @@ declare(strict_types=1); */ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_Interface { - private const API_BASE_URL = 'https://openrouter.ai/api/v1'; + /** + * Default direct-to-OpenRouter endpoint. Used when no Cloudflare AI Gateway + * URL is configured. The active endpoint is resolved per-request via + * get_api_base_url() so admins can flip to an AI Gateway proxy without code. + */ + private const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1'; + + /** + * Resolve the API base URL. + * + * If an admin has configured a Cloudflare AI Gateway URL in settings, + * we route through that instead of hitting OpenRouter directly. The + * gateway is a transparent OpenAI/OpenRouter-compatible proxy that + * adds caching, cost logging, rate-limiting, and provider fallback + * — see ARCHITECTURE.md "External API Integrations". + * + * Expected gateway URL shape: + * https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter + * + * @return string Base URL with no trailing slash. + */ + private function get_api_base_url(): string { + $override = trim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ) ); + if ( '' === $override ) { + return self::DEFAULT_API_BASE_URL; + } + // Only accept https to avoid plaintext-auth regressions. + if ( 0 !== stripos( $override, 'https://' ) ) { + return self::DEFAULT_API_BASE_URL; + } + return rtrim( $override, '/' ); + } + + /** + * Cache TTL (seconds) for AI Gateway response caching. + * + * Cloudflare honours the `cf-aig-cache-ttl` request header and serves + * cached responses for identical payloads within the TTL window. Only + * meaningful when a gateway base URL is configured; harmless otherwise. + * + * @return int Non-negative integer seconds; 0 disables caching. + */ + private function get_cache_ttl_seconds(): int { + $ttl = (int) get_option( 'prautoblogger_ai_gateway_cache_ttl', 0 ); + return $ttl > 0 ? $ttl : 0; + } /** * Send a chat completion request to OpenRouter. @@ -88,40 +133,55 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In $last_error = ''; + $base_url = $this->get_api_base_url(); + $base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST ); + $cache_ttl = $this->get_cache_ttl_seconds(); + $via_gateway = self::DEFAULT_API_BASE_URL !== $base_url; + + // Build headers once so the wp_remote_post call and the cURL belt-and- + // suspenders filter stay in lockstep. The cf-aig-* headers are only + // meaningful when going through a Cloudflare AI Gateway; they are + // ignored by direct OpenRouter calls, so sending them unconditionally + // when a gateway is configured is safe and keeps the code branch-free. + $request_headers = [ + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + 'HTTP-Referer' => home_url(), + 'X-Title' => 'PRAutoBlogger WordPress Plugin', + ]; + if ( $via_gateway && $cache_ttl > 0 ) { + $request_headers['cf-aig-cache-ttl'] = (string) $cache_ttl; + } + // Belt-and-suspenders: inject Authorization header at cURL level. // Some hosting environments (Hostinger, certain proxies) strip the // Authorization header from wp_remote_post's 'headers' array before // the request is sent. The http_api_curl action fires after WordPress // configures the cURL handle but before curl_exec — re-setting - // CURLOPT_HTTPHEADER here ensures the header reaches OpenRouter. - $auth_header_value = 'Bearer ' . $api_key; - $curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $auth_header_value ): void { - // Only touch requests aimed at OpenRouter — leave all others alone. - if ( false === strpos( $url, 'openrouter.ai' ) ) { + // CURLOPT_HTTPHEADER here ensures the header reaches the upstream + // (either OpenRouter directly or the Cloudflare AI Gateway proxy). + $curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void { + // Scope to the configured upstream host only — never leak auth + // into unrelated outbound requests made elsewhere in WordPress. + if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) { return; } + $curl_headers = []; + foreach ( $request_headers as $name => $value ) { + $curl_headers[] = $name . ': ' . $value; + } // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt - curl_setopt( $handle, CURLOPT_HTTPHEADER, [ - 'Authorization: ' . $auth_header_value, - 'Content-Type: application/json', - 'HTTP-Referer: ' . home_url(), - 'X-Title: PRAutoBlogger WordPress Plugin', - ] ); + curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers ); }; add_action( 'http_api_curl', $curl_auth_filter, 99, 3 ); try { for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) { $response = wp_remote_post( - self::API_BASE_URL . '/chat/completions', + $base_url . '/chat/completions', [ 'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS, - 'headers' => [ - 'Authorization' => 'Bearer ' . $api_key, - 'Content-Type' => 'application/json', - 'HTTP-Referer' => home_url(), - 'X-Title' => 'PRAutoBlogger WordPress Plugin', - ], + 'headers' => $request_headers, 'body' => wp_json_encode( $body ), ] ); @@ -328,9 +388,11 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In } // Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion). + $base_url = $this->get_api_base_url(); + $base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST ); $auth_header_value = 'Bearer ' . $api_key; - $curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value ): void { - if ( false === strpos( $url, 'openrouter.ai' ) ) { + $curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void { + if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt @@ -341,7 +403,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 ); $response = wp_remote_get( - self::API_BASE_URL . '/auth/key', + $base_url . '/auth/key', [ 'timeout' => 15, 'headers' => [