*/ public static function headers(#[\SensitiveParameter] string $secret): array { return ['x-goog-api-key: ' . $secret, 'Content-Type: application/json', 'Accept: text/event-stream']; } public function id(): string { return 'gemini-generate-content'; } 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); $url = self::streamUrl($request->model); if ($cancel->isCancelled()) { throw new ProviderException('cancelled', false, 'Request cancelled before dispatch.'); } $contents = array_map(static fn(array $t): array => ['role' => $t['role'] === 'assistant' ? 'model' : 'user', 'parts' => [['text' => $t['content']]]], Conversation::turns($request)); $body = [ 'contents' => $contents, 'systemInstruction' => ['parts' => [['text' => $request->instruction]]], 'generationConfig' => ['maxOutputTokens' => $request->maxOutputTokens], ]; RequestPayload::assertWithinLimits($request, $body, $this->inputByteLimit); // logical limit, then the serialized payload bound (both before dispatch) $text = ''; $prompt = null; $total = null; $finish = null; $blocked = false; $ref = null; $served = null; $cached = null; $thoughts = null; $toolUse = null; $tier = null; $decoder = new SseDecoder(function (string $event, string $json) use (&$text, &$prompt, &$total, &$finish, &$blocked, &$ref, &$served, &$cached, &$thoughts, &$toolUse, &$tier, $onDelta, $cancel): void { 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.'); } if (isset($value['error'])) { $code = (int) ($value['error']['code'] ?? 0); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(HttpsStreamTransport::kindFor($code), true, 'Provider stream failed; usage is uncertain.'); } if (is_string($value['promptFeedback']['blockReason'] ?? null)) { $blocked = true; } $candidate = $value['candidates'][0] ?? null; if (is_array($candidate)) { foreach ((array) ($candidate['content']['parts'] ?? []) as $part) { if (!is_array($part) || !empty($part['thought']) || !is_string($part['text'] ?? null)) { continue; // thoughts and non-text parts are not shown } if (strlen($text) + strlen($part['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 .= $part['text']; if ($onDelta !== null) { $onDelta($part['text']); } } if (is_string($candidate['finishReason'] ?? null)) { $finish = $candidate['finishReason']; } } $usage = $value['usageMetadata'] ?? null; if (is_array($usage)) { $prompt = Conversation::count($usage['promptTokenCount'] ?? null) ?? $prompt; $total = Conversation::count($usage['totalTokenCount'] ?? null) ?? $total; $cached = Conversation::count($usage['cachedContentTokenCount'] ?? null) ?? $cached; $thoughts = Conversation::count($usage['thoughtsTokenCount'] ?? null) ?? $thoughts; $toolUse = Conversation::count($usage['toolUsePromptTokenCount'] ?? null) ?? $toolUse; $tier = UsageDetail::cleanTier($usage['serviceTier'] ?? null) ?? $tier; } $served = UsageDetail::cleanModel($value['modelVersion'] ?? null) ?? $served; if (is_string($value['responseId'] ?? null) && preg_match('/^[a-zA-Z0-9_-]{1,180}$/D', $value['responseId'])) { $ref = $value['responseId']; } }); try { $this->transport($url)->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 && $finish === null) { return new ModelResult($text, 0, 0, null, true, null, true); } throw $e; } $decoder->finish(); $known = $prompt !== null && $total !== null && $total >= $prompt; $usageTokens = $known ? $total : null; if ($blocked || ($text === '' && $finish !== null)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException($finish === 'MAX_TOKENS' && !$blocked ? ProviderException::OUTPUT_LIMIT : ProviderException::CONTENT_FILTERED, true, $finish === 'MAX_TOKENS' ? '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 : 'Provider returned no supported text answer.', $usageTokens); } if ($finish === null || !$known) { // 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 without a finish reason and usage; outcome is uncertain.', $usageTokens); } if (!in_array($finish, self::COMPLETE, true)) { // 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 provider stopped the answer early (' . preg_replace('/[^A-Z_]/', '', $finish) . ').', $usageTokens); } // Output usage includes thinking tokens, which are billed as output. // totalTokenCount = prompt + tool-use prompt + thoughts + candidates, so a request that used tools would put tool-use tokens into the // derived output: they are recorded as "other" and never priced. This request declares no tools, so an absent tool-use count is none. // The thinking count stays unknown when it is not given. The cached count and the tier follow the rules below. return new ModelResult($text, $prompt, $total - $prompt, $ref, false, UsageDetail::from( 'gemini', $served, $prompt, $total - $prompt, self::cachedCount($cached, $prompt, $served ?? $request->model), $thoughts, $toolUse ?? 0, self::servedTier(null, $tier) // this adapter never puts a serviceTier in the request body )); } /** * Prompt size below which implicit caching cannot happen, from the documentation of implicit caching ("minimum input token count for * each model"). Only models the documentation lists are here; for any other the minimum is unknown. */ private const IMPLICIT_CACHE_MIN = ['gemini-2.5-flash' => 2048, 'gemini-2.5-pro' => 2048, 'gemini-3.1-pro-preview' => 4096]; /** * Tokens of the prompt that came from a cache. A reported count (including a reported 0) is taken as it is. An absent count is NOT * assumed to be zero: implicit caching is on by default for Gemini 2.5 and newer, and the documentation does not say that a missing * field means none. It is zero only when the prompt is smaller than the documented minimum for implicit caching of that model, where * no cache hit is possible; otherwise it is unknown (null), so the request is not priced. */ public static function cachedCount(?int $reported, int $prompt, string $model): ?int { if ($reported !== null) { return $reported; } $min = self::IMPLICIT_CACHE_MIN[strtolower($model)] ?? null; return $min !== null && $prompt < $min ? 0 : null; } /** * The processing tier a response was served at. A tier the response names is taken (an "unspecified" one means the default). When the * response names none, standard is concluded only if this request did not ask for a tier: the API documents that a request without a * serviceTier is processed at the standard tier (Flex and Priority guides, ServiceTier enum), and only a Priority request can be * downgraded. If a tier WAS requested and the response does not confirm it, the tier is unknown (null). */ public static function servedTier(?string $requested, ?string $reported): ?string { $reported = $reported === 'unspecified' ? null : $reported; if ($reported !== null) { return $reported; } return $requested === null || $requested === 'unspecified' ? 'standard' : null; } private function transport(string $url): ResponseTransport { return ($this->transportFor)($url); } }