Skip to content

Conversation

@BoDonkey
Copy link
Contributor

@BoDonkey BoDonkey commented Dec 26, 2025

Summary

Summarize the changes briefly, including which issue/ticket this resolves. If it closes an existing Github issue, include "Closes #[issue number]"
This PR refactors the AI helper module to use a provider module structure, rather than being hardcoded for OpenAI. It also creates provider files for OpenAI, Anthropic, and Gemini. Finally, it adds a "skills" file that devs can use to speed their creation of custom provider files.
I will still need help before acceptance with the correct steps for the CHANGELOG in the new mono-repo

What are the specific steps to test this change?

For example:

  1. Run the website and log in as an admin
  2. Open a piece manager modal and select several pieces
  3. Click the "Archive" button on the top left of the manager and confirm that it should proceed
  4. Check that all pieces have been archived properly
    I can provide any API keys that are needed for testing purposes.
  1. Install the base module and any desired providers.
  2. Test text generation in rich text - all three providers.
  3. Test image generation and variation - OpenAI and Gemini.

What kind of change does this PR introduce?

(Check at least one)

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • Build-related changes
  • Other

Make sure the PR fulfills these requirements:

  • It includes a) the existing issue ID being resolved, b) a convincing reason for adding this feature, or c) a clear description of the bug it resolves
  • The changelog is updated
  • Related documentation has been updated
  • Related tests have been updated

If adding a new feature without an already open issue, it's best to open a feature request issue first and wait for approval before working on it.

Other information:

@BoDonkey BoDonkey requested a review from boutell December 26, 2025 16:09
@BoDonkey BoDonkey marked this pull request as draft December 27, 2025 10:26
@BoDonkey BoDonkey marked this pull request as ready for review December 29, 2025 16:08
Copy link
Member

@boutell boutell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very cool stuff.

I'm pushing hard to get it right because I can see us pivoting this into core as the way we interface to models in general.

// APOS_AI_HELPER_LOG_USAGE env var
// When true, usage data is logged to the console
// for cost tracking and auditing
logUsage: process.env.APOS_AI_HELPER_LOG_USAGE === 'true' || false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just check it for truthiness IMHO, but if not we must support 1 in particular for consistency with common practice including our own.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logUsage eliminated.

self.aposAiHelperUsage = self.apos.db.collection('aposAiHelperUsage');
await self.aposAiHelperUsage.createIndex(
{ createdAt: -1 },
{ name: 'createdAt_-1' }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd remove this as it's not better than the default convention (I think it might be the default convention).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

userId: 1,
createdAt: -1
},
{ name: 'userId_1_createdAt_-1' }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same re: not adding name if it's just the same as the default convention, which is fine

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are removing the storage, this goes away.

modules: getBundleModuleNames()
},
options: {
textModel: 'gpt-5.1',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have bc provisions I assume?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provisions added.

* @param {boolean} [config.image] - Supports image generation
* @param {boolean} [config.imageVariation] - Supports image variations
*/
registerProvider(provider, config) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think a full apostrophecms module is overkill for a provider. Not worth the startup overhead. I would go with passing an ordinary object. It's fine if it happens to be an apostrophe module.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recognize people will want to override options of the individual providers, but they only need to do that for the ones they are actually using. So if you add textProviderOptions and imageProviderOptions to the main module that should address that concern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to factories.

@@ -0,0 +1,120 @@
module.exports = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be an object and not a full apos module.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Developers can call registerProvider in project level init of the ai-helper module and pass their own provider objects. The main init would call it once for each of the standard providers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed.


// Retry logic for transient failures
let lastError;
for (let attempt = 1; attempt <= 3; attempt++) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make the number of attempts configurable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added and documented as an option.

lastError = e;

// Log for debugging
console.error(`Anthropic request failed (attempt ${attempt}/3):`, e.message);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Providers should be registered as functions that accept a services object with more appropriate logging functions (e.g. access to structured logging).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;

// Gemini returns one image per request,
// so make multiple requests for multiple images
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parallel requests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored for parallel requests.

// Default text generation settings
textMaxTokens: 1000,
imageModel: 'gpt-image-1-mini',
imageSize: '1024x1024',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't check, but make sure these are standardized across providers whenever it makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option names were standardized where possible.

@BoDonkey BoDonkey requested a review from boutell January 5, 2026 20:07
@boutell boutell requested a review from myovchev January 6, 2026 14:23

const result = await provider.module.generateText(req, prompt, {
const result = await provider.generateText(req, prompt, {
maxTokens: aiHelper.options.textMaxTokens,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought you moved this into a textProviderOptions object.

const imageProvider = self.options.imageProvider;

// Register bundled providers
self.registerProvider(openaiProvider(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're trying too hard to provide for multiple text providers, multiple image providers. We don't have a proven need for that yet, so we shouldn't prematurely implement it.

Registering a provider shouldn't immediately instantiate it. It just puts it on the list of candidates that are valid for the textProvider and imageProvider options.

I think this flow makes sense:

  • In init, call self.registerProviders, which calls registerProvider for the built-in choices, and is intended to be extended via extendMethods to register more providers
  • Then call activateProviders, which activates only the needed providers (one for text and one for images)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the providers, it's pretty cheap to instantiate. We basically store a simple object representing the meta AND the interface, it's quite smart. Splitting it to register and activate brings more chore IMO.

I always prefer the idea of explicit registration call (aiHelper.registerProvider(xxx)) vs extending (things can go south much easily).

return {
post: {
async aiHelper(req) {
console.log('req.body', req.body);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup

Copy link
Contributor

@myovchev myovchev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm giving a high level feedback - I like what I see. The provider contract is clean. It's flexible. I can see how even "non-active" providers (not pointed by the respective options) are still available and can be used in different scenarios programmatically (e.g. I can provide a runtime list in my custom project UI).

Few lint issue has to be fixed.

});
},
createVariant() {
if (!this.variantPrompt.trim()) return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lint issue

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. (Hate that rule though.)

* @returns {Array} Array of provider info
*/
listProviders() {
return Array.from(self.providers.entries()).map(([name, info]) => ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lint issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants