Using AiM from your extension
The three ways to dispatch a request, from the one-line proxy call to full pipeline access, and what capabilities an extension can ask for.
Trying AiM from the command line
Once a provider configuration exists, you can fire requests without writing an extension first. The aim:test command sends a one-off request through the full pipeline and reports the response, model used, token usage, cost, timing, and whether a request-log row was written:
# Text generation (default capability)
vendor/bin/typo3 aim:test text --prompt "Write a haiku about TYPO3"
# Conversation, against a specific provider
vendor/bin/typo3 aim:test conversation -p "anthropic:*" --prompt "Explain dependency injection"
# Translation
vendor/bin/typo3 aim:test translate --prompt "Hello world" --from English --to German
# Embeddings
vendor/bin/typo3 aim:test embed --prompt "TYPO3 is an open-source CMS"
The capability is a positional argument (text, conversation, translate, or embed; defaults to text). Options:
| Option | Purpose |
|---|---|
--prompt |
The prompt / text to send |
--provider / -p |
Provider notation (openai:gpt-4o, anthropic:*); defaults to the configured default |
--site |
Resolve the provider from a site's settings.yaml instead of the database; takes precedence over --provider. See Configuring a provider in site settings instead for the keys |
--system-prompt |
Optional system prompt |
--max-tokens |
Token limit for the response |
--from / --to |
Source / target language (translate only) |
Because it runs through the real pipeline, every call also lands in the request log. A quick way to see logging, cost tracking, smart routing, and grading in action before integrating the API into your own code.
Calling AiM from PHP
Tier 1: Proxy (recommended)
The simplest way. Extensions never see providers, configurations, or API keys:
use B13\Aim\Ai;
public function __construct(
private readonly Ai $ai,
) {}
// Vision (e.g. alt text generation)
$response = $this->ai->vision(
imageData: base64_encode($fileContent),
mimeType: 'image/jpeg',
prompt: 'Generate alt text for this image',
extensionKey: 'my_extension',
);
echo $response->content;
// Text generation
$response = $this->ai->text(
prompt: 'Write a meta description for a bakery website.',
maxTokens: 160,
extensionKey: 'my_extension',
);
// Translation
$response = $this->ai->translate(
text: 'Hello world',
sourceLanguage: 'English',
targetLanguage: 'German',
extensionKey: 'my_extension',
);
// Conversation
$response = $this->ai->conversation(
messages: [new UserMessage('What is TYPO3?')],
systemPrompt: 'You are a CMS expert.',
extensionKey: 'my_extension',
);
// Embeddings
$response = $this->ai->embed(
input: 'TYPO3 is an open-source CMS',
dimensions: 256,
extensionKey: 'my_extension',
);
// Image generation
$response = $this->ai->generateImage(
prompt: 'A minimalist header illustration of a lighthouse at sunset',
options: ['size' => '1536x1024', 'quality' => 'high'], // provider-specific, passed through as-is
extensionKey: 'my_extension',
);
if ($response instanceof \B13\Aim\Response\ImageGenerationResponse) {
foreach ($response->images as $image) {
if ($image->isUrl()) {
// Some providers return a temporary URL instead of the bytes.
file_put_contents('header.png', file_get_contents($image->url));
} else {
file_put_contents('header.png', base64_decode($image->data));
}
}
}
Image generation with a reference image (style transfer)
Every editor prompting an image generator on their own produces a different look, inconsistent styles, colors, and composition scattered across the site. Instead, pass an existing on-brand image as a style reference alongside the prompt. AiM asks the provider to generate an image-to-image edit guided by it, so headers, teasers, and illustrations stay visually consistent site-wide instead of looking like they came from ten different tools:
$response = $this->ai->generateImage(
prompt: 'A lighthouse at sunset, for the "About us" page header',
referenceImageData: base64_encode(file_get_contents('brand-style-reference.png')),
referenceMimeType: 'image/png',
options: ['size' => '1536x1024'],
extensionKey: 'my_extension',
);
options is a generic pass-through bag since valid keys/values differ per provider (e.g. OpenAI also supports background for transparent images and output_format for png/jpeg/webp). The same option is available on the fluent builder via ->referenceImage($imageData, $mimeType) (see Tier 2 below).
Provider preference
Extensions can request a specific provider without hardcoding configuration UIDs:
// Use OpenAI, admin picks the model
$response = $this->ai->text(
prompt: 'Summarize this.',
provider: 'openai:*',
extensionKey: 'my_extension',
);
// Use a specific model
$response = $this->ai->vision(
imageData: $data,
mimeType: 'image/jpeg',
prompt: 'Describe this image',
provider: 'openai:gpt-4.1',
extensionKey: 'my_extension',
);
A requested provider is the one the request is sent to, and heads its fallback chain; other capable configurations are only tried if it fails. If the requested provider is unavailable (not installed, no configuration), AiM falls back to the default with a logged warning.
Tier 2: Fluent Builder
More control over parameters, still provider-agnostic:
$response = $this->ai->request()
->vision($imageData, 'image/jpeg')
->prompt('Generate alt text for this image')
->systemPrompt('You are an accessibility expert.')
->maxTokens(100)
->temperature(0.3)
->provider('openai:*')
->from('my_extension')
->send();
The same builder covers image generation, including the reference-image style transfer shown above:
$response = $this->ai->request()
->image()
->prompt('A lighthouse at sunset, for the "About us" page header')
->referenceImage($imageData, 'image/png')
->options(['size' => '1536x1024'])
->from('my_extension')
->send();
Tier 3: Direct pipeline access
Full control. You choose the provider, build the request, and dispatch through the pipeline:
use B13\Aim\Capability\TextGenerationCapableInterface;
use B13\Aim\Middleware\AiMiddlewarePipeline;
use B13\Aim\Provider\ProviderResolver;
use B13\Aim\Request\TextGenerationRequest;
$resolvedProvider = $this->providerResolver->resolveForCapability(
TextGenerationCapableInterface::class
);
$request = new TextGenerationRequest(
configuration: $resolvedProvider->configuration,
prompt: 'Write a meta description for a bakery website.',
maxTokens: 160,
metadata: ['extension' => 'my_extension'],
);
$response = $this->pipeline->dispatch($request, $resolvedProvider);
All three tiers flow through the same middleware chain: Logging, governance, cost tracking, and events always fire regardless of how the request was initiated.
Structured output (JSON Schema)
use B13\Aim\Request\ResponseFormat;
$response = $this->ai->text(
prompt: 'Extract the product name and price from: "The MacBook Pro costs $2449.99"',
responseFormat: ResponseFormat::jsonSchema('product', [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'price' => ['type' => 'number'],
],
'required' => ['name', 'price'],
'additionalProperties' => false,
]),
extensionKey: 'my_extension',
);
$data = json_decode($response->content, true);
Tool calling
For simple cases, $ai->toolCalling() is the recommended Tier 1 entry point; no manual provider resolution or pipeline dispatch needed:
use B13\Aim\Request\ToolDefinition;
use B13\Aim\Request\Message\UserMessage;
use B13\Aim\Response\ToolCallingResponse;
$response = $this->ai->toolCalling(
messages: [new UserMessage('What is the weather in Berlin?')],
tools: [
new ToolDefinition(
name: 'get_weather',
description: 'Get current weather for a city',
parameters: [
'type' => 'object',
'properties' => [
'city' => ['type' => 'string', 'description' => 'City name'],
],
'required' => ['city'],
'additionalProperties' => false,
],
strict: true,
),
],
extensionKey: 'my_extension',
);
if ($response instanceof ToolCallingResponse && $response->requiresToolExecution()) {
foreach ($response->toolCalls as $toolCall) {
// $toolCall->name, $toolCall->getDecodedArguments()
}
}
The instanceof check is necessary because toolCalling() returns the base TextResponse type; governance middlewares (access control, budgets, rate limits) can short-circuit with a plain TextResponse before the provider is ever called, so a narrower return type would risk a TypeError on a denied request.
For full control over the request (custom maxTokens, direct fallback-chain access, etc.), Tier 3 direct pipeline access is still available:
use B13\Aim\Request\ToolCallingRequest;
use B13\Aim\Request\ToolDefinition;
use B13\Aim\Request\Message\UserMessage;
$request = new ToolCallingRequest(
configuration: $resolvedProvider->configuration,
messages: [new UserMessage('What is the weather in Berlin?')],
tools: [
new ToolDefinition(
name: 'get_weather',
description: 'Get current weather for a city',
parameters: [
'type' => 'object',
'properties' => [
'city' => ['type' => 'string', 'description' => 'City name'],
],
'required' => ['city'],
'additionalProperties' => false,
],
strict: true,
),
],
);
$response = $this->pipeline->dispatch($request, $resolvedProvider);
if ($response->requiresToolExecution()) {
foreach ($response->toolCalls as $toolCall) {
// $toolCall->name, $toolCall->getDecodedArguments()
}
}
Tool schemas are serialized natively per provider (OpenAI, Anthropic, Gemini, ...) via the underlying Symfony AI bridge. You never need to worry about wire-format differences between providers.
Multi-turn: feeding tool results back
A single round only gets you the model's request to call a tool. To let the model use the result, execute the tool yourself, then send a follow-up ToolCallingRequest carrying the assistant's tool-call message plus the result:
use B13\Aim\Request\Message\AssistantMessage;
use B13\Aim\Request\ToolResult;
// $response is the ToolCallingResponse from the first round above.
$followUp = new ToolCallingRequest(
configuration: $resolvedProvider->configuration,
messages: [
new UserMessage('What is the weather in Berlin?'),
new AssistantMessage($response->content, $response->toolCalls),
],
tools: [/* same tool definitions as the first round */],
toolResults: array_map(
static fn($toolCall) => new ToolResult(
toolCallId: $toolCall->id,
name: $toolCall->name,
output: json_encode(['temperature' => 21, 'condition' => 'sunny']), // your tool's actual result
),
$response->toolCalls,
),
);
$response = $this->pipeline->dispatch($followUp, $resolvedProvider);
// $response->content now contains the model's answer using the tool result.
// Repeat while $response->requiresToolExecution() for agentic, multi-step tool use.
Keep looping (execute tool calls → send toolResults → check requiresToolExecution() again) until the model returns plain content. Always cap the number of rounds: nothing in AiM stops a model from calling tools indefinitely.
Capabilities
Each provider implements one or more capability interfaces:
| Interface | Request | Response | Use Case |
|---|---|---|---|
VisionCapableInterface |
VisionRequest |
TextResponse |
Image analysis, alt text generation |
ConversationCapableInterface |
ConversationRequest |
ConversationResponse |
Conversations, chatbots, multi-turn dialogs |
TextGenerationCapableInterface |
TextGenerationRequest |
TextResponse |
Content generation, summaries |
TranslationCapableInterface |
TranslationRequest |
TextResponse |
Text translation |
ToolCallingCapableInterface |
ToolCallingRequest |
ToolCallingResponse |
Agentic workflows, function calling |
EmbeddingCapableInterface |
EmbeddingRequest |
EmbeddingResponse |
Vector embeddings, semantic search, RAG |
Model-level capabilities
Providers can declare per-model capabilities via modelCapabilities. Models listed get only the specified capabilities. Unlisted models inherit all provider capabilities except specialized ones (e.g. embedding-only models).
#[AsAiProvider(
identifier: 'openai',
supportedModels: ['gpt-4o' => 'GPT-4o', 'text-embedding-3-small' => 'Embeddings'],
modelCapabilities: [
'text-embedding-3-small' => [EmbeddingCapableInterface::class],
// gpt-4o inherits all capabilities EXCEPT embedding
],
)]
Auto model switch
When a provider config has gpt-4o but an embedding request comes in, AiM automatically switches to the cheapest capable model (e.g. text-embedding-3-small) using the same API key. The selection is data-driven: if historical cost data exists in the request log, AiM picks the cheapest model with a good success rate. Otherwise it falls back to the most specialized model.
The switch is:
- Logged with model_requested, model_used, and reroute reason
- Controllable at three levels:
| Level | Setting | Default |
|---|---|---|
| Per config | auto_model_switch toggle in TCA |
On |
| Per user/group | aim.autoModelSwitch = 0 in TSconfig |
On |
| Admin | Always allowed | - |
Part of the AiM documentation, generated from Documentation/Usage.md. Found something wrong? Open an issue.