*/ public static function chatControls(string $model, string $mode = self::THINKING_CHAT): array { if ($mode !== self::THINKING_CHAT) { return []; } return match (true) { $model === 'claude-sonnet-5-5' => ['thinking' => ['type' => 'between_tools']], in_array($model, ['claude-haiku-5-5', 'claude-sonnet-5', 'claude-opus-5'], true) => ['thinking' => ['type' => 'disabled']], in_array($model, ['claude-opus-5-5', 'claude-fable-5-1', 'claude-fable-5'], true) => ['output_config' => ['effort' => 'low']], default => [], }; } /** @return list */ public static function headers(#[\SensitiveParameter] string $secret): array { return ['x-api-key: ' . $secret, 'anthropic-version: ' . self::API_VERSION, 'content-type: application/json', 'accept: text/event-stream']; } public function id(): string { return 'anthropic-messages'; } public function connectionId(): ?int { return $this->connectionId; } public function inputReservation(ModelRequest $request): int { return InputEstimate::bytes($request) + 2048; } public function capabilities(): array { return ['streaming' => true, 'max_output_tokens' => self::MAX_OUTPUT, 'simulated' => false, 'usage' => 'reported', 'cancellation' => 'between_chunks']; } public function complete(ModelRequest $request, CancellationToken $cancel, ?callable $onDelta = null): ModelResult { Conversation::validate($request, self::MAX_OUTPUT); if ($cancel->isCancelled()) { throw new ProviderException('cancelled', false, 'Request cancelled before dispatch.'); } $body = [ 'model' => $request->model, 'max_tokens' => $request->maxOutputTokens, 'system' => $request->instruction, 'messages' => Conversation::turns($request), 'stream' => true, ] + self::chatControls($request->model, $this->thinking); RequestPayload::assertWithinLimits($request, $body, $this->inputByteLimit); // logical limit, then the serialized payload bound (both before dispatch) $text = ''; $input = null; $output = null; $stop = null; $ref = null; $served = null; $tier = null; $cacheRead = null; $cacheWrite = null; $done = false; $final = false; // message_start carries only a placeholder output count; the cumulative figure comes from message_delta $decoder = new SseDecoder(function (string $event, string $json) use (&$text, &$input, &$output, &$stop, &$ref, &$served, &$tier, &$cacheRead, &$cacheWrite, &$done, &$final, $onDelta, $cancel): void { if ($done) { return; } if ($cancel->isCancelled()) { throw new ProviderException('cancelled', true, 'Transport stopped; usage is uncertain.'); } try { $value = json_decode($json, true, 64, JSON_THROW_ON_ERROR); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes catch (\JsonException) { throw new ProviderException(ProviderException::UNAVAILABLE, true, 'Invalid provider stream; usage is uncertain.'); } $type = (string) ($value['type'] ?? $event); if ($type === 'message_start') { $usage = $value['message']['usage'] ?? []; $input = Conversation::count($usage['input_tokens'] ?? null) ?? $input; $output = Conversation::count($usage['output_tokens'] ?? null) ?? $output; $cacheRead = Conversation::count($usage['cache_read_input_tokens'] ?? null) ?? $cacheRead; $cacheWrite = Conversation::count($usage['cache_creation_input_tokens'] ?? null) ?? $cacheWrite; $served = UsageDetail::cleanModel($value['message']['model'] ?? null) ?? $served; $tier = UsageDetail::cleanTier($usage['service_tier'] ?? null) ?? $tier; // "standard", "priority" or "batch"; null when not reported $id = $value['message']['id'] ?? null; $ref = is_string($id) && preg_match('/^msg_[a-zA-Z0-9_-]{1,180}$/D', $id) ? $id : null; } elseif ($type === 'content_block_delta') { $delta = $value['delta'] ?? []; if (($delta['type'] ?? '') === 'text_delta') { if (!is_string($delta['text'] ?? null) || strlen($text) + strlen($delta['text']) > 262144) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(ProviderException::UNAVAILABLE, true, 'Provider output exceeded the local bound.'); } $text .= $delta['text']; if ($onDelta !== null) { $onDelta($delta['text']); } } // Other delta types (thinking, signatures, tool input) are not shown or stored. } elseif ($type === 'message_delta') { $usage = $value['usage'] ?? []; $reported = Conversation::count($usage['output_tokens'] ?? null); if ($reported !== null) { $output = $reported; $final = true; } // cumulative $input = Conversation::count($usage['input_tokens'] ?? null) ?? $input; $cacheRead = Conversation::count($usage['cache_read_input_tokens'] ?? null) ?? $cacheRead; $cacheWrite = Conversation::count($usage['cache_creation_input_tokens'] ?? null) ?? $cacheWrite; if (is_string($value['delta']['stop_reason'] ?? null)) { $stop = $value['delta']['stop_reason']; } } elseif ($type === 'message_stop') { $done = true; } elseif ($type === 'error') { $kind = match ((string) ($value['error']['type'] ?? '')) { 'rate_limit_error' => ProviderException::RATE_LIMITED, 'authentication_error', 'permission_error', 'billing_error' => ProviderException::AUTH, 'invalid_request_error', 'not_found_error', 'request_too_large' => ProviderException::INVALID_REQUEST, 'timeout_error' => ProviderException::TIMEOUT, default => ProviderException::UNAVAILABLE, }; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException($kind, true, 'Provider stream failed; usage is uncertain.'); } // ping and unknown event types are ignored, as the documentation advises. }); try { $this->transport->stream($body, $request->timeoutSeconds, $cancel, [$decoder, 'push']); } catch (ProviderException $e) { // The visitor pressed Stop (or left) after the request went out: what was already streamed is the answer so far, its usage is NOT known (billing may // continue at the provider), so the result says so and the caller holds the reservation as uncertain instead of settling it. if ($e->kind === 'cancelled' && $e->dispatched && !$done) { return new ModelResult($text, 0, 0, null, true, null, true); } throw $e; } $decoder->finish(); if (!$done) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(ProviderException::UNAVAILABLE, true, 'Provider stream ended before message_stop; outcome is uncertain.'); } if ($input === null || $output === null || !$final) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(ProviderException::UNAVAILABLE, true, 'Provider did not report final usage; outcome is uncertain.'); } if ($stop === 'refusal') { // A refusal is a normal HTTP 200 with stop_reason "refusal". Whatever text came before it is not a trustworthy answer: discard it. Usage is known and settled. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(ProviderException::CONTENT_FILTERED, true, 'The model declined to answer.', $input + $output); } if ($text === '') { // Only a refusal (handled above) is a content decision. Hitting a limit is OUTPUT_LIMIT; a normal stop with no text at all (the // documentation says Claude sometimes returns two or three tokens with no content) is an EMPTY_RESPONSE, not a content filter. $limit = $stop === 'max_tokens' || $stop === 'model_context_window_exceeded'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException($limit ? ProviderException::OUTPUT_LIMIT : ProviderException::EMPTY_RESPONSE, true, $limit ? 'The output limit was reached before any answer text (thinking counts toward the limit).' // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes : 'The provider finished without any answer text.', $input + $output); } // Anthropic's input_tokens exclude cache reads and writes; those are an extra category (null when the response did not say). $other = $cacheRead === null && $cacheWrite === null ? null : (int) $cacheRead + (int) $cacheWrite; return new ModelResult($text, $input, $output, $ref, false, UsageDetail::from('anthropic', $served, $input, $output, null, null, $other, $tier)); } }