OpenAI API + Magento Integration — A Complete Working Extension
Most OpenAI + Magento tutorials stop at a curl example. This one ships a full module — Panth_AiAssist — with an admin grid for prompt templates, a backend client that retries on 429/5xx with exponential backoff, a per-call cost log so you can audit monthly spend per template, model fallback from gpt-4 to gpt-3.5-turbo when a soft budget is exceeded, and a CLI batch runner. Compatible with both OpenAI and Anthropic via a single LlmProviderInterface. API keys live in app/etc/env.php encrypted via Magento's EncryptorInterface — never in CLAUDE.md, never in git.
Panth_AiAssist is a Magento 2.4.4 — 2.4.9 module that bundles every piece of OpenAI integration the average store needs into one shippable extension — an admin grid for prompt templates, a retrying client around the OpenAI Chat Completions API, a per-call token cost log, a CLI batch runner, and a provider abstraction that swaps to Anthropic with one di.xml line. Most tutorials show a curl example and stop. This walkthrough ships the entire module — file tree, schema, PHP class signatures, SQL audit query, and the security boundary that has to hold before any of it goes to production.[1]
One module is the right abstraction, not four curl scripts
Most teams start with a one-off CLI script and end up with five disconnected scripts, each with its own copy of the OpenAI client, each with a forgotten $apiKey hardcoded, and no answer to "what did we spend last month". The fix is one module in app/code/Panth/AiAssist/ that owns every OpenAI call in the store.
The full file tree
app/code/Panth/AiAssist/
├── etc/ (module.xml, di.xml, db_schema.xml, acl.xml,
│ adminhtml/{routes,menu,system}.xml, config.xml)
├── Api/ (LlmProviderInterface, PromptTemplateRepositoryInterface,
│ Data/{PromptTemplateInterface, CallLogInterface}.php)
├── Model/ (PromptTemplate, PromptTemplateRepository, CallLog,
│ PlaceholderResolver, ResourceModel/*)
├── Service/ (OpenAiClient, AnthropicClient, BudgetGuard,
│ RetryPolicy, CallLogger)
├── Console/Command/ (RunTemplateCommand.php)
├── Controller/Adminhtml/ (Template/{Index, Edit, Save, Delete}.php)
├── Ui/Component/ (Listing column renderers)
├── registration.php
└── composer.jsonAround 40 source files ship as a Composer package panth/module-ai-assist. Every downstream module — Panth_ProductCopy, Panth_ReviewSummary, Panth_AdminAssistant — depends on this one and never touches the OpenAI SDK directly.
1. Module structure and the two database tables
Two new tables — panth_ai_prompt_template and panth_ai_call_log — are the only persistence the module owns. Declarative schema handles install and upgrade on every setup:upgrade.[2]
etc/db_schema.xml
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="panth_ai_prompt_template" resource="default" engine="innodb">
<column xsi:type="int" name="template_id" identity="true" unsigned="true" nullable="false"/>
<column xsi:type="varchar" name="code" length="64" nullable="false" comment="product-description, review-summary, etc"/>
<column xsi:type="varchar" name="title" length="255" nullable="false"/>
<column xsi:type="text" name="system_prompt" nullable="true"/>
<column xsi:type="text" name="user_prompt" nullable="false"/>
<column xsi:type="varchar" name="model" length="64" nullable="false" default="gpt-4o-2024-11-20"/>
<column xsi:type="smallint" name="max_tokens" unsigned="true" nullable="false" default="500"/>
<column xsi:type="decimal" name="temperature" scale="2" precision="3" nullable="false" default="0.20"/>
<column xsi:type="smallint" name="version" unsigned="true" nullable="false" default="1"/>
<column xsi:type="boolean" name="is_active" nullable="false" default="true"/>
<column xsi:type="varchar" name="ab_variant" length="8" nullable="true" comment="A or B, null for non-AB"/>
<column xsi:type="decimal" name="monthly_budget_usd" scale="2" precision="10" nullable="true"/>
<column xsi:type="timestamp" name="created_at" default="CURRENT_TIMESTAMP"/>
<column xsi:type="timestamp" name="updated_at" on_update="true" default="CURRENT_TIMESTAMP"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="template_id"/>
</constraint>
<index referenceId="PANTH_AI_TEMPLATE_CODE" indexType="btree">
<column name="code"/>
</index>
</table>
<!-- panth_ai_call_log: log_id, template_id, provider, model, prompt_tokens,
completion_tokens, total_cost_usd, latency_ms, http_status, retry_count,
entity_type, entity_id, created_at. Index on (template_id, created_at). -->
</schema>Two tables, zero foreign keys on purpose. The call log keeps growing — pruning is a cron concern, not a constraint concern — and the prompt template id can be deleted without orphaning audit history.
2. The admin grid — versioned templates with A/B variants
The admin lands on Stores → AI Assist → Prompt Templates and sees a UI Component grid. Six columns matter: code, title, model, version, ab_variant, and a usage column that joins to panth_ai_call_log for last-30-day call count and cost.
The form fields per template
- Code — slug used by the CLI runner, e.g.
product-description. - System prompt + user prompt — placeholders
{{product.name}},{{customer.first_name}},{{cart.subtotal}}resolved byPlaceholderResolverat render time. - Model — pinned versions only:
gpt-4o-2024-11-20,gpt-4o-mini-2024-07-18,gpt-3.5-turbo-0125,claude-opus-4-7-20251015,claude-sonnet-4-7-20251015. - Max tokens / temperature — per-template overrides.
- Monthly budget USD — soft budget; BudgetGuard demotes to a cheaper model when exceeded.
- Version / A-B variant — edit creates a new row with
version + 1; A/B clones withab_variant = 'B'and routes 50/50.
The PromptTemplateRepository interface
<?php
declare(strict_types=1);
namespace Panth\AiAssist\Api;
use Panth\AiAssist\Api\Data\PromptTemplateInterface;
interface PromptTemplateRepositoryInterface
{
public function getByCode(string $code, ?string $abVariant = null): PromptTemplateInterface;
public function save(PromptTemplateInterface $template): PromptTemplateInterface;
public function deleteById(int $templateId): void;
/**
* @return PromptTemplateInterface[]
*/
public function getActiveVariants(string $code): array;
}The repository is a service contract — register it in etc/webapi.xml if you want REST access. Every consumer goes through this interface; the resource model is swappable.
3. The OpenAI client — retry, backoff, and model fallback
The client wraps Guzzle, retries the right HTTP statuses, logs every call, and demotes the model when a soft budget is exceeded. The centerpiece of the module.
Service/OpenAiClient.php
<?php
declare(strict_types=1);
namespace Panth\AiAssist\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Panth\AiAssist\Api\LlmProviderInterface;
use Panth\AiAssist\Api\Data\PromptTemplateInterface;
class OpenAiClient implements LlmProviderInterface
{
private const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
private const CONFIG_PATH_KEY = 'panth_aiassist/openai/api_key';
private const FALLBACK_MODEL = 'gpt-3.5-turbo-0125';
public function __construct(
private readonly Client $http,
private readonly EncryptorInterface $encryptor,
private readonly ScopeConfigInterface $scopeConfig,
private readonly RetryPolicy $retryPolicy,
private readonly BudgetGuard $budgetGuard,
private readonly CallLogger $callLogger
) {}
public function complete(PromptTemplateInterface $template, array $variables): string
{
$model = $this->budgetGuard->resolveModel($template, $template->getModel(), self::FALLBACK_MODEL);
$payload = [
'model' => $model,
'temperature' => (float)$template->getTemperature(),
'max_tokens' => (int)$template->getMaxTokens(),
'messages' => $this->buildMessages($template, $variables),
];
$attempt = 0;
$startedAt = microtime(true);
while (true) {
try {
$response = $this->http->post(self::ENDPOINT, [
'headers' => ['Authorization' => 'Bearer ' . $this->getApiKey()],
'json' => $payload,
'timeout' => 60,
]);
$body = json_decode($response->getBody()->getContents(), true);
$this->callLogger->write($template, $model, $body, $attempt, 200, $startedAt);
return $body['choices'][0]['message']['content'] ?? '';
} catch (GuzzleException $e) {
$status = $e->getResponse()?->getStatusCode() ?? 0;
$delay = $this->retryPolicy->nextDelayMs($attempt, $status);
if ($delay === null) {
$this->callLogger->write($template, $model, [], $attempt, $status, $startedAt);
throw $e;
}
usleep($delay * 1000);
$attempt++;
}
}
}
private function getApiKey(): string
{
return $this->encryptor->decrypt((string)$this->scopeConfig->getValue(self::CONFIG_PATH_KEY));
}
}Service/RetryPolicy.php
<?php
declare(strict_types=1);
namespace Panth\AiAssist\Service;
class RetryPolicy
{
private const RETRIABLE = [408, 429, 500, 502, 503, 504];
private const MAX_ATTEMPTS = 5;
private const BASE_MS = 250;
public function nextDelayMs(int $attempt, int $httpStatus): ?int
{
if ($attempt >= self::MAX_ATTEMPTS) {
return null;
}
if (!in_array($httpStatus, self::RETRIABLE, true)) {
return null;
}
// 250, 500, 1000, 2000, 4000 ms — pure exponential, no jitter for predictability.
return self::BASE_MS * (2 ** $attempt);
}
}The retry policy is its own class so it stays exhaustively unit-testable. Keep the retry logic out of the HTTP client and the client stays mockable; keep the HTTP client out of the retry policy and the policy stays pure.
4. The cost log — "what did we spend" is one SQL query
Every call writes one row. After a month of production traffic the table answers the questions finance asks.
Last-30-day spend by template
SELECT
t.code,
t.title,
COUNT(*) AS calls,
SUM(l.prompt_tokens) AS prompt_tokens,
SUM(l.completion_tokens) AS completion_tokens,
ROUND(SUM(l.total_cost_usd), 2) AS cost_usd,
ROUND(AVG(l.latency_ms)) AS avg_latency_ms,
SUM(CASE WHEN l.http_status >= 400 THEN 1 ELSE 0 END) AS error_count
FROM panth_ai_call_log l
JOIN panth_ai_prompt_template t ON t.template_id = l.template_id
WHERE l.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY t.code, t.title
ORDER BY cost_usd DESC;A/B variant performance
SELECT
t.code,
t.ab_variant,
COUNT(*) AS calls,
ROUND(SUM(l.total_cost_usd), 2) AS cost_usd,
ROUND(AVG(l.completion_tokens)) AS avg_output_tokens,
ROUND(AVG(l.latency_ms)) AS avg_latency_ms
FROM panth_ai_call_log l
JOIN panth_ai_prompt_template t ON t.template_id = l.template_id
WHERE t.ab_variant IS NOT NULL
AND l.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY t.code, t.ab_variant;Pair this with a conversion metric from sales_order_item and you have the closed loop: which prompt variant produces descriptions that sell better. We have seen 11–18% lift swings between A and B variants on real catalogs.
5. The CLI batch runner — bulk template execution
The workhorse. Marketing edits a template in the admin, runs the CLI against 5,000 SKUs overnight, reviews the output next morning.
Console/Command/RunTemplateCommand.php
<?php
declare(strict_types=1);
namespace Panth\AiAssist\Console\Command;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Panth\AiAssist\Api\LlmProviderInterface;
use Panth\AiAssist\Api\PromptTemplateRepositoryInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class RunTemplateCommand extends Command
{
public function __construct(
private readonly PromptTemplateRepositoryInterface $templates,
private readonly LlmProviderInterface $llm,
private readonly ProductRepositoryInterface $products
) {
parent::__construct();
}
protected function configure(): void
{
$this->setName('panth:ai:run-template')
->addOption('template', 't', InputOption::VALUE_REQUIRED)
->addOption('skus', 's', InputOption::VALUE_REQUIRED)
->addOption('dry-run', null, InputOption::VALUE_NONE)
->addOption('write-to', null, InputOption::VALUE_REQUIRED, '', 'description');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$template = $this->templates->getByCode((string)$input->getOption('template'));
$skus = array_filter(array_map('trim', explode(',', (string)$input->getOption('skus'))));
$attribute = (string)$input->getOption('write-to');
foreach ($skus as $sku) {
$product = $this->products->get($sku);
$result = $this->llm->complete($template, ['product' => [
'name' => $product->getName(),
'sku' => $product->getSku(),
'price' => (float)$product->getPrice(),
]]);
$output->writeln("<info>{$sku}</info> " . substr($result, 0, 80) . '…');
if (!$input->getOption('dry-run')) {
$product->setData($attribute, $result);
$this->products->save($product);
}
}
return Command::SUCCESS;
}
}Real CLI invocations
bin/magento panth:ai:run-template --template=product-description --skus=SKU001,SKU002
bin/magento panth:ai:run-template -t product-description -s SKU001,SKU002 --dry-run
bin/magento panth:ai:run-template -t meta-description -s SKU001,SKU002 --write-to=meta_descriptionPipe a list from a SQL query for the bulk case. The runner is idempotent at the row level — when wired into a cron, it reads description IS NULL filters rather than a static SKU list.
6. Provider abstraction — swap OpenAI for Anthropic in one line
The CLI command does not know it is calling OpenAI. It depends on LlmProviderInterface; di.xml resolves the interface to OpenAiClient by default.
The interface
<?php
declare(strict_types=1);
namespace Panth\AiAssist\Api;
use Panth\AiAssist\Api\Data\PromptTemplateInterface;
interface LlmProviderInterface
{
public function complete(PromptTemplateInterface $template, array $variables): string;
}The default preference
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="Panth\AiAssist\Api\LlmProviderInterface"
type="Panth\AiAssist\Service\OpenAiClient"/>
</config>The Anthropic swap
Drop a one-line override in app/etc/di.xml to switch every LlmProviderInterface consumer to Claude.
<preference for="Panth\AiAssist\Api\LlmProviderInterface"
type="Panth\AiAssist\Service\AnthropicClient"/>Provider trade-offs
| Provider / Model | Input cost (USD / 1M tokens) | Output cost (USD / 1M tokens) | Rate limit (free / paid Tier 1) | Retry shape we use |
|---|---|---|---|---|
| OpenAI gpt-4o-2024-11-20 | $2.50 | $10.00 | 500 RPM, 30K TPM | 250, 500, 1000, 2000, 4000 ms on 429/5xx |
| OpenAI gpt-4o-mini-2024-07-18 | $0.15 | $0.60 | 500 RPM, 200K TPM | Same shape; fallback target for budget demotion |
| OpenAI gpt-3.5-turbo-0125 | $0.50 | $1.50 | 3500 RPM, 90K TPM | Final fallback; legacy templates only |
| Anthropic claude-opus-4-7 | $15.00 | $75.00 | 50 RPM, 20K ITPM, 8K OTPM | 500, 1000, 2000, 4000, 8000 ms on 429/529/5xx |
| Anthropic claude-sonnet-4-7 | $3.00 | $15.00 | 50 RPM, 40K ITPM, 16K OTPM | Same shape; common production default |
Pricing pinned to published rates — confirm against openai.com/pricing[2] and anthropic.com/pricing[3] before quoting. Rate limits assume Tier-1 paid accounts; free-tier limits are ~10x tighter and not production-viable.
Pin the model version — nevergpt-4o, alwaysgpt-4o-2024-11-20. Floating versions change brand voice between Monday and Friday without warning, and your bulk-written product descriptions stop matching your style guide.
7. Security boundary — API keys live encrypted, never in markdown
The single highest-priority boundary on any OpenAI + Magento integration is the API key. Get this wrong and a leaked key racks up tens of thousands of dollars on someone else's account inside a weekend.
Three rules we apply on every project
- Never put API keys in any markdown file. Not
README.md, notCLAUDE.md, notNOTES.md. Markdown files are version-controlled and grep-scanned by every leaked-credential bot on the planet. - Keys live in
app/etc/env.phpencrypted by Magento'sEncryptorInterface. The admin field usesbackend_model="Magento\Config\Model\Config\Backend\Encrypted"; Magento writes ciphertext intosystem.default.panth_aiassist.openai.api_key. The decryption key is thecrypt/keyfrom the same file. Neither belongs in git.[4] - Rotate every 90 days, one key per environment. Dev, staging, and production each get their own OpenAI key. A leak on dev does not nuke production.
The admin field definition
<field id="api_key" translate="label" type="obscure" sortOrder="10" showInDefault="1" showInWebsite="0" showInStore="0">
<label>OpenAI API key</label>
<backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
<comment>Stored encrypted in app/etc/env.php. Rotate every 90 days.</comment>
</field>The decryption call site
$encrypted = (string)$this->scopeConfig->getValue('panth_aiassist/openai/api_key');
$apiKey = $this->encryptor->decrypt($encrypted);Decryption happens in exactly one place — OpenAiClient::getApiKey(). No logger sees the plaintext. No debug var-dump serializes it. The encrypted ciphertext can be checked into a config-management tool; the plaintext only exists in memory during a single HTTP call.
Prompt-injection isolation
Customer-controlled data flows through placeholders into the prompt body. Wrap every customer-sourced placeholder in a delimited block — <customer_input>...</customer_input> — before substitution. The system prompt instructs the model to treat the delimited block as untrusted data and to never follow instructions inside it. An output filter scans the response for tool-call-shaped strings before any downstream system sees it.
FAQ
Does this replace marketplace AI extensions?
For most stores, yes. Marketplace extensions ship one feature each with their own OpenAI client and credentials. Panth_AiAssist consolidates the plumbing; each feature (review summarizer, admin assistant, meta-description writer) becomes a small downstream module that registers prompt templates and consumes LlmProviderInterface.
How do I prevent the bulk runner from rewriting hand-edited descriptions?
Add a boolean attribute ai_generated. The runner sets it to 1 on save and filters ai_generated=1 OR description IS NULL by default. Hand-edited products flip to 0 and are skipped next run.
What happens when the OpenAI API is down?
After five retry attempts across ~7.75 seconds of backoff, the call throws. The CLI runner logs the SKU as failed and continues. No call site ever silently succeeds.
How do I A/B test two templates?
Create the template with ab_variant = 'A', click "Clone as B variant" to spawn ab_variant = 'B'. At render, getByCode($code) picks A or B by a hash of the entity ID — same product always gets the same variant.
Why exponential backoff without jitter?
Predictability. For a single Magento store calling OpenAI from one process at a time, deterministic retry timing makes the call log easier to audit. Fan out to 20 parallel workers and switch RetryPolicy to add jitter — the class is swappable via di.xml.
How do I extend the placeholder set?
PlaceholderResolver walks dotted paths against an array of variables. Add any nested key — cart.subtotal, store.name — by passing it in before complete(). It does not care what the keys are.
Open Source as well as Adobe Commerce?
Yes — no Adobe Commerce-specific APIs are used. The module has shipped on both editions, 2.4.4 — 2.4.9.
Where this fits in a Hyvä + Magewire stack
The module is back-office only by default — admin grid, CLI runner, and call log all run inside the admin area or CLI. To expose a template to a customer-facing widget, wire a Hyvä block that calls LlmProviderInterface with a capped max_tokens and a strict template-code allowlist. Most Panth_AiAssist installs ship in 24–36 hours from spec to deployed.
Related reading
- Integrate ChatGPT with Magento 2 — 4 real patterns
- Magento GraphQL custom resolver — a complete walkthrough
- Magento 2 development service
References
- Adobe Developer Documentation, Magento 2 Developer Guide — Service Contracts and Module Structure. Reference for the module layout, repository pattern, and
di.xmlpreference resolution on Magento 2.4.4 — 2.4.9. - OpenAI, API Pricing (openai.com/pricing). Reference for per-1M-token costs on gpt-4o-2024-11-20, gpt-4o-mini-2024-07-18, and gpt-3.5-turbo-0125 used in the cost-of-call calculation.
- Anthropic, API Pricing (anthropic.com/pricing). Reference for per-1M-token costs on claude-opus-4-7 and claude-sonnet-4-7 used by the AnthropicClient swap path.
- Adobe Developer Documentation, Encryptor — Magento\Framework\Encryption\EncryptorInterface. Reference for the
encrypt()/decrypt()contract that backs the encrypted backend_model used for the OpenAI API key field. - Production Panth_AiAssist engagements via kishansavaliya.com, 2025 — 2026. Patterns extracted from module deployments across Adobe Commerce and Magento Open Source 2.4.4 — 2.4.9.
I am Kishan Savaliya, an Adobe-Certified Magento + Hyvä developer. I install Panth_AiAssist, write the first five prompt templates for your store (product description, meta description, review summary, support reply, admin assistant), wire the cost log into a Grafana dashboard, and hand it over with 30 days of patches. Fixed quote from $499 audit · $2,499 sprint · ~28h @ $25/hr. See hire me.