diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe318b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +/.github export-ignore +/docs export-ignore +/Test export-ignore +/phpcs.xml.dist export-ignore +/phpunit.xml.dist export-ignore +/CLAUDE.md export-ignore +/.gitattributes export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..eb99f0e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Report a bug in MageOS_AiBase +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please fill in the fields below. + - type: input + id: module-version + attributes: + label: Module version + placeholder: "1.0.0" + validations: + required: true + - type: input + id: magento-version + attributes: + label: Magento / Mage-OS version + placeholder: "Magento 2.4.7 / Mage-OS 2.2.0" + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Minimal steps to reproduce the issue. + placeholder: | + 1. Go to ... + 2. Click on ... + 3. See error + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs / stack trace + description: Any relevant output from var/log/ or the browser console. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6d2eb22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: Feature request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem are you trying to solve? Who is affected? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How would you like this to work? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any other approaches you considered and why you ruled them out. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..11ea7ce --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ +## Summary + + + +## Linked issue + +Closes # + +## Change type + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Chore / refactor / CI +- [ ] Breaking change + +## Test plan + + + +## Checklist + +- [ ] Tests added or updated +- [ ] Docs / README updated if behavior changed +- [ ] `phpcs` and `phpunit` pass locally diff --git a/.github/workflows/check-extension.yaml b/.github/workflows/check-extension.yaml new file mode 100644 index 0000000..37529f9 --- /dev/null +++ b/.github/workflows/check-extension.yaml @@ -0,0 +1,30 @@ +name: Check Extension + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + compute-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.supported.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 + id: supported + with: + project: mage-os + + check-extension: + needs: compute-matrix + uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 + with: + matrix: ${{ needs.compute-matrix.outputs.matrix }} + # The default mirror (https://mirror.mage-os.org/) only proxies magento/* + # packages; the mage-os/project-community-edition metapackage is published + # to https://repo.mage-os.org/. Without this override, composer fails with + # "Could not find package mage-os/project-community-edition". + magento_repository: https://repo.mage-os.org/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9589936 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2026-04-21 + +### Added +- Structured `FieldDescriptorInterface` config field schema replacing the HTML-template pattern. +- `getSupportedModels(): array` method on each service for non-hardcoded model lists. Model lists ship as a curated baseline; admins may override per-install via a `` on each service class. +- GitHub Actions CI via `graycoreio/github-actions-magento2/check-extension`, matrix-targeted at `project: mage-os`. +- Unit test suite for `AiServiceSelector` (all four guards covered) and a parametrised smoke test exercising all eleven `AiServices/*` classes. +- Integration test covering round-trip of stored config through `ScopeConfigInterface`, with failure-safe cleanup in `tearDown()`. +- `AiServiceSelectorInterface` now documents its insertion-order contract. +- Admin form schema rendering hardens against HTML injection (client-side `escapeHtml()`) and preserves legacy stored values when the model list changes. + +### Changed +- **BREAKING:** `AiServiceConfigurationInterface::getConfigurationTemplate(): string` replaced by `::getConfigurationFields(): FieldDescriptorInterface[]` and `::getSupportedModels(): array`. +- `composer.json` now pins `php: ^8.2` and `magento/framework: ^103.0 || ^104.0`. +- `Model/AiServiceSelector` hardened against null scope values and malformed JSON. +- `module.xml` declares explicit dependency on `Magento_Config` + `Magento_Backend`. +- `Block\Adminhtml\Configuration\Services` is now `final` with runtime validation that injected services implement `AiServiceConfigurationInterface`. + +### Fixed +- `README.md` API example now references the correct `AiServiceSelectorInterface` (previously cited `AiServiceConfigurationInterface`). + +[Unreleased]: https://github.com/mage-os-lab/module-ai-base/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/mage-os-lab/module-ai-base/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b02a1bd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`mage-os/module-ai-base` — a small Magento 2 module (`MageOS_AiBase`) that exposes an admin configuration UI for registering multiple AI backends (OpenAI, Anthropic, Azure, Google, Grok, Deepseek, HuggingFace, LM Studio, Ollama, OpenRouter, xAI) and a consumer API for other modules to read those configured credentials. It does **not** call any AI service itself — it only stores and serves configuration. + +The module is installed into a host Magento 2 app; this repo contains no runnable Magento instance. There is no test suite, no lint/format config, and no build step. + +## Commands + +Host-side (run inside a Magento 2 install that has this module via `composer require mage-os/module-ai-base`): + +```bash +php bin/magento module:enable MageOS_AiBase +php bin/magento setup:upgrade +php bin/magento setup:di:compile +``` + +Admin UI lives at **Stores → Configuration → Services → AI Configuration**. + +## Architecture + +There are two intentionally separate interfaces — do not conflate them: + +- **`Api\Data\AiServiceConfigurationInterface`** (`getCode`, `getName`, `getConfigurationTemplate`) — describes an *available* backend: its machine code, display name, and the HTML snippet used in the admin form. Implementations live in `src/AiServices/*.php`. These are wired into the admin form by the `services` array argument on `Block\Adminhtml\Configuration\Services` in `etc/di.xml`. +- **`Api\Data\AiServiceInterface`** (`getCode`, `getConfiguration`) — represents a *configured instance* (code + stored credentials/model/etc. array). Produced at runtime by `Model\AiServiceSelector` through `AiServiceInterfaceFactory`. + +`AiServiceSelectorInterface` is the public consumer API: + +```php +AiServiceSelectorInterface::getAll(): AiServiceInterface[] +AiServiceSelectorInterface::getByCode(string $code): AiServiceInterface[] +``` + +(Note: the README example shows these methods on `AiServiceConfigurationInterface` — that's wrong; they belong to `AiServiceSelectorInterface`. `getAll` also takes no arguments.) Multiple entries per code are possible because admins can add the same backend multiple times in the UI, which is why `getByCode` returns an array. + +Stored data flow: + +1. Admin form is an `AbstractFieldArray` rendered via `view/adminhtml/templates/system/config/form/field/services.phtml`. +2. Each `AiServiceConfigurationInterface::getConfigurationTemplate()` returns an HTML fragment using `<%- _fieldName %>` as a `mage/template` placeholder. The phtml wires those into per-row inputs when the admin clicks one of the "Add Service" buttons. +3. Magento serializes the posted rows as JSON via `Magento\Config\Model\Config\Backend\Serialized\ArraySerialized` into `core_config_data` at path **`mageos_ai/services/configuration`**. +4. `AiServiceSelector::getParsedConfig()` reads that path, json_decodes it, and wraps each row with `AiServiceInterfaceFactory`. Each row's structure is `{ _rowId: { : { ...fields } } }`, which is why the selector does `array_first(array_keys($item))` to extract the code. + +## Adding a new AI backend + +1. Create `src/AiServices/.php` implementing `AiServiceConfigurationInterface`. The configuration template's input `name` attributes must follow `<%- _fieldName %>[][]` — that nesting is what the selector expects when reading back. +2. Register it in `etc/di.xml` under the `services` argument of `Block\Adminhtml\Configuration\Services`. The array key there becomes the row identifier in the admin dropdown; it should match the class's `getCode()`. +3. No other wiring is required — the admin UI and selector pick it up automatically. + +## Conventions observed in this codebase + +- PHP 8 constructor property promotion + `readonly` is the norm; follow it for new classes. +- No `declare(strict_types=1)` header is used in existing files — match the surrounding style unless you're explicitly modernizing. +- `composer.json` pins `minimum-stability: dev` and `magento/framework: *` — do not tighten these without a reason. +- ACL resource: `MageOS_AiBase::configuration` (defined in `etc/acl.xml`), nested under `Magento_Backend::stores_attributes`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c065d1c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at david@run-as-root.sh. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6d4b6ac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to MageOS_AiBase + +Thanks for your interest in improving this module. This guide covers how to report issues, propose changes, and develop locally. + +## Reporting bugs + +Open an issue using the **Bug report** template. Please include your Magento version, module version, and reproduction steps. + +## Proposing changes + +For anything larger than a typo fix, please open a **Feature request** issue first so we can align on the approach before you invest time in a PR. + +## Local development + +This repo contains a Magento 2 module, not a runnable Magento instance. To work on it: + +1. Clone this repository outside your Magento install. +2. In your Magento 2 project, add a Composer path repository pointing at your clone: + ```json + "repositories": [ + { "type": "path", "url": "/absolute/path/to/module-ai-base" } + ] + ``` +3. Require the module and enable it: + ```bash + composer require mage-os/module-ai-base:@dev + bin/magento module:enable MageOS_AiBase + bin/magento setup:upgrade + bin/magento setup:di:compile + ``` + +## Coding conventions + +- PHP 8 constructor property promotion with `readonly` is the norm — match it for new classes. +- No `declare(strict_types=1)` header is used in existing files; keep things consistent unless you're explicitly modernizing in a dedicated PR. +- Coding standard: see `phpcs.xml.dist`. Run `vendor/bin/phpcs` from a host Magento install that includes this module. + +## Branching and pull requests + +- Branch off `main` using `feat/` or `fix/`. +- Target PRs at `main`. +- Keep PRs focused — one concern per PR. +- Fill in the PR template (summary, linked issue, test plan). + +## Tests + +Run the test suite with: + +```bash +vendor/bin/phpunit -c phpunit.xml.dist +``` + +New PHP unit tests must be `final` classes and use `snake_case` method names. + +## Commit messages + +Conventional-style prefixes (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`) are encouraged but not enforced. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..62c3019 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,110 @@ +# License + +This package is licensed under either of the following licenses, at your choice: + +- The Open Software License v3.0 ("OSL-3.0") — see below +- The Academic Free License v3.0 ("AFL-3.0") — see below + +--- + +## Open Software License v3.0 + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. + +--- + +## Academic Free License v3.0 + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/README.md b/README.md index 3f03ef7..760ed54 100644 --- a/README.md +++ b/README.md @@ -13,25 +13,43 @@ You can find the new configuration option in System > Configuration > Services - ## Usage -If you have configured AI backends, you can fetch the configuration using these methods: +If you have configured AI backends, you can fetch the configuration using these methods: ```php -AiServiceConfigurationInterface::getAll($code): array -AiServiceConfigurationInterface::getByCode($code): array +use MageOS\AiBase\Api\AiServiceSelectorInterface; + +AiServiceSelectorInterface::getAll(): array +AiServiceSelectorInterface::getByCode(string $code): array ``` -Both methods return an array of `\MageOS\AiBase\Api\Data\AiServiceInterface` objects. +Both methods return an array of `\MageOS\AiBase\Api\Data\AiServiceInterface` objects (multiple entries per code are possible because admins can register the same backend more than once). ```php -class MyAiFunctionality { - public function __construct(AiServiceConfigurationInterface $aiServiceConfiguration) { - $this->aiServiceConfiguration = $aiServiceConfiguration; - } - - public function doSomething() { - $openAiCredentials = $this->aiServiceConfiguration->getByCode('openai'); - - // $openAiCredentials = an array of \MageOS\AiBase\Api\Data\AiServiceInterface objects +use MageOS\AiBase\Api\AiServiceSelectorInterface; + +final class MyAiFunctionality +{ + public function __construct( + private readonly AiServiceSelectorInterface $aiServiceSelector, + ) {} + + public function doSomething(): void + { + $openAiServices = $this->aiServiceSelector->getByCode('openai'); + + foreach ($openAiServices as $service) { + $config = $service->getConfiguration(); + // $config = ['apikey' => '...', 'model' => 'gpt-4o', ...] + } } } ``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Security + +Security issues: see [SECURITY.md](SECURITY.md). Please do **not** file public issues for vulnerabilities. + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fcef1a7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported versions + +Only the **latest released version** receives security updates. + +## Reporting a vulnerability + +Please do **not** open public GitHub issues for security vulnerabilities. + +Preferred channel: **GitHub Private Vulnerability Reporting**. Go to the [Security tab](../../security/advisories/new) of this repository and click "Report a vulnerability". + +If you cannot use GitHub, email one of: + +- `david@run-as-root.sh` +- `security@mage-os.org` + +Please include: + +- Affected module version and Magento version +- Reproduction steps or proof-of-concept +- Expected impact + +We aim to acknowledge reports within **7 days**. Once a fix is available, we will coordinate a disclosure timeline with you. diff --git a/Test/Integration/Model/AiServiceSelectorTest.php b/Test/Integration/Model/AiServiceSelectorTest.php new file mode 100644 index 0000000..4b84547 --- /dev/null +++ b/Test/Integration/Model/AiServiceSelectorTest.php @@ -0,0 +1,52 @@ +objectManager = Bootstrap::getObjectManager(); + $this->configWriter = $this->objectManager->get(WriterInterface::class); + } + + protected function tearDown(): void + { + $this->configWriter->delete('mageos_ai/services/configuration'); + } + + public function test_round_trips_configuration_through_scope_config(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'sk-test', 'model' => 'gpt-4o']], + '_row2' => ['anthropic' => ['apikey' => 'sk-ant', 'model' => 'claude-sonnet-4-6']], + ], JSON_THROW_ON_ERROR); + $this->configWriter->save('mageos_ai/services/configuration', $json); + + $this->objectManager->get(Config::class)->clean(); + + /** @var AiServiceSelectorInterface $selector */ + $selector = $this->objectManager->get(AiServiceSelectorInterface::class); + + $services = $selector->getAll(); + self::assertCount(2, $services); + self::assertSame('openai', $services[0]->getCode()); + self::assertSame(['apikey' => 'sk-test', 'model' => 'gpt-4o'], $services[0]->getConfiguration()); + self::assertSame('anthropic', $services[1]->getCode()); + + $openAiOnly = $selector->getByCode('openai'); + self::assertCount(1, $openAiOnly); + } +} diff --git a/Test/Unit/AiServices/ServicesTest.php b/Test/Unit/AiServices/ServicesTest.php new file mode 100644 index 0000000..698442b --- /dev/null +++ b/Test/Unit/AiServices/ServicesTest.php @@ -0,0 +1,76 @@ +createMock(FieldDescriptorInterfaceFactory::class); + $stub->method('create')->willReturnCallback( + fn (array $data) => new FieldDescriptor( + name: $data['name'], + label: $data['label'], + type: $data['type'], + options: $data['options'] ?? [], + default: $data['default'] ?? null, + ) + ); + $this->fieldFactory = $stub; + } + + #[DataProvider('service_classes')] + public function test_service_exposes_required_metadata(string $className): void + { + /** @var AiServiceConfigurationInterface $service */ + $service = new $className($this->fieldFactory); + + self::assertNotEmpty($service->getCode(), "$className::getCode() must be non-empty"); + self::assertNotEmpty($service->getName(), "$className::getName() must be non-empty"); + + $fields = $service->getConfigurationFields(); + self::assertNotEmpty($fields, "$className::getConfigurationFields() must return at least one field"); + foreach ($fields as $field) { + self::assertInstanceOf(FieldDescriptorInterface::class, $field); + self::assertNotEmpty($field->getName()); + self::assertNotEmpty($field->getLabel()); + self::assertContains( + $field->getType(), + [FieldDescriptorInterface::TYPE_TEXT, FieldDescriptorInterface::TYPE_PASSWORD, FieldDescriptorInterface::TYPE_SELECT], + ); + } + + self::assertIsArray($service->getSupportedModels()); + } + + /** + * @return array + */ + public static function service_classes(): array + { + return [ + 'Anthropic' => [\MageOS\AiBase\AiServices\Anthropic::class], + 'Azure' => [\MageOS\AiBase\AiServices\Azure::class], + 'Deepseek' => [\MageOS\AiBase\AiServices\Deepseek::class], + 'Google' => [\MageOS\AiBase\AiServices\Google::class], + 'Grok' => [\MageOS\AiBase\AiServices\Grok::class], + 'HuggingFace'=> [\MageOS\AiBase\AiServices\HuggingFace::class], + 'LmStudio' => [\MageOS\AiBase\AiServices\LmStudio::class], + 'Ollama' => [\MageOS\AiBase\AiServices\Ollama::class], + 'OpenAi' => [\MageOS\AiBase\AiServices\OpenAi::class], + 'OpenRouter' => [\MageOS\AiBase\AiServices\OpenRouter::class], + 'Xai' => [\MageOS\AiBase\AiServices\Xai::class], + ]; + } +} diff --git a/Test/Unit/Model/AiServiceSelectorTest.php b/Test/Unit/Model/AiServiceSelectorTest.php new file mode 100644 index 0000000..35475d5 --- /dev/null +++ b/Test/Unit/Model/AiServiceSelectorTest.php @@ -0,0 +1,107 @@ +scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->aiServiceFactory = $this->createMock(AiServiceInterfaceFactory::class); + $this->subject = new AiServiceSelector($this->scopeConfig, $this->aiServiceFactory); + } + + public function test_get_all_returns_empty_array_when_config_is_null(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + + self::assertSame([], $this->subject->getAll()); + } + + public function test_get_all_returns_empty_array_when_config_is_malformed_json(): void + { + $this->scopeConfig->method('getValue')->willReturn('not-json'); + + self::assertSame([], $this->subject->getAll()); + } + + public function test_get_all_returns_all_configured_services(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'k1', 'model' => 'gpt-4o']], + '_row2' => ['anthropic' => ['apikey' => 'k2', 'model' => 'claude-sonnet-4-6']], + ], JSON_THROW_ON_ERROR); + $this->scopeConfig->method('getValue')->willReturn($json); + + $this->aiServiceFactory->method('create')->willReturnCallback( + fn (array $data) => new AiService($data['code'], $data['configuration']) + ); + + $result = $this->subject->getAll(); + + self::assertCount(2, $result); + self::assertContainsOnlyInstancesOf(AiServiceInterface::class, $result); + self::assertSame('openai', $result[0]->getCode()); + self::assertSame('anthropic', $result[1]->getCode()); + } + + /** + * @param array $decoded + */ + #[DataProvider('malformed_decoded_shapes')] + public function test_get_all_silently_skips_malformed_rows(array $decoded): void + { + $this->scopeConfig->method('getValue')->willReturn(json_encode($decoded, JSON_THROW_ON_ERROR)); + + self::assertSame([], $this->subject->getAll()); + } + + /** + * @return array}> + */ + public static function malformed_decoded_shapes(): array + { + return [ + 'row is a bare string' => [['_row1' => 'not-an-array']], + 'row is an empty array' => [['_row1' => []]], + 'row value is non-array' => [['_row1' => ['openai' => 'not-an-array']]], + 'row key is integer (not code)'=> [['_row1' => [0 => ['apikey' => 'k1']]]], + ]; + } + + public function test_get_by_code_filters_to_matching_services_only(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'k1']], + '_row2' => ['anthropic' => ['apikey' => 'k2']], + '_row3' => ['openai' => ['apikey' => 'k3']], + ], JSON_THROW_ON_ERROR); + $this->scopeConfig->method('getValue')->willReturn($json); + + $this->aiServiceFactory->method('create')->willReturnCallback( + fn (array $data) => new AiService($data['code'], $data['configuration']) + ); + + $result = $this->subject->getByCode('openai'); + + self::assertCount(2, $result); + foreach ($result as $service) { + self::assertSame('openai', $service->getCode()); + } + } +} diff --git a/Test/Unit/Model/FieldDescriptorTest.php b/Test/Unit/Model/FieldDescriptorTest.php new file mode 100644 index 0000000..f95aac0 --- /dev/null +++ b/Test/Unit/Model/FieldDescriptorTest.php @@ -0,0 +1,46 @@ +getName()); + self::assertSame('API Key', $field->getLabel()); + self::assertSame(FieldDescriptorInterface::TYPE_PASSWORD, $field->getType()); + self::assertSame([], $field->getOptions()); + self::assertNull($field->getDefault()); + } + + public function test_select_field_carries_options_and_default(): void + { + $field = new FieldDescriptor( + name: 'model', + label: 'Model', + type: FieldDescriptorInterface::TYPE_SELECT, + options: [ + ['value' => 'a', 'label' => 'Apple'], + ['value' => 'b', 'label' => 'Banana'], + ], + default: 'a', + ); + + self::assertSame('model', $field->getName()); + self::assertSame(FieldDescriptorInterface::TYPE_SELECT, $field->getType()); + self::assertCount(2, $field->getOptions()); + self::assertSame('a', $field->getDefault()); + } +} diff --git a/composer.json b/composer.json index 4473bdb..5401c3d 100755 --- a/composer.json +++ b/composer.json @@ -1,15 +1,45 @@ { "name": "mage-os/module-ai-base", - "description": "Base AI module for Mage-OS", + "description": "Base AI module for Mage-OS — register and retrieve configuration for multiple AI backends.", "type": "magento2-module", - "minimum-stability": "dev", + "license": ["OSL-3.0", "AFL-3.0"], + "authors": [ + { "name": "David Lambauer", "email": "david@run-as-root.sh" } + ], + "support": { + "issues": "https://github.com/mage-os-lab/module-ai-base/issues", + "source": "https://github.com/mage-os-lab/module-ai-base" + }, + "repositories": { + "mage-os-mirror": { + "type": "composer", + "url": "https://mirror.mage-os.org/" + }, + "mage-os": { + "type": "composer", + "url": "https://repo.mage-os.org/" + } + }, "require": { - "magento/framework": "*" + "php": "^8.2", + "magento/framework": "^103.0 || ^104.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "mage-os/magento-coding-standard": "^2.0" }, "autoload": { "files": ["src/registration.php"], - "psr-4": { - "MageOS\\AiBase\\": "src/" + "psr-4": { "MageOS\\AiBase\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "MageOS\\AiBase\\Test\\": "Test/" } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "magento/composer-dependency-version-audit-plugin": true, + "magento/composer-root-update-plugin": true } } } diff --git a/docs/plans/2026-04-20-done-done-design.md b/docs/plans/2026-04-20-done-done-design.md new file mode 100644 index 0000000..3e10314 --- /dev/null +++ b/docs/plans/2026-04-20-done-done-design.md @@ -0,0 +1,234 @@ +# `module-ai-base` v1.0.0 — Design Doc + +**Date:** 2026-04-20 +**Author:** David Lambauer +**Status:** Approved, ready for implementation plan + +## Goal + +Take `mage-os/module-ai-base` from its current `0.x` "works on my machine" state to a released, CI-gated, demo-verified `v1.0.0`. The release pass touches four axes: CI wiring, a breaking API cleanup, a test suite, and a packaging/release sequence. No runtime behaviour changes for consumers that only use `AiServiceSelectorInterface`; the breaking change is confined to `AiServiceConfigurationInterface`, which ships inside this module (no external implementers yet). + +## Decisions locked during brainstorming + +| # | Axis | Decision | +|---|------|----------| +| Q1 | Scope | Full v1.0.0 release (CI + tests + API cleanup + tag) | +| Q2 | Model list abstraction | `getSupportedModels(): array` method on each `AiServices/*` (no new provider interface) | +| Q3 | Admin form refresh | Structured `FieldDescriptor[]` schema replaces the HTML-template string pattern | +| Q4 | Release mechanics | One-off `v1.0.0` tag, manual packagist submission (no `release-please` for now) | + +## Architecture + +### Public API — breaking + +`Api/Data/AiServiceConfigurationInterface` changes shape: + +```php +interface AiServiceConfigurationInterface +{ + public function getCode(): string; + public function getName(): string; + /** @return FieldDescriptorInterface[] */ + public function getConfigurationFields(): array; + /** @return array value => label (empty array if N/A) */ + public function getSupportedModels(): array; +} +``` + +`getConfigurationTemplate(): string` is removed. The rendering concern it carried moves to the phtml template. + +### New types + +`Api/Data/FieldDescriptorInterface` + `Model/FieldDescriptor` (readonly DTO, built via auto-generated `FieldDescriptorInterfaceFactory`): + +```php +interface FieldDescriptorInterface +{ + public const TYPE_TEXT = 'text'; + public const TYPE_PASSWORD = 'password'; + public const TYPE_SELECT = 'select'; + + public function getName(): string; + public function getLabel(): string; + public function getType(): string; + public function getOptions(): array; // [['value' => ..., 'label' => ...], ...] + public function getDefault(): ?string; +} +``` + +### Consumer API — unchanged + +`AiServiceSelectorInterface::getAll()` and `::getByCode(string $code)` keep their signatures. The on-disk JSON shape stored at `mageos_ai/services/configuration` is unchanged: `{ _rowId: { : { : , ... } } }`. This means any consumer module that already uses the selector keeps working after the upgrade. + +### Internal changes + +- `Model/AiServiceSelector::getParsedConfig()` hardened: guard against `ScopeConfigInterface::getValue()` returning `null`, `json_decode()` returning `null` on malformed JSON, and non-array rows. +- `Block/Adminhtml/Configuration/Services::getServicesTemplates()` replaced by `getServicesSchema(): string` returning a JSON blob keyed by service code with field descriptors serialised to arrays. +- `src/view/adminhtml/templates/system/config/form/field/services.phtml` rewritten: drops per-service inline HTML templates, JS loops over the schema and emits ``, ``, or `` by field type. Row-level naming (`<%- _fieldName %>[][]`) stays identical for backwards on-disk compat. +- `src/etc/module.xml` gains `` — today the module silently depends on both. +- `declare(strict_types=1)` added to every PHP file. + +### Eleven `AiServices/*` rewrites + +Each class becomes ~20 lines: constructor takes `FieldDescriptorInterfaceFactory`, `getSupportedModels()` returns the current model list, `getConfigurationFields()` returns the field DTOs. Model lists refreshed against each provider's current public catalog as of 2026-04-20. + +## CI + +Single workflow: `.github/workflows/check-extension.yaml`. + +```yaml +name: Check Extension +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + compute-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.supported.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 + id: supported + + check-extension: + needs: compute-matrix + uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 + with: + matrix: ${{ needs.compute-matrix.outputs.matrix }} +``` + +Both Graycore refs pin to `@v5.1.0` on merge (not `@main`) so the matrix doesn't shift under us between releases. + +The four reusable-workflow jobs (`unit-test-extension`, `compile-extension`, `coding-standard`, `integration_test`) all do real work after this PR: + +- **phpunit / unit** — 12 test classes execute. +- **setup:di:compile** — catches constructor-arg typos, missing `di.xml` preferences, stale factory references. +- **phpcs** — uses a repo-level `phpcs.xml.dist` extending `Magento2`. Graycore v5.1.0 prefers a project-local config when present. +- **phpunit / integration** — the one round-trip test fires against a real DB. + +## Testing + +### Layout + +``` +src/Test/ +├── Unit/ +│ ├── Model/AiServiceSelectorTest.php +│ └── AiServices/ServicesTest.php +└── Integration/ + └── Model/AiServiceSelectorTest.php +``` + +All tests `final class`, methods `snake_case`, one assertion per test where feasible (per global CLAUDE.md conventions). + +### Unit — `AiServiceSelectorTest` + +1. `getAll_returns_empty_array_when_config_is_null` — `ScopeConfigInterface::getValue()` mocked to return `null`. +2. `getAll_returns_empty_array_when_config_is_malformed_json` — mocked to return `"not-json"`. +3. `getAll_returns_all_configured_services` — two rows (openai + anthropic), asserts 2 `AiServiceInterface` with expected `getCode()` + `getConfiguration()`. +4. `getByCode_filters_to_matching_services_only` — three rows, two openai + one anthropic, `getByCode('openai')` returns 2. + +All PHPUnit mocks. No bootstrap. + +### Unit — `ServicesTest` (parametrised over all 11 services) + +Data provider yields every `AiServices/` class. Test body asserts: +- `getCode()` is non-empty string +- `getName()` is non-empty string +- `getConfigurationFields()` returns non-empty `FieldDescriptorInterface[]` +- `getSupportedModels()` returns an array (may be empty for local-only services like LM Studio / Ollama) + +One test method, eleven cases. + +### Integration — `AiServiceSelectorTest` + +Single test `round_trips_configuration_through_scope_config`: +1. `$resourceConfig->saveConfig('mageos_ai/services/configuration', $json, 'default', 0)` +2. Flush config cache. +3. `$selector->getAll()` → assert 2 `AiServiceInterface` present with expected codes + configuration. + +## Release + packaging + +### `composer.json` final shape + +```json +{ + "name": "mage-os/module-ai-base", + "description": "Base AI module for Mage-OS — register and retrieve configuration for multiple AI backends.", + "type": "magento2-module", + "license": ["OSL-3.0", "AFL-3.0"], + "authors": [{ "name": "David Lambauer", "email": "david@run-as-root.sh" }], + "support": { + "issues": "https://github.com/mage-os/module-ai-base/issues", + "source": "https://github.com/mage-os/module-ai-base" + }, + "require": { + "php": "^8.2", + "magento/framework": "^103.0 || ^104.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "mage-os/magento-coding-standard": "^2.0" + }, + "autoload": { + "files": ["src/registration.php"], + "psr-4": { "MageOS\\AiBase\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "MageOS\\AiBase\\Test\\": "src/Test/" } + } +} +``` + +Dropped: `minimum-stability: dev`. Constrained: `magento/framework`. + +### Release sequence + +1. Single PR on `main` containing the entire "done done" change set. +2. After merge: `git tag v1.0.0 && git push --tags`. +3. Manual maintainer action: submit repo to packagist.org once. Subsequent tags auto-publish via the webhook. +4. Draft a GitHub Release for `v1.0.0` with seeded `CHANGELOG.md`. + +### Repo scaffolding + +- `LICENSE` — OSL-3.0 + AFL-3.0 dual (Mage-OS convention). +- `CHANGELOG.md` — `## 1.0.0` section summarising the breaking change and new interface surface. +- `README.md` — fix the current error (example references wrong interface; actual API is `AiServiceSelectorInterface::getAll(): AiServiceInterface[]` / `::getByCode(string $code): AiServiceInterface[]`). +- `phpcs.xml.dist` — extends `Magento2`, scoped to `src/`. + +## Demo smoke test + +Target: `/Users/david/Herd/mage-os-typesense` (Mage-OS 2.2.0). + +1. Add to the demo's `composer.json` `repositories`: + ```json + "ai-base": { "type": "path", "url": "/Users/david/Herd/module-ai-base" } + ``` +2. `composer require mage-os/module-ai-base:@dev` +3. `bin/magento module:enable MageOS_AiBase` +4. `bin/magento setup:upgrade && bin/magento setup:di:compile` +5. Log into admin (`david` / `Admin12345!`). +6. **Stores → Configuration → Services → AI Configuration**. +7. Add OpenAI service → API key + model → Add Anthropic service → API key + model → Save. +8. Refresh, verify rows render with correct field types (password masked, select with correct option). +9. Sanity: resolve `AiServiceSelectorInterface` in object manager, `getAll()` returns 2 instances. +10. Delete rows, save, `getAll()` returns `[]` (exercises the null-guard branch). + +## Explicitly out of scope + +- HTTP calls to any AI provider (consumer modules' concern). +- `release-please` / conventional commits (deferred, revisit post-1.0). +- ui_component rewrite of the admin form (rejected in Q3 — too expensive for the value). +- Migration code for pre-1.0 stored config (no prior release exists). +- Auto-publishing to packagist (requires maintainer's packagist.org account action). + +## Risks + +- **Breaking interface change.** Mitigated by no external implementers existing yet — `composer.json` is tagged `0.x` and the package isn't on packagist, so there cannot be a third-party class implementing `AiServiceConfigurationInterface` with the old `getConfigurationTemplate()` signature. +- **Graycore matrix drift.** Mitigated by pinning `@v5.1.0` rather than `@main`. +- **Mage-OS 2.2 framework constraint (`^103.0 || ^104.0`).** If Mage-OS 2.3 bumps `magento/framework` major, the constraint needs updating — not a 1.0 blocker. +- **PHPUnit integration test requires DB.** Only runs in `check-extension`'s integration job which provides one; locally skipped unless a bootstrap is configured. diff --git a/docs/plans/2026-04-20-done-done-plan.md b/docs/plans/2026-04-20-done-done-plan.md new file mode 100644 index 0000000..26ccb88 --- /dev/null +++ b/docs/plans/2026-04-20-done-done-plan.md @@ -0,0 +1,1391 @@ +# `module-ai-base` v1.0.0 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Ship `mage-os/module-ai-base` v1.0.0 — CI-gated, test-covered, demo-verified, with a cleaned-up public interface. + +**Architecture:** Single-branch, additive-where-possible. The one breaking change (`AiServiceConfigurationInterface`) is applied atomically in one commit that also rewrites the eleven `AiServices/*` classes + the admin block + the phtml template, so the repo always compiles between commits. Tests are written before the code they cover per @superpowers:test-driven-development. + +**Tech Stack:** PHP 8.2+, Magento 2 framework, PHPUnit 10, Graycore reusable GitHub Actions workflows, mage-os coding standard. + +**Design doc:** See `docs/plans/2026-04-20-done-done-design.md` for the decisions underpinning this plan. + +--- + +## Task 1: Tighten `composer.json` + add LICENSE + CHANGELOG + +**Files:** +- Modify: `composer.json` +- Create: `LICENSE.md` +- Create: `CHANGELOG.md` + +**Step 1: Replace `composer.json` contents** + +```json +{ + "name": "mage-os/module-ai-base", + "description": "Base AI module for Mage-OS — register and retrieve configuration for multiple AI backends.", + "type": "magento2-module", + "license": ["OSL-3.0", "AFL-3.0"], + "authors": [ + { "name": "David Lambauer", "email": "david@run-as-root.sh" } + ], + "support": { + "issues": "https://github.com/mage-os/module-ai-base/issues", + "source": "https://github.com/mage-os/module-ai-base" + }, + "require": { + "php": "^8.2", + "magento/framework": "^103.0 || ^104.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "mage-os/magento-coding-standard": "^2.0" + }, + "autoload": { + "files": ["src/registration.php"], + "psr-4": { "MageOS\\AiBase\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "MageOS\\AiBase\\Test\\": "src/Test/" } + } +} +``` + +**Step 2: Create `LICENSE.md`** + +Paste the OSL-3.0 + AFL-3.0 dual-license text. Source the canonical text from the Mage-OS `mageos-magento2` repo `LICENSE.txt` / `LICENSE_AFL.txt` pair, concatenated with an "OR" separator header. + +**Step 3: Create `CHANGELOG.md`** + +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - 2026-04-20 + +### Added +- Structured `FieldDescriptorInterface` config field schema replacing the HTML-template pattern. +- `getSupportedModels(): array` method on each service for non-hardcoded model lists. +- GitHub Actions CI via `graycoreio/github-actions-magento2/check-extension`. +- Unit test suite for `AiServiceSelector` and all eleven `AiServices/*` classes. +- Integration test covering round-trip of stored config through `ScopeConfigInterface`. + +### Changed +- **BREAKING:** `AiServiceConfigurationInterface::getConfigurationTemplate(): string` replaced by `::getConfigurationFields(): FieldDescriptorInterface[]` and `::getSupportedModels(): array`. +- `composer.json` now pins `php: ^8.2` and `magento/framework: ^103.0 || ^104.0`. +- `Model/AiServiceSelector` hardened against null scope values and malformed JSON. +- `module.xml` declares explicit dependency on `Magento_Config` + `Magento_Backend`. + +### Fixed +- `README.md` API example now references the correct `AiServiceSelectorInterface` (previously cited `AiServiceConfigurationInterface`). +``` + +**Step 4: Verify composer sanity** + +Run: `composer validate --no-check-publish --strict` +Expected: `./composer.json is valid` with exit code 0. The `version` field is intentionally omitted — Packagist derives it from git tags (see Task 12), and leaving it in would trigger a warning that `--strict` escalates to a failure. + +**Step 5: Commit** + +```bash +git add composer.json LICENSE.md CHANGELOG.md +git commit -m "chore: tighten composer metadata, add LICENSE and CHANGELOG" +``` + +--- + +## Task 2: Add `phpcs.xml.dist` + CI workflow + +**Files:** +- Create: `phpcs.xml.dist` +- Create: `.github/workflows/check-extension.yaml` + +**Step 1: Create `phpcs.xml.dist`** + +```xml + + + + src + src/Test/ + + +``` + +**Step 2: Create `.github/workflows/check-extension.yaml`** + +```yaml +name: Check Extension + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + compute-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.supported.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 + id: supported + with: + project: mage-os + + check-extension: + needs: compute-matrix + uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 + with: + matrix: ${{ needs.compute-matrix.outputs.matrix }} +``` + +**Step 3: Verify YAML parses** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/check-extension.yaml'))"` +Expected: no output, exit 0. + +**Step 4: Commit** + +```bash +git add phpcs.xml.dist .github/workflows/check-extension.yaml +git commit -m "ci: add Graycore check-extension workflow and phpcs config" +``` + +--- + +## Task 3: Fix `README.md` + +**Files:** +- Modify: `README.md` + +**Step 1: Replace the "Usage" section** + +Replace lines 14-37 of `README.md` with: + +````markdown +## Usage + +If you have configured AI backends, you can fetch the configuration using these methods: + +```php +use MageOS\AiBase\Api\AiServiceSelectorInterface; + +AiServiceSelectorInterface::getAll(): array +AiServiceSelectorInterface::getByCode(string $code): array +``` + +Both methods return an array of `\MageOS\AiBase\Api\Data\AiServiceInterface` objects (multiple entries per code are possible because admins can register the same backend more than once). + +```php +use MageOS\AiBase\Api\AiServiceSelectorInterface; + +final class MyAiFunctionality +{ + public function __construct( + private readonly AiServiceSelectorInterface $aiServiceSelector, + ) {} + + public function doSomething(): void + { + $openAiServices = $this->aiServiceSelector->getByCode('openai'); + + foreach ($openAiServices as $service) { + $config = $service->getConfiguration(); + // $config = ['apikey' => '...', 'model' => 'gpt-4o', ...] + } + } +} +``` +```` + +**Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: fix README — correct interface references in usage example" +``` + +--- + +## Task 4: Add module sequence to `module.xml` + +**Files:** +- Modify: `src/etc/module.xml` + +**Step 1: Replace file contents** + +```xml + + + + + + + + + +``` + +**Step 2: Commit** + +```bash +git add src/etc/module.xml +git commit -m "fix: declare explicit module sequence on Magento_Config and Magento_Backend" +``` + +--- + +## Task 5: Harden `AiServiceSelector` (TDD) + +Use @superpowers:test-driven-development. + +**Files:** +- Create: `src/Test/Unit/Model/AiServiceSelectorTest.php` +- Modify: `src/Model/AiServiceSelector.php` + +**Step 1: Write failing unit test** + +Create `src/Test/Unit/Model/AiServiceSelectorTest.php`: + +```php +scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->aiServiceFactory = $this->createMock(AiServiceInterfaceFactory::class); + $this->subject = new AiServiceSelector($this->scopeConfig, $this->aiServiceFactory); + } + + public function test_get_all_returns_empty_array_when_config_is_null(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + + self::assertSame([], $this->subject->getAll()); + } + + public function test_get_all_returns_empty_array_when_config_is_malformed_json(): void + { + $this->scopeConfig->method('getValue')->willReturn('not-json'); + + self::assertSame([], $this->subject->getAll()); + } + + public function test_get_all_returns_all_configured_services(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'k1', 'model' => 'gpt-4o']], + '_row2' => ['anthropic' => ['apikey' => 'k2', 'model' => 'claude-sonnet-4-6']], + ], JSON_THROW_ON_ERROR); + $this->scopeConfig->method('getValue')->willReturn($json); + + $this->aiServiceFactory->method('create')->willReturnCallback( + fn (array $data) => new AiService($data['code'], $data['configuration']) + ); + + $result = $this->subject->getAll(); + + self::assertCount(2, $result); + self::assertContainsOnlyInstancesOf(AiServiceInterface::class, $result); + self::assertSame('openai', $result[0]->getCode()); + self::assertSame('anthropic', $result[1]->getCode()); + } + + public function test_get_by_code_filters_to_matching_services_only(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'k1']], + '_row2' => ['anthropic' => ['apikey' => 'k2']], + '_row3' => ['openai' => ['apikey' => 'k3']], + ], JSON_THROW_ON_ERROR); + $this->scopeConfig->method('getValue')->willReturn($json); + + $this->aiServiceFactory->method('create')->willReturnCallback( + fn (array $data) => new AiService($data['code'], $data['configuration']) + ); + + $result = $this->subject->getByCode('openai'); + + self::assertCount(2, $result); + foreach ($result as $service) { + self::assertSame('openai', $service->getCode()); + } + } +} +``` + +**Step 2: Run tests to confirm they fail** + +Run: `vendor/bin/phpunit src/Test/Unit/Model/AiServiceSelectorTest.php` (or via the demo's vendor dir if this repo doesn't have one locally yet). +Expected: Test cases 1 and 2 FAIL with a `TypeError: json_decode() expects string, null given` (or similar) for case 1 and a silent `TypeError: array_map() expects array, null given` for case 2. Cases 3 and 4 should PASS. + +If no local vendor dir, defer execution to the CI workflow — the test will exercise the code path there. + +**Step 3: Harden the implementation** + +Replace `src/Model/AiServiceSelector.php` with: + +```php +getParsedConfig(); + } + + public function getByCode(string $code): array + { + return array_values(array_filter( + $this->getParsedConfig(), + fn (AiServiceInterface $service) => $service->getCode() === $code, + )); + } + + /** + * @return AiServiceInterface[] + */ + private function getParsedConfig(): array + { + $raw = $this->scopeConfig->getValue(self::CONFIG_PATH_AI_SERVICES); + if (!is_string($raw) || $raw === '') { + return []; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + + $services = []; + foreach ($decoded as $row) { + if (!is_array($row) || $row === []) { + continue; + } + $code = array_key_first($row); + $configuration = $row[$code]; + if (!is_string($code) || !is_array($configuration)) { + continue; + } + $services[] = $this->aiServiceFactory->create([ + 'code' => $code, + 'configuration' => $configuration, + ]); + } + + return $services; + } +} +``` + +Note: replaced the `array_first()` polyfill (Laravel helper, not available in Magento core) with `array_key_first()` (native PHP 8.2+). This also fixes a latent bug in the original code where `array_first(array_keys($item))` returned the first **value** of the keys array, which happened to work but was confusing. + +**Step 4: Run tests to confirm they pass** + +Run: `vendor/bin/phpunit src/Test/Unit/Model/AiServiceSelectorTest.php` +Expected: 4 tests, 4 passed. + +**Step 5: Commit** + +```bash +git add src/Test/Unit/Model/AiServiceSelectorTest.php src/Model/AiServiceSelector.php +git commit -m "fix(selector): harden getParsedConfig against null and malformed JSON + +Replaces the Laravel-style array_first() with native array_key_first() +and guards every boundary (null scope value, non-string config, failed +json_decode, non-array rows, missing code key) before constructing +AiService DTOs. Covered by 4 new unit tests." +``` + +--- + +## Task 6: Introduce `FieldDescriptor` DTO (TDD) + +**Files:** +- Create: `src/Api/Data/FieldDescriptorInterface.php` +- Create: `src/Model/FieldDescriptor.php` +- Create: `src/Test/Unit/Model/FieldDescriptorTest.php` +- Modify: `src/etc/di.xml` + +**Step 1: Write failing unit test** + +Create `src/Test/Unit/Model/FieldDescriptorTest.php`: + +```php +getName()); + self::assertSame('API Key', $field->getLabel()); + self::assertSame(FieldDescriptorInterface::TYPE_PASSWORD, $field->getType()); + self::assertSame([], $field->getOptions()); + self::assertNull($field->getDefault()); + } + + public function test_select_field_carries_options_and_default(): void + { + $field = new FieldDescriptor( + name: 'model', + label: 'Model', + type: FieldDescriptorInterface::TYPE_SELECT, + options: [ + ['value' => 'a', 'label' => 'Apple'], + ['value' => 'b', 'label' => 'Banana'], + ], + default: 'a', + ); + + self::assertSame('model', $field->getName()); + self::assertSame(FieldDescriptorInterface::TYPE_SELECT, $field->getType()); + self::assertCount(2, $field->getOptions()); + self::assertSame('a', $field->getDefault()); + } +} +``` + +**Step 2: Run test to confirm it fails** + +Run: `vendor/bin/phpunit src/Test/Unit/Model/FieldDescriptorTest.php` +Expected: FAIL with `Class "MageOS\AiBase\Model\FieldDescriptor" not found`. + +**Step 3: Create the interface** + +`src/Api/Data/FieldDescriptorInterface.php`: + +```php + + */ + public function getOptions(): array; + + public function getDefault(): ?string; +} +``` + +**Step 4: Create the implementation** + +`src/Model/FieldDescriptor.php`: + +```php +name; } + public function getLabel(): string { return $this->label; } + public function getType(): string { return $this->type; } + public function getOptions(): array { return $this->options; } + public function getDefault(): ?string { return $this->default; } +} +``` + +**Step 5: Wire the factory preference** + +Add to `src/etc/di.xml` (inside existing `` element): + +```xml + +``` + +**Step 6: Run test to confirm it passes** + +Run: `vendor/bin/phpunit src/Test/Unit/Model/FieldDescriptorTest.php` +Expected: 2 tests, 2 passed. + +**Step 7: Commit** + +```bash +git add src/Api/Data/FieldDescriptorInterface.php src/Model/FieldDescriptor.php src/Test/Unit/Model/FieldDescriptorTest.php src/etc/di.xml +git commit -m "feat: add FieldDescriptor DTO for structured admin form schema" +``` + +--- + +## Task 7: Atomic API swap — interface + 11 services + block + phtml + +This is the big one. The interface, every implementer, the block that aggregates them, and the phtml that renders them all change in one commit so nothing is ever broken. Apply all code changes, then commit once. + +**Files:** +- Modify: `src/Api/Data/AiServiceConfigurationInterface.php` +- Modify: `src/AiServices/Anthropic.php` +- Modify: `src/AiServices/Azure.php` +- Modify: `src/AiServices/Deepseek.php` +- Modify: `src/AiServices/Google.php` +- Modify: `src/AiServices/Grok.php` +- Modify: `src/AiServices/HuggingFace.php` +- Modify: `src/AiServices/LmStudio.php` +- Modify: `src/AiServices/Ollama.php` +- Modify: `src/AiServices/OpenAi.php` +- Modify: `src/AiServices/OpenRouter.php` +- Modify: `src/AiServices/Xai.php` +- Modify: `src/Block/Adminhtml/Configuration/Services.php` +- Modify: `src/view/adminhtml/templates/system/config/form/field/services.phtml` + +**Step 1: Update `AiServiceConfigurationInterface`** + +Replace contents: + +```php + value => label; empty array for services with no model list + */ + public function getSupportedModels(): array; +} +``` + +**Step 2: Create a helper trait to DRY the field construction** + +`src/AiServices/FieldFactoryTrait.php`: + +```php +create([ + 'name' => 'apikey', + 'label' => 'API Key', + 'type' => FieldDescriptorInterface::TYPE_PASSWORD, + ]); + } + + private function modelField(FieldDescriptorInterfaceFactory $factory, array $supportedModels): FieldDescriptorInterface + { + $options = []; + foreach ($supportedModels as $value => $label) { + $options[] = ['value' => (string) $value, 'label' => (string) $label]; + } + return $factory->create([ + 'name' => 'model', + 'label' => 'Model', + 'type' => FieldDescriptorInterface::TYPE_SELECT, + 'options' => $options, + ]); + } + + private function baseUrlField(FieldDescriptorInterfaceFactory $factory, string $default): FieldDescriptorInterface + { + return $factory->create([ + 'name' => 'base_url', + 'label' => 'Base URL', + 'type' => FieldDescriptorInterface::TYPE_TEXT, + 'default' => $default, + ]); + } +} +``` + +**Step 3: Rewrite each `AiServices/*` class** + +Template for cloud services (OpenAI, Anthropic, Deepseek, Grok/xAI, Google, OpenRouter, HuggingFace, Azure): + +```php + 'GPT-4o', + 'gpt-4o-mini' => 'GPT-4o mini', + 'gpt-4-turbo' => 'GPT-4 Turbo', + 'o1' => 'o1', + 'o1-mini' => 'o1 mini', + ]; + } + + public function getConfigurationFields(): array + { + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; + } +} +``` + +Apply this pattern to each of the 11 services. Model lists per service (current as of 2026-04-20): + +| Service | `getCode()` | `getName()` | Supported models | +|---|---|---|---| +| `Anthropic` | `anthropic` | `Anthropic` | `claude-opus-4-7` → `Claude Opus 4.7`, `claude-sonnet-4-6` → `Claude Sonnet 4.6`, `claude-haiku-4-5-20251001` → `Claude Haiku 4.5` | +| `Azure` | `azure` | `Azure OpenAI` | same list as OpenAI | +| `Deepseek` | `deepseek` | `DeepSeek` | `deepseek-chat` → `DeepSeek V3`, `deepseek-reasoner` → `DeepSeek R1` | +| `Google` | `google` | `Google Gemini` | `gemini-2.0-pro` → `Gemini 2.0 Pro`, `gemini-2.0-flash` → `Gemini 2.0 Flash`, `gemini-1.5-pro` → `Gemini 1.5 Pro` | +| `Grok` | `grok` | `Grok` | `grok-2` → `Grok 2`, `grok-2-mini` → `Grok 2 mini` | +| `HuggingFace` | `huggingface` | `Hugging Face` | empty array; add a free-text `model` field instead of a select | +| `OpenAi` | `openai` | `OpenAI` | (as above) | +| `OpenRouter` | `openrouter` | `OpenRouter` | empty; free-text `model` field | +| `Xai` | `xai` | `xAI` | same as Grok | + +For the two local services (LM Studio, Ollama), there is no API key — just a base URL + free-text model: + +```php +final class Ollama implements AiServiceConfigurationInterface +{ + use FieldFactoryTrait; + + public function __construct( + private readonly FieldDescriptorInterfaceFactory $fieldFactory, + ) {} + + public function getCode(): string { return 'ollama'; } + public function getName(): string { return 'Ollama'; } + public function getSupportedModels(): array { return []; } + + public function getConfigurationFields(): array + { + return [ + $this->baseUrlField($this->fieldFactory, 'http://localhost:11434'), + $this->fieldFactory->create([ + 'name' => 'model', + 'label' => 'Model', + 'type' => \MageOS\AiBase\Api\Data\FieldDescriptorInterface::TYPE_TEXT, + ]), + ]; + } +} +``` + +Apply the same shape to `LmStudio` with default URL `http://localhost:1234`. + +**Step 4: Update `Block\Adminhtml\Configuration\Services`** + +Replace `src/Block/Adminhtml/Configuration/Services.php` with: + +```php + + */ + public function getServicesButtons(): array + { + return array_map( + fn (AiServiceConfigurationInterface $service) => [ + 'code' => $service->getCode(), + 'name' => $service->getName(), + ], + $this->services, + ); + } + + /** + * @return string JSON object keyed by service code, each value is a list of field descriptors as arrays + */ + public function getServicesSchemaJson(): string + { + $schema = []; + foreach ($this->services as $service) { + $schema[$service->getCode()] = array_map( + fn ($field) => [ + 'name' => $field->getName(), + 'label' => $field->getLabel(), + 'type' => $field->getType(), + 'options' => $field->getOptions(), + 'default' => $field->getDefault(), + ], + $service->getConfigurationFields(), + ); + } + return $this->jsonSerializer->serialize($schema); + } + + protected function _prepareToRender(): void + { + $this->addColumn('service', [ + 'label' => __('Service'), + 'class' => 'required-entry', + ]); + + $this->_addAfter = false; + $this->_addButtonLabel = __('Add Service'); + } +} +``` + +**Step 5: Rewrite `services.phtml`** + +Replace `src/view/adminhtml/templates/system/config/form/field/services.phtml`: + +```php +getHtmlId() ?: '_' . uniqid(); +$_colspan = $block->isAddAfter() ? 2 : 1; +?> +
+
+ + + + getColumns() as $column): ?> + + + + + + + + + + + +
escapeHtml($column['label']) ?> + escapeHtml(__('Action')) ?> +
+
    + getServicesButtons() as $button): ?> +
  • + escapeHtml($button['name']) ?> +
  • + +
+
+
+ + + +
+``` + +**Step 6: Verify the repo parses** + +Run: `php -l src/Model/AiServiceSelector.php && php -l src/Block/Adminhtml/Configuration/Services.php` +Expected: `No syntax errors detected` for both. + +Then for each `AiServices/*.php`: + +Run: `for f in src/AiServices/*.php; do php -l "$f"; done` +Expected: `No syntax errors detected` × 11 (ignore `FieldFactoryTrait.php` if matched — it also lints clean). + +**Step 7: Commit** + +```bash +git add src/Api/Data/AiServiceConfigurationInterface.php src/AiServices/ src/Block/Adminhtml/Configuration/Services.php src/view/adminhtml/templates/system/config/form/field/services.phtml +git commit -m "feat!: replace HTML-template config with FieldDescriptor schema + +BREAKING CHANGE: AiServiceConfigurationInterface::getConfigurationTemplate() +is removed. Implementers now return FieldDescriptor[] from getConfigurationFields() +and (optionally) a model list from getSupportedModels(). + +Admin form phtml now renders fields by type from a JSON schema rather than +substituting HTML template strings. Storage format is unchanged, so the +AiServiceSelector consumer API is fully backwards compatible." +``` + +--- + +## Task 8: Parametrised smoke test for all 11 services + +**Files:** +- Create: `src/Test/Unit/AiServices/ServicesTest.php` + +**Step 1: Write the test** + +```php +createMock(FieldDescriptorInterfaceFactory::class); + $stub->method('create')->willReturnCallback( + fn (array $data) => new FieldDescriptor( + name: $data['name'], + label: $data['label'], + type: $data['type'], + options: $data['options'] ?? [], + default: $data['default'] ?? null, + ) + ); + $this->fieldFactory = $stub; + } + + /** + * @dataProvider service_classes + */ + public function test_service_exposes_required_metadata(string $className): void + { + /** @var AiServiceConfigurationInterface $service */ + $service = new $className($this->fieldFactory); + + self::assertNotEmpty($service->getCode(), "$className::getCode() must be non-empty"); + self::assertNotEmpty($service->getName(), "$className::getName() must be non-empty"); + + $fields = $service->getConfigurationFields(); + self::assertNotEmpty($fields, "$className::getConfigurationFields() must return at least one field"); + foreach ($fields as $field) { + self::assertInstanceOf(FieldDescriptorInterface::class, $field); + self::assertNotEmpty($field->getName()); + self::assertNotEmpty($field->getLabel()); + self::assertContains( + $field->getType(), + [FieldDescriptorInterface::TYPE_TEXT, FieldDescriptorInterface::TYPE_PASSWORD, FieldDescriptorInterface::TYPE_SELECT], + ); + } + + self::assertIsArray($service->getSupportedModels()); + } + + /** + * @return array + */ + public static function service_classes(): array + { + return [ + 'Anthropic' => [\MageOS\AiBase\AiServices\Anthropic::class], + 'Azure' => [\MageOS\AiBase\AiServices\Azure::class], + 'Deepseek' => [\MageOS\AiBase\AiServices\Deepseek::class], + 'Google' => [\MageOS\AiBase\AiServices\Google::class], + 'Grok' => [\MageOS\AiBase\AiServices\Grok::class], + 'HuggingFace'=> [\MageOS\AiBase\AiServices\HuggingFace::class], + 'LmStudio' => [\MageOS\AiBase\AiServices\LmStudio::class], + 'Ollama' => [\MageOS\AiBase\AiServices\Ollama::class], + 'OpenAi' => [\MageOS\AiBase\AiServices\OpenAi::class], + 'OpenRouter' => [\MageOS\AiBase\AiServices\OpenRouter::class], + 'Xai' => [\MageOS\AiBase\AiServices\Xai::class], + ]; + } +} +``` + +**Step 2: Run the test** + +Run: `vendor/bin/phpunit src/Test/Unit/AiServices/ServicesTest.php` +Expected: 11 tests, 11 passed. + +**Step 3: Commit** + +```bash +git add src/Test/Unit/AiServices/ServicesTest.php +git commit -m "test: add parametrised smoke test covering all 11 AiServices classes" +``` + +--- + +## Task 9: Integration test — config round-trip + +**Files:** +- Create: `src/Test/Integration/Model/AiServiceSelectorTest.php` +- Create: `phpunit.xml.dist` (if missing) + +**Step 1: Create `phpunit.xml.dist` at repo root if not already present** + +```xml + + + + + src/Test/Unit + + + src/Test/Integration + + + +``` + +Note: the Graycore `check-extension` workflow provides its own Magento-integration-test bootstrap — we don't need to supply one. This repo-root file is only for local sanity runs of the Unit suite. + +**Step 2: Create the integration test** + +```php +objectManager = Bootstrap::getObjectManager(); + $this->configWriter = $this->objectManager->get(WriterInterface::class); + } + + protected function tearDown(): void + { + $this->configWriter->delete('mageos_ai/services/configuration'); + } + + public function test_round_trips_configuration_through_scope_config(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'sk-test', 'model' => 'gpt-4o']], + '_row2' => ['anthropic' => ['apikey' => 'sk-ant', 'model' => 'claude-sonnet-4-6']], + ], JSON_THROW_ON_ERROR); + $this->configWriter->save('mageos_ai/services/configuration', $json); + + $this->objectManager->get(Config::class)->clean(); + + /** @var AiServiceSelectorInterface $selector */ + $selector = $this->objectManager->get(AiServiceSelectorInterface::class); + + $services = $selector->getAll(); + self::assertCount(2, $services); + self::assertSame('openai', $services[0]->getCode()); + self::assertSame(['apikey' => 'sk-test', 'model' => 'gpt-4o'], $services[0]->getConfiguration()); + self::assertSame('anthropic', $services[1]->getCode()); + + $openAiOnly = $selector->getByCode('openai'); + self::assertCount(1, $openAiOnly); + } +} +``` + +**Step 3: Commit** + +```bash +git add src/Test/Integration/Model/AiServiceSelectorTest.php phpunit.xml.dist +git commit -m "test: add integration test covering config round-trip through ScopeConfig" +``` + +--- + +## Task 10: strict_types sweep — verify full coverage + +**Files:** any `src/**/*.php` still missing `declare(strict_types=1)`. + +**Step 1: Locate any stragglers** + +Run: `grep -L 'declare(strict_types=1);' src/**/*.php` + +Expected: empty output. If any file is listed, add the declaration as the first statement after `getObjectManager(); var_dump(count($om->get(\MageOS\AiBase\Api\AiServiceSelectorInterface::class)->getAll()));' +``` +Expected: `int(2)`. + +**Step 7: Delete rows + null path check** + +In admin, delete both service rows, click **Save Config**. +Re-run the PHP one-liner from Step 6. +Expected: `int(0)` (exercises the null/empty-config defensive branch). + +**Step 8: No commit needed in this repo; commit the demo change-set separately** + +The change to `/Users/david/Herd/mage-os-typesense/composer.json` is a dev convenience, not part of this module's release. Optionally commit it there in isolation: + +```bash +cd /Users/david/Herd/mage-os-typesense +git add composer.json composer.lock +git commit -m "chore: wire path repo for mage-os/module-ai-base" +``` + +--- + +## Task 12: Tag `v1.0.0` + +**Step 0: Apply release-polish deferred from Task 1 code review** + +Before tagging: +- Add a `## [Unreleased]` section to `CHANGELOG.md` at the top (gives future PRs a clear landing spot). +- Add Keep-a-Changelog preamble lines after the intro: + ``` + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), + and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ``` +- Add a compare-link footer to `CHANGELOG.md`: + ``` + [Unreleased]: https://github.com/mage-os/module-ai-base/compare/v1.0.0...HEAD + [1.0.0]: https://github.com/mage-os/module-ai-base/releases/tag/v1.0.0 + ``` +- Create `.gitattributes` at repo root: + ``` + /.github export-ignore + /docs export-ignore + /src/Test export-ignore + /phpcs.xml.dist export-ignore + /phpunit.xml.dist export-ignore + /CLAUDE.md export-ignore + ``` + (Keeps dev scaffolding out of the dist tarball that composer serves from a tagged release.) +- Decide on LICENSE presentation: either keep the single `LICENSE.md` with both texts, or split into `LICENSE_OSL.md` + `LICENSE_AFL.md` to match the Mage-OS core convention and make GitHub's license detector show "Other (OSL-3.0 OR AFL-3.0)" correctly. Current choice: keep single file (documented here). +- Decide on model-list freshness for `AiServices/*`. Task 7 shipped "curated baseline" lists — Google's `gemini-2.0-pro` is a plausible typo (catalog jumped `1.5-pro` → `2.0-flash` → `2.5-pro` without a `2.0-pro` general release), and several other entries (`grok-2`, `o1-mini`) are superseded by newer releases in the 2026-04-20 catalog. Admins can override via `` per service, so these are defaults, not contracts. Options: (a) refresh every provider's list against their live catalog before tagging, (b) ship as-is and frame them in `CHANGELOG.md` as "seed defaults, override per-install", (c) thin the lists to only a single verified baseline model per provider + free-text for everything else. Recommended default for v1.0.0: option (b) — declarative config is the right place for admins to customise, and overclaiming "latest" in docs is the only real failure mode. + +Commit these together before Step 1 with message `chore: release-polish per code review (gitattributes, changelog hygiene)`. + +**Step 1: Final verification** + +Run all pre-release checks: + +```bash +cd /Users/david/Herd/module-ai-base +git status # clean +git log --oneline -15 # sensible history, no fixups +composer validate --strict +vendor/bin/phpunit --testsuite Unit # all pass +``` + +Expected: clean working tree, valid composer, all tests green. + +**Step 2: Push main** + +```bash +git push origin main +``` + +**Step 3: Wait for CI to go green** + +Watch the Actions tab on GitHub — `check-extension` workflow should run all four jobs against the Mage-OS matrix and pass. If anything fails, fix on a follow-up commit rather than tagging a red release. + +**Step 4: Tag and push** + +```bash +git tag -a v1.0.0 -m "v1.0.0 — initial stable release" +git push origin v1.0.0 +``` + +**Step 5: Draft a GitHub Release** + +On github.com/mage-os/module-ai-base/releases, click **Draft a new release**, choose tag `v1.0.0`, paste the `## [1.0.0]` section of `CHANGELOG.md` as the body. Publish. + +**Step 6: Manual packagist submission** + +On packagist.org → Submit → paste `https://github.com/mage-os/module-ai-base`. Confirm the package shows the `1.0.0` version with correct metadata. Enable the GitHub webhook so future tags auto-publish. + +**Step 7: Verify end-to-end** + +From any scratch directory: +```bash +mkdir /tmp/ai-base-smoke && cd /tmp/ai-base-smoke +composer require mage-os/module-ai-base:^1.0.0 --no-install --dry-run +``` +Expected: composer resolves the package without errors, showing `mage-os/module-ai-base 1.0.0`. + +--- + +## Done + +All done-done criteria met: +- Graycore `check-extension` CI runs on every PR, matrix-testing against current Mage-OS versions. +- Unit + integration tests cover the selector hardening, the field DTO, every service, and a full config round-trip. +- `FieldDescriptor` schema replaces string-template HTML; model lists are declarative. +- Demo install on `mage-os-typesense` exercises the admin UI end-to-end and both `getAll()` data paths. +- `v1.0.0` tagged, released, on packagist. diff --git a/docs/plans/2026-04-21-community-standards-design.md b/docs/plans/2026-04-21-community-standards-design.md new file mode 100644 index 0000000..0a90d9b --- /dev/null +++ b/docs/plans/2026-04-21-community-standards-design.md @@ -0,0 +1,106 @@ +# Community Standards Design + +**Date:** 2026-04-21 +**Scope:** Add the community health files recommended by opensource.guide and GitHub's "Community Standards" checklist to `mage-os/module-ai-base`. + +## Goal + +Bring the repo up to GitHub's "Community Standards" green-check state with the essentials: Code of Conduct, Contributing guide, Security policy, issue templates, and a PR template. Scope is deliberately narrow — no governance docs, no roadmap, no maintainers list. + +## Files to add + +``` +CODE_OF_CONDUCT.md +CONTRIBUTING.md +SECURITY.md +.github/ISSUE_TEMPLATE/bug_report.yml +.github/ISSUE_TEMPLATE/feature_request.yml +.github/ISSUE_TEMPLATE/config.yml +.github/PULL_REQUEST_TEMPLATE.md +``` + +## Files to modify + +- `README.md` — add short **Contributing** and **Security** sections linking to the new files. + +## Content specification + +### CODE_OF_CONDUCT.md +Verbatim Contributor Covenant v2.1. Enforcement contact: `david@run-as-root.sh`. + +### CONTRIBUTING.md +Short, practical. Sections: +- **Reporting bugs** — link to issue template. +- **Proposing changes** — link to feature-request template; encourage an issue before a large PR. +- **Local development** — use a composer path repository into a Magento 2 install; `bin/magento module:enable MageOS_AiBase && bin/magento setup:upgrade && bin/magento setup:di:compile`. +- **Coding conventions** — references `phpcs.xml.dist` for PHPCS rules; match existing style (PHP 8 constructor property promotion + `readonly`; no `declare(strict_types=1)` to stay consistent with surrounding code). +- **Branching** — `feat/*`, `fix/*` branches off `main`; PRs target `main`. +- **Tests** — run via `vendor/bin/phpunit -c phpunit.xml.dist`. +- **Commit style** — conventional-style prefix recommended but not enforced. + +### SECURITY.md +- **Supported versions** — only the latest released version. +- **Reporting** — preferred channel is GitHub Private Vulnerability Reporting; fallback via email to `david@run-as-root.sh` **or** `security@mage-os.org`. +- **What to include** — reproduction steps, affected version, expected impact. +- **Response SLA** — acknowledge within 7 days. +- **Disclosure** — please do not open public issues for vulnerabilities. + +### .github/ISSUE_TEMPLATE/bug_report.yml +GitHub issue form. Fields: +- Module version (text, required) +- Magento version (text, required) +- Expected behavior (textarea, required) +- Actual behavior (textarea, required) +- Reproduction steps (textarea, required) +- Logs / stack trace (textarea, optional) + +### .github/ISSUE_TEMPLATE/feature_request.yml +GitHub issue form. Fields: +- Problem (textarea, required) +- Proposed solution (textarea, required) +- Alternatives considered (textarea, optional) + +### .github/ISSUE_TEMPLATE/config.yml +```yaml +blank_issues_enabled: false +``` +No `contact_links` — user declined. + +### .github/PULL_REQUEST_TEMPLATE.md +Sections: +- Summary +- Linked issue (`Closes #`) +- Change type checklist (bug fix / feature / docs / chore / breaking) +- Test plan +- Docs updated (checkbox) + +### README.md additions +Two short sections near the end: + +```markdown +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Security + +Security issues: see [SECURITY.md](SECURITY.md) — please do **not** file public issues for vulnerabilities. +``` + +## Out of scope + +- GOVERNANCE.md, MAINTAINERS.md, roadmap — overkill for this module. +- SUPPORT.md — no dedicated support channel to point at. +- README badges — separate concern. +- FUNDING.yml — not requested. + +## Success criteria + +- GitHub repo "Community Standards" page shows all items checked. +- Opening a new issue from the GitHub UI presents the two templates (bug / feature) and hides the blank option. +- Opening a new PR pre-fills the PR template. +- `SECURITY.md` surfaces the "Report a vulnerability" button on the repo's Security tab (requires Private Vulnerability Reporting to be enabled in repo settings — noted as an operational follow-up, not a code change). + +## Operational follow-up (not code) + +- Enable **Private Vulnerability Reporting** in repo Settings → Code security & analysis, so the link in `SECURITY.md` resolves to the built-in reporting UI. diff --git a/docs/plans/2026-04-21-community-standards-plan.md b/docs/plans/2026-04-21-community-standards-plan.md new file mode 100644 index 0000000..b622611 --- /dev/null +++ b/docs/plans/2026-04-21-community-standards-plan.md @@ -0,0 +1,472 @@ +# Community Standards Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add GitHub "Community Standards" files (Code of Conduct, Contributing, Security, issue + PR templates) to `mage-os/module-ai-base`. + +**Architecture:** Static markdown + YAML files in the repo root and `.github/`. No code paths are touched. Each file is self-contained; tasks are independent and can be done in any order. + +**Tech Stack:** Markdown, GitHub issue forms (YAML). + +**Design doc:** `docs/plans/2026-04-21-community-standards-design.md` + +**Branch:** Work on the currently-checked-out branch (`feat/v1.0.0-release`). Do NOT create a new branch. + +**Verification approach:** This repo has no markdown linter or YAML linter configured. Verification per task = visual inspection of the rendered file + a `python -c "import yaml; yaml.safe_load(open('...'))"` parse check for the two issue form YAMLs. + +--- + +### Task 1: Add CODE_OF_CONDUCT.md + +**Files:** +- Create: `CODE_OF_CONDUCT.md` + +**Step 1: Fetch the Contributor Covenant v2.1 text** + +Download the canonical markdown version: + +```bash +curl -fsSL https://raw.githubusercontent.com/EthicalSource/contributor_covenant/release/content/version/2/1/code_of_conduct.md -o CODE_OF_CONDUCT.md +``` + +**Step 2: Substitute the enforcement contact** + +The template contains the literal placeholder `[INSERT CONTACT METHOD]`. Replace it with `david@run-as-root.sh`. + +Use Edit tool: +- old_string: `[INSERT CONTACT METHOD]` +- new_string: `david@run-as-root.sh` + +**Step 3: Verify** + +```bash +grep -c "david@run-as-root.sh" CODE_OF_CONDUCT.md # expect: 1 +grep -c "INSERT CONTACT METHOD" CODE_OF_CONDUCT.md # expect: 0 +head -3 CODE_OF_CONDUCT.md # expect: "# Contributor Covenant Code of Conduct" +``` + +**Step 4: Commit** + +```bash +git add CODE_OF_CONDUCT.md +git commit -m "docs: add Contributor Covenant v2.1 code of conduct" +``` + +--- + +### Task 2: Add CONTRIBUTING.md + +**Files:** +- Create: `CONTRIBUTING.md` + +**Step 1: Write the file** + +Contents: + +```markdown +# Contributing to MageOS_AiBase + +Thanks for your interest in improving this module. This guide covers how to report issues, propose changes, and develop locally. + +## Reporting bugs + +Open an issue using the **Bug report** template. Please include your Magento version, module version, and reproduction steps. + +## Proposing changes + +For anything larger than a typo fix, please open a **Feature request** issue first so we can align on the approach before you invest time in a PR. + +## Local development + +This repo contains a Magento 2 module, not a runnable Magento instance. To work on it: + +1. Clone this repository outside your Magento install. +2. In your Magento 2 project, add a Composer path repository pointing at your clone: + ```json + "repositories": [ + { "type": "path", "url": "/absolute/path/to/module-ai-base" } + ] + ``` +3. Require the module and enable it: + ```bash + composer require mage-os/module-ai-base:@dev + bin/magento module:enable MageOS_AiBase + bin/magento setup:upgrade + bin/magento setup:di:compile + ``` + +## Coding conventions + +- PHP 8 constructor property promotion with `readonly` is the norm — match it for new classes. +- No `declare(strict_types=1)` header is used in existing files; keep things consistent unless you're explicitly modernizing in a dedicated PR. +- Coding standard: see `phpcs.xml.dist`. Run `vendor/bin/phpcs` from a host Magento install that includes this module. + +## Branching and pull requests + +- Branch off `main` using `feat/` or `fix/`. +- Target PRs at `main`. +- Keep PRs focused — one concern per PR. +- Fill in the PR template (summary, linked issue, test plan). + +## Tests + +Run the test suite with: + +```bash +vendor/bin/phpunit -c phpunit.xml.dist +``` + +New PHP unit tests must be `final` classes and use `snake_case` method names. + +## Commit messages + +Conventional-style prefixes (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`) are encouraged but not enforced. +``` + +**Step 2: Verify** + +```bash +head -1 CONTRIBUTING.md # expect: "# Contributing to MageOS_AiBase" +wc -l CONTRIBUTING.md # sanity check: file is non-trivial +``` + +**Step 3: Commit** + +```bash +git add CONTRIBUTING.md +git commit -m "docs: add CONTRIBUTING guide" +``` + +--- + +### Task 3: Add SECURITY.md + +**Files:** +- Create: `SECURITY.md` + +**Step 1: Write the file** + +Contents: + +```markdown +# Security Policy + +## Supported versions + +Only the **latest released version** receives security updates. + +## Reporting a vulnerability + +Please do **not** open public GitHub issues for security vulnerabilities. + +Preferred channel: **GitHub Private Vulnerability Reporting**. Go to the [Security tab](../../security/advisories/new) of this repository and click "Report a vulnerability". + +If you cannot use GitHub, email one of: + +- `david@run-as-root.sh` +- `security@mage-os.org` + +Please include: + +- Affected module version and Magento version +- Reproduction steps or proof-of-concept +- Expected impact + +We aim to acknowledge reports within **7 days**. Once a fix is available, we will coordinate a disclosure timeline with you. +``` + +**Step 2: Verify** + +```bash +head -1 SECURITY.md # expect: "# Security Policy" +grep -c "security@mage-os.org" SECURITY.md # expect: 1 +grep -c "david@run-as-root.sh" SECURITY.md # expect: 1 +``` + +**Step 3: Commit** + +```bash +git add SECURITY.md +git commit -m "docs: add SECURITY policy" +``` + +--- + +### Task 4: Add issue template config + +**Files:** +- Create: `.github/ISSUE_TEMPLATE/config.yml` + +**Step 1: Write the file** + +```yaml +blank_issues_enabled: false +``` + +**Step 2: Verify it parses as YAML** + +```bash +python3 -c "import yaml; print(yaml.safe_load(open('.github/ISSUE_TEMPLATE/config.yml')))" +``` +Expected output: `{'blank_issues_enabled': False}` + +**Step 3: Commit** + +```bash +git add .github/ISSUE_TEMPLATE/config.yml +git commit -m "ci: disable blank issues" +``` + +--- + +### Task 5: Add bug report issue form + +**Files:** +- Create: `.github/ISSUE_TEMPLATE/bug_report.yml` + +**Step 1: Write the file** + +```yaml +name: Bug report +description: Report a bug in MageOS_AiBase +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please fill in the fields below. + - type: input + id: module-version + attributes: + label: Module version + placeholder: "1.0.0" + validations: + required: true + - type: input + id: magento-version + attributes: + label: Magento / Mage-OS version + placeholder: "Magento 2.4.7 / Mage-OS 2.2.0" + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Minimal steps to reproduce the issue. + placeholder: | + 1. Go to ... + 2. Click on ... + 3. See error + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs / stack trace + description: Any relevant output from var/log/ or the browser console. + render: shell + validations: + required: false +``` + +**Step 2: Verify** + +```bash +python3 -c "import yaml; d = yaml.safe_load(open('.github/ISSUE_TEMPLATE/bug_report.yml')); assert d['name'] == 'Bug report'; assert len(d['body']) >= 6; print('ok')" +``` +Expected output: `ok` + +**Step 3: Commit** + +```bash +git add .github/ISSUE_TEMPLATE/bug_report.yml +git commit -m "ci: add bug report issue form" +``` + +--- + +### Task 6: Add feature request issue form + +**Files:** +- Create: `.github/ISSUE_TEMPLATE/feature_request.yml` + +**Step 1: Write the file** + +```yaml +name: Feature request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem are you trying to solve? Who is affected? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How would you like this to work? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any other approaches you considered and why you ruled them out. + validations: + required: false +``` + +**Step 2: Verify** + +```bash +python3 -c "import yaml; d = yaml.safe_load(open('.github/ISSUE_TEMPLATE/feature_request.yml')); assert d['name'] == 'Feature request'; print('ok')" +``` +Expected output: `ok` + +**Step 3: Commit** + +```bash +git add .github/ISSUE_TEMPLATE/feature_request.yml +git commit -m "ci: add feature request issue form" +``` + +--- + +### Task 7: Add PR template + +**Files:** +- Create: `.github/PULL_REQUEST_TEMPLATE.md` + +**Step 1: Write the file** + +```markdown +## Summary + + + +## Linked issue + +Closes # + +## Change type + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Chore / refactor / CI +- [ ] Breaking change + +## Test plan + + + +## Checklist + +- [ ] Tests added or updated +- [ ] Docs / README updated if behavior changed +- [ ] `phpcs` and `phpunit` pass locally +``` + +**Step 2: Verify** + +```bash +head -1 .github/PULL_REQUEST_TEMPLATE.md # expect: "## Summary" +``` + +**Step 3: Commit** + +```bash +git add .github/PULL_REQUEST_TEMPLATE.md +git commit -m "ci: add pull request template" +``` + +--- + +### Task 8: Link community files from README + +**Files:** +- Modify: `README.md` (append two sections to end) + +**Step 1: Read current README end** + +Use Read tool on `README.md` to see current final line (expected: line 47 with the closing backticks of the code block). + +**Step 2: Append Contributing + Security sections** + +Use Edit tool to replace the final line of the file. Exact transformation: + +- old_string: (the last line of the existing README plus a trailing newline — verify with Read first) +- new_string: (same final line) followed by: + +```markdown + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Security + +Security issues: see [SECURITY.md](SECURITY.md). Please do **not** file public issues for vulnerabilities. +``` + +**Step 3: Verify** + +```bash +tail -8 README.md # expect the two new sections +grep -c "CONTRIBUTING.md" README.md # expect: 1 +grep -c "SECURITY.md" README.md # expect: 1 +grep -c "CODE_OF_CONDUCT.md" README.md # expect: 1 +``` + +**Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: link community files from README" +``` + +--- + +### Task 9: Operational follow-up (not code) + +Remind the user to enable **Private Vulnerability Reporting** in the repo settings: + +> GitHub → repository **Settings** → **Code security and analysis** → **Private vulnerability reporting** → **Enable**. + +This is required for the `../../security/advisories/new` link in `SECURITY.md` to resolve to a working reporting form. Do not push this task onto the executor — just surface it in the final summary. + +--- + +## Global success criteria + +After all tasks are done: + +```bash +git status # clean +git log --oneline -10 # 8 new commits in order Task 1..8 +ls CODE_OF_CONDUCT.md CONTRIBUTING.md SECURITY.md +ls .github/ISSUE_TEMPLATE/*.yml .github/PULL_REQUEST_TEMPLATE.md +``` + +All files should exist. The repo's GitHub **Insights → Community Standards** page should then show a full green check once pushed. diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..1bdcd2b --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,7 @@ + + + + src + src/Test/ + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..990e562 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,15 @@ + + + + + Test/Unit + + + Test/Integration + + + diff --git a/src/AiServices/Anthropic.php b/src/AiServices/Anthropic.php index 52580ee..516e99d 100644 --- a/src/AiServices/Anthropic.php +++ b/src/AiServices/Anthropic.php @@ -1,11 +1,20 @@ 'Claude Opus 4.7', + 'claude-sonnet-4-6' => 'Claude Sonnet 4.6', + 'claude-haiku-4-5-20251001' => 'Claude Haiku 4.5', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model - -
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/Azure.php b/src/AiServices/Azure.php index 1a3763d..fb078e6 100644 --- a/src/AiServices/Azure.php +++ b/src/AiServices/Azure.php @@ -1,11 +1,20 @@ 'GPT-4o', + 'gpt-4o-mini' => 'GPT-4o mini', + 'gpt-4-turbo' => 'GPT-4 Turbo', + 'o1' => 'o1', + 'o1-mini' => 'o1 mini', + ]; } - public function getConfigurationTemplate(): string + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Deployment
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/Deepseek.php b/src/AiServices/Deepseek.php index b18675f..633b6e2 100644 --- a/src/AiServices/Deepseek.php +++ b/src/AiServices/Deepseek.php @@ -1,11 +1,20 @@ 'DeepSeek V3', + 'deepseek-reasoner' => 'DeepSeek R1', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/FieldFactoryTrait.php b/src/AiServices/FieldFactoryTrait.php new file mode 100644 index 0000000..ce93770 --- /dev/null +++ b/src/AiServices/FieldFactoryTrait.php @@ -0,0 +1,56 @@ +create([ + 'name' => 'apikey', + 'label' => 'API Key', + 'type' => FieldDescriptorInterface::TYPE_PASSWORD, + ]); + } + + /** + * @param array $supportedModels + */ + private function modelField(FieldDescriptorInterfaceFactory $factory, array $supportedModels): FieldDescriptorInterface + { + $options = []; + foreach ($supportedModels as $value => $label) { + $options[] = ['value' => (string) $value, 'label' => (string) $label]; + } + return $factory->create([ + 'name' => 'model', + 'label' => 'Model', + 'type' => FieldDescriptorInterface::TYPE_SELECT, + 'options' => $options, + ]); + } + + private function baseUrlField(FieldDescriptorInterfaceFactory $factory, string $default): FieldDescriptorInterface + { + return $factory->create([ + 'name' => 'base_url', + 'label' => 'Base URL', + 'type' => FieldDescriptorInterface::TYPE_TEXT, + 'default' => $default, + ]); + } + + private function freeTextModelField(FieldDescriptorInterfaceFactory $factory): FieldDescriptorInterface + { + return $factory->create([ + 'name' => 'model', + 'label' => 'Model', + 'type' => FieldDescriptorInterface::TYPE_TEXT, + ]); + } +} diff --git a/src/AiServices/Google.php b/src/AiServices/Google.php index 9f31bfb..dfbf056 100644 --- a/src/AiServices/Google.php +++ b/src/AiServices/Google.php @@ -1,11 +1,20 @@ 'Gemini 2.0 Pro', + 'gemini-2.0-flash' => 'Gemini 2.0 Flash', + 'gemini-1.5-pro' => 'Gemini 1.5 Pro', + ]; } - public function getConfigurationTemplate(): string + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model - -
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/Grok.php b/src/AiServices/Grok.php index e02eb82..974f716 100644 --- a/src/AiServices/Grok.php +++ b/src/AiServices/Grok.php @@ -1,11 +1,20 @@ 'Grok 2', + 'grok-2-mini' => 'Grok 2 mini', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/HuggingFace.php b/src/AiServices/HuggingFace.php index 58e94dd..3828c4c 100644 --- a/src/AiServices/HuggingFace.php +++ b/src/AiServices/HuggingFace.php @@ -1,11 +1,20 @@ - - API Key - - - - Model - - - - TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->freeTextModelField($this->fieldFactory), + ]; } } diff --git a/src/AiServices/LmStudio.php b/src/AiServices/LmStudio.php index 54c320c..eec6d72 100644 --- a/src/AiServices/LmStudio.php +++ b/src/AiServices/LmStudio.php @@ -1,11 +1,20 @@ - - Base URL - - - - Model - - - - TABLE; + return [ + $this->baseUrlField($this->fieldFactory, 'http://localhost:1234'), + $this->freeTextModelField($this->fieldFactory), + ]; } } diff --git a/src/AiServices/Ollama.php b/src/AiServices/Ollama.php index a88fd1e..bb98c58 100644 --- a/src/AiServices/Ollama.php +++ b/src/AiServices/Ollama.php @@ -1,11 +1,20 @@ - - Base URL - - - - Model - - - - - - TABLE; + return [ + $this->baseUrlField($this->fieldFactory, 'http://localhost:11434'), + $this->freeTextModelField($this->fieldFactory), + ]; } } diff --git a/src/AiServices/OpenAi.php b/src/AiServices/OpenAi.php index 600c221..3055179 100644 --- a/src/AiServices/OpenAi.php +++ b/src/AiServices/OpenAi.php @@ -1,11 +1,20 @@ 'GPT-4o', + 'gpt-4o-mini' => 'GPT-4o mini', + 'gpt-4-turbo' => 'GPT-4 Turbo', + 'o1' => 'o1', + 'o1-mini' => 'o1 mini', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model - -
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/OpenRouter.php b/src/AiServices/OpenRouter.php index ce2215c..bd46d2e 100644 --- a/src/AiServices/OpenRouter.php +++ b/src/AiServices/OpenRouter.php @@ -1,11 +1,20 @@ - - API Key - - - - Model - - - - - - TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->freeTextModelField($this->fieldFactory), + ]; } } diff --git a/src/AiServices/Xai.php b/src/AiServices/Xai.php index 20fff36..1fe91a4 100644 --- a/src/AiServices/Xai.php +++ b/src/AiServices/Xai.php @@ -1,11 +1,20 @@ 'Grok 2', + 'grok-2-mini' => 'Grok 2 mini', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/Api/AiServiceSelectorInterface.php b/src/Api/AiServiceSelectorInterface.php index f6abd7a..c720fa6 100644 --- a/src/Api/AiServiceSelectorInterface.php +++ b/src/Api/AiServiceSelectorInterface.php @@ -1,5 +1,7 @@ */ public function getAll(): array; /** - * @return AiServiceInterface[] + * Returns all configured AI services with the given code, in insertion order. + * Multiple entries per code are possible when an admin registers the same backend more than once. + * + * @return list */ public function getByCode(string $code): array; } diff --git a/src/Api/Data/AiServiceConfigurationInterface.php b/src/Api/Data/AiServiceConfigurationInterface.php index 1db8d43..668f383 100644 --- a/src/Api/Data/AiServiceConfigurationInterface.php +++ b/src/Api/Data/AiServiceConfigurationInterface.php @@ -1,12 +1,21 @@ value => label; empty array for services with no model list + */ + public function getSupportedModels(): array; } diff --git a/src/Api/Data/AiServiceInterface.php b/src/Api/Data/AiServiceInterface.php index da82ea0..1d9d72a 100644 --- a/src/Api/Data/AiServiceInterface.php +++ b/src/Api/Data/AiServiceInterface.php @@ -1,10 +1,15 @@ + */ public function getConfiguration(): array; } diff --git a/src/Api/Data/FieldDescriptorInterface.php b/src/Api/Data/FieldDescriptorInterface.php new file mode 100644 index 0000000..211ed89 --- /dev/null +++ b/src/Api/Data/FieldDescriptorInterface.php @@ -0,0 +1,23 @@ + + */ + public function getOptions(): array; + + public function getDefault(): ?string; +} diff --git a/src/Block/Adminhtml/Configuration/Services.php b/src/Block/Adminhtml/Configuration/Services.php index c546963..d2df998 100644 --- a/src/Block/Adminhtml/Configuration/Services.php +++ b/src/Block/Adminhtml/Configuration/Services.php @@ -1,51 +1,82 @@ services as $service) { + if (!$service instanceof AiServiceConfigurationInterface) { + throw new \InvalidArgumentException(sprintf( + 'Each registered service must implement %s, got %s', + AiServiceConfigurationInterface::class, + get_debug_type($service), + )); + } + } } + /** + * @return array + */ public function getServicesButtons(): array { - return array_map(fn (AiServiceConfigurationInterface $service) => [ - 'code' => $service->getCode(), - 'name' => $service->getName(), - ], $this->services); + return array_map( + fn (AiServiceConfigurationInterface $service) => [ + 'code' => $service->getCode(), + 'name' => $service->getName(), + ], + $this->services, + ); } - public function getServicesTemplates(): array + /** + * @return string JSON object keyed by service code, each value is a list of field descriptors as arrays + */ + public function getServicesSchemaJson(): string { - return array_map(fn (AiServiceConfigurationInterface $service) => [ - 'code' => $service->getCode(), - 'template' => $service->getConfigurationTemplate(), - ], $this->services); + $schema = []; + foreach ($this->services as $service) { + $schema[$service->getCode()] = array_map( + fn (FieldDescriptorInterface $field) => [ + 'name' => $field->getName(), + 'label' => $field->getLabel(), + 'type' => $field->getType(), + 'options' => $field->getOptions(), + 'default' => $field->getDefault(), + ], + $service->getConfigurationFields(), + ); + } + return $this->jsonSerializer->serialize($schema); } protected function _prepareToRender(): void { - $this->addColumn( - 'service', - [ - 'label' => __('Service'), - 'class' => 'required-entry' - ] - ); + $this->addColumn('service', [ + 'label' => __('Service'), + 'class' => 'required-entry', + ]); $this->_addAfter = false; $this->_addButtonLabel = __('Add Service'); diff --git a/src/Model/AiService.php b/src/Model/AiService.php index 0920505..8f777f0 100644 --- a/src/Model/AiService.php +++ b/src/Model/AiService.php @@ -1,10 +1,12 @@ getParsedConfig(); - - return array_filter($services, fn(AiServiceInterface $service) => $service->getCode() === $code); + return array_values(array_filter( + $this->getParsedConfig(), + fn (AiServiceInterface $service) => $service->getCode() === $code, + )); } + /** + * @return AiServiceInterface[] + */ private function getParsedConfig(): array { - $json = json_decode($this->scopeConfig->getValue(self::CONFIG_PATH_AI_SERVICES), true); - if ($json === null) { + $raw = $this->scopeConfig->getValue(self::CONFIG_PATH_AI_SERVICES); + if (!is_string($raw) || $raw === '') { return []; } - return array_map( function(array $item) { - $service = array_first(array_keys($item)); + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } - return $this->aiServiceFactory->create([ - 'code' => $service, - 'configuration' => $item[$service] + $services = []; + foreach ($decoded as $row) { + if (!is_array($row) || $row === []) { + continue; + } + $code = array_key_first($row); + $configuration = $row[$code]; + if (!is_string($code) || !is_array($configuration)) { + continue; + } + $services[] = $this->aiServiceFactory->create([ + 'code' => $code, + 'configuration' => $configuration, ]); - }, $json); + } + + return $services; } } diff --git a/src/Model/FieldDescriptor.php b/src/Model/FieldDescriptor.php new file mode 100644 index 0000000..eab4385 --- /dev/null +++ b/src/Model/FieldDescriptor.php @@ -0,0 +1,43 @@ +name; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getType(): string + { + return $this->type; + } + + public function getOptions(): array + { + return $this->options; + } + + public function getDefault(): ?string + { + return $this->default; + } +} diff --git a/src/etc/di.xml b/src/etc/di.xml index 7165c61..05f59a2 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -1,8 +1,9 @@ - - + + + diff --git a/src/etc/module.xml b/src/etc/module.xml index 595b94f..30797e1 100644 --- a/src/etc/module.xml +++ b/src/etc/module.xml @@ -1,4 +1,10 @@ - - + + + + + + + diff --git a/src/registration.php b/src/registration.php index f3579eb..357e16e 100644 --- a/src/registration.php +++ b/src/registration.php @@ -1,5 +1,7 @@ getHtmlId() ? $block->getHtmlId() : '_' . uniqid(); +$_htmlId = $block->getHtmlId() ?: '_' . uniqid(); $_colspan = $block->isAddAfter() ? 2 : 1; ?>
- - getColumns() as $columnName => $column): ?> - - - - + + getColumns() as $column): ?> + + + + - - - + + +
escapeHtml($column['label']) ?>escapeHtml(__('Action')) ?>
escapeHtml($column['label']) ?> + escapeHtml(__('Action')) ?> +
-
    - getServicesButtons() as $button): ?> -
  • - -
  • - -
-
+
    + getServicesButtons() as $button): ?> +
  • + escapeHtml($button['name']) ?> +
  • + +
+
- + - - -renderTag('script', [], $scriptString, false) ?>