feat: optional Cloudflare AI Gateway proxy for OpenRouter calls
Adds two WP options: prautoblogger_ai_gateway_base_url (routes OpenRouter calls through a Cloudflare AI Gateway when set to an https:// URL; empty = direct, the default) and prautoblogger_ai_gateway_cache_ttl (seconds for cf-aig-cache-ttl response caching; 0 = off). The gateway is a transparent OpenRouter-compatible proxy, so no consuming class, no provider abstraction, and no response-parsing path changes. The http_api_curl belt-and-suspenders Authorization filter is rescoped to the configured upstream host so it still runs under gateway.ai.cloudflare.com. Rationale: leverages existing Cloudflare infrastructure to satisfy the CTO cost-control rules (caching, cost tracking, rate limiting, fallback) without building equivalent middleware in-plugin. Fully opt-in and reversible from the admin UI. Docs: ARCHITECTURE.md options + integrations tables updated; key decision #15 added. Uninstall is unaffected (wildcard prautoblogger_% delete). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
[
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
Reference in New Issue
Block a user