Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions inc/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
],
],
]
);

Expand Down Expand Up @@ -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( [
Expand Down Expand Up @@ -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(
Expand Down
36 changes: 32 additions & 4 deletions src/Authentication/AnthropicOAuthRequestAuthentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
*
Expand All @@ -60,19 +71,36 @@ public function __construct(PoolManager $pool)
*/
public function authenticateRequest(Request $request): Request
{
$token = $this->pool->getActiveToken();
$this->activeEmail = null;
$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;
}

/**
Expand Down
82 changes: 80 additions & 2 deletions src/Models/AnthropicMaxTextGenerationModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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<Message> $prompt The prompt messages.
Expand All @@ -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.
*
Expand Down
Loading