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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,9 +14,10 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
# Environment & credentials
|
||||
.env
|
||||
.env.local
|
||||
.env.credentials
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user