Expose API response metadata (like usageMetadata) in ApiClient service for token tracking
>>> [!note] Migrated issue <!-- Drupal.org comment --> <!-- Migrated from issue #3549099. --> Reported by: [maxilein](https://www.drupal.org/user/1020354) Related to !23 >>> <h3 id="summary-problem-motivation">Problem/Motivation</h3> <p>Currently, the ApiClient service is designed to be very simple: the generateText() method makes a call to the Gemini API, extracts only the text portion of the response, and returns it as a string.</p> <p>All other valuable information from the API response, most importantly the usageMetadata object which contains the totalTokenCount, is discarded.</p> <p>This makes it impossible to perform essential tasks like:</p> <p>Tracking token usage for cost analysis and budgeting.</p> <p>Logging API consumption per user or per action.</p> <p>Building custom ECA (Events - Conditions - Actions) workflows that react to the number of tokens used.</p> <p>My specific use case is to create a custom ECA Action Plugin that calls the ApiClient service and then saves the totalTokenCount to a field on a Drupal entity. This is currently not possible because the data is not exposed by the service.</p> <h4 id="summary-steps-reproduce">Steps to reproduce</h4> <h3 id="summary-proposed-resolution">Proposed resolution</h3> <p>The proposed solution is to make a small, non-breaking addition to the ApiClient service to make it more extensible.</p> <p>Add a private property, $lastResponse, to the ApiClient class to store the full, decoded JSON response from the most recent API call.</p> <p>In the generateText() method, populate this property with the response data before returning the text string.</p> <p>Add a new public getter method, getLastResponse(): ?array, that allows other services and plugins to retrieve the full response data after a call has been made.</p> <p>This change maintains perfect backward compatibility for existing integrations while enabling advanced use cases for developers who need access to the full API response.</p> <p>Here is the suggested code for the gemini_provider/src/ApiClient.php file, incorporating the changes needed to expose the API usage data.<br> The new additions are clearly marked with // &lt;-- ADDED comments for clarity.</p> <pre>namespace Drupal\gemini_provider;<br><br>use Drupal\Core\Config\ConfigFactoryInterface;<br>use Drupal\Core\Logger\LoggerChannelFactoryInterface;<br>use GuzzleHttp\ClientInterface;<br>use GuzzleHttp\Exception\RequestException;<br><br>/**<br> * Service to interact with the Gemini API.<br> */<br>class ApiClient {<br><br>&nbsp; /**<br>&nbsp;&nbsp; * The HTTP client.<br>&nbsp;&nbsp; *<br>&nbsp;&nbsp; * @var \GuzzleHttp\ClientInterface<br>&nbsp;&nbsp; */<br>&nbsp; protected ClientInterface $httpClient;<br><br>&nbsp; /**<br>&nbsp;&nbsp; * The config factory.<br>&nbsp;&nbsp; *<br>&nbsp;&nbsp; * @var \Drupal\Core\Config\ConfigFactoryInterface<br>&nbsp;&nbsp; */<br>&nbsp; protected ConfigFactoryInterface $configFactory;<br><br>&nbsp; /**<br>&nbsp;&nbsp; * The logger.<br>&nbsp;&nbsp; *<br>&nbsp;&nbsp; * @var \Drupal\Core\Logger\LoggerChannelInterface<br>&nbsp;&nbsp; */<br>&nbsp; protected $logger;<br><br>&nbsp; /**<br>&nbsp;&nbsp; * Stores the full decoded JSON response from the last API call.<br>&nbsp;&nbsp; *<br>&nbsp;&nbsp; * @var ?array<br>&nbsp;&nbsp; */<br>&nbsp; protected ?array $lastResponse = NULL; // &lt;-- ADDED<br><br>&nbsp; /**<br>&nbsp;&nbsp; * Constructs an ApiClient object.<br>&nbsp;&nbsp; *<br>&nbsp;&nbsp; * @param \GuzzleHttp\ClientInterface $http_client<br>&nbsp;&nbsp; * The HTTP client.<br>&nbsp;&nbsp; * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory<br>&nbsp;&nbsp; * The config factory.<br>&nbsp;&nbsp; * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory<br>&nbsp;&nbsp; * The logger factory.<br>&nbsp;&nbsp; */<br>&nbsp; public function __construct(ClientInterface $http_client, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory) {<br>&nbsp;&nbsp;&nbsp; $this-&gt;httpClient = $http_client;<br>&nbsp;&nbsp;&nbsp; $this-&gt;configFactory = $config_factory;<br>&nbsp;&nbsp;&nbsp; $this-&gt;logger = $logger_factory-&gt;get('gemini_provider');<br>&nbsp; }<br><br>&nbsp; /**<br>&nbsp;&nbsp; * Generates text from a prompt.<br>&nbsp;&nbsp; *<br>&nbsp;&nbsp; * @param string $prompt<br>&nbsp;&nbsp; * The prompt to generate text from.<br>&nbsp;&nbsp; *<br>&nbsp;&nbsp; * @return string|null<br>&nbsp;&nbsp; * The generated text, or null on error.<br>&nbsp;&nbsp; */<br>&nbsp; public function generateText(string $prompt): ?string {<br>&nbsp;&nbsp;&nbsp; $config = $this-&gt;configFactory-&gt;get('gemini_provider.settings');<br>&nbsp;&nbsp;&nbsp; $apiKey = $config-&gt;get('api_key');<br>&nbsp;&nbsp;&nbsp; $model = $config-&gt;get('model');<br>&nbsp;&nbsp;&nbsp; $url = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent";<br><br>&nbsp;&nbsp;&nbsp; $options = [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'headers' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'Content-Type' =&gt; 'application/json',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'x-goog-api-key' =&gt; $apiKey,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'json' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'contents' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'parts' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'text' =&gt; $prompt,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp; ];<br><br>&nbsp;&nbsp;&nbsp; try {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $response = $this-&gt;httpClient-&gt;post($url, $options);<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $data = json_decode($response-&gt;getBody()-&gt;getContents(), TRUE);<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $this-&gt;lastResponse = $data; // &lt;-- ADDED<br><br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; return $data['candidates'][0]['content']['parts'][0]['text'] ?? NULL;<br>&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp; catch (RequestException $e) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $this-&gt;lastResponse = NULL; // &lt;-- ADDED<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $this-&gt;logger-&gt;error('Error generating text: @message', ['@message' =&gt; $e-&gt;getMessage()]);<br>&nbsp;&nbsp;&nbsp; }<br><br>&nbsp;&nbsp;&nbsp; return NULL;<br>&nbsp; }<br><br>&nbsp; /**<br>&nbsp;&nbsp; * Returns the full decoded response from the most recent API call.<br>&nbsp;&nbsp; *<br>&nbsp;&nbsp; * @return ?array<br>&nbsp;&nbsp; * The last response, or null if no call has been made or an error occurred.<br>&nbsp;&nbsp; */<br>&nbsp; public function getLastResponse(): ?array { // &lt;-- ADDED<br>&nbsp;&nbsp;&nbsp; return $this-&gt;lastResponse;<br>&nbsp; }<br><br>}</pre><h3 id="summary-remaining-tasks">Remaining tasks</h3> <p>Review the proposed approach.</p> <p>Create a patch with the changes.</p> <p>Commit the patch.</p> <h3 id="summary-ui-changes">User interface changes</h3> <h3 id="summary-api-changes">API changes</h3> <p>A new, non-breaking public method will be added to the gemini_provider.api_client service:</p> <p>public function getLastResponse(): ?array</p> <h3 id="summary-data-model-changes">Data model changes</h3> <p>None.</p>
issue