diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9cb52c6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Don't include in releases. +.docker/ export-ignore +.dockerignore export-ignore +docker-compose.yml export-ignore +Makefile export-ignore +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.github/ export-ignore +.gitlab-ci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9417a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +vendor/ +.idea/ +.vscode/ +.claude/ +.DS_Store +*.log diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..94fb846 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/ai_provider_quant_cloud.info.yml b/ai_provider_quant_cloud.info.yml index 35b071f..d14b657 100644 --- a/ai_provider_quant_cloud.info.yml +++ b/ai_provider_quant_cloud.info.yml @@ -1,11 +1,12 @@ name: 'Quant Cloud AI Provider' description: 'AI provider for Quant Cloud platform (QuantCDN/QuantGov). Provides chat and embeddings via Dashboard API with OAuth authentication.' type: module -package: 'AI' +package: 'AI Providers' +lifecycle: experimental +lifecycle_link: 'https://www.drupal.org/project/ai_provider_quant_cloud' core_version_requirement: ^10.3 || ^11 || ^12 configure: ai_provider_quant_cloud.settings_form dependencies: - ai:ai - key:key - diff --git a/ai_provider_quant_cloud.install b/ai_provider_quant_cloud.install index 41dcb41..39c6948 100644 --- a/ai_provider_quant_cloud.install +++ b/ai_provider_quant_cloud.install @@ -64,3 +64,16 @@ function ai_provider_quant_cloud_update_10002(): void { $config->set('model.max_tokens', 16384)->save(TRUE); } } + +/** + * Raise default max tokens from 16384 to 32768 for richer tool-use chains. + */ +function ai_provider_quant_cloud_update_10003(): void { + $config = \Drupal::configFactory() + ->getEditable('ai_provider_quant_cloud.settings'); + + $max_tokens = (int) $config->get('model.max_tokens'); + if ($max_tokens <= 16384) { + $config->set('model.max_tokens', 32768)->save(TRUE); + } +} diff --git a/ai_provider_quant_cloud.links.menu.yml b/ai_provider_quant_cloud.links.menu.yml index ea215d9..8c105c0 100644 --- a/ai_provider_quant_cloud.links.menu.yml +++ b/ai_provider_quant_cloud.links.menu.yml @@ -4,4 +4,3 @@ ai_provider_quant_cloud.settings: route_name: ai_provider_quant_cloud.settings_form parent: ai.admin_providers weight: 10 - diff --git a/ai_provider_quant_cloud.module b/ai_provider_quant_cloud.module index 5a70117..4d3ab58 100644 --- a/ai_provider_quant_cloud.module +++ b/ai_provider_quant_cloud.module @@ -15,7 +15,7 @@ function ai_provider_quant_cloud_help($route_name, RouteMatchInterface $route_ma case 'help.page.ai_provider_quant_cloud': $output = '

' . t('About') . '

'; $output .= '

' . t('The Quant Cloud AI Provider module enables integration with Quant Cloud AI services, providing access to AWS Bedrock models through the Quant Cloud platform.') . '

'; - + $output .= '

' . t('Features') . '

'; $output .= '
    '; $output .= '
  • ' . t('Chat Completions: Multi-turn conversations with Claude and Nova models') . '
  • '; @@ -25,19 +25,19 @@ function ai_provider_quant_cloud_help($route_name, RouteMatchInterface $route_ma $output .= '
  • ' . t('Multi-Tenant: Organization-level access control') . '
  • '; $output .= '
  • ' . t('Government-Ready: Designed for compliance with Australian government standards') . '
  • '; $output .= '
'; - + $output .= '

' . t('Platform Support') . '

'; $output .= '

' . t('This provider works with both:') . '

'; $output .= '
    '; $output .= '
  • ' . t('QuantCDN: https://dashboard.quantcdn.io') . '
  • '; $output .= '
  • ' . t('QuantGov Cloud: https://dash.quantgov.cloud') . '
  • '; $output .= '
'; - + $output .= '

' . t('Configuration') . '

'; $output .= '

' . t('Configure the provider at Administration > Configuration > AI > Quant Cloud AI Settings.', [ ':url' => \Drupal::url('ai_provider_quant_cloud.settings_form'), ]) . '

'; - + $output .= '

' . t('Authentication') . '

'; $output .= '

' . t('The module supports OAuth2 authentication through the Quant Cloud dashboard. You will need:') . '

'; $output .= '
    '; @@ -45,8 +45,7 @@ function ai_provider_quant_cloud_help($route_name, RouteMatchInterface $route_ma $output .= '
  • ' . t('API access token from your dashboard') . '
  • '; $output .= '
  • ' . t('Organization ID') . '
  • '; $output .= '
'; - + return $output; } } - diff --git a/composer.json b/composer.json index 2f91c53..40bc817 100644 --- a/composer.json +++ b/composer.json @@ -20,13 +20,14 @@ "require": { "php": ">=8.2", "drupal/core": "^10.3 || ^11 || ^12", - "drupal/ai": "^1.0", - "drupal/key": "^1.17" + "drupal/ai": "^1.2.0", + "drupal/key": "^1.17", + "drupal/search_api": "^1.36" }, "require-dev": { "drupal/core-dev": "^10.3 || ^11 || ^12" }, - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true } diff --git a/config/install/ai_provider_quant_cloud.settings.yml b/config/install/ai_provider_quant_cloud.settings.yml index 200272e..c724e07 100644 --- a/config/install/ai_provider_quant_cloud.settings.yml +++ b/config/install/ai_provider_quant_cloud.settings.yml @@ -22,7 +22,7 @@ auth: model: default: 'amazon.nova-lite-v1:0' temperature: 0.7 - max_tokens: 16384 + max_tokens: 32768 # Advanced request settings. advanced: diff --git a/config/schema/ai_provider_quant_cloud.schema.yml b/config/schema/ai_provider_quant_cloud.schema.yml index faceea7..efb7114 100644 --- a/config/schema/ai_provider_quant_cloud.schema.yml +++ b/config/schema/ai_provider_quant_cloud.schema.yml @@ -81,5 +81,3 @@ ai_provider.plugin.quant_cloud: type: string organization_id: type: string - - diff --git a/definitions/api_defaults.yml b/definitions/api_defaults.yml index 13b2727..30ebfb3 100644 --- a/definitions/api_defaults.yml +++ b/definitions/api_defaults.yml @@ -15,7 +15,7 @@ chat: label: 'Max Tokens' description: 'The maximum number of tokens to generate. Supported upper limits vary by model.' type: 'integer' - default: 16384 + default: 32768 required: false constraints: min: 1 diff --git a/modules/ai_provider_quant_cloud_vdb/src/Plugin/VdbProvider/QuantCloudVdbProvider.php b/modules/ai_provider_quant_cloud_vdb/src/Plugin/VdbProvider/QuantCloudVdbProvider.php index 48ced48..af9b055 100644 --- a/modules/ai_provider_quant_cloud_vdb/src/Plugin/VdbProvider/QuantCloudVdbProvider.php +++ b/modules/ai_provider_quant_cloud_vdb/src/Plugin/VdbProvider/QuantCloudVdbProvider.php @@ -445,8 +445,17 @@ public function insertIntoCollection( $response = $this->vdbClient->uploadDocuments($collection_id, $documents); // Store ID mapping in state for later retrieval. - $document_ids = $response['documentIds'] ?? []; - if (!empty($document_ids) && !empty($data['drupal_long_id'])) { + $document_ids = $response['uploadedDocuments'][0]['documentIds'] ?? []; + if (empty($document_ids)) { + // Upload returned 2xx but no document IDs — treat as a partial + // failure so the operator can investigate rather than silently + // continuing with a missing id-mapping entry. + $this->getLogger('ai_provider_quant_cloud')->warning( + 'Document upload to collection @collection returned no document IDs. Response shape may have changed; mapping not stored.', + ['@collection' => $collection_name], + ); + } + elseif (!empty($data['drupal_long_id'])) { $state_key = "ai_provider_quant_cloud.vdb_mapping.{$collection_name}"; $mapping = \Drupal::state()->get($state_key, []); $mapping[$data['drupal_long_id']] = $document_ids[0]; @@ -620,7 +629,8 @@ public function vectorSearch( $vector, $limit, 0.0, - TRUE // Include metadata + // Include metadata. + TRUE ); // Map API response to expected format. @@ -628,14 +638,15 @@ public function vectorSearch( // - 'distance' for score (used by setScore(), skipped by extractMetadata()) // - 'drupal_entity_id' for entity lookup // - 'id' for the full chunk ID (skipped by extractMetadata()) - // - 'content' is added to extra data for display + // - 'content' is added to extra data for display. $results = []; foreach ($response['results'] ?? [] as $result) { $metadata = $result['metadata'] ?? []; $results[] = [ 'id' => $metadata['drupal_long_id'] ?? $result['documentId'], 'drupal_entity_id' => $metadata['drupal_entity_id'] ?? NULL, - 'distance' => $result['score'] ?? 0.0, // Backend expects 'distance', not 'score' + // Backend expects 'distance', not 'score'. + 'distance' => $result['score'] ?? 0.0, 'content' => $result['content'] ?? '', ]; } diff --git a/src/Client/QuantCloudClient.php b/src/Client/QuantCloudClient.php index f516495..b551878 100644 --- a/src/Client/QuantCloudClient.php +++ b/src/Client/QuantCloudClient.php @@ -2,6 +2,7 @@ namespace Drupal\ai_provider_quant_cloud\Client; +use GuzzleHttp\Exception\RequestException; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\ai_provider_quant_cloud\Service\AuthService; @@ -31,7 +32,7 @@ class QuantCloudClient { /** * Default maximum response tokens for chat requests. */ - public const DEFAULT_MAX_TOKENS = 16384; + public const DEFAULT_MAX_TOKENS = 32768; /** * Default HTTP request timeout, in seconds. @@ -83,7 +84,7 @@ public function __construct( ClientInterface $http_client, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, - AuthService $auth_service + AuthService $auth_service, ) { $this->httpClient = $http_client; $this->configFactory = $config_factory; @@ -110,17 +111,17 @@ protected function getAccessToken(): ?string { */ protected function getHeaders(): array { $access_token = $this->getAccessToken(); - + $headers = [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ]; - + if ($access_token) { - // Dashboard API uses Bearer token authentication + // Dashboard API uses Bearer token authentication. $headers['Authorization'] = 'Bearer ' . $access_token; } - + return $headers; } @@ -130,48 +131,48 @@ protected function getHeaders(): array { protected function getDashboardUrl(): string { $config = $this->getConfig(); $platform = $config->get('platform') ?: 'quantcdn'; - - // Map platform to dashboard URL + + // Map platform to dashboard URL. $urls = [ 'quantcdn' => 'https://dashboard.quantcdn.io', 'quantgov' => 'https://dash.quantgov.cloud', 'quantcdn_staging' => 'https://portal.stage.quantcdn.io', 'quantgov_staging' => 'https://dash.stage.quantgov.cloud', ]; - + $dashboard_url = $urls[$platform] ?? $urls['quantcdn']; - + return rtrim($dashboard_url, '/'); } - + /** * Get organization ID from config. */ protected function getOrganizationId(): string { $config = $this->getConfig(); $org_id = $config->get('auth.organization_id'); - + if (!$org_id) { throw new \RuntimeException('Organization ID not configured'); } - + return $org_id; } - + /** * Build full API endpoint URL for dashboard API. - * + * * @param string $path - * API path relative to /api/v3/organisations/{orgId}/ai/ + * API path relative to /api/v3/organisations/{orgId}/ai/. */ protected function buildApiUrl(string $path): string { $dashboard_url = $this->getDashboardUrl(); $org_id = $this->getOrganizationId(); - - // Dashboard API pattern: /api/v3/organisations/{orgId}/ai/{endpoint} + + // Dashboard API pattern: /api/v3/organisations/{orgId}/ai/{endpoint}. $base_path = "/api/v3/organisations/{$org_id}/ai"; $full_path = ltrim($path, '/'); - + return "{$dashboard_url}{$base_path}/{$full_path}"; } @@ -197,7 +198,7 @@ public function post(string $path, array $data, array $request_options = []): ar $timeout = $request_options['timeout'] ?? $config->get('advanced.timeout') ?? self::DEFAULT_TIMEOUT; - + $options = [ 'headers' => $this->getHeaders(), 'json' => $data, @@ -206,7 +207,7 @@ public function post(string $path, array $data, array $request_options = []): ar ?? $config->get('advanced.connect_timeout') ?? self::DEFAULT_CONNECT_TIMEOUT, ]; - + try { if ($config->get('advanced.enable_logging')) { $this->logger->info('Quant Dashboard AI request: @method @url', [ @@ -214,41 +215,48 @@ public function post(string $path, array $data, array $request_options = []): ar '@url' => $url, ]); } - + $response = $this->httpClient->post($url, $options); $body = $response->getBody()->getContents(); $result = json_decode($body, TRUE); - + if ($config->get('advanced.enable_logging')) { $this->logger->info('Quant Dashboard AI response: @status', [ '@status' => $response->getStatusCode(), ]); } - + return $result; - + } catch (GuzzleException $e) { + $status = NULL; + $reason = NULL; + if ($e instanceof RequestException && $e->getResponse()) { + $status = $e->getResponse()->getStatusCode(); + $reason = $e->getResponse()->getReasonPhrase(); + } $this->logger->error( - 'Quant Dashboard AI request failed for @path after @timeout seconds: @message', + 'Quant Dashboard AI request failed for @path after @timeout seconds (status: @status @reason)', [ '@path' => $path, '@timeout' => $timeout, - '@message' => $e->getMessage(), + '@status' => $status ?? 'n/a', + '@reason' => $reason ?? 'transport error', ] ); - throw new \RuntimeException('AI API request failed: ' . $e->getMessage(), 0, $e); + throw new \RuntimeException('AI API request failed (status: ' . ($status ?? 'n/a') . ')', 0, $e); } } /** * Chat completion request (buffered). - * - * Dashboard API route: POST /api/v3/organisations/{orgId}/ai/chat + * + * Dashboard API route: POST /api/v3/organisations/{orgId}/ai/chat. */ public function chat(array $messages, string $model_id, array $options = []): array { $config = $this->getConfig(); - + $data = [ 'messages' => $messages, 'modelId' => $model_id, @@ -259,35 +267,35 @@ public function chat(array $messages, string $model_id, array $options = []): ar ?? $config->get('model.max_tokens') ?? self::DEFAULT_MAX_TOKENS, ]; - - // Add structured output (JSON Schema) if provided - if (isset($options['responseFormat'])) { - $data['responseFormat'] = $options['responseFormat']; + + // Add structured output (JSON Schema) if provided. + if (isset($options['response_format'])) { + $data['response_format'] = $options['response_format']; } - - // Add function calling (tools) if provided + + // Add function calling (tools) if provided. if (isset($options['toolConfig'])) { $data['toolConfig'] = $options['toolConfig']; } - - // Add system prompt if provided + + // Add system prompt if provided. if (isset($options['systemPrompt'])) { $data['systemPrompt'] = $options['systemPrompt']; } - + return $this->post('chat', $data, $this->getRequestOptions($options)); } /** * Text completion request. - * + * * Note: This uses chat models for text-to-text operations as that's how * the Dashboard API is structured. For Drupal AI compatibility. */ public function complete(string $prompt, string $model_id, array $options = []): array { $config = $this->getConfig(); - - // Convert text-to-text to a chat message format + + // Convert text-to-text to a chat message format. $data = [ 'messages' => [ [ @@ -303,7 +311,7 @@ public function complete(string $prompt, string $model_id, array $options = []): ?? $config->get('model.max_tokens') ?? self::DEFAULT_MAX_TOKENS, ]; - + return $this->post('chat', $data, $this->getRequestOptions($options)); } @@ -329,7 +337,7 @@ protected function getRequestOptions(array $options): array { /** * Embeddings request. - * + * * Note: Embeddings may not be available through the dashboard API yet. * This is a placeholder for future implementation. */ @@ -338,15 +346,15 @@ public function embeddings(string $text, string $model_id, array $options = []): 'input' => $text, 'modelId' => $model_id, ]; - - // Optional: dimensions and normalize if API supports them + + // Optional: dimensions and normalize if API supports them. if (isset($options['dimensions'])) { $data['dimensions'] = $options['dimensions']; } if (isset($options['normalize'])) { $data['normalize'] = $options['normalize']; } - + return $this->post('embeddings', $data); } @@ -367,18 +375,18 @@ public function embeddings(string $text, string $model_id, array $options = []): public function get(string $path, array $query_params = []): array { $config = $this->getConfig(); $url = $this->buildApiUrl($path); - - // Build URL with query parameters + + // Build URL with query parameters. if (!empty($query_params)) { $query = http_build_query($query_params); $url .= '?' . $query; } - + $options = [ 'headers' => $this->getHeaders(), 'timeout' => $config->get('advanced.timeout') ?? self::DEFAULT_TIMEOUT, ]; - + try { if ($config->get('advanced.enable_logging')) { $this->logger->info('Quant Dashboard AI request: @method @url', [ @@ -386,32 +394,39 @@ public function get(string $path, array $query_params = []): array { '@url' => $url, ]); } - + $response = $this->httpClient->get($url, $options); $body = $response->getBody()->getContents(); $result = json_decode($body, TRUE); - + if ($config->get('advanced.enable_logging')) { $this->logger->info('Quant Dashboard AI response: @status', [ '@status' => $response->getStatusCode(), ]); } - + return $result; - + } catch (GuzzleException $e) { - $this->logger->error('Quant Dashboard AI request failed: @message', [ - '@message' => $e->getMessage(), + $status = NULL; + $reason = NULL; + if ($e instanceof RequestException && $e->getResponse()) { + $status = $e->getResponse()->getStatusCode(); + $reason = $e->getResponse()->getReasonPhrase(); + } + $this->logger->error('Quant Dashboard AI request failed (status: @status @reason)', [ + '@status' => $status ?? 'n/a', + '@reason' => $reason ?? 'transport error', ]); - throw new \RuntimeException('AI API request failed: ' . $e->getMessage(), 0, $e); + throw new \RuntimeException('AI API request failed (status: ' . ($status ?? 'n/a') . ')', 0, $e); } } /** * Get available models. - * - * Dashboard API route: GET /api/v3/organisations/{orgId}/ai/models + * + * Dashboard API route: GET /api/v3/organisations/{orgId}/ai/models. */ public function getModels(array $filters = []): array { return $this->get('models', $filters); @@ -419,7 +434,7 @@ public function getModels(array $filters = []): array { /** * Get model details. - * + * * Dashboard API route: GET /api/v3/organisations/{orgId}/ai/models/{modelId} */ public function getModelDetails(string $model_id): array { @@ -427,4 +442,3 @@ public function getModelDetails(string $model_id): array { } } - diff --git a/src/Client/QuantCloudStreamingClient.php b/src/Client/QuantCloudStreamingClient.php index 3395a4f..8b71531 100644 --- a/src/Client/QuantCloudStreamingClient.php +++ b/src/Client/QuantCloudStreamingClient.php @@ -2,6 +2,8 @@ namespace Drupal\ai_provider_quant_cloud\Client; +use Psr\Http\Message\StreamInterface; + /** * Streaming HTTP client for Quant Cloud AI API (Server-Sent Events). * @@ -16,23 +18,23 @@ class QuantCloudStreamingClient extends QuantCloudClient { /** * Chat with streaming response (SSE) - returns raw stream. - * - * Dashboard API route: POST /api/v3/organisations/{orgId}/ai/chat/stream + * + * Dashboard API route: POST /api/v3/organisations/{orgId}/ai/chat/stream. * * @param array $messages * Chat messages. * @param string $model_id * Model ID. * @param array $options - * Additional options (responseFormat, toolConfig, systemPrompt, etc.). + * Additional options (response_format, toolConfig, systemPrompt, etc.). * * @return \Psr\Http\Message\StreamInterface * The raw HTTP response stream for iteration. */ - public function chatStreamRaw(array $messages, string $model_id, array $options = []): \Psr\Http\Message\StreamInterface { + public function chatStreamRaw(array $messages, string $model_id, array $options = []): StreamInterface { $config = $this->getConfig(); $url = $this->buildApiUrl('chat/stream'); - + $data = [ 'messages' => $messages, 'modelId' => $model_id, @@ -43,25 +45,26 @@ public function chatStreamRaw(array $messages, string $model_id, array $options ?? $config->get('model.max_tokens') ?? self::DEFAULT_MAX_TOKENS, ]; - - // Add structured output (JSON Schema) if provided - if (isset($options['responseFormat'])) { - $data['responseFormat'] = $options['responseFormat']; + + // Add structured output (JSON Schema) if provided. + if (isset($options['response_format'])) { + $data['response_format'] = $options['response_format']; } - - // Add function calling (tools) if provided + + // Add function calling (tools) if provided. if (isset($options['toolConfig'])) { $data['toolConfig'] = $options['toolConfig']; } - - // Add system prompt if provided + + // Add system prompt if provided. if (isset($options['systemPrompt'])) { $data['systemPrompt'] = $options['systemPrompt']; } - + $request_options = [ 'headers' => array_merge($this->getHeaders(), [ - 'Accept' => 'text/event-stream', // SSE + // SSE. + 'Accept' => 'text/event-stream', ]), 'json' => $data, 'stream' => TRUE, @@ -70,13 +73,13 @@ public function chatStreamRaw(array $messages, string $model_id, array $options 'connect_timeout' => $config->get('advanced.connect_timeout') ?? self::DEFAULT_CONNECT_TIMEOUT, ]; - + try { $response = $this->httpClient->post($url, $request_options); - - // Return the raw stream for the iterator to consume + + // Return the raw stream for the iterator to consume. return $response->getBody(); - + } catch (\Exception $e) { $this->logger->error('Streaming request failed: @message', [ @@ -88,8 +91,8 @@ public function chatStreamRaw(array $messages, string $model_id, array $options /** * Chat with streaming response (SSE) - legacy buffered version. - * - * Dashboard API route: POST /api/v3/organisations/{orgId}/ai/chat/stream + * + * Dashboard API route: POST /api/v3/organisations/{orgId}/ai/chat/stream. * * @param array $messages * Chat messages. @@ -98,7 +101,7 @@ public function chatStreamRaw(array $messages, string $model_id, array $options * @param callable $callback * Callback function to handle each chunk. * @param array $options - * Additional options (responseFormat, toolConfig, systemPrompt, etc.). + * Additional options (response_format, toolConfig, systemPrompt, etc.). * * @return array * Final response data. @@ -108,7 +111,7 @@ public function chatStreamRaw(array $messages, string $model_id, array $options public function chatStream(array $messages, string $model_id, callable $callback, array $options = []): array { $config = $this->getConfig(); $url = $this->buildApiUrl('chat/stream'); - + $data = [ 'messages' => $messages, 'modelId' => $model_id, @@ -119,25 +122,26 @@ public function chatStream(array $messages, string $model_id, callable $callback ?? $config->get('model.max_tokens') ?? self::DEFAULT_MAX_TOKENS, ]; - - // Add structured output (JSON Schema) if provided - if (isset($options['responseFormat'])) { - $data['responseFormat'] = $options['responseFormat']; + + // Add structured output (JSON Schema) if provided. + if (isset($options['response_format'])) { + $data['response_format'] = $options['response_format']; } - - // Add function calling (tools) if provided + + // Add function calling (tools) if provided. if (isset($options['toolConfig'])) { $data['toolConfig'] = $options['toolConfig']; } - - // Add system prompt if provided + + // Add system prompt if provided. if (isset($options['systemPrompt'])) { $data['systemPrompt'] = $options['systemPrompt']; } - + $request_options = [ 'headers' => array_merge($this->getHeaders(), [ - 'Accept' => 'text/event-stream', // SSE + // SSE. + 'Accept' => 'text/event-stream', ]), 'json' => $data, 'stream' => TRUE, @@ -146,23 +150,23 @@ public function chatStream(array $messages, string $model_id, callable $callback 'connect_timeout' => $config->get('advanced.connect_timeout') ?? self::DEFAULT_CONNECT_TIMEOUT, ]; - + try { $response = $this->httpClient->post($url, $request_options); $body = $response->getBody(); - + $full_content = ''; $final_data = NULL; $decode_warnings = 0; - - // Read SSE stream + + // Read SSE stream. while (!$body->eof()) { $line = $this->readLine($body); - - // Parse SSE format + + // Parse SSE format. if (strpos($line, 'data: ') === 0) { $json_data = json_decode(substr($line, 6), TRUE); - + if (json_last_error() !== JSON_ERROR_NONE) { $decode_warnings++; if ($decode_warnings <= self::MAX_SSE_DECODE_WARNINGS) { @@ -177,24 +181,24 @@ public function chatStream(array $messages, string $model_id, callable $callback } continue; } - + if (isset($json_data['delta'])) { $full_content .= $json_data['delta']; $callback($json_data['delta'], FALSE); } - + if ($json_data['complete'] ?? FALSE) { $final_data = $json_data; break; } } } - + return $final_data ?? [ 'response' => ['role' => 'assistant', 'content' => $full_content], 'complete' => TRUE, ]; - + } catch (\Exception $e) { $this->logger->error('Streaming request failed: @message', [ @@ -221,22 +225,21 @@ protected function readLine($stream): string { /** * Completion with streaming (SSE). - * + * * Note: Uses chat/stream endpoint as Dashboard API doesn't have a separate * completion endpoint. Converts prompt to chat message format. */ public function completeStream(string $prompt, string $model_id, callable $callback, array $options = []): array { - // Convert text-to-text to chat message format + // Convert text-to-text to chat message format. $messages = [ [ 'role' => 'user', 'content' => $prompt, ], ]; - - // Use chatStream for completion + + // Use chatStream for completion. return $this->chatStream($messages, $model_id, $callback, $options); } } - diff --git a/src/Client/QuantCloudVectorDbClient.php b/src/Client/QuantCloudVectorDbClient.php index f79cd87..f8f2831 100644 --- a/src/Client/QuantCloudVectorDbClient.php +++ b/src/Client/QuantCloudVectorDbClient.php @@ -154,7 +154,7 @@ protected function getOrganizationId(): string { * Build full API endpoint URL for VectorDB API. * * @param string $path - * API path relative to /api/v3/organisations/{orgId}/ai/vector-db/ + * API path relative to /api/v3/organisations/{orgId}/ai/vector-db/. * * @return string * The full API URL. @@ -173,7 +173,7 @@ protected function buildApiUrl(string $path): string { * Make a POST request to the VectorDB API. * * @param string $path - * API path relative to /vector-db/ + * API path relative to /vector-db/. * @param array $data * Request body data. * @param array $request_options @@ -228,7 +228,7 @@ protected function post(string $path, array $data, array $request_options = []): * Make a GET request to the VectorDB API. * * @param string $path - * API path relative to /vector-db/ + * API path relative to /vector-db/. * @param array $query_params * Query parameters. * @@ -283,7 +283,7 @@ protected function get(string $path, array $query_params = []): array { * Make a DELETE request to the VectorDB API. * * @param string $path - * API path relative to /vector-db/ + * API path relative to /vector-db/. * * @return array * Response data. @@ -474,7 +474,7 @@ public function queryByVector(string $collection_id, array $vector, int $limit = * Supports three deletion modes: * 1. purgeAll - Delete ALL documents in the collection * 2. documentIds - Delete specific documents by UUID - * 3. metadata - Delete by metadata field match + * 3. metadata - Delete by metadata field match. * * @param string $collection_id * The collection UUID. diff --git a/src/Controller/OAuthController.php b/src/Controller/OAuthController.php index 686e879..d6dc3eb 100644 --- a/src/Controller/OAuthController.php +++ b/src/Controller/OAuthController.php @@ -44,7 +44,7 @@ public static function create(ContainerInterface $container) { */ public function __construct( AuthService $auth_service, - KeyRepositoryInterface $key_repository + KeyRepositoryInterface $key_repository, ) { $this->authService = $auth_service; $this->keyRepository = $key_repository; @@ -149,4 +149,3 @@ public function disconnect(Request $request) { } } - diff --git a/src/Plugin/AiProvider/QuantCloudProvider.php b/src/Plugin/AiProvider/QuantCloudProvider.php index abaff69..83aca4f 100644 --- a/src/Plugin/AiProvider/QuantCloudProvider.php +++ b/src/Plugin/AiProvider/QuantCloudProvider.php @@ -2,6 +2,7 @@ namespace Drupal\ai_provider_quant_cloud\Plugin\AiProvider; +use Drupal\ai\Enum\AiModelCapability; use Drupal\ai\Attribute\AiProvider; use Drupal\ai\Base\AiProviderClientBase; use Drupal\Component\Serialization\Json; @@ -20,6 +21,7 @@ use Drupal\ai\OperationType\ImageToImage\ImageToImageInterface; use Drupal\ai\OperationType\ImageToImage\ImageToImageOutput; use Drupal\ai\OperationType\GenericType\ImageFile; +use Drupal\ai\Traits\OperationType\ChatTrait; use Drupal\ai_provider_quant_cloud\Client\QuantCloudClient; use Drupal\ai_provider_quant_cloud\Client\QuantCloudStreamingClient; use Drupal\ai_provider_quant_cloud\QuantCloudChatMessageIterator; @@ -36,13 +38,15 @@ id: 'quant_cloud', label: new TranslatableMarkup('Quant Cloud AI'), )] -class QuantCloudProvider extends AiProviderClientBase implements +class QuantCloudProvider extends AiProviderClientBase implements ContainerFactoryPluginInterface, ChatInterface, EmbeddingsInterface, TextToImageInterface, ImageToImageInterface { + use ChatTrait; + /** * The Quant Cloud client. * @@ -104,24 +108,24 @@ public function getApiDefinition(): array { /** * {@inheritdoc} */ - public function isUsable(?string $operation_type = NULL, $capabilities = []): bool { + public function isUsable(?string $operation_type = NULL, array $capabilities = []): bool { $config = $this->getConfig(); - - // Check basic configuration: token and organization ID are required + + // Check basic configuration: token and organization ID are required. if (!$config->get('auth.access_token_key') || !$config->get('auth.organization_id')) { return FALSE; } - - // Check platform is configured + + // Check platform is configured. if (!$config->get('platform')) { return FALSE; } - - // If operation type is specified, check if we support it + + // If operation type is specified, check if we support it. if ($operation_type) { return in_array($operation_type, $this->getSupportedOperationTypes()); } - + return TRUE; } @@ -140,11 +144,11 @@ public function getSupportedOperationTypes(): array { /** * {@inheritdoc} */ - public function getConfiguredModels(?string $operation_type = NULL, $capabilities = []): array { - // Fetch models dynamically from the API via ModelsService + public function getConfiguredModels(?string $operation_type = NULL, array $capabilities = []): array { + // Fetch models dynamically from the API via ModelsService. try { // Get models for the operation type (defaults to 'chat' if not specified) - // Map Drupal operation types to API features + // Map Drupal operation types to API features. $feature_map = [ 'chat' => 'chat', 'embeddings' => 'embeddings', @@ -153,50 +157,50 @@ public function getConfiguredModels(?string $operation_type = NULL, $capabilitie ]; $feature = $feature_map[$operation_type] ?? ($operation_type ?: 'chat'); $api_models = $this->modelsService->getModels($feature); - + $models = []; foreach ($api_models as $model) { $model_id = $model['id'] ?? NULL; if (!$model_id) { continue; } - - // Filter by capabilities if specified + + // Filter by capabilities if specified. if (!empty($capabilities)) { $model_capabilities = $model['capabilities'] ?? []; - - // Check if model supports all required capabilities + + // Check if model supports all required capabilities. $supports_all = TRUE; foreach ($capabilities as $capability) { - // Convert enum to string value if needed - $capability_string = $capability instanceof \Drupal\ai\Enum\AiModelCapability ? $capability->value : (string)$capability; - - // Map Drupal capability names to our API capability flags + // Convert enum to string value if needed. + $capability_string = $capability instanceof AiModelCapability ? $capability->value : (string) $capability; + + // Map Drupal capability names to our API capability flags. $capability_flag = $this->mapCapabilityFlag($capability_string); - + if ($capability_flag && empty($model_capabilities[$capability_flag])) { $supports_all = FALSE; break; } } - - // Skip this model if it doesn't support required capabilities + + // Skip this model if it doesn't support required capabilities. if (!$supports_all) { continue; } } - - // Drupal expects simple string labels for form dropdowns + + // Drupal expects simple string labels for form dropdowns. $model_name = $model['name'] ?? $model_id; $models[$model_id] = $model_name; } - + return $models; - + } catch (\Exception $e) { // Return empty array if API is not configured or fails - // The provider won't be usable until configuration is complete + // The provider won't be usable until configuration is complete. return []; } } @@ -212,20 +216,20 @@ public function getConfiguredModels(?string $operation_type = NULL, $capabilitie */ protected function mapCapabilityFlag(string $capability): ?string { $mapping = [ - // Function calling / Tools + // Function calling / Tools. 'chat_tools' => 'supportsTools', 'chat_combined_tools_and_structured_response' => 'supportsTools', - - // Structured output / JSON + + // Structured output / JSON. 'chat_json_output' => 'supportsStructuredOutput', 'chat_structured_response' => 'supportsStructuredOutput', - - // Vision / Multimodal + + // Vision / Multimodal. 'chat_with_image_vision' => 'supportsVision', 'chat_with_video' => 'supportsMultimodal', 'chat_with_audio' => 'supportsMultimodal', ]; - + return $mapping[$capability] ?? NULL; } @@ -240,15 +244,15 @@ protected function mapCapabilityFlag(string $capability): ?string { */ protected function mapFeaturesToOperations(array $features): array { $operations = []; - + if (in_array('chat', $features)) { $operations[] = 'chat'; } - + if (in_array('embeddings', $features)) { $operations[] = 'embeddings'; } - + return $operations; } @@ -256,7 +260,7 @@ protected function mapFeaturesToOperations(array $features): array { * {@inheritdoc} */ public function chat(ChatInput|array|string $input, string $model_id, array $tags = []): ChatOutput { - // Normalize input to ChatInput + // Normalize input to ChatInput. if (is_string($input)) { $input = new ChatInput([new ChatMessage('user', $input)]); } @@ -269,30 +273,32 @@ public function chat(ChatInput|array|string $input, string $model_id, array $tag } $input = new ChatInput($messages); } - + // Format messages (supports multimodal content) $messages = $this->formatMessages($input->getMessages()); - - // Build request options + + // Build request options. $options = []; - + // Check for structured output (JSON Schema) if ($input->getChatStructuredJsonSchema()) { $schema = $input->getChatStructuredJsonSchema(); - $options['responseFormat'] = [ - 'type' => 'json', - 'jsonSchema' => $schema, + $options['response_format'] = [ + 'type' => 'json_schema', + 'schema' => $schema['schema'], + 'name' => $schema['name'] ?? 'json_schema', + 'strict' => $schema['strict'] ?? FALSE, ]; } - - // Check for tools/function calling + + // Check for tools/function calling. if ($input->getChatTools()) { $tools_input = $input->getChatTools(); $options['toolConfig'] = [ 'tools' => $this->formatToolsForApi($tools_input), ]; } - + // Check for system prompt - use chatSystemRole from base class (like OpenAI provider) // This is set via setChatSystemRole() by agents before calling chat() if ($this->chatSystemRole) { @@ -320,24 +326,24 @@ public function chat(ChatInput|array|string $input, string $model_id, array $tag $use_streaming = $this->streamed ?? FALSE; if ($use_streaming) { - // Streaming via SSE - return iterator for real-time streaming + // Streaming via SSE - return iterator for real-time streaming. $stream = $this->streamingClient->chatStreamRaw($messages, $model_id, $options); // Create streaming iterator (like AWS Bedrock provider does) $message = QuantCloudChatMessageIterator::create($stream, $this->logger); // Return ChatOutput with the iterator as the message - // The iterator will be consumed by AI Explorer for real-time display + // The iterator will be consumed by AI Explorer for real-time display. return new ChatOutput($message, [], NULL); } else { - // Buffered (default) - best for forms and batch processing + // Buffered (default) - best for forms and batch processing. $response_data = $this->client->chat($messages, $model_id, $options); - + // Handle Lambda response format: - // { "response": { "content": "...", "role": "assistant", "toolUse": {...} }, "usage": {...} } + // { "response": { "content": "...", "role": "assistant", "toolUse": {...} }, "usage": {...} }. if (isset($response_data['response'])) { - // Standard nested format + // Standard nested format. $message_data = $response_data['response']; $content = $message_data['content'] ?? ''; $role = $message_data['role'] ?? 'assistant'; @@ -361,16 +367,16 @@ public function chat(ChatInput|array|string $input, string $model_id, array $tag $tool_use_data ); - // Create ChatMessage for the response + // Create ChatMessage for the response. $message = new ChatMessage($role, $content); // Check if response includes tool use - // toolUse can be a single object or an array of tool requests + // toolUse can be a single object or an array of tool requests. if ($tool_use_data) { - // Normalize to array - handle both single tool and array of tools + // Normalize to array - handle both single tool and array of tools. $tool_use_array = []; if (isset($tool_use_data['name'])) { - // Single tool object + // Single tool object. $tool_use_array = [$tool_use_data]; } elseif (is_array($tool_use_data) && !empty($tool_use_data)) { @@ -399,10 +405,10 @@ public function chat(ChatInput|array|string $input, string $model_id, array $tag $message->setTools($tools); } } - + return new ChatOutput($message, $response_data, NULL); } - + } catch (\Exception $e) { throw new \RuntimeException('Chat request failed: ' . $e->getMessage(), 0, $e); @@ -608,26 +614,26 @@ protected function getStopReason(array $response_data): ?string { * {@inheritdoc} */ public function embeddings(string|EmbeddingsInput $input, string $model_id, array $tags = []): EmbeddingsOutput { - // Normalize input - extract text from EmbeddingsInput + // Normalize input - extract text from EmbeddingsInput. if ($input instanceof EmbeddingsInput) { $text = $input->getPrompt(); } else { $text = $input; } - + try { - // Call API with single text string + // Call API with single text string. $result = $this->client->embeddings($text, $model_id); - + // Extract embedding vector from response - // API returns: { "embeddings": [...], "model": "...", "usage": {...} } + // API returns: { "embeddings": [...], "model": "...", "usage": {...} }. $embedding = $result['embeddings'] ?? []; - + // EmbeddingsOutput expects array of embeddings (even for single input) - // Our API returns the vector directly, so wrap it + // Our API returns the vector directly, so wrap it. return new EmbeddingsOutput([$embedding], $result, []); - + } catch (\Exception $e) { throw new \RuntimeException('Embeddings request failed: ' . $e->getMessage(), 0, $e); @@ -638,16 +644,16 @@ public function embeddings(string|EmbeddingsInput $input, string $model_id, arra * {@inheritdoc} */ public function textToImage(string|TextToImageInput $input, string $model_id, array $tags = []): TextToImageOutput { - // Normalize input and extract images if provided + // Normalize input and extract images if provided. $prompt = ''; $source_images = []; - + if ($input instanceof TextToImageInput) { $prompt = $input->getText(); // Check if input has images (for image-to-image operations) if (method_exists($input, 'getImages') && !empty($input->getImages())) { foreach ($input->getImages() as $image) { - // Convert Drupal ImageFile to base64 + // Convert Drupal ImageFile to base64. $source_images[] = base64_encode($image->getBinary()); } } @@ -657,33 +663,33 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar } try { - // Determine task type based on configuration and presence of source images + // Determine task type based on configuration and presence of source images. $task_type = $this->configuration['task_type'] ?? 'TEXT_IMAGE'; - - // Auto-detect: if images provided but task type is TEXT_IMAGE, switch to IMAGE_VARIATION + + // Auto-detect: if images provided but task type is TEXT_IMAGE, switch to IMAGE_VARIATION. if (!empty($source_images) && $task_type === 'TEXT_IMAGE') { $task_type = 'IMAGE_VARIATION'; } - // Build image generation request for Nova Canvas + // Build image generation request for Nova Canvas. $payload = [ 'modelId' => $model_id, 'taskType' => $task_type, 'imageGenerationConfig' => [], ]; - // Build task-specific parameters + // Build task-specific parameters. switch ($task_type) { case 'TEXT_IMAGE': $payload['textToImageParams'] = [ 'text' => $prompt, ]; - + // Add style if specified (Nova Canvas visual styles) if (!empty($this->configuration['style'])) { $payload['textToImageParams']['style'] = $this->configuration['style']; } - + // Add negative prompt if specified (what NOT to include) if (!empty($this->configuration['negativePrompt'])) { $payload['textToImageParams']['negativeText'] = $this->configuration['negativePrompt']; @@ -694,12 +700,12 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar if (empty($source_images)) { throw new \InvalidArgumentException('IMAGE_VARIATION requires source image(s)'); } - + $payload['imageVariationParams'] = [ 'images' => $source_images, 'text' => $prompt ?: 'Generate a variation of this image', ]; - + // Similarity strength (0.2-1.0, higher = more similar to original) if (isset($this->configuration['similarity_strength'])) { $payload['imageVariationParams']['similarityStrength'] = (float) $this->configuration['similarity_strength']; @@ -710,13 +716,13 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar if (empty($source_images)) { throw new \InvalidArgumentException('INPAINTING requires source image and mask'); } - + $payload['inPaintingParams'] = [ 'image' => $source_images[0], 'text' => $prompt ?: 'Fill the masked region', ]; - - // Mask image is typically the second image + + // Mask image is typically the second image. if (isset($source_images[1])) { $payload['inPaintingParams']['maskImage'] = $source_images[1]; } @@ -726,13 +732,13 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar if (empty($source_images)) { throw new \InvalidArgumentException('OUTPAINTING requires source image'); } - + $payload['outPaintingParams'] = [ 'image' => $source_images[0], 'text' => $prompt ?: 'Extend the image borders', ]; - - // Optional mask prompt for directional expansion + + // Optional mask prompt for directional expansion. if (isset($this->configuration['mask_prompt'])) { $payload['outPaintingParams']['maskPrompt'] = $this->configuration['mask_prompt']; } @@ -742,7 +748,7 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar if (empty($source_images)) { throw new \InvalidArgumentException('BACKGROUND_REMOVAL requires source image'); } - + $payload['backgroundRemovalParams'] = [ 'image' => $source_images[0], ]; @@ -752,13 +758,13 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar throw new \InvalidArgumentException("Unsupported task type: {$task_type}"); } - // Add optional configuration from provider settings + // Add optional configuration from provider settings. if (isset($this->configuration['width']) && isset($this->configuration['height'])) { $payload['imageGenerationConfig']['width'] = (int) $this->configuration['width']; $payload['imageGenerationConfig']['height'] = (int) $this->configuration['height']; } elseif (isset($this->configuration['resolution'])) { - // Handle 'custom' resolution option + // Handle 'custom' resolution option. if ($this->configuration['resolution'] === 'custom') { if (isset($this->configuration['custom_width']) && isset($this->configuration['custom_height'])) { $payload['imageGenerationConfig']['width'] = (int) $this->configuration['custom_width']; @@ -766,7 +772,7 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar } } else { - // Support resolution format like "1024x1024" + // Support resolution format like "1024x1024". $parts = explode('x', $this->configuration['resolution']); if (count($parts) === 2) { $payload['imageGenerationConfig']['width'] = (int) $parts[0]; @@ -787,19 +793,20 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar $payload['imageGenerationConfig']['cfgScale'] = (float) $this->configuration['cfgScale']; } - if (isset($this->configuration['seed']) && $this->configuration['seed'] !== null) { + if (isset($this->configuration['seed']) && $this->configuration['seed'] !== NULL) { $payload['imageGenerationConfig']['seed'] = (int) $this->configuration['seed']; } // Nova Canvas requires specific regions (us-east-1, ap-northeast-1, eu-west-1) - // Default to us-east-1 if not specified + // Default to us-east-1 if not specified. if (isset($this->configuration['nova_canvas_region'])) { $payload['region'] = $this->configuration['nova_canvas_region']; } // Call image generation API with extended timeout (image generation can take 10-30s) $response = $this->client->post('image-generation', $payload, [ - 'timeout' => 60, // 60s timeout for image generation (handles premium + multiple images) + // 60s timeout for image generation (handles premium + multiple images) + 'timeout' => 60, 'connect_timeout' => 10, ]); @@ -808,31 +815,31 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar } // API returns compressed thumbnail data URLs (data:image/jpeg;base64,...) - // Extract actual base64 data for Drupal + // Extract actual base64 data for Drupal. $images = []; foreach ($response['images'] as $index => $data_url) { - // Check if it's a data URL or raw base64 + // Check if it's a data URL or raw base64. if (str_starts_with($data_url, 'data:image/')) { - // Extract base64 from data URL: data:image/jpeg;base64, + // Extract base64 from data URL: data:image/jpeg;base64,. $parts = explode(',', $data_url, 2); $base64_data = $parts[1] ?? $data_url; } else { - // Already raw base64 + // Already raw base64. $base64_data = $data_url; } - + $image_data = base64_decode($base64_data); - + // Determine format from data URL MIME type or default to JPEG (thumbnails are JPEG) $format = 'jpeg'; if (str_contains($data_url, 'image/png')) { $format = 'png'; } - + $images[] = new ImageFile( - $image_data, - "image/{$format}", + $image_data, + "image/{$format}", "generated-{$index}.{$format}" ); } @@ -848,10 +855,10 @@ public function textToImage(string|TextToImageInput $input, string $model_id, ar * {@inheritdoc} */ public function imageToImage(ImageToImageInput|array|string $input, string $model_id, array $tags = []): ImageToImageOutput { - // Normalize input and extract images + // Normalize input and extract images. $prompt = ''; $source_images = []; - + if ($input instanceof ImageToImageInput) { // ImageToImageInput might use getPrompt() instead of getText() $prompt = ''; @@ -861,13 +868,13 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode elseif (method_exists($input, 'getText')) { $prompt = $input->getText() ?? ''; } - + // Get the source image using getImageFile() if (method_exists($input, 'getImageFile') && $input->getImageFile()) { $image = $input->getImageFile(); $source_images[] = base64_encode($image->getBinary()); } - + // Get the mask image if present (for INPAINTING) $mask_image = NULL; if (method_exists($input, 'getMask') && $input->getMask()) { @@ -878,22 +885,22 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode elseif (is_array($input)) { // Handle array input format (from forms/AJAX) $prompt = $input['prompt'] ?? $input['text'] ?? ''; - - // Extract images from array + + // Extract images from array. if (isset($input['images']) && is_array($input['images'])) { foreach ($input['images'] as $image) { if ($image instanceof ImageFile) { $source_images[] = base64_encode($image->getBinary()); } elseif (is_string($image)) { - // Already base64 encoded + // Already base64 encoded. $source_images[] = $image; } } } } else { - // String input is just the prompt + // String input is just the prompt. $prompt = $input; } @@ -905,21 +912,21 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode // Determine task type from configuration (default to IMAGE_VARIATION) $task_type = $this->configuration['task_type'] ?? 'IMAGE_VARIATION'; - // Build image generation request for Nova Canvas + // Build image generation request for Nova Canvas. $payload = [ 'modelId' => $model_id, 'taskType' => $task_type, 'imageGenerationConfig' => [], ]; - // Build task-specific parameters + // Build task-specific parameters. switch ($task_type) { case 'IMAGE_VARIATION': $payload['imageVariationParams'] = [ 'images' => $source_images, 'text' => $prompt ?: 'Generate a variation of this image', ]; - + // Similarity strength (0.2-1.0, higher = more similar to original) if (isset($this->configuration['similarity_strength'])) { $payload['imageVariationParams']['similarityStrength'] = (float) $this->configuration['similarity_strength']; @@ -931,8 +938,8 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode 'image' => $source_images[0], 'text' => $prompt ?: 'Fill the masked region', ]; - - // Use mask from input if available, otherwise try second image + + // Use mask from input if available, otherwise try second image. if (isset($mask_image)) { $payload['inPaintingParams']['maskImage'] = $mask_image; } @@ -946,8 +953,8 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode 'image' => $source_images[0], 'text' => $prompt ?: 'Extend the image borders', ]; - - // Optional mask prompt for directional expansion + + // Optional mask prompt for directional expansion. if (isset($this->configuration['mask_prompt'])) { $payload['outPaintingParams']['maskPrompt'] = $this->configuration['mask_prompt']; } @@ -963,13 +970,13 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode throw new \InvalidArgumentException("Unsupported task type for image-to-image: {$task_type}"); } - // Add optional configuration from provider settings + // Add optional configuration from provider settings. if (isset($this->configuration['width']) && isset($this->configuration['height'])) { $payload['imageGenerationConfig']['width'] = (int) $this->configuration['width']; $payload['imageGenerationConfig']['height'] = (int) $this->configuration['height']; } elseif (isset($this->configuration['resolution'])) { - // Handle 'custom' resolution option + // Handle 'custom' resolution option. if ($this->configuration['resolution'] === 'custom') { if (isset($this->configuration['custom_width']) && isset($this->configuration['custom_height'])) { $payload['imageGenerationConfig']['width'] = (int) $this->configuration['custom_width']; @@ -977,7 +984,7 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode } } else { - // Support resolution format like "1024x1024" + // Support resolution format like "1024x1024". $parts = explode('x', $this->configuration['resolution']); if (count($parts) === 2) { $payload['imageGenerationConfig']['width'] = (int) $parts[0]; @@ -998,7 +1005,7 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode $payload['imageGenerationConfig']['cfgScale'] = (float) $this->configuration['cfgScale']; } - if (isset($this->configuration['seed']) && $this->configuration['seed'] !== null) { + if (isset($this->configuration['seed']) && $this->configuration['seed'] !== NULL) { $payload['imageGenerationConfig']['seed'] = (int) $this->configuration['seed']; } @@ -1007,9 +1014,10 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode $payload['region'] = $this->configuration['nova_canvas_region']; } - // Call image generation API with extended timeout + // Call image generation API with extended timeout. $response = $this->client->post('image-generation', $payload, [ - 'timeout' => 60, // 60s timeout for image generation + // 60s timeout for image generation + 'timeout' => 60, 'connect_timeout' => 10, ]); @@ -1018,27 +1026,27 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode } // API returns compressed thumbnail data URLs (data:image/jpeg;base64,...) - // Extract actual base64 data for Drupal + // Extract actual base64 data for Drupal. $images = []; foreach ($response['images'] as $index => $data_url) { - // Check if it's a data URL or raw base64 + // Check if it's a data URL or raw base64. if (str_starts_with($data_url, 'data:image/')) { - // Extract base64 from data URL: data:image/jpeg;base64, + // Extract base64 from data URL: data:image/jpeg;base64,. $parts = explode(',', $data_url, 2); $base64_data = $parts[1] ?? $data_url; } else { $base64_data = $data_url; } - + $image_data = base64_decode($base64_data); - + // Determine format (JPEG for thumbnails, PNG for originals) $format = str_contains($data_url, 'image/png') ? 'png' : 'jpeg'; - + $images[] = new ImageFile( - $image_data, - "image/{$format}", + $image_data, + "image/{$format}", "variation-{$index}.{$format}" ); } @@ -1054,10 +1062,10 @@ public function imageToImage(ImageToImageInput|array|string $input, string $mode * {@inheritdoc} */ public function requiresImageToImageMask(string $model_id): bool { - // Get the configured task type + // Get the configured task type. $task_type = $this->configuration['task_type'] ?? 'IMAGE_VARIATION'; - - // INPAINTING requires a mask image + + // INPAINTING requires a mask image. return $task_type === 'INPAINTING'; } @@ -1073,9 +1081,9 @@ public function hasImageToImageMask(string $model_id): bool { * {@inheritdoc} */ public function requiresImageToImagePrompt(string $model_id): bool { - // Get the configured task type + // Get the configured task type. $task_type = $this->configuration['task_type'] ?? 'IMAGE_VARIATION'; - + // BACKGROUND_REMOVAL doesn't require a prompt, others do (or benefit from one) return $task_type !== 'BACKGROUND_REMOVAL'; } @@ -1084,7 +1092,7 @@ public function requiresImageToImagePrompt(string $model_id): bool { * {@inheritdoc} */ public function hasImageToImagePrompt(string $model_id): bool { - // Nova Canvas supports text prompts for all image-to-image operations + // Nova Canvas supports text prompts for all image-to-image operations. return TRUE; } @@ -1093,7 +1101,7 @@ public function hasImageToImagePrompt(string $model_id): bool { */ public function setAuthentication(mixed $authentication): void { // Store authentication for later use by the client - // The authentication is passed to the HTTP client via headers + // The authentication is passed to the HTTP client via headers. $this->configuration['authentication'] = $authentication; } @@ -1113,7 +1121,7 @@ public function setStreamed(bool $streamed): void { public function getModelSettings(string $model_id, array $generalConfig = []): array { // Return model-specific configuration // For now, return the general config as-is - // Future: fetch model-specific limits from the API + // Future: fetch model-specific limits from the API. return $generalConfig; } @@ -1122,7 +1130,7 @@ public function getModelSettings(string $model_id, array $generalConfig = []): a */ public function maxEmbeddingsInput(string $model_id = ''): int { // Maximum number of texts that can be embedded in a single request - // This is a reasonable default for most models + // This is a reasonable default for most models. return 96; } @@ -1146,7 +1154,7 @@ public function embeddingsVectorSize(string $model_id): int { * {@inheritdoc} */ public function getMaxInputTokens(string $model_id): int { - // Try to get from API first + // Try to get from API first. try { $model_details = $this->modelsService->getModelDetails($model_id); if ($model_details && isset($model_details['contextWindow'])) { @@ -1154,10 +1162,10 @@ public function getMaxInputTokens(string $model_id): int { } } catch (\Exception $e) { - // Fall through to defaults + // Fall through to defaults. } - // Fallback to hardcoded limits if API unavailable + // Fallback to hardcoded limits if API unavailable. $limits = [ 'amazon.nova-lite-v1:0' => 300000, 'amazon.nova-pro-v1:0' => 300000, @@ -1178,7 +1186,7 @@ public function getMaxInputTokens(string $model_id): int { * {@inheritdoc} */ public function getMaxOutputTokens(string $model_id): int { - // Try to get from API first + // Try to get from API first. try { $model_details = $this->modelsService->getModelDetails($model_id); if ($model_details && isset($model_details['maxOutputTokens'])) { @@ -1186,10 +1194,10 @@ public function getMaxOutputTokens(string $model_id): int { } } catch (\Exception $e) { - // Fall through to defaults + // Fall through to defaults. } - // Fallback to hardcoded limits if API unavailable + // Fallback to hardcoded limits if API unavailable. $limits = [ 'amazon.nova-lite-v1:0' => 5000, 'amazon.nova-pro-v1:0' => 5000, @@ -1228,19 +1236,19 @@ protected function formatMessages(array $messages): array { if ($role === 'assistant' && !empty($tools)) { $content_blocks = []; - // Add text content first if present + // Add text content first if present. if ($content) { $content_blocks[] = ['text' => $content]; } - // Add tool use blocks - use getRenderedTools() like AWS Bedrock provider + // Add tool use blocks - use getRenderedTools() like AWS Bedrock provider. $tool_uses = $message->getRenderedTools(); foreach ($tool_uses as $tool_use) { $content_blocks[] = [ 'toolUse' => [ 'toolUseId' => $tool_use['id'], 'name' => $tool_use['function']['name'], - // AWS wants the structured object, not the string + // AWS wants the structured object, not the string. 'input' => Json::decode($tool_use['function']['arguments']), ], ]; @@ -1255,7 +1263,7 @@ protected function formatMessages(array $messages): array { // Handle tool_result messages - convert to Bedrock format // Drupal AI uses role "tool" or has toolsId set, Bedrock expects role "user" with toolResult content block - // Match AWS Bedrock provider pattern: (role === 'tool' || getToolsId()) && role !== 'assistant' + // Match AWS Bedrock provider pattern: (role === 'tool' || getToolsId()) && role !== 'assistant'. if (($role === 'tool' || $role === 'tool_result' || $message->getToolsId()) && $role !== 'assistant') { $formatted[] = [ 'role' => 'user', @@ -1264,7 +1272,7 @@ protected function formatMessages(array $messages): array { 'toolResult' => [ 'toolUseId' => $message->getToolsId(), 'content' => [ - // Need to set text to tool result, if empty use placeholder + // Need to set text to tool result, if empty use placeholder. ['text' => $content !== '' ? $content : 'Tool Result'], ], ], @@ -1275,20 +1283,20 @@ protected function formatMessages(array $messages): array { } // Check if message has images (multimodal content) - // Drupal AI module uses getImages() returning ImageFile objects + // Drupal AI module uses getImages() returning ImageFile objects. $images = method_exists($message, 'getImages') ? $message->getImages() : []; $has_images = !empty($images); if ($has_images) { - // Build multimodal content array + // Build multimodal content array. $content_blocks = []; - // Add images first + // Add images first. foreach ($images as $image) { $content_blocks[] = $this->formatImageFile($image); } - // Add text prompt last + // Add text prompt last. if ($message->getText()) { $content_blocks[] = ['text' => $message->getText()]; } @@ -1299,7 +1307,7 @@ protected function formatMessages(array $messages): array { ]; } else { - // Simple text message + // Simple text message. $formatted[] = [ 'role' => $role, 'content' => $content, @@ -1324,8 +1332,9 @@ protected function formatImageFile($image): array { $mime_type = $image->getMimeType(); $binary = $image->getBinary(); - // Extract format from MIME type - $format = 'jpeg'; // default + // Extract format from MIME type. + // Default. + $format = 'jpeg'; if (str_contains($mime_type, 'png')) { $format = 'png'; } @@ -1336,7 +1345,7 @@ protected function formatImageFile($image): array { $format = 'webp'; } - // Return Bedrock/Claude image format + // Return Bedrock/Claude image format. return [ 'image' => [ 'format' => $format, @@ -1355,12 +1364,13 @@ protected function formatImageFile($image): array { * Formatted content block for the API. */ protected function formatAttachment($attachment): array { - // Determine attachment type + // Determine attachment type. $type = $attachment['type'] ?? 'image'; $mime_type = $attachment['mime_type'] ?? $attachment['mimeType'] ?? ''; - - // Extract format from MIME type - $format = 'jpeg'; // default + + // Extract format from MIME type. + // Default. + $format = 'jpeg'; if (str_contains($mime_type, 'png')) { $format = 'png'; } @@ -1382,16 +1392,16 @@ protected function formatAttachment($attachment): array { elseif (str_contains($mime_type, 'pdf')) { $format = 'pdf'; } - - // Handle different attachment sources + + // Handle different attachment sources. if (isset($attachment['uri'])) { - // S3 URI or file path + // S3 URI or file path. if (str_starts_with($attachment['uri'], 's3://')) { - // S3 URI - use directly + // S3 URI - use directly. return $this->formatS3Content($type, $format, $attachment['uri'], $attachment['name'] ?? NULL); } else { - // Local file - convert to base64 + // Local file - convert to base64. $file_contents = file_get_contents($attachment['uri']); if ($file_contents !== FALSE) { $base64 = base64_encode($file_contents); @@ -1400,12 +1410,12 @@ protected function formatAttachment($attachment): array { } } elseif (isset($attachment['data']) || isset($attachment['base64'])) { - // Base64 encoded data + // Base64 encoded data. $base64 = $attachment['base64'] ?? $attachment['data']; return $this->formatBase64Content($type, $format, $base64, $attachment['name'] ?? NULL); } - - // Fallback to empty text block + + // Fallback to empty text block. return ['text' => '']; } @@ -1438,7 +1448,7 @@ protected function formatBase64Content(string $type, string $format, string $bas ], ]; } - + return ['text' => '']; } @@ -1477,7 +1487,7 @@ protected function formatS3Content(string $type, string $format, string $uri, ?s ], ]; } - + return ['text' => '']; } @@ -1491,14 +1501,14 @@ protected function formatS3Content(string $type, string $format, string $uri, ?s * API-formatted tools. */ protected function formatToolsForApi($tools_input): array { - // Use renderToolsArray() like AWS Bedrock provider does + // Use renderToolsArray() like AWS Bedrock provider does. $tools = $tools_input->renderToolsArray(); $api_tools = []; - + foreach ($tools as $tool) { $tool_spec = $tool['function']; - - // Map 'parameters' to 'inputSchema' + + // Map 'parameters' to 'inputSchema'. if (isset($tool['function']['parameters'])) { $tool_spec['inputSchema']['json'] = $tool['function']['parameters']; } @@ -1507,17 +1517,16 @@ protected function formatToolsForApi($tools_input): array { 'type' => 'object', ]; } - - // Remove 'parameters' as we've moved it to inputSchema + + // Remove 'parameters' as we've moved it to inputSchema. unset($tool_spec['parameters']); - + $api_tools[] = [ 'toolSpec' => $tool_spec, ]; } - + return $api_tools; } } - diff --git a/src/QuantCloudChatMessageIterator.php b/src/QuantCloudChatMessageIterator.php index 246a290..e298580 100644 --- a/src/QuantCloudChatMessageIterator.php +++ b/src/QuantCloudChatMessageIterator.php @@ -38,16 +38,19 @@ class QuantCloudChatMessageIterator extends StreamedChatMessageIterator { * The iterator instance. */ public static function create(StreamInterface $stream, LoggerInterface $logger): static { - // Create wrapper that implements IteratorAggregate + // Create wrapper that implements IteratorAggregate. $wrapper = new class($stream, $logger) implements \IteratorAggregate { private StreamInterface $stream; private LoggerInterface $logger; - + public function __construct(StreamInterface $stream, LoggerInterface $logger) { $this->stream = $stream; $this->logger = $logger; } - + + /** + * + */ public function getIterator(): \Generator { while (!$this->stream->eof()) { $line = $this->readLine(); @@ -69,7 +72,7 @@ public function getIterator(): \Generator { ]; } - // Handle tool use events - yield tool data for the iterator consumer + // Handle tool use events - yield tool data for the iterator consumer. if (isset($json_data['toolUse'])) { yield [ 'delta' => '', @@ -81,7 +84,7 @@ public function getIterator(): \Generator { ]; } - // Also handle tool_request events from done event + // Also handle tool_request events from done event. if (isset($json_data['response']['toolUse'])) { yield [ 'delta' => '', @@ -99,18 +102,24 @@ public function getIterator(): \Generator { } } } - + + /** + * + */ private function readLine(): string { $line = ''; while (!$this->stream->eof()) { $char = $this->stream->read(1); - if ($char === "\n") break; + if ($char === "\n") { + break; + } $line .= $char; } return trim($line); } + }; - + $instance = new static($wrapper); $instance->stream = $stream; $instance->logger = $logger; @@ -131,4 +140,3 @@ public function getIterator(): \Generator { } } - diff --git a/src/Service/AuthService.php b/src/Service/AuthService.php index 210e48a..2f5169d 100644 --- a/src/Service/AuthService.php +++ b/src/Service/AuthService.php @@ -2,6 +2,9 @@ namespace Drupal\ai_provider_quant_cloud\Service; +use Drupal\Core\Lock\LockBackendInterface; +use Drupal\Core\State\StateInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\key\KeyRepositoryInterface; @@ -69,9 +72,9 @@ public function __construct( ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, KeyRepositoryInterface $key_repository, - \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager, - \Drupal\Core\State\StateInterface $state, - \Drupal\Core\Lock\LockBackendInterface $lock + EntityTypeManagerInterface $entity_type_manager, + StateInterface $state, + LockBackendInterface $lock, ) { $this->httpClient = $http_client; $this->configFactory = $config_factory; @@ -91,11 +94,11 @@ public function __construct( public function getAccessToken(): ?string { $config = $this->configFactory->get('ai_provider_quant_cloud.settings'); $key_id = $config->get('auth.access_token_key'); - + if (!$key_id) { return NULL; } - + $key = $this->keyRepository->getKey($key_id); return $key ? $key->getKeyValue() : NULL; } @@ -156,7 +159,8 @@ public function getOrganizations(): array { 'machine_name' => $org['machine_name'] ?? '', ], array_values($orgs)); - } catch (\Exception $e) { + } + catch (\Exception $e) { $this->logger->error('Error fetching user/orgs: @message', [ '@message' => $e->getMessage(), ]); @@ -263,9 +267,9 @@ public function exchangeCodeForToken(string $code, string $redirect_uri, string ]); if ($response->getStatusCode() !== 200) { - $this->logger->error('Token exchange failed with status @status: @body', [ + $this->logger->error('Token exchange failed with status @status: @reason', [ '@status' => $response->getStatusCode(), - '@body' => substr((string) $response->getBody(), 0, 500), + '@reason' => $response->getReasonPhrase(), ]); return NULL; } @@ -273,7 +277,8 @@ public function exchangeCodeForToken(string $code, string $redirect_uri, string $data = json_decode($response->getBody()->getContents(), TRUE); return isset($data['access_token']) ? $data : NULL; - } catch (\Exception $e) { + } + catch (\Exception $e) { $this->logger->error('Failed to exchange OAuth code: @message', [ '@message' => $e->getMessage(), ]); @@ -389,9 +394,9 @@ public function refreshToken(string $refresh_token): ?array { ]); if ($response->getStatusCode() !== 200) { - $this->logger->error('Refresh failed with status @status: @body', [ + $this->logger->error('Refresh failed with status @status: @reason', [ '@status' => $response->getStatusCode(), - '@body' => substr((string) $response->getBody(), 0, 500), + '@reason' => $response->getReasonPhrase(), ]); return NULL; } @@ -399,7 +404,8 @@ public function refreshToken(string $refresh_token): ?array { $data = json_decode($response->getBody()->getContents(), TRUE); return isset($data['access_token']) ? $data : NULL; - } catch (\Exception $e) { + } + catch (\Exception $e) { $this->logger->error('Failed to refresh token: @message', [ '@message' => $e->getMessage(), ]); @@ -486,13 +492,12 @@ protected function isAccessTokenExpiring(): bool { public function getTokenGenerationUrl(): string { $config = $this->configFactory->get('ai_provider_quant_cloud.settings'); $platform = $config->get('platform') ?: 'quantcdn'; - + $dashboard_url = $config->get("platforms.{$platform}.dashboard_url"); - + // Assume there's a tokens or API settings page - // Adjust this path based on your actual dashboard structure + // Adjust this path based on your actual dashboard structure. return $dashboard_url . '/account/api-tokens'; } } - diff --git a/src/Service/ModelsService.php b/src/Service/ModelsService.php index 349c121..29c2c52 100644 --- a/src/Service/ModelsService.php +++ b/src/Service/ModelsService.php @@ -43,7 +43,7 @@ class ModelsService { public function __construct( QuantCloudClient $client, CacheBackendInterface $cache, - LoggerChannelFactoryInterface $logger_factory + LoggerChannelFactoryInterface $logger_factory, ) { $this->client = $client; $this->cache = $cache; @@ -63,41 +63,41 @@ public function __construct( */ public function getModels(?string $feature = NULL, bool $bypass_cache = FALSE): array { $cache_key = 'ai_provider_quant_cloud:models:' . ($feature ?? 'all'); - - // Try cache first + + // Try cache first. if (!$bypass_cache) { $cached = $this->cache->get($cache_key); if ($cached && !empty($cached->data)) { return $cached->data; } } - + try { - // Fetch from API + // Fetch from API. $filters = []; if ($feature) { $filters['feature'] = $feature; } - + $response = $this->client->getModels($filters); $models = $response['models'] ?? []; - - // Cache the result + + // Cache the result. $this->cache->set( $cache_key, $models, time() + self::CACHE_LIFETIME ); - + return $models; - + } catch (\Exception $e) { $this->logger->error('Failed to fetch models from API: @message', [ '@message' => $e->getMessage(), ]); - - // Return fallback minimal list + + // Return fallback minimal list. return $this->getFallbackModels($feature); } } @@ -116,15 +116,15 @@ public function getModels(?string $feature = NULL, bool $bypass_cache = FALSE): public function getModelDetails(string $model_id, bool $bypass_cache = FALSE): ?array { // First, try to get from the cached models list (more efficient) $all_models = $this->getModels(NULL, $bypass_cache); - + foreach ($all_models as $model) { if (isset($model['id']) && $model['id'] === $model_id) { return $model; } } - + // If not found in list, return NULL - // The Dashboard API doesn't have a single-model endpoint yet + // The Dashboard API doesn't have a single-model endpoint yet. return NULL; } @@ -138,18 +138,18 @@ public function getModelDetails(string $model_id, bool $bypass_cache = FALSE): ? * Associative array of model_id => model_label (simple string). */ public function getModelsForOperation(string $operation_type): array { - // Map Drupal AI operation types to API features + // Map Drupal AI operation types to API features. $feature_map = [ 'chat' => 'chat', 'embeddings' => 'embeddings', 'text_to_image' => 'image_generation', 'image_to_image' => 'image_generation', ]; - + $feature = $feature_map[$operation_type] ?? NULL; $models = $this->getModels($feature); - - // Drupal expects simple string labels for form dropdowns + + // Drupal expects simple string labels for form dropdowns. $result = []; foreach ($models as $model) { $model_id = $model['id'] ?? NULL; @@ -158,7 +158,7 @@ public function getModelsForOperation(string $operation_type): array { $result[$model_id] = $model_name; } } - + return $result; } @@ -212,14 +212,14 @@ protected function getFallbackModels(?string $feature = NULL): array { 'supportedFeatures' => ['image_generation'], ], ]; - - // Filter by feature if specified + + // Filter by feature if specified. if ($feature) { - $all_models = array_filter($all_models, function($model) use ($feature) { + $all_models = array_filter($all_models, function ($model) use ($feature) { return in_array($feature, $model['supportedFeatures']); }); } - + return array_values($all_models); } @@ -229,7 +229,7 @@ protected function getFallbackModels(?string $feature = NULL): array { * Useful after configuration changes or for troubleshooting. */ public function clearCache(): void { - // Clear all model-related cache entries + // Clear all model-related cache entries. $this->cache->deleteMultiple([ 'ai_provider_quant_cloud:models:all', 'ai_provider_quant_cloud:models:chat', @@ -240,4 +240,3 @@ public function clearCache(): void { } } - diff --git a/tests/src/Unit/QuantCloudProviderFailureDetectionTest.php b/tests/src/Unit/QuantCloudProviderFailureDetectionTest.php index c8d800b..0e84503 100644 --- a/tests/src/Unit/QuantCloudProviderFailureDetectionTest.php +++ b/tests/src/Unit/QuantCloudProviderFailureDetectionTest.php @@ -22,7 +22,6 @@ class QuantCloudProviderFailureDetectionTest extends UnitTestCase { * Response content. * @param bool $expected * Expected result. - * */ #[DataProvider('emptyContentProvider')] public function testIsEmptyResponseContent( @@ -67,7 +66,6 @@ public static function emptyContentProvider(): array { * Configured token limit. * @param bool $expected * Expected result. - * */ #[DataProvider('tokenLimitProvider')] public function testIsLikelyTokenLimited( @@ -136,7 +134,6 @@ public static function tokenLimitProvider(): array { * Response data. * @param int $expected * Expected token count. - * */ #[DataProvider('outputTokenProvider')] public function testGetOutputTokenCount( @@ -191,7 +188,6 @@ public static function outputTokenProvider(): array { * Response data. * @param string|null $expected * Expected stop reason. - * */ #[DataProvider('stopReasonProvider')] public function testGetStopReason(