diff --git a/src/Data/Candidate.php b/src/Data/Candidate.php index 62ea6fb..d809a0d 100644 --- a/src/Data/Candidate.php +++ b/src/Data/Candidate.php @@ -27,6 +27,8 @@ final class Candidate implements Arrayable * @param GroundingMetadata|null $groundingMetadata Output only. Grounding metadata for the candidate. This field is populated for GenerateContent calls. * @param LogprobsResult|null $logprobsResult Output only. Log-likelihood scores for the response tokens and top tokens. * @param UrlRetrievalMetadata|null $urlRetrievalMetadata Output only. Metadata related to url context retrieval tool. + * @param string|null $finishMessage Optional. Output only. Details the reason why the model stopped generating tokens. This is populated only when `finishReason` is set. + * @param UrlContextMetadata|null $urlContextMetadata Output only. Metadata related to url context retrieval tool. */ public function __construct( public readonly Content $content, @@ -40,10 +42,12 @@ public function __construct( public readonly ?GroundingMetadata $groundingMetadata = null, public readonly ?LogprobsResult $logprobsResult = null, public readonly ?UrlRetrievalMetadata $urlRetrievalMetadata = null, + public readonly ?string $finishMessage = null, + public readonly ?UrlContextMetadata $urlContextMetadata = null, ) {} /** - * @param array{ content: ?array{ parts: array{ array{ text: ?string, inlineData: ?array{ mimeType: string, data: string }, fileData: ?array{ fileUri: string, mimeType: string }, functionCall: ?array{ name: string, args: array|null }, functionResponse: ?array{ name: string, response: array } } }, role: string }, finishReason: ?string, safetyRatings: ?array{ array{ category: string, probability: string, blocked: ?bool } }, citationMetadata: ?array{ citationSources: array{ array{ startIndex: int, endIndex: int, uri: ?string, license: ?string} } }, index: ?int, tokenCount: ?int, avgLogprobs: ?float, groundingAttributions: ?array|null }, functionResponse: ?array{ name: string, response: array } } }, role: string } }>, groundingMetadata?: array{ groundingChunks: ?array } } }>, groundingSupports: ?array|null, confidenceScores: array|null, segment: ?array{ partIndex: ?int, startIndex: ?int, endIndex: ?int, text: ?string } }>, webSearchQueries: ?array, searchEntryPoint?: array{ renderedContent?: string|null, sdkBlob?: string|null }, retrievalMetadata: ?array{ googleSearchDynamicRetrievalScore?: float|null } }, logprobsResult?: array{ topCandidates: array }>, chosenCandidates: array }, urlRetrievalMetadata?: array{ urlRetrievalContexts: array } } $attributes + * @param array{ content: ?array{ parts: array{ array{ text: ?string, inlineData: ?array{ mimeType: string, data: string }, fileData: ?array{ fileUri: string, mimeType: string }, functionCall: ?array{ name: string, args: array|null }, functionResponse: ?array{ name: string, response: array } } }, role: string }, finishReason: ?string, safetyRatings: ?array{ array{ category: string, probability: string, blocked: ?bool } }, citationMetadata: ?array{ citationSources: array{ array{ startIndex: int, endIndex: int, uri: ?string, license: ?string} } }, index: ?int, tokenCount: ?int, avgLogprobs: ?float, groundingAttributions: ?array|null }, functionResponse: ?array{ name: string, response: array } } }, role: string } }>, groundingMetadata?: array{ groundingChunks: ?array } } }>, groundingSupports: ?array|null, confidenceScores: array|null, segment: ?array{ partIndex: ?int, startIndex: ?int, endIndex: ?int, text: ?string } }>, webSearchQueries: ?array, searchEntryPoint?: array{ renderedContent?: string|null, sdkBlob?: string|null }, retrievalMetadata: ?array{ googleSearchDynamicRetrievalScore?: float|null } }, logprobsResult?: array{ topCandidates: array }>, chosenCandidates: array }, urlRetrievalMetadata?: array{ urlRetrievalContexts: array }, finishMessage?: string, urlContextMetadata?: array{ urlMetadata?: array } } $attributes */ public static function from(array $attributes): self { @@ -69,6 +73,8 @@ public static function from(array $attributes): self groundingMetadata: isset($attributes['groundingMetadata']) ? GroundingMetadata::from($attributes['groundingMetadata']) : null, logprobsResult: isset($attributes['logprobsResult']) ? LogprobsResult::from($attributes['logprobsResult']) : null, urlRetrievalMetadata: isset($attributes['urlRetrievalMetadata']) ? UrlRetrievalMetadata::from($attributes['urlRetrievalMetadata']) : null, + finishMessage: $attributes['finishMessage'] ?? null, + urlContextMetadata: isset($attributes['urlContextMetadata']) ? UrlContextMetadata::from($attributes['urlContextMetadata']) : null, ); } @@ -92,6 +98,8 @@ public function toArray(): array 'groundingMetadata' => $this->groundingMetadata?->toArray(), 'logprobsResult' => $this->logprobsResult?->toArray(), 'urlRetrievalMetadata' => $this->urlRetrievalMetadata?->toArray(), + 'finishMessage' => $this->finishMessage, + 'urlContextMetadata' => $this->urlContextMetadata?->toArray(), ]; } } diff --git a/src/Data/ModelStatus.php b/src/Data/ModelStatus.php new file mode 100644 index 0000000..e402aa2 --- /dev/null +++ b/src/Data/ModelStatus.php @@ -0,0 +1,48 @@ + $this->modelStage?->value, + 'retirementTime' => $this->retirementTime, + 'message' => $this->message, + ]; + } +} diff --git a/src/Data/UrlContextMetadata.php b/src/Data/UrlContextMetadata.php new file mode 100644 index 0000000..3149064 --- /dev/null +++ b/src/Data/UrlContextMetadata.php @@ -0,0 +1,45 @@ + $urlMetadata List of url context. + */ + public function __construct( + public readonly array $urlMetadata = [], + ) {} + + /** + * @param array{ urlMetadata?: array } $attributes + */ + public static function from(array $attributes): self + { + return new self( + urlMetadata: array_map( + static fn (array $urlMetadata): UrlMetadata => UrlMetadata::from($urlMetadata), + $attributes['urlMetadata'] ?? [], + ), + ); + } + + public function toArray(): array + { + return [ + 'urlMetadata' => array_map( + static fn (UrlMetadata $urlMetadata): array => $urlMetadata->toArray(), + $this->urlMetadata, + ), + ]; + } +} diff --git a/src/Data/UrlMetadata.php b/src/Data/UrlMetadata.php new file mode 100644 index 0000000..aae96dd --- /dev/null +++ b/src/Data/UrlMetadata.php @@ -0,0 +1,44 @@ + $this->retrievedUrl, + 'urlRetrievalStatus' => $this->urlRetrievalStatus?->value, + ]; + } +} diff --git a/src/Data/UsageMetadata.php b/src/Data/UsageMetadata.php index 94ed145..dd2a684 100644 --- a/src/Data/UsageMetadata.php +++ b/src/Data/UsageMetadata.php @@ -5,6 +5,7 @@ namespace Gemini\Data; use Gemini\Contracts\Arrayable; +use Gemini\Enums\ServiceTier; /** * Metadata on the generation request's token usage. @@ -24,6 +25,7 @@ final class UsageMetadata implements Arrayable * @param list|null $cacheTokensDetails List of modalities of the cached content in the request input. * @param list|null $candidatesTokensDetails List of modalities that were returned in the response. * @param list|null $toolUsePromptTokensDetails List of modalities that were processed for tool-use request inputs. + * @param ServiceTier|null $serviceTier Output only. Service tier of the request. */ public function __construct( public readonly int $promptTokenCount, @@ -36,10 +38,11 @@ public function __construct( public readonly ?array $cacheTokensDetails = null, public readonly ?array $candidatesTokensDetails = null, public readonly ?array $toolUsePromptTokensDetails = null, + public readonly ?ServiceTier $serviceTier = null, ) {} /** - * @param array{ promptTokenCount?: int, totalTokenCount: int, candidatesTokenCount: ?int, cachedContentTokenCount: ?int, toolUsePromptTokenCount: ?int, thoughtsTokenCount: ?int, promptTokensDetails: list|null, cacheTokensDetails: list|null, candidatesTokensDetails: list|null, toolUsePromptTokensDetails: list|null } $attributes + * @param array{ promptTokenCount?: int, totalTokenCount: int, candidatesTokenCount: ?int, cachedContentTokenCount: ?int, toolUsePromptTokenCount: ?int, thoughtsTokenCount: ?int, promptTokensDetails: list|null, cacheTokensDetails: list|null, candidatesTokensDetails: list|null, toolUsePromptTokensDetails: list|null, serviceTier?: string } $attributes */ public static function from(array $attributes): self { @@ -67,6 +70,7 @@ public static function from(array $attributes): self static fn (array $toolUsePromptTokensDetail): ModalityTokenCount => ModalityTokenCount::from($toolUsePromptTokensDetail), $attributes['toolUsePromptTokensDetails'] ?? [], ), + serviceTier: isset($attributes['serviceTier']) ? ServiceTier::tryFrom($attributes['serviceTier']) : null, ); } @@ -95,6 +99,7 @@ public function toArray(): array static fn (ModalityTokenCount $toolUsePromptTokensDetail): array => $toolUsePromptTokensDetail->toArray(), $this->toolUsePromptTokensDetails ?? [], ), + 'serviceTier' => $this->serviceTier?->value, ]; } } diff --git a/src/Enums/ModelStage.php b/src/Enums/ModelStage.php new file mode 100644 index 0000000..ef5e65d --- /dev/null +++ b/src/Enums/ModelStage.php @@ -0,0 +1,53 @@ +|null }, functionResponse: ?array{ name: string, response: array } } }, role: string }, finishReason: ?string, safetyRatings: ?array{ array{ category: string, probability: string, blocked: ?bool } }, citationMetadata: ?array{ citationSources: array{ array{ startIndex: int, endIndex: int, uri: ?string, license: ?string} } }, index: ?int, tokenCount: ?int, avgLogprobs: ?float, groundingAttributions: ?array|null }, functionResponse: ?array{ name: string, response: array } } }, role: string } }>, groundingMetadata?: array{ groundingChunks: ?array } } }>, groundingSupports: ?array|null, confidenceScores: array|null, segment: ?array{ partIndex: ?int, startIndex: ?int, endIndex: ?int, text: ?string } }>, webSearchQueries: ?array, searchEntryPoint?: array{ renderedContent?: string|null, sdkBlob?: string|null }, retrievalMetadata: ?array{ googleSearchDynamicRetrievalScore?: float|null } }, logprobsResult?: array{ topCandidates: array }>, chosenCandidates: array }, urlRetrievalMetadata?: array{ urlRetrievalContexts: array } }>, promptFeedback: ?array{ safetyRatings: array{ array{ category: string, probability: string, blocked: ?bool } }, blockReason: ?string }, usageMetadata: array{ promptTokenCount: int, totalTokenCount: int, candidatesTokenCount: ?int, cachedContentTokenCount: ?int, toolUsePromptTokenCount: ?int, thoughtsTokenCount: ?int, promptTokensDetails: list|null, cacheTokensDetails: list|null, candidatesTokensDetails: list|null, toolUsePromptTokensDetails: list|null }, modelVersion: ?string } $attributes + * @param array{ candidates: ?array|null }, functionResponse: ?array{ name: string, response: array } } }, role: string }, finishReason: ?string, safetyRatings: ?array{ array{ category: string, probability: string, blocked: ?bool } }, citationMetadata: ?array{ citationSources: array{ array{ startIndex: int, endIndex: int, uri: ?string, license: ?string} } }, index: ?int, tokenCount: ?int, avgLogprobs: ?float, groundingAttributions: ?array|null }, functionResponse: ?array{ name: string, response: array } } }, role: string } }>, groundingMetadata?: array{ groundingChunks: ?array } } }>, groundingSupports: ?array|null, confidenceScores: array|null, segment: ?array{ partIndex: ?int, startIndex: ?int, endIndex: ?int, text: ?string } }>, webSearchQueries: ?array, searchEntryPoint?: array{ renderedContent?: string|null, sdkBlob?: string|null }, retrievalMetadata: ?array{ googleSearchDynamicRetrievalScore?: float|null } }, logprobsResult?: array{ topCandidates: array }>, chosenCandidates: array }, urlRetrievalMetadata?: array{ urlRetrievalContexts: array }, finishMessage?: string, urlContextMetadata?: array{ urlMetadata?: array } }>, promptFeedback: ?array{ safetyRatings: array{ array{ category: string, probability: string, blocked: ?bool } }, blockReason: ?string }, usageMetadata: array{ promptTokenCount: int, totalTokenCount: int, candidatesTokenCount: ?int, cachedContentTokenCount: ?int, toolUsePromptTokenCount: ?int, thoughtsTokenCount: ?int, promptTokensDetails: list|null, cacheTokensDetails: list|null, candidatesTokensDetails: list|null, toolUsePromptTokensDetails: list|null }, modelVersion: ?string, responseId?: string, modelStatus?: array{ modelStage?: string, retirementTime?: string, message?: string } } $attributes */ public static function from(array $attributes): self { @@ -121,6 +126,8 @@ public static function from(array $attributes): self usageMetadata: UsageMetadata::from($attributes['usageMetadata']), promptFeedback: $promptFeedback, modelVersion: $attributes['modelVersion'] ?? null, + responseId: $attributes['responseId'] ?? null, + modelStatus: isset($attributes['modelStatus']) ? ModelStatus::from($attributes['modelStatus']) : null, ); } @@ -134,6 +141,8 @@ public function toArray(): array 'promptFeedback' => $this->promptFeedback?->toArray(), 'usageMetadata' => $this->usageMetadata->toArray(), 'modelVersion' => $this->modelVersion, + 'responseId' => $this->responseId, + 'modelStatus' => $this->modelStatus?->toArray(), ]; } } diff --git a/tests/Responses/GenerativeModel/GenerateContentResponse.php b/tests/Responses/GenerativeModel/GenerateContentResponse.php index a2953b0..1950158 100644 --- a/tests/Responses/GenerativeModel/GenerateContentResponse.php +++ b/tests/Responses/GenerativeModel/GenerateContentResponse.php @@ -6,6 +6,9 @@ use Gemini\Enums\FinishReason; use Gemini\Enums\HarmCategory; use Gemini\Enums\MimeType; +use Gemini\Enums\ModelStage; +use Gemini\Enums\ServiceTier; +use Gemini\Enums\UrlRetrievalStatus; use Gemini\Responses\GenerativeModel\GenerateContentResponse; test('from', function () { @@ -20,6 +23,111 @@ }); +test('response metadata fields', function () { + $attributes = [ + 'candidates' => [ + [ + 'content' => [ + 'parts' => [['text' => 'Hello']], + 'role' => 'model', + ], + 'finishReason' => FinishReason::STOP->value, + 'finishMessage' => 'The model stopped generating tokens.', + 'urlContextMetadata' => [ + 'urlMetadata' => [ + ['retrievedUrl' => 'https://example.com', 'urlRetrievalStatus' => 'URL_RETRIEVAL_STATUS_SUCCESS'], + ['retrievedUrl' => 'https://example.com/paywall', 'urlRetrievalStatus' => 'URL_RETRIEVAL_STATUS_PAYWALL'], + ], + ], + 'index' => 0, + ], + ], + 'usageMetadata' => [ + 'promptTokenCount' => 5, + 'candidatesTokenCount' => 1, + 'totalTokenCount' => 6, + 'serviceTier' => 'flex', + ], + 'modelVersion' => 'gemini-2.5-flash', + 'responseId' => 'abc123', + 'modelStatus' => [ + 'modelStage' => 'PREVIEW', + 'retirementTime' => '2026-12-31T00:00:00Z', + 'message' => 'This model will be retired soon.', + ], + ]; + + $response = GenerateContentResponse::from($attributes); + + expect($response) + ->responseId->toBe('abc123') + ->modelStatus->modelStage->toBe(ModelStage::PREVIEW) + ->modelStatus->retirementTime->toBe('2026-12-31T00:00:00Z') + ->modelStatus->message->toBe('This model will be retired soon.') + ->usageMetadata->serviceTier->toBe(ServiceTier::FLEX) + ->candidates->{0}->finishMessage->toBe('The model stopped generating tokens.') + ->candidates->{0}->urlContextMetadata->urlMetadata->toHaveCount(2) + ->candidates->{0}->urlContextMetadata->urlMetadata->{0}->retrievedUrl->toBe('https://example.com') + ->candidates->{0}->urlContextMetadata->urlMetadata->{0}->urlRetrievalStatus->toBe(UrlRetrievalStatus::URL_RETRIEVAL_STATUS_SUCCESS) + ->candidates->{0}->urlContextMetadata->urlMetadata->{1}->urlRetrievalStatus->toBe(UrlRetrievalStatus::URL_RETRIEVAL_STATUS_PAYWALL); + + expect($response->toArray()) + ->responseId->toBe('abc123') + ->modelStatus->toBe($attributes['modelStatus']) + ->usageMetadata->serviceTier->toBe('flex') + ->candidates->{0}->finishMessage->toBe('The model stopped generating tokens.') + ->candidates->{0}->urlContextMetadata->toBe($attributes['candidates'][0]['urlContextMetadata']); +}); + +test('response metadata fields with unknown enum values', function () { + $response = GenerateContentResponse::from([ + 'candidates' => [ + [ + 'urlContextMetadata' => [ + 'urlMetadata' => [ + ['retrievedUrl' => 'https://example.com', 'urlRetrievalStatus' => 'SOME_NEW_STATUS'], + ], + ], + ], + ], + 'usageMetadata' => [ + 'promptTokenCount' => 0, + 'totalTokenCount' => 0, + 'serviceTier' => 'some-new-tier', + ], + 'modelStatus' => [ + 'modelStage' => 'SOME_NEW_STAGE', + ], + ]); + + expect($response) + ->modelStatus->modelStage->toBeNull() + ->usageMetadata->serviceTier->toBeNull() + ->candidates->{0}->urlContextMetadata->urlMetadata->{0}->retrievedUrl->toBe('https://example.com') + ->candidates->{0}->urlContextMetadata->urlMetadata->{0}->urlRetrievalStatus->toBeNull(); +}); + +test('response metadata fields are optional', function () { + $response = GenerateContentResponse::from([ + 'candidates' => [ + [ + 'finishReason' => FinishReason::STOP->value, + ], + ], + 'usageMetadata' => [ + 'promptTokenCount' => 0, + 'totalTokenCount' => 0, + ], + ]); + + expect($response) + ->responseId->toBeNull() + ->modelStatus->toBeNull() + ->usageMetadata->serviceTier->toBeNull() + ->candidates->{0}->finishMessage->toBeNull() + ->candidates->{0}->urlContextMetadata->toBeNull(); +}); + test('recitation finish reason', function () { $response = GenerateContentResponse::from([ 'candidates' => [