From 067af82d824a346db860f57d3cb0dfb3e08d8800 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 3 Apr 2026 23:29:10 -0600 Subject: [PATCH 1/2] Align OAuth pool with Claude CLI: Retry-After, accountId, scope validation, User-Agent, login_hint - Parse Retry-After header (seconds or HTTP-date) on 429/529 responses and use it as the pool cooldown duration instead of the fixed 5-minute default. Implemented in PoolManager::markRateLimited(), a new markRateLimitedFromResponse() helper, and AnthropicMaxTextGenerationModel::handleRateLimitResponse(). - Store accountId (account.uuid from token response) alongside each pool entry. Exposed in listAccounts() and healthCheck() output for diagnostics. - Validate user:inference scope after token exchange. Returns a scope_error result when the granted scope list is missing the required scope; the REST API surfaces this as HTTP 403 with a clear message before the account is added to the pool. - Update User-Agent to claude-cli/2.1.80 (wordpress-plugin) to align with Claude CLI format on token exchange and refresh requests. - Add alignment comments for REDIRECT_URI fallback (platform.claude.com) and AUTHORIZE_URL fallback (claude.com/cai/oauth/authorize). - Add login_hint, login_method, and org_uuid params to startOAuthFlow() and the /authorize REST endpoint, matching Claude CLI optional authorize params. --- inc/rest-api.php | 51 +++- .../AnthropicOAuthRequestAuthentication.php | 35 ++- .../AnthropicMaxTextGenerationModel.php | 82 ++++- src/OAuthPool/PoolManager.php | 289 ++++++++++++++++-- src/OAuthPool/TokenRefresher.php | 2 +- 5 files changed, 426 insertions(+), 33 deletions(-) diff --git a/inc/rest-api.php b/inc/rest-api.php index 3a3310d..66b6141 100644 --- a/inc/rest-api.php +++ b/inc/rest-api.php @@ -43,6 +43,27 @@ function register_routes(): void { 'methods' => 'GET', 'callback' => __NAMESPACE__ . '\\rest_start_oauth', 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => [ + 'login_hint' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + 'description' => 'Pre-populate the email field on the Anthropic login page.', + ], + 'login_method' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'enum' => [ 'sso', 'magic_link', 'google' ], + 'description' => 'Request a specific login method.', + ], + 'org_uuid' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Pre-select an org UUID for team/enterprise logins.', + ], + ], ] ); @@ -145,11 +166,21 @@ function rest_list_accounts(): \WP_REST_Response { /** * Starts the OAuth PKCE flow and returns the authorize URL. * + * Accepts optional query params: + * - login_hint: pre-populate the email on the Anthropic login page. + * - login_method: request a specific login method ('sso', 'magic_link', 'google'). + * - org_uuid: pre-select an org for team/enterprise logins. + * + * @param \WP_REST_Request $request The request object. * @return \WP_REST_Response */ -function rest_start_oauth(): \WP_REST_Response { +function rest_start_oauth( \WP_REST_Request $request ): \WP_REST_Response { $pool = PoolManager::getInstance(); - $data = $pool->startOAuthFlow(); + $data = $pool->startOAuthFlow( + $request->get_param( 'login_hint' ) ?: null, + $request->get_param( 'login_method' ) ?: null, + $request->get_param( 'org_uuid' ) ?: null + ); // Only return the authorize URL and state (never the verifier). return rest_ensure_response( [ @@ -188,6 +219,22 @@ function rest_exchange_code( \WP_REST_Request $request ) { ); } + // Scope validation failure: the token was issued but is missing user:inference. + // The account was NOT added to the pool. Return a clear error so the UI can + // prompt the user to re-authorize with the correct scopes. + if ( ! empty( $result['scope_error'] ) ) { + $granted = implode( ' ', (array) ( $result['granted_scopes'] ?? [] ) ); + return new \WP_Error( + 'insufficient_scope', + sprintf( + /* translators: %s: space-separated list of granted scopes */ + __( 'Authorization succeeded but the token is missing the required "user:inference" scope. Granted scopes: %s. Please re-authorize and ensure you are using a Claude Max subscription account.', 'ai-provider-for-anthropic-max' ), + $granted ?: __( '(none)', 'ai-provider-for-anthropic-max' ) + ), + [ 'status' => 403 ] + ); + } + return rest_ensure_response( [ 'success' => true, 'message' => sprintf( diff --git a/src/Authentication/AnthropicOAuthRequestAuthentication.php b/src/Authentication/AnthropicOAuthRequestAuthentication.php index 7c56252..a31acf7 100644 --- a/src/Authentication/AnthropicOAuthRequestAuthentication.php +++ b/src/Authentication/AnthropicOAuthRequestAuthentication.php @@ -35,6 +35,16 @@ class AnthropicOAuthRequestAuthentication implements RequestAuthenticationInterf */ private PoolManager $pool; + /** + * The email of the account whose token was last used. + * + * Populated by authenticateRequest() so callers can identify which + * account to mark as rate-limited on a 429/529 response. + * + * @var string|null + */ + private ?string $activeEmail = null; + /** * Constructor. * @@ -49,7 +59,8 @@ public function __construct(PoolManager $pool) * Authenticates the request with an OAuth Bearer token. * * Retrieves the best available token from the pool, auto-refreshing - * expired tokens as needed. + * expired tokens as needed. Caches the active account email so that + * callers can mark it as rate-limited on a 429/529 response. * * @since 1.0.0 * @@ -60,19 +71,35 @@ public function __construct(PoolManager $pool) */ public function authenticateRequest(Request $request): Request { - $token = $this->pool->getActiveToken(); + $result = $this->pool->getActiveTokenWithEmail(); - if (empty($token)) { + if ($result === null || empty($result['token'])) { throw new \RuntimeException( 'No active OAuth token available in the Anthropic Max account pool. ' . 'Add an account via Settings > Connectors.' ); } + $this->activeEmail = $result['email']; + $request = $request->withHeader('anthropic-version', self::ANTHROPIC_API_VERSION); $request = $request->withHeader('anthropic-beta', self::ANTHROPIC_OAUTH_BETA); - return $request->withHeader('Authorization', 'Bearer ' . $token); + return $request->withHeader('Authorization', 'Bearer ' . $result['token']); + } + + /** + * Returns the email of the account whose token was last used. + * + * Returns null if authenticateRequest() has not been called yet. + * + * @since 1.0.0 + * + * @return string|null + */ + public function getActiveEmail(): ?string + { + return $this->activeEmail; } /** diff --git a/src/Models/AnthropicMaxTextGenerationModel.php b/src/Models/AnthropicMaxTextGenerationModel.php index b1e7a43..5d511f5 100644 --- a/src/Models/AnthropicMaxTextGenerationModel.php +++ b/src/Models/AnthropicMaxTextGenerationModel.php @@ -36,6 +36,7 @@ use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\WebSearch; use AnthropicMaxAiProvider\Authentication\AnthropicOAuthRequestAuthentication; +use AnthropicMaxAiProvider\OAuthPool\PoolManager; use AnthropicMaxAiProvider\Provider\AnthropicMaxProvider; /** @@ -76,13 +77,17 @@ public function getRequestAuthentication(): RequestAuthenticationInterface } // Fallback: build OAuth auth from the pool manager. - $pool = \AnthropicMaxAiProvider\OAuthPool\PoolManager::getInstance(); + $pool = PoolManager::getInstance(); return new AnthropicOAuthRequestAuthentication($pool); } /** * Generates a text result using the Anthropic Messages API. * + * On 429 (rate limit) or 529 (API overloaded) responses, the active account + * is marked as rate-limited in the pool using the Retry-After header value + * when present, falling back to the pool's DEFAULT_COOLDOWN_MS. + * * @since 1.0.0 * * @param list $prompt The prompt messages. @@ -108,14 +113,87 @@ final public function generateTextResult(array $prompt): GenerativeAiResult $this->getRequestOptions() ); - $request = $this->getRequestAuthentication()->authenticateRequest($request); + $auth = $this->getRequestAuthentication(); + $request = $auth->authenticateRequest($request); $response = $httpTransporter->send($request); + // On rate-limit or overload responses, mark the active account with the + // server-specified cooldown before letting the SDK throw its exception. + // 429 = rate limited; 529 = Anthropic API overloaded. + $status_code = $response->getStatusCode(); + if ($status_code === 429 || $status_code === 529) { + $this->handleRateLimitResponse($auth, $response); + } + ResponseUtil::throwIfNotSuccessful($response); return $this->parseResponseToGenerativeAiResult($response); } + /** + * Marks the active pool account as rate-limited based on the API response. + * + * Extracts the Retry-After header (seconds or HTTP-date) and passes it to + * the pool manager. Falls back to DEFAULT_COOLDOWN_MS when absent. + * + * @since 1.0.0 + * + * @param RequestAuthenticationInterface $auth The request authentication instance. + * @param Response $response The rate-limit response. + * @return void + */ + protected function handleRateLimitResponse( + RequestAuthenticationInterface $auth, + Response $response + ): void { + // Only act when using our OAuth auth — we need the pool manager. + if (!($auth instanceof AnthropicOAuthRequestAuthentication)) { + return; + } + + $pool = PoolManager::getInstance(); + $email = $auth->getActiveEmail(); + + if ($email === null) { + return; + } + + // Parse Retry-After header: integer seconds or HTTP-date. + $retry_after_secs = $this->parseRetryAfterHeader($response); + $pool->markRateLimited($email, null, $retry_after_secs); + } + + /** + * Parses the Retry-After header from an SDK Response. + * + * Supports integer (seconds) and HTTP-date formats per RFC 7231. + * + * @since 1.0.0 + * + * @param Response $response The HTTP response. + * @return int|null Retry-After in seconds, or null if absent/unparseable. + */ + protected function parseRetryAfterHeader(Response $response): ?int + { + $header = $response->getHeaderAsString('retry-after'); + if ($header === null || $header === '') { + return null; + } + + // Integer seconds: "Retry-After: 60" + if (ctype_digit($header)) { + return (int) $header; + } + + // HTTP-date: "Retry-After: Wed, 21 Oct 2025 07:28:00 GMT" + $timestamp = strtotime($header); + if ($timestamp !== false && $timestamp > time()) { + return $timestamp - time(); + } + + return null; + } + /** * Prepares the API request parameters from the prompt and model configuration. * diff --git a/src/OAuthPool/PoolManager.php b/src/OAuthPool/PoolManager.php index a5c4749..60806c8 100644 --- a/src/OAuthPool/PoolManager.php +++ b/src/OAuthPool/PoolManager.php @@ -34,6 +34,19 @@ class PoolManager /** * Anthropic OAuth constants (same as Claude CLI). + * + * Alignment notes (vs Claude CLI codebase): + * CLIENT_ID — identical to Claude CLI (public, same for all clients). + * TOKEN_ENDPOINT — identical to Claude CLI prod config. + * AUTHORIZE_URL — we hit claude.ai directly; Claude CLI now routes through + * https://claude.com/cai/oauth/authorize for attribution, + * which 307-redirects to claude.ai. Ours is the final dest. + * FALLBACK: if authorize breaks, try https://claude.com/cai/oauth/authorize. + * REDIRECT_URI — console.anthropic.com is the legacy Console domain. + * platform.claude.com is the current primary domain. Both + * redirect URIs are currently registered by Anthropic. + * FALLBACK: 'https://platform.claude.com/oauth/code/callback' + * SCOPES — identical to Claude CLI's ALL_OAUTH_SCOPES union. */ public const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; public const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token'; @@ -41,11 +54,26 @@ class PoolManager public const REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback'; public const SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload'; + /** + * User-Agent sent on token exchange and refresh requests. + * + * Aligned with Claude CLI format: "claude-cli/{version} (external, cli)". + * The "(wordpress-plugin)" suffix identifies this client to Anthropic. + * FALLBACK: if UA filtering causes issues, try 'claude-cli/2.1.80'. + */ + public const USER_AGENT = 'claude-cli/2.1.80 (wordpress-plugin)'; + /** * Default cooldown duration for rate-limited accounts (5 minutes). */ public const DEFAULT_COOLDOWN_MS = 300000; + /** + * Required scope that must be present in the granted scope list. + * A token missing this scope will fail at inference time. + */ + public const REQUIRED_SCOPE = 'user:inference'; + /** * Singleton instance. * @@ -116,10 +144,98 @@ public function listAccounts(): array 'expiresIn' => max(0, intdiv($expires_in_ms, 1000)), 'hasRefresh' => !empty($account['refresh']), 'cooldownUntil' => $account['cooldownUntil'] ?? null, + 'accountId' => $account['accountId'] ?? null, ]; }, $accounts); } + /** + * Returns the best available access token and its account email. + * + * Same selection logic as getActiveToken(). Returns an associative array + * with 'token' and 'email' keys, or null if no account is available. + * Used by AnthropicOAuthRequestAuthentication to track which account + * is in use so it can be marked rate-limited on 429/529 responses. + * + * @return array{token: string, email: string}|null + */ + public function getActiveTokenWithEmail(): ?array + { + $pool = $this->loadPool(); + $now_ms = $this->nowMs(); + + // Clear expired cooldowns. + $changed = false; + foreach ($pool['accounts'] as &$account) { + if ( + ($account['status'] ?? '') === 'rate-limited' && + isset($account['cooldownUntil']) && + $account['cooldownUntil'] > 0 && + $account['cooldownUntil'] <= $now_ms + ) { + $account['status'] = 'idle'; + $account['cooldownUntil'] = null; + $changed = true; + } + } + unset($account); + + if ($changed) { + $this->savePool($pool); + } + + $best = null; + $best_index = -1; + + foreach ($pool['accounts'] as $index => $account) { + $status = $account['status'] ?? 'idle'; + if ($status === 'rate-limited') { + continue; + } + if (empty($account['access'])) { + continue; + } + if ($best === null || ($account['lastUsed'] ?? '') < ($best['lastUsed'] ?? '')) { + $best = $account; + $best_index = $index; + } + } + + if ($best === null) { + return null; + } + + $expires = $best['expires'] ?? 0; + if ($expires > 0 && $expires <= $now_ms && !empty($best['refresh'])) { + $refresher = new TokenRefresher(); + $refreshed = $refresher->refresh($best['refresh']); + + if ($refreshed !== null) { + $pool['accounts'][$best_index]['access'] = $refreshed['access_token']; + $pool['accounts'][$best_index]['expires'] = $now_ms + ($refreshed['expires_in'] * 1000); + $pool['accounts'][$best_index]['status'] = 'active'; + $pool['accounts'][$best_index]['lastUsed'] = gmdate('Y-m-d\TH:i:s\Z'); + + if (!empty($refreshed['refresh_token'])) { + $pool['accounts'][$best_index]['refresh'] = $refreshed['refresh_token']; + } + + $this->savePool($pool); + return ['token' => $refreshed['access_token'], 'email' => $best['email'] ?? '']; + } + + $pool['accounts'][$best_index]['status'] = 'refresh-failed'; + $this->savePool($pool); + return null; + } + + $pool['accounts'][$best_index]['status'] = 'active'; + $pool['accounts'][$best_index]['lastUsed'] = gmdate('Y-m-d\TH:i:s\Z'); + $this->savePool($pool); + + return ['token' => $best['access'], 'email' => $best['email'] ?? '']; + } + /** * Returns the best available access token from the pool. * @@ -220,17 +336,19 @@ public function getActiveToken(): ?string /** * Adds or updates an account in the pool. * - * @param string $email The account email. - * @param string $access_token The OAuth access token. - * @param string $refresh_token The OAuth refresh token. - * @param int $expires_in Token lifetime in seconds. + * @param string $email The account email. + * @param string $access_token The OAuth access token. + * @param string $refresh_token The OAuth refresh token. + * @param int $expires_in Token lifetime in seconds. + * @param string|null $account_id Optional account UUID from the token response. * @return int The total number of accounts in the pool. */ public function addAccount( string $email, string $access_token, string $refresh_token, - int $expires_in + int $expires_in, + ?string $account_id = null ): int { $pool = $this->loadPool(); $now_ms = $this->nowMs(); @@ -248,14 +366,17 @@ public function addAccount( $account['lastUsed'] = $now; $account['status'] = 'active'; $account['cooldownUntil'] = null; - $found = true; + if ($account_id !== null) { + $account['accountId'] = $account_id; + } + $found = true; break; } } unset($account); if (!$found) { - $pool['accounts'][] = [ + $entry = [ 'email' => $email, 'access' => $access_token, 'refresh' => $refresh_token, @@ -265,6 +386,10 @@ public function addAccount( 'status' => 'active', 'cooldownUntil' => null, ]; + if ($account_id !== null) { + $entry['accountId'] = $account_id; + } + $pool['accounts'][] = $entry; } $this->savePool($pool); @@ -301,16 +426,23 @@ static function (array $account) use ($email): bool { /** * Marks an account as rate-limited with a cooldown period. * - * @param string $email The account email. - * @param int|null $cooldown_ms Cooldown in milliseconds (null for default). + * Respects a Retry-After value when provided (e.g. from a 429 response + * header). Falls back to DEFAULT_COOLDOWN_MS when not specified. + * + * @param string $email The account email. + * @param int|null $cooldown_ms Cooldown in milliseconds (null for default). + * @param int|null $retry_after_secs Retry-After header value in seconds (overrides cooldown_ms). * @return bool Whether the account was found. */ - public function markRateLimited(string $email, ?int $cooldown_ms = null): bool + public function markRateLimited(string $email, ?int $cooldown_ms = null, ?int $retry_after_secs = null): bool { $pool = $this->loadPool(); $now_ms = $this->nowMs(); - if ($cooldown_ms === null) { + if ($retry_after_secs !== null && $retry_after_secs > 0) { + // Retry-After takes precedence — use the server-specified window. + $cooldown_ms = $retry_after_secs * 1000; + } elseif ($cooldown_ms === null) { $cooldown_ms = self::DEFAULT_COOLDOWN_MS; } @@ -327,6 +459,22 @@ public function markRateLimited(string $email, ?int $cooldown_ms = null): bool return false; } + /** + * Marks the currently active account as rate-limited based on an HTTP response. + * + * Parses the Retry-After header (seconds or HTTP-date) from the response + * and uses it as the cooldown duration. Falls back to DEFAULT_COOLDOWN_MS. + * + * @param string $email The account email to mark. + * @param array $response The wp_remote_* response array. + * @return bool Whether the account was found and marked. + */ + public function markRateLimitedFromResponse(string $email, array $response): bool + { + $retry_after_secs = $this->parseRetryAfter($response); + return $this->markRateLimited($email, null, $retry_after_secs); + } + /** * Refreshes a specific account's token. * @@ -395,6 +543,7 @@ public function healthCheck(): array 'status' => $status, 'tokenExpired' => ($account['expires'] ?? 0) <= $now_ms, 'hasRefresh' => !empty($account['refresh']), + 'accountId' => $account['accountId'] ?? null, 'validity' => 'unknown', ]; @@ -423,6 +572,7 @@ protected function validateToken(string $token): string 'Authorization' => 'Bearer ' . $token, 'anthropic-version' => '2023-06-01', 'anthropic-beta' => 'oauth-2025-04-20', + 'User-Agent' => self::USER_AGENT, ], 'timeout' => 10, ] @@ -448,10 +598,21 @@ protected function validateToken(string $token): string /** * Generates a PKCE verifier and challenge, stores the verifier in a transient. * + * Optional parameters align with Claude CLI's authorize URL support: + * - login_hint: pre-populate the email field on the login page (standard OIDC). + * - login_method: request a specific login method ('sso', 'magic_link', 'google'). + * - org_uuid: pre-select an org for team/enterprise logins. + * + * @param string|null $login_hint Optional email to pre-populate on the login page. + * @param string|null $login_method Optional login method ('sso', 'magic_link', 'google'). + * @param string|null $org_uuid Optional org UUID for team/enterprise logins. * @return array{verifier: string, challenge: string, state: string, authorize_url: string} */ - public function startOAuthFlow(): array - { + public function startOAuthFlow( + ?string $login_hint = null, + ?string $login_method = null, + ?string $org_uuid = null + ): array { // Generate PKCE code_verifier (43 chars, base64url no padding). $verifier = $this->generateVerifier(); $challenge = $this->generateChallenge($verifier); @@ -460,16 +621,31 @@ public function startOAuthFlow(): array // Store verifier + state in a transient (10 minute TTL). set_transient(self::PKCE_TRANSIENT_PREFIX . $state, $verifier, 600); - $authorize_url = self::AUTHORIZE_URL . '?' . http_build_query([ + $params = [ 'client_id' => self::CLIENT_ID, - 'response_type' => 'code', - 'redirect_uri' => self::REDIRECT_URI, - 'scope' => self::SCOPES, - 'code_challenge' => $challenge, + 'response_type' => 'code', + 'redirect_uri' => self::REDIRECT_URI, + 'scope' => self::SCOPES, + 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', - 'state' => $state, - 'code' => 'true', - ]); + 'state' => $state, + // Alignment: &code=true matches Claude CLI — tells the login page to + // show the Claude Max upsell. Required for parity with the official client. + 'code' => 'true', + ]; + + // Optional params supported by Claude CLI (not sent unless provided). + if ($login_hint !== null && $login_hint !== '') { + $params['login_hint'] = $login_hint; + } + if ($login_method !== null && $login_method !== '') { + $params['login_method'] = $login_method; + } + if ($org_uuid !== null && $org_uuid !== '') { + $params['orgUUID'] = $org_uuid; + } + + $authorize_url = self::AUTHORIZE_URL . '?' . http_build_query($params); return [ 'verifier' => $verifier, @@ -482,10 +658,19 @@ public function startOAuthFlow(): array /** * Exchanges an authorization code for tokens. * + * After a successful exchange, validates that the granted scope includes + * 'user:inference'. A token missing this scope will fail at inference time; + * catching it here gives a clearer error at add-account time. + * + * The token response may also contain an 'account' object with a UUID + * (account.uuid) which is stored as accountId for diagnostics. + * * @param string $code The authorization code. * @param string $state The state nonce. * @param string $email The account email. * @return array{access_token: string, refresh_token: string, expires_in: int}|null + * Returns null on failure. Returns WP_Error-compatible array on scope failure + * via the 'scope_error' key set to true. */ public function exchangeCode(string $code, string $state, string $email): ?array { @@ -511,7 +696,7 @@ public function exchangeCode(string $code, string $state, string $email): ?array [ 'headers' => [ 'Content-Type' => 'application/json', - 'User-Agent' => 'ai-provider-for-anthropic-max/1.0.0', + 'User-Agent' => self::USER_AGENT, ], 'body' => $body, 'timeout' => 15, @@ -532,14 +717,40 @@ public function exchangeCode(string $code, string $state, string $email): ?array return null; } + // Validate that the granted scope includes user:inference. + // The 'scope' field is a space-delimited string of granted scopes. + // Diagnostic: d.get('scope', '').split() should include 'user:inference'. + if (!empty($data['scope'])) { + $granted_scopes = explode(' ', (string) $data['scope']); + if (!in_array(self::REQUIRED_SCOPE, $granted_scopes, true)) { + return [ + 'scope_error' => true, + 'granted_scopes' => $granted_scopes, + ]; + } + } + + // Extract accountId from the 'account' object if present. + // Alignment: Claude CLI's token response contains 'account' => ['uuid' => ..., 'email_address' => ...]. + $account_id = null; + if (!empty($data['account']) && is_array($data['account'])) { + $account_id = $data['account']['uuid'] ?? null; + } + $result = [ 'access_token' => $data['access_token'], 'refresh_token' => $data['refresh_token'] ?? '', 'expires_in' => (int) ($data['expires_in'] ?? 3600), ]; - // Store in pool. - $this->addAccount($email, $result['access_token'], $result['refresh_token'], $result['expires_in']); + // Store in pool, including accountId if available. + $this->addAccount( + $email, + $result['access_token'], + $result['refresh_token'], + $result['expires_in'], + $account_id + ); return $result; } @@ -586,4 +797,34 @@ protected function generateChallenge(string $verifier): string $hash = hash('sha256', $verifier, true); return rtrim(strtr(base64_encode($hash), '+/', '-_'), '='); } + + /** + * Parses the Retry-After header from a wp_remote_* response. + * + * Supports both integer (seconds) and HTTP-date formats per RFC 7231. + * Returns null if the header is absent or unparseable. + * + * @param array $response The wp_remote_* response array. + * @return int|null Retry-After in seconds, or null if not present. + */ + protected function parseRetryAfter(array $response): ?int + { + $header = wp_remote_retrieve_header($response, 'retry-after'); + if (empty($header)) { + return null; + } + + // Integer seconds format: "Retry-After: 60" + if (ctype_digit((string) $header)) { + return (int) $header; + } + + // HTTP-date format: "Retry-After: Wed, 21 Oct 2025 07:28:00 GMT" + $timestamp = strtotime((string) $header); + if ($timestamp !== false && $timestamp > time()) { + return $timestamp - time(); + } + + return null; + } } diff --git a/src/OAuthPool/TokenRefresher.php b/src/OAuthPool/TokenRefresher.php index 61358f6..84c34a5 100644 --- a/src/OAuthPool/TokenRefresher.php +++ b/src/OAuthPool/TokenRefresher.php @@ -45,7 +45,7 @@ public function refresh(string $refresh_token): ?array [ 'headers' => [ 'Content-Type' => 'application/json', - 'User-Agent' => 'ai-provider-for-anthropic-max/1.0.0', + 'User-Agent' => PoolManager::USER_AGENT, ], 'body' => $body, 'timeout' => 15, From 6bdd5b1bb77d41b59e4a30b8e1dfa1803d40e565 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 3 Apr 2026 23:53:16 -0600 Subject: [PATCH 2/2] Fix three CodeRabbit review issues on PR #4 - AnthropicOAuthRequestAuthentication::authenticateRequest(): reset $this->activeEmail = null before getActiveTokenWithEmail() to prevent stale email cache when the result is null. - PoolManager::markRateLimited(): treat Retry-After=0 as authoritative; only fall back to DEFAULT_COOLDOWN_MS when $retry_after_secs is null (previously the > 0 guard incorrectly skipped the 0 case). - PoolManager::startOAuthFlow(): remove unsupported orgUUID parameter from the authorize URL params; the claude.ai endpoint does not accept this parameter. $org_uuid is retained in the method signature for API compatibility but is no longer appended to the URL. --- .../AnthropicOAuthRequestAuthentication.php | 1 + src/OAuthPool/PoolManager.php | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Authentication/AnthropicOAuthRequestAuthentication.php b/src/Authentication/AnthropicOAuthRequestAuthentication.php index a31acf7..6b984b5 100644 --- a/src/Authentication/AnthropicOAuthRequestAuthentication.php +++ b/src/Authentication/AnthropicOAuthRequestAuthentication.php @@ -71,6 +71,7 @@ public function __construct(PoolManager $pool) */ public function authenticateRequest(Request $request): Request { + $this->activeEmail = null; $result = $this->pool->getActiveTokenWithEmail(); if ($result === null || empty($result['token'])) { diff --git a/src/OAuthPool/PoolManager.php b/src/OAuthPool/PoolManager.php index 60806c8..fe454ba 100644 --- a/src/OAuthPool/PoolManager.php +++ b/src/OAuthPool/PoolManager.php @@ -439,8 +439,9 @@ public function markRateLimited(string $email, ?int $cooldown_ms = null, ?int $r $pool = $this->loadPool(); $now_ms = $this->nowMs(); - if ($retry_after_secs !== null && $retry_after_secs > 0) { + if ($retry_after_secs !== null) { // Retry-After takes precedence — use the server-specified window. + // A value of 0 is authoritative (server says retry immediately). $cooldown_ms = $retry_after_secs * 1000; } elseif ($cooldown_ms === null) { $cooldown_ms = self::DEFAULT_COOLDOWN_MS; @@ -601,11 +602,14 @@ protected function validateToken(string $token): string * Optional parameters align with Claude CLI's authorize URL support: * - login_hint: pre-populate the email field on the login page (standard OIDC). * - login_method: request a specific login method ('sso', 'magic_link', 'google'). - * - org_uuid: pre-select an org for team/enterprise logins. + * + * Note: org_uuid is accepted for API compatibility but is not appended to the + * authorize URL — the claude.ai authorize endpoint does not support orgUUID as + * a query parameter. * * @param string|null $login_hint Optional email to pre-populate on the login page. * @param string|null $login_method Optional login method ('sso', 'magic_link', 'google'). - * @param string|null $org_uuid Optional org UUID for team/enterprise logins. + * @param string|null $org_uuid Accepted for compatibility; not used in the authorize URL. * @return array{verifier: string, challenge: string, state: string, authorize_url: string} */ public function startOAuthFlow( @@ -641,9 +645,6 @@ public function startOAuthFlow( if ($login_method !== null && $login_method !== '') { $params['login_method'] = $login_method; } - if ($org_uuid !== null && $org_uuid !== '') { - $params['orgUUID'] = $org_uuid; - } $authorize_url = self::AUTHORIZE_URL . '?' . http_build_query($params);