status(); $usage = $this->extractUsage($response); if ($response->failed()) { $body = $response->body(); $error = Str::limit($body, 1000); $responseBody = $this->truncate($body); } return $response; } catch (Throwable $e) { $error = $e->getMessage(); // RequestException carries the response, ConnectionException // doesn't. Pull the body when it's available. if ($e instanceof RequestException) { $responseBody = $this->truncate($e->response->body()); $usage = $this->extractUsage($e->response); } throw $e; } finally { ApiLog::create([ 'service' => $service, 'method' => strtoupper($method), 'url' => $url, 'status_code' => $statusCode, 'duration_ms' => (int) round((microtime(true) - $start) * 1000), 'error' => $error, 'response_body' => $responseBody, ...$usage, ]); } } private function truncate(string $body): string { return strlen($body) > self::RESPONSE_BODY_CAP ? substr($body, 0, self::RESPONSE_BODY_CAP) : $body; } /** * Pull token-usage and rate-limit telemetry from a provider response. * * Today only Anthropic exposes both. Other providers return mostly * NULLs — callers don't need to know which is which. * * @return array */ private function extractUsage(?Response $response): array { if ($response === null) { return []; } $usage = $response->json('usage'); $tokens = is_array($usage) ? $usage : []; $reset = $response->header('anthropic-ratelimit-input-tokens-reset'); $remaining = $response->header('anthropic-ratelimit-input-tokens-remaining'); return [ 'input_tokens' => $this->intOrNull($tokens['input_tokens'] ?? null), 'output_tokens' => $this->intOrNull($tokens['output_tokens'] ?? null), 'cache_read_tokens' => $this->intOrNull($tokens['cache_read_input_tokens'] ?? null), 'cache_write_tokens' => $this->intOrNull($tokens['cache_creation_input_tokens'] ?? null), 'ratelimit_remaining' => $this->intOrNull($remaining !== '' ? $remaining : null), 'ratelimit_reset_at' => $reset !== '' ? $reset : null, ]; } private function intOrNull(mixed $value): ?int { return is_numeric($value) ? (int) $value : null; } }