$headers full header lines (they may contain the credential) * @param list $allowedHosts */ public function __construct( private string $url, #[\SensitiveParameter] private array $headers, private array $allowedHosts, private ?string $secretForCheck = null, private ?NetworkPolicy $policy = null, /** @var list statuses this provider documents as refused before processing (Providers::rejectionStatuses); empty = never settle at zero */ private array $rejectionStatuses = [], /** Anthropic only: read the first bytes of a 400/429 error body to tell a spend or usage limit from a momentary rate limit. */ private bool $detectSpendCap = false, /** @var list statuses that mean an account balance or credit limit for THIS provider (DeepSeek and OpenRouter: 402); empty for the other providers, whose 402 handling is unchanged */ private array $quotaStatuses = [], ) { } public function stream(array $body, int $timeoutSeconds, CancellationToken $cancel, callable $onBytes): void { OutboundGuard::assertAllowed(); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- pure PHP helper without a WordPress dependency (unit-tested standalone) $parts = parse_url($this->url); if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || !in_array($parts['host'] ?? '', $this->allowedHosts, true) || isset($parts['user']) || isset($parts['port'])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(ProviderException::INVALID_REQUEST, false, 'Destination is not an allowed provider host.'); } if ($this->secretForCheck !== null && ($this->secretForCheck === '' || strlen($this->secretForCheck) > 4096 || preg_match('/[^\x21-\x7e]/', $this->secretForCheck))) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(ProviderException::AUTH, false, 'Configure a valid server-side credential.'); } if ($cancel->isCancelled()) { throw new ProviderException('cancelled', false, 'Request cancelled before dispatch.'); } $json = RequestPayload::encode($body); // the one wire encoding: UTF-8 characters unescaped, hard size cap, refused before dispatch // The site's block list, WP_ACCESSIBLE_HOSTS, pre_http_request interception and the plugin's own streaming switch are checked first, so a // refused request is reported as "not dispatched" rather than as an uncertain provider failure. ($this->policy ?? NetworkPolicy::forWordPress())->prepare($this->url); $headers = []; foreach ($this->headers as $line) { $pos = strpos($line, ':'); if ($pos !== false) { $headers[trim(substr($line, 0, $pos))] = trim(substr($line, $pos + 1)); } } $headers['Accept-Encoding'] = 'identity'; // a compressed stream would be delivered in blocks, not live $received = 0; $mode = null; // null = undecided, 'stream' = server-sent events, 'error' = a JSON or HTML document $pending = ''; $errorHead = ''; // at most 4 KB of a 400/429 error body, parsed for a fixed limit marker and never kept or shown $callbackError = null; $detect = $this->detectSpendCap; $stop = false; // set when this side wants the connection closed (too large, consumer failed); read by the progress callback below $name = StreamSink::open(static function (string $bytes) use (&$received, &$mode, &$pending, &$errorHead, &$callbackError, &$stop, $detect, $cancel, $onBytes): void { $received += strlen($bytes); if ($cancel->isCancelled() || $received > self::MAX_RESPONSE_BYTES) { $stop = true; throw new StreamAbort('stop'); } if ($mode === null) { $pending .= $bytes; $first = ltrim($pending); if ($first === '') { return; } $mode = in_array($first[0], ['{', '[', '<'], true) ? 'error' : 'stream'; $bytes = $pending; $pending = ''; } if ($mode === 'stream') { try { $onBytes($bytes); } catch (\Throwable $e) { $callbackError = $e; $stop = true; throw new StreamAbort('consumer failed'); } } elseif ($detect && strlen($errorHead) < 4096) { $errorHead .= substr($bytes, 0, 4096 - strlen($errorHead)); } }); $status = 0; $error = 0; $failed = false; $proxyRefused = false; // Stop: the WordPress HTTP API has no way to end a running request, so the one extension point WordPress documents for the cURL handle // (the http_api_curl action, which the Plugins Team names as the place for setopt) installs a progress callback for THIS request only. It // returns non-zero when the visitor pressed Stop (or this side wants the connection closed), which makes cURL close the connection at once. // Whether the provider keeps generating or billing after its client has gone is the provider's business: usage stays unknown. $abort = static function () use ($cancel, &$stop): int { return ($cancel->isCancelled() || $stop) ? 1 : 0; }; $hook = static function ($handle, $args) use ($name, $abort): void { if (is_array($args) && ($args['filename'] ?? null) === $name) { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- inside the documented http_api_curl action, for this request only; the request itself is made by wp_remote_post() curl_setopt($handle, CURLOPT_NOPROGRESS, false); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- same: a progress callback that returns non-zero closes the connection when the visitor pressed Stop curl_setopt($handle, CURLOPT_XFERINFOFUNCTION, static fn($h, $dt, $dn, $ut, $un): int => $abort()); } }; add_action('http_api_curl', $hook, 10, 2); try { $response = wp_remote_post($this->url, [ 'method' => 'POST', 'headers' => $headers, 'body' => $json, 'timeout' => max(1, min(120, $timeoutSeconds)), 'redirection' => 0, 'httpversion' => '1.1', 'sslverify' => true, 'decompress' => false, 'stream' => true, 'filename' => $name, ]); if (is_wp_error($response)) { $failed = true; $message = $response->get_error_message(); $error = preg_match('/cURL error (\d+)/', $message, $m) === 1 ? (int) $m[1] : 0; $proxyRefused = str_contains($message, 'after CONNECT'); // the proxy refused the tunnel: the provider never saw the request } else { $status = (int) wp_remote_retrieve_response_code($response); } } catch (StreamAbort) { $failed = true; $error = 23; // aborted by this side while writing, as a cURL write-callback abort reports if (function_exists('reset_mbstring_encoding')) { reset_mbstring_encoding(); // the HTTP API did not get to restore it } } finally { remove_action('http_api_curl', $hook, 10); StreamSink::close($name); } if ($callbackError instanceof ProviderException) { throw $callbackError; } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes if ($callbackError !== null) { throw new ProviderException(ProviderException::UNAVAILABLE, true, 'Response processing failed; usage is uncertain.'); } $dispatched = !CurlErrors::notDispatched($error, $proxyRefused); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes if ($cancel->isCancelled()) { throw new ProviderException('cancelled', $dispatched, 'Transport stopped. Remote processing and billing may continue.'); } if ($status < 200 || $status >= 300 || $failed) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(($detect && self::isSpendCap($status, $errorHead)) || in_array($status, $this->quotaStatuses, true) ? ProviderException::QUOTA_EXHAUSTED : self::kindFor($status, $error), $dispatched, 'Provider request failed. Check connection settings and provider availability.', null, self::isDefinitiveRejection($status, $this->rejectionStatuses, $error, $received), $status > 0 ? $status : null); } } /** An error body larger than this is not a normal provider refusal; such a response stays ambiguous. */ public const REJECTION_BODY_LIMIT = 65536; /** * Whether a failed transfer counts as a refusal BEFORE any processing. Every condition must hold, so no single signal decides it: * the transfer completed normally (curl error 0: not a timeout, reset, abort or size cut-off), the final status is one that THIS * provider documents as a pre-processing refusal ($allowed, from Providers::rejectionStatuses(); an empty list means never), the status * was not 2xx (so no answer stream was ever handed to the parser), and the error body is small. Everything else, including every 5xx * (also overloaded 529), 408/409/425, status 0 and any failure after a 2xx stream started, stays ambiguous and is accounted as uncertain. * That these statuses are never billed is an assumption that D7 must confirm with real 401, 404 and 429 answers. * * @param list $allowed */ public static function isDefinitiveRejection(int $status, array $allowed, int $curlError = 0, int $receivedBytes = 0): bool { return $curlError === 0 && $receivedBytes <= self::REJECTION_BODY_LIMIT && ($status < 200 || $status >= 300) && in_array($status, $allowed, true); } /** * Anthropic spend and usage limits (documented): a tier or organisation spend cap answers 429 with error.details.error_code * "enforced_spend_limit_reached" (no Retry-After; access is paused until the next month), and a limit the owner set answers 400 * "You have reached your specified [workspace] API usage limits". Both are definite refusals that waiting seconds does not cure, unlike a * momentary 429 rate limit. Only these fixed markers are recognised; nothing from the body is kept or shown. */ public static function isSpendCap(int $status, string $head): bool { if (($status !== 429 && $status !== 400) || $head === '') { return false; } $json = json_decode($head, true, 8); $error = is_array($json) && is_array($json['error'] ?? null) ? $json['error'] : []; if ($status === 429) { return is_array($error['details'] ?? null) && ($error['details']['error_code'] ?? null) === 'enforced_spend_limit_reached'; } return ($error['type'] ?? null) === 'invalid_request_error' && is_string($error['message'] ?? null) && preg_match('/^You have reached your specified (workspace )?API usage limits/', $error['message']) === 1; } /** * Anthropic answers 400 "This API key is not scoped to a workspace, so this request must include the anthropic-workspace-id header ..." * for a key that is not tied to one workspace. Aiwend sends no such header, so the owner has to create the key inside a workspace. */ public static function isUnscopedKey(int $status, string $head): bool { if ($status !== 400 || $head === '') { return false; } $json = json_decode($head, true, 8); $error = is_array($json) && is_array($json['error'] ?? null) ? $json['error'] : []; return ($error['type'] ?? null) === 'invalid_request_error' && is_string($error['message'] ?? null) && preg_match('/^This API key is not scoped to a workspace/', $error['message']) === 1; } /** HTTP status to error kind, shared by the providers (their documented status codes agree). */ public static function kindFor(int $status, int $curlError = 0): string { return match (true) { $status === 401 || $status === 402 || $status === 403 => ProviderException::AUTH, $status === 429 => ProviderException::RATE_LIMITED, in_array($status, [400, 404, 409, 413, 422], true) => ProviderException::INVALID_REQUEST, $status === 504 || $curlError === 28 => ProviderException::TIMEOUT, default => ProviderException::UNAVAILABLE, }; } }