* @since 1.1.0 */ private array $signatures = []; /** * Whether the HTTP filters are currently registered. * * @var bool * @since 1.1.0 */ private bool $active = false; /** * Re-entrancy guard for the continuation loop. * * The continuation legs are sent through the WordPress HTTP API and would * re-enter repairResponse(); while this flag is set the filter passes inner * responses through untouched and the outer loop processes them itself. * * @var bool * @since 1.1.0 */ private bool $continuing = false; /** * {@inheritDoc} * * @since 1.1.0 */ public function register(): void { if (! self::ENABLED || $this->active) { return; } $this->signatures = []; $this->active = true; add_filter('http_request_args', [$this, 'repairRequest'], 10, 2); add_filter('http_response', [$this, 'repairResponse'], 10, 3); } /** * {@inheritDoc} * * @since 1.1.0 */ public function unregister(): void { if (! $this->active) { return; } remove_filter('http_request_args', [$this, 'repairRequest'], 10); remove_filter('http_response', [$this, 'repairResponse'], 10); $this->active = false; $this->continuing = false; $this->signatures = []; } /** * Applies every request-side repair to an outgoing Anthropic call. * * Re-injects captured thinking signatures into outgoing thinking blocks and * drops the blocks that would still be unsigned (repair 1), flattens * top-level combiners out of the tool schemas (repair 3) and raises the * connector's fallback output cap (repair 4). * * @param mixed $args The WordPress HTTP request arguments. * @param string $url The request URL. * * @return mixed The (possibly modified) request arguments. * @since 1.1.0 */ public function repairRequest($args, $url) { if (! is_array($args) || ! $this->isAnthropicMessagesUrl($url)) { return $args; } if (empty($args['body']) || ! is_string($args['body'])) { return $args; } // Decode preserving the object/array distinction so untouched empty // objects ({}) are not flattened to arrays ([]) on re-encode. $payload = json_decode($args['body']); if (! $payload instanceof stdClass) { return $args; } // Repair 3 runs on the tool list, which is present on every call — // including the very first one, before any message carries a thinking // block — so it is deliberately not gated on $payload->messages. $changed = $this->flattenToolSchemas($payload); if ($this->raiseDefaultMaxTokens($payload)) { $changed = true; } foreach ((array) ($payload->messages ?? []) as $message) { if (! $message instanceof stdClass || empty($message->content) || ! is_array($message->content)) { continue; } foreach ($message->content as $block) { if ( ! $block instanceof stdClass || 'thinking' !== ($block->type ?? null) || ! isset($block->thinking) || ! is_string($block->thinking) || ! empty($block->signature) ) { continue; } $hash = $this->hashThinking($block->thinking); if (null === $hash) { continue; } $signature = $this->lookupSignature($hash); if (null !== $signature) { $block->signature = $signature; $changed = true; } } if ($this->removeUnsignedThinkingBlocks($message)) { $changed = true; } } if ($changed) { $args['body'] = wp_json_encode($payload); } return $args; } /** * Raises the connector's fallback output cap (repair 4). * * @param stdClass $payload The decoded request payload (modified in place). * * @return bool True if `max_tokens` was changed. * @since 1.3.5 */ private function raiseDefaultMaxTokens(stdClass $payload): bool { if (self::CONNECTOR_DEFAULT_MAX_TOKENS !== ($payload->max_tokens ?? null)) { return false; } $model = (string) ($payload->model ?? ''); if ('' === $model || 0 === strpos($model, 'claude-3')) { return false; } /** * Filters the output cap sent to Claude instead of the connector's 4096 fallback. * * Must not exceed what the model accepts, or the API rejects the request. * * @param int $maxTokens Output cap. * @param string $model Model id of the request. * * @since 1.3.5 */ $maxTokens = (int) apply_filters('agent_mod_anthropic_max_tokens', self::RAISED_MAX_TOKENS, $model); if ($maxTokens <= self::CONNECTOR_DEFAULT_MAX_TOKENS) { return false; } $payload->max_tokens = $maxTokens; return true; } /** * Flattens top-level JSON Schema combiners out of every tool schema (repair 3). * * @param stdClass $payload The decoded request payload (modified in place). * * @return bool True if any schema was rewritten. * @since 1.2.2 */ private function flattenToolSchemas(stdClass $payload): bool { if (empty($payload->tools) || ! is_array($payload->tools)) { return false; } $changed = false; foreach ($payload->tools as $tool) { // Server-side tools (web_search) carry no input_schema at all. if ( ! $tool instanceof stdClass || ! isset($tool->input_schema) || ! $tool->input_schema instanceof stdClass ) { continue; } if ($this->flattenTopLevelCombiners($tool->input_schema)) { $changed = true; } } return $changed; } /** * Merges a schema's top-level `oneOf` / `anyOf` / `allOf` branches into itself. * * Only the top level is rewritten, because that is the only place the API * refuses a combiner. A schema that has none is left byte-identical, which * keeps this a no-op for well-formed abilities and for a future connector * that normalises the schemas itself. * * @param stdClass $schema The tool input schema (modified in place). * * @return bool True if a combiner was flattened. * @since 1.2.2 */ private function flattenTopLevelCombiners(stdClass $schema): bool { $changed = false; foreach (['oneOf', 'anyOf', 'allOf'] as $combiner) { if (empty($schema->{$combiner}) || ! is_array($schema->{$combiner})) { continue; } $branches = $schema->{$combiner}; unset($schema->{$combiner}); // `allOf` is a conjunction: every branch applies, so their required // fields add up. `oneOf` / `anyOf` are disjunctions, where only the // fields required by every branch are certainly required. $this->mergeBranchesInto($schema, $branches, 'allOf' === $combiner); $changed = true; } if ($changed) { // The API also insists the top level is an object schema. $schema->type = 'object'; } return $changed; } /** * Merges combiner branches into the parent schema. * * Keys already present on the parent win, so an explicit top-level * declaration is never overwritten by a branch. * * @param stdClass $schema The parent schema (modified in place). * @param array $branches The combiner branches. * @param bool $conjunction True for `allOf` (union of required), * false for `oneOf` / `anyOf` * (intersection of required). * * @return void * @since 1.2.2 */ private function mergeBranchesInto(stdClass $schema, array $branches, bool $conjunction): void { $requiredSets = []; foreach ($branches as $branch) { if (! $branch instanceof stdClass) { continue; } $branchRequired = []; foreach (get_object_vars($branch) as $key => $value) { if ('properties' === $key) { $this->mergeProperties($schema, $value); continue; } if ('required' === $key) { if (is_array($value)) { $branchRequired = array_values(array_filter($value, 'is_string')); } continue; } // The merged schema is an object by definition; a branch `type` // must not override that. if ('type' === $key) { continue; } if (! property_exists($schema, $key)) { $schema->{$key} = $value; } } $requiredSets[] = $branchRequired; } if (empty($requiredSets)) { return; } $merged = array_shift($requiredSets); foreach ($requiredSets as $set) { $merged = $conjunction ? array_merge($merged, $set) : array_intersect($merged, $set); } // A field the parent already required stays required regardless of the // branch it came from. $existing = (isset($schema->required) && is_array($schema->required)) ? $schema->required : []; $required = array_values(array_unique(array_merge($existing, $merged))); if (empty($required)) { unset($schema->required); return; } $schema->required = $required; } /** * Unions one branch's `properties` map into the parent schema. * * A property that several branches declare keeps the first branch's schema, * except for `enum`, which is unioned. That matters for discriminator * properties: WooCommerce gives `product_type_alias` a single-value enum per * branch, and taking only the first would silently leave the model able to * create nothing but a "physical" product. * * @param stdClass $schema The parent schema (modified in place). * @param mixed $properties The branch's `properties` map. * * @return void * @since 1.2.2 */ private function mergeProperties(stdClass $schema, $properties): void { if (! $properties instanceof stdClass) { return; } if (! isset($schema->properties) || ! $schema->properties instanceof stdClass) { $schema->properties = new stdClass(); } foreach (get_object_vars($properties) as $name => $child) { if (! property_exists($schema->properties, $name)) { $schema->properties->{$name} = $child; continue; } $this->mergeEnums($schema->properties->{$name}, $child); } } /** * Widens an already-merged property's `enum` with another branch's values. * * A branch that constrains the property to no enum at all is the widest * case, so the merged property drops its enum entirely. * * @param mixed $existing The property schema kept so far (modified in place). * @param mixed $incoming The same property from a later branch. * * @return void * @since 1.2.2 */ private function mergeEnums($existing, $incoming): void { if (! $existing instanceof stdClass || ! $incoming instanceof stdClass) { return; } if (! isset($existing->enum) || ! is_array($existing->enum)) { return; } if (! isset($incoming->enum) || ! is_array($incoming->enum)) { unset($existing->enum); return; } $existing->enum = array_values( array_unique(array_merge($existing->enum, $incoming->enum), SORT_REGULAR) ); } /** * Applies every response-side repair to a raw Anthropic response. * * Captures thinking signatures (repair 1), continues turns the API paused * with `pause_turn` and merges the sliced answer text into a single text * block (repair 2). * * @param mixed $response The WordPress HTTP response array. * @param mixed $args The request arguments. * @param string $url The request URL. * * @return mixed The (possibly modified) response. * @since 1.1.0 */ public function repairResponse($response, $args, $url) { if ($this->continuing || ! is_array($response) || ! $this->isAnthropicMessagesUrl($url)) { return $response; } $body = wp_remote_retrieve_body($response); if ('' === $body) { return $response; } // Object-preserving decode, same reason as on the request side. $data = json_decode($body); if (! $data instanceof stdClass || empty($data->content) || ! is_array($data->content)) { return $response; } $this->captureThinkingSignatures($data->content); $accumulatedContent = $data->content; $accumulatedUsage = $this->extractUsage($data); $continued = $this->continuePausedTurn($data, $args, $url, $accumulatedContent, $accumulatedUsage); // Merge unconditionally: a server-tool turn slices its answer across // several text blocks even when it never paused, and toText() reads // only the first one. Single-text turns pass through unchanged. $mergedContent = $this->mergeTextBlocks($accumulatedContent); if (! $continued && $mergedContent === $accumulatedContent) { return $response; } $data->content = $mergedContent; $data->usage = (object) $accumulatedUsage; $response['body'] = wp_json_encode($data); if (isset($response['http_response']) && is_object($response['http_response'])) { // Keep the Requests-level response object in sync for consumers // that read the body from it instead of the array key. $requestsResponse = $response['http_response']; if (method_exists($requestsResponse, 'get_response_object')) { $requestsResponse->get_response_object()->body = $response['body']; } } return $response; } /** * Continues a turn the API paused with `pause_turn` (repair 2). * * Mirrors the upstream continuation loop: the paused assistant leg is * appended to the request messages and the identical request is replayed * until the turn finishes or the bound is hit. Content and usage of every * leg are accumulated into the caller's references; `$data` is updated to * the last leg so the final body carries its raw stop_reason. A failed leg * ends the loop with everything gathered so far — at the HTTP layer a * partial answer is strictly better than replacing a 200 with an error. * * @param stdClass $data The decoded response data (updated to the last leg). * @param mixed $args The original request arguments. * @param string $url The request URL. * @param array $accumulatedContent Accumulated content blocks (by reference). * @param array $accumulatedUsage Accumulated token usage (by reference). * * @return bool True when at least one continuation leg was executed. * @since 1.1.0 */ private function continuePausedTurn( stdClass $data, $args, string $url, array &$accumulatedContent, array &$accumulatedUsage ): bool { if ('pause_turn' !== ($data->stop_reason ?? null)) { return false; } if (! is_array($args) || empty($args['body']) || ! is_string($args['body'])) { return false; } $payload = json_decode($args['body']); if (! $payload instanceof stdClass || ! isset($payload->messages) || ! is_array($payload->messages)) { return false; } $continued = false; $leg = $data; for ($i = 0; $i < self::MAX_CONTINUATIONS; $i++) { // Append the paused assistant leg so the model continues where it // left off, exactly as the API expects. $payload->messages[] = (object) [ 'role' => is_string($leg->role ?? null) ? $leg->role : 'assistant', 'content' => is_array($leg->content ?? null) ? $leg->content : [], ]; $legArgs = $args; $legArgs['body'] = wp_json_encode($payload); $this->continuing = true; $legResponse = wp_remote_request($url, $legArgs); $this->continuing = false; if (is_wp_error($legResponse) || 200 !== (int) wp_remote_retrieve_response_code($legResponse)) { break; } $legData = json_decode(wp_remote_retrieve_body($legResponse)); if (! $legData instanceof stdClass || empty($legData->content) || ! is_array($legData->content)) { break; } $this->captureThinkingSignatures($legData->content); foreach ($legData->content as $block) { $accumulatedContent[] = $block; } $legUsage = $this->extractUsage($legData); foreach ($legUsage as $key => $value) { $accumulatedUsage[$key] += $value; } // Adopt the leg's top-level fields (id, stop_reason, …) so the final // body reflects the last leg, mirroring the upstream loop. foreach (get_object_vars($legData) as $key => $value) { $data->{$key} = $value; } $continued = true; $leg = $legData; if ('pause_turn' !== ($legData->stop_reason ?? null)) { break; } } return $continued; } /** * Merges the text slices of an assistant turn into a single text block. * * Mirror of the upstream mergeTextBlocks(): the slices are joined into the * first text block, non-text blocks keep their position, and a turn with a * single text block passes through unchanged (the identical array is * returned so the caller can detect that nothing changed). * * @param array $content The accumulated content blocks. * * @return array The content blocks with the text slices joined. * @since 1.1.0 */ private function mergeTextBlocks(array $content): array { $merged = []; $textIndex = null; $changed = false; foreach ($content as $block) { if ( $block instanceof stdClass && 'text' === ($block->type ?? null) && isset($block->text) && is_string($block->text) ) { if (null !== $textIndex) { $merged[$textIndex]->text .= $block->text; $changed = true; continue; } $textIndex = count($merged); } $merged[] = $block; } return $changed ? $merged : $content; } /** * Drops thinking blocks that would go out without a signature (repair 1). * * Mirror of the upstream removeUnsignedThinkingBlocks(): the API rejects any * thinking block sent without its signature, and thinking blocks only have * to be echoed back for the turn they belong to, so dropping an unsigned * block is preferable to failing the whole request. The content is left * untouched when filtering would empty it, so a message never ends up empty. * * @param stdClass $message A decoded request message (modified in place). * * @return bool True if any block was removed. * @since 1.1.0 */ private function removeUnsignedThinkingBlocks(stdClass $message): bool { $filtered = array_values(array_filter( $message->content, static function ($block): bool { return ! $block instanceof stdClass || 'thinking' !== ($block->type ?? null) || ! empty($block->signature); } )); if ([] === $filtered || count($filtered) === count($message->content)) { return false; } $message->content = $filtered; return true; } /** * Captures the signatures of the thinking blocks in a response content list (repair 1). * * @param array $content Decoded response content blocks. * * @return void * @since 1.1.0 */ private function captureThinkingSignatures(array $content): void { foreach ($content as $block) { if ( ! $block instanceof stdClass || 'thinking' !== ($block->type ?? null) || ! isset($block->thinking) || ! is_string($block->thinking) || empty($block->signature) || ! is_string($block->signature) ) { continue; } $hash = $this->hashThinking($block->thinking); if (null === $hash) { continue; } $this->signatures[$hash] = $block->signature; // Persist across PHP requests: a write ability pauses the run for // user confirmation and resumes in a new request, which must still // be able to re-inject this signature into the re-serialised block. set_transient( $this->signatureTransientKey($hash), $block->signature, self::SIGNATURE_TRANSIENT_TTL ); } } /** * Resolves a captured signature by hash, falling back to the transient store. * * The in-memory map only survives a single PHP request; signatures captured * before a confirmation pause live in the transient written by * captureThinkingSignatures() and are re-cached on first use. * * @param string $hash The thinking-text hash. * * @return string|null The signature, or null when none was captured. * @since 1.1.0 */ private function lookupSignature(string $hash): ?string { if (isset($this->signatures[$hash])) { return $this->signatures[$hash]; } $stored = get_transient($this->signatureTransientKey($hash)); if (is_string($stored) && '' !== $stored) { $this->signatures[$hash] = $stored; return $stored; } return null; } /** * Builds the transient key for a captured signature hash. * * @param string $hash The thinking-text hash. * * @return string * @since 1.1.0 */ private function signatureTransientKey(string $hash): string { return self::SIGNATURE_TRANSIENT_PREFIX . get_current_user_id() . '_' . $hash; } /** * Extracts the token usage of a response leg as a plain sum-ready array. * * @param stdClass $data Decoded response data. * * @return array * @since 1.1.0 */ private function extractUsage(stdClass $data): array { $usage = ($data->usage ?? null) instanceof stdClass ? $data->usage : new stdClass(); return [ 'input_tokens' => (int) ($usage->input_tokens ?? 0), 'output_tokens' => (int) ($usage->output_tokens ?? 0), 'cache_creation_input_tokens' => (int) ($usage->cache_creation_input_tokens ?? 0), 'cache_read_input_tokens' => (int) ($usage->cache_read_input_tokens ?? 0), ]; } /** * Builds a stable hash for a thinking block from its text. * * The signature is bound to the exact reasoning content, and the connector * re-emits that text verbatim, so hashing it matches a captured signature to * the block that carries it on the next turn. * * @param string $thinking The thinking block text. * * @return string|null The hash, or null when the text is empty. * @since 1.1.0 */ private function hashThinking(string $thinking): ?string { if ('' === $thinking) { return null; } return md5($thinking); } /** * Determines whether a URL targets the Anthropic messages endpoint. * * @param mixed $url The URL to check. * * @return bool * @since 1.1.0 */ private function isAnthropicMessagesUrl($url): bool { return is_string($url) && false !== strpos($url, 'api.anthropic.com') && false !== strpos($url, '/v1/messages'); } }