fix: resolve OpenRouter 401 by injecting auth header at cURL level

Hostinger's hosting environment appears to strip the Authorization
header from outgoing wp_remote_post requests before they reach the
cURL transport. This caused OpenRouter to respond with HTTP 401
"Missing Authentication header" even though the header was set in
the wp_remote_post args array.

Fixes:
- Add http_api_curl filter (priority 99) that re-sets the
  Authorization header directly on the cURL handle, bypassing any
  intermediate header stripping. Scoped to openrouter.ai URLs only.
- Applied to all three OpenRouter call sites: send_chat_completion,
  validate_credentials_detailed, and get_available_models.
- Filter is cleaned up in a finally block to avoid leaking.

Also:
- Add pre-flight key format validation (must start with "sk-or-")
  to catch corrupted decryption early with a clear error message.
- Fix retry condition: was `>= 429` (incorrectly retried 430-499),
  now `=== 429` (only retry rate-limit).
- Add detailed diagnostic logging on 401/403 auth failures.
- Add .env.credentials to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Terence Ong
2026-04-13 08:30:37 +07:00
parent b21d102afb
commit 98ef81f49a
3 changed files with 183 additions and 94 deletions

3
.gitignore vendored
View File

@@ -14,9 +14,10 @@ Thumbs.db
*.swp
*.swo
# Environment
# Environment & credentials
.env
.env.local
.env.credentials
# Logs
*.log

View File

@@ -51,6 +51,19 @@ class PRAutoBlogger_OpenRouter_Pricing {
return [];
}
// Belt-and-suspenders: inject Authorization at cURL level (see OpenRouter_Provider).
$auth_header_value = 'Bearer ' . $api_key;
$curl_auth_filter_mod = function ( $handle, $parsed_args, $url ) use ( $auth_header_value ): void {
if ( false === strpos( $url, 'openrouter.ai' ) ) {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
'Authorization: ' . $auth_header_value,
] );
};
add_action( 'http_api_curl', $curl_auth_filter_mod, 99, 3 );
$response = wp_remote_get(
'https://openrouter.ai/api/v1/models',
[
@@ -61,6 +74,8 @@ class PRAutoBlogger_OpenRouter_Pricing {
]
);
remove_action( 'http_api_curl', $curl_auth_filter_mod, 99 );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
PRAutoBlogger_Logger::instance()->warning( 'Failed to fetch OpenRouter models list.', 'openrouter' );
return [];

View File

@@ -55,6 +55,22 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
);
}
// Validate key format — OpenRouter keys start with "sk-or-".
// A key that decrypts to garbage (e.g. after a salt change) won't match.
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Decrypted API key has unexpected format (prefix="%s", len=%d). Re-enter your key in settings.',
substr( $api_key, 0, 6 ),
strlen( $api_key )
),
'openrouter'
);
throw new \RuntimeException(
__( 'OpenRouter API key appears corrupted (unexpected format). Please re-enter your key in PRAutoBlogger → Settings.', 'prautoblogger' )
);
}
$body = [
'model' => $model,
'messages' => $messages,
@@ -72,6 +88,29 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$last_error = '';
// 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' ) ) {
return;
}
// 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',
] );
};
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',
@@ -105,8 +144,8 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$body_raw = wp_remote_retrieve_body( $response );
$data = json_decode( $body_raw, true );
// Rate limited or server error — retry.
if ( $status_code >= 429 || $status_code >= 500 ) {
// Rate limited (429) or server error (5xx) — retry with backoff.
if ( 429 === $status_code || $status_code >= 500 ) {
$last_error = sprintf( 'HTTP %d: %s', $status_code, $body_raw );
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'OpenRouter HTTP %d (attempt %d/%d): %s', $status_code, $attempt, PRAUTOBLOGGER_MAX_RETRIES, substr( $body_raw, 0, 500 ) ),
@@ -129,6 +168,21 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$error_msg = isset( $data['error']['message'] )
? $data['error']['message']
: 'HTTP ' . $status_code;
// Log detailed diagnostic info for auth errors to aid debugging.
if ( 401 === $status_code || 403 === $status_code ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Auth failure HTTP %d: key_prefix=%s, key_len=%d, error=%s',
$status_code,
substr( $api_key, 0, 8 ),
strlen( $api_key ),
$error_msg
),
'openrouter'
);
}
throw new \RuntimeException(
sprintf(
/* translators: %d: HTTP status code, %s: error message */
@@ -167,6 +221,10 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$last_error
)
);
} finally {
// Always clean up the cURL filter to avoid leaking into other requests.
remove_action( 'http_api_curl', $curl_auth_filter, 99 );
}
}
/**
@@ -253,6 +311,19 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$key_prefix = substr( $api_key, 0, 6 );
$key_len = strlen( $api_key );
// Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion).
$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' ) ) {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
'Authorization: ' . $auth_header_value,
] );
};
add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 );
$response = wp_remote_get(
self::API_BASE_URL . '/auth/key',
[
@@ -263,6 +334,8 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
]
);
remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 );
if ( is_wp_error( $response ) ) {
$err_msg = $response->get_error_message();
PRAutoBlogger_Logger::instance()->error(