Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

- **Bugfix "Capabilities of static catalogs"**: A Symfony AI bridge with a static model catalog
advertises only the capabilities its models have (#41). Every bridge was granted all of them, so a
bridge without a single image model was listed with image generation. Catalog models that map to no
AiM capability (e.g. text-to-speech or speech-to-text models) are no longer treated as chat, vision or
image models, a model the catalog does not list never inherits image generation, and `provider:*`
picks the first enabled configuration whose model supports the requested capability instead of the
first enabled one. A configuration using such a model is skipped when resolving by capability, and
fails explicit resolution and the connection test, instead of failing at the remote API. Flush the
caches after updating, so the DI container is rebuilt.

- **Bugfix "Temperature on current models"**: Requests to Claude Opus 4.7 and later, Sonnet 5 and other
models that no longer accept sampling parameters failed with "`temperature` is deprecated for this
model", because every request carried a temperature whether the caller had asked for one or not.
Expand Down
30 changes: 18 additions & 12 deletions Classes/DependencyInjection/SymfonyAiCompilerPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ final class SymfonyAiCompilerPass implements CompilerPassInterface
];

/**
* All AiM capability interfaces — used as the provider-level default
* when a bridge has no ModelCatalog to read from.
* All AiM capability interfaces, used as the provider-level capabilities
* when the bridge's ModelCatalog yields no models to derive them from
* (dynamic or initially empty catalogs). A static catalog narrows the
* provider to the union of its models' capabilities instead.
*
* Note: ImageGenerationCapableInterface is included here (provider-level "this
* bridge family can do it") but AiProviderManifest::hasModelCapability() special-cases
* it so unlisted/dynamic-catalog models don't silently inherit it, see there for why.
* ImageGenerationCapableInterface is included, but
* AiProviderManifest::hasModelCapability() never grants it to a model the
* catalog does not list, see there for why.
*/
private const ALL_CAPABILITIES = [
VisionCapableInterface::class,
Expand Down Expand Up @@ -125,7 +127,7 @@ public function process(ContainerBuilder $container): void
$bridge['description'],
'tx-aim',
$bridge['models'],
self::ALL_CAPABILITIES,
$bridge['capabilities'],
$serviceId,
new Reference(ContainerInterface::class),
$featuresDefinition,
Expand Down Expand Up @@ -194,7 +196,7 @@ private function resolveNamespace(string $packageName): ?string
/**
* Build a complete bridge definition from a discovered package.
*
* @return array{identifier: string, name: string, description: string, factoryClass: string, factoryParam: string, models: array<string, string>, modelCapabilities: array<string, list<string>>, features: array<string, mixed>}|null
* @return array{identifier: string, name: string, description: string, factoryClass: string, factoryParam: string, models: array<string, string>, capabilities: list<string>, modelCapabilities: array<string, list<string>>, features: array<string, mixed>}|null
*/
private function buildBridgeDefinition(array $package): ?array
{
Expand Down Expand Up @@ -261,13 +263,19 @@ private function buildBridgeDefinition(array $package): ?array
}
}

$capabilities = self::ALL_CAPABILITIES;
if ($modelCapabilities !== []) {
$capabilities = array_values(array_unique(array_merge(...array_values($modelCapabilities))));
}

return [
'identifier' => $identifier,
'name' => $name,
'description' => $this->deriveName($package['name']) . ' models via Symfony AI',
'factoryClass' => $factoryClass,
'factoryParam' => $factoryParam,
'models' => $models,
'capabilities' => $capabilities,
'modelCapabilities' => $modelCapabilities,
'features' => $features,
];
Expand Down Expand Up @@ -296,11 +304,9 @@ private function extractModelsFromCatalog(array $catalogModels, array $features)
// Build display label from model ID
$models[$safeModelId] = $this->buildModelLabel($safeModelId, $modelConfig['capabilities'] ?? []);

// Map Symfony AI capabilities to AiM interfaces
$aiCapabilities = $this->mapCapabilities($modelConfig['capabilities'] ?? []);
if ($aiCapabilities !== []) {
$modelCapabilities[$safeModelId] = $aiCapabilities;
}
// Listed even when nothing maps (e.g. text-to-speech), so the model
// is not treated as unlisted and does not inherit provider capabilities.
$modelCapabilities[$safeModelId] = $this->mapCapabilities($modelConfig['capabilities'] ?? []);

// Detect features from capabilities
foreach ($modelConfig['capabilities'] ?? [] as $cap) {
Expand Down
29 changes: 16 additions & 13 deletions Classes/Domain/Model/AiProviderManifest.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,27 +58,30 @@ public function hasCapability(string $capabilityFqcn): bool
/**
* Check if a specific model supports a capability.
*
* If the model is listed in modelCapabilities, only those capabilities apply.
* Otherwise the model inherits all provider-level capabilities.
* A model listed in modelCapabilities has exactly the capabilities listed
* for it, possibly none. An unlisted model never gets image generation and
* otherwise inherits the provider-level capabilities, minus those only
* specialized catalog models offer.
*
* @param class-string<AiCapabilityInterface> $capabilityFqcn
*/
public function hasModelCapability(string $model, string $capabilityFqcn): bool
{
// No model-level overrides. All models inherit provider capabilities.
// Exception: image generation needs a genuinely different request contract
// (a dedicated image endpoint, not a chat/messages payload) than every other
// capability here, so it's never safe to assume an unlisted model supports it
// (e.g. a dynamic-catalog bridge like Ollama, which has no image endpoint at all).
// It's only granted when a static ModelCatalog explicitly lists it per model.
if ($this->modelCapabilities === []) {
return $capabilityFqcn !== ImageGenerationCapableInterface::class
&& $this->hasCapability($capabilityFqcn);
}
// Model explicitly listed — use only its declared capabilities
// Model explicitly listed: use only its declared capabilities
if (isset($this->modelCapabilities[$model])) {
return in_array($capabilityFqcn, $this->modelCapabilities[$model], true);
}
// Image generation needs a different request contract (a dedicated image
// endpoint, not a chat/messages payload), so an unlisted model is never
// assumed to support it, whether the catalog is discovered at runtime or
// static (e.g. an unknown model on a bridge whose chat model outputs images).
if ($capabilityFqcn === ImageGenerationCapableInterface::class) {
return false;
}
// No model-level data: all models inherit provider capabilities
if ($this->modelCapabilities === []) {
return $this->hasCapability($capabilityFqcn);
}
// Model not listed but modelCapabilities is populated:
// Inherit all provider capabilities EXCEPT those that are exclusive
// to listed models (i.e. capabilities not shared by any listed model
Expand Down
13 changes: 8 additions & 5 deletions Classes/Provider/ProviderResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,10 @@ public function resolveByString(string $notation, string $capabilityFqcn, ?strin
}

$manifest = $this->registry->getProvider($providerIdentifier);
$checkModel = $model !== '*' ? $model : '';
if (!$manifest->hasModelCapability($checkModel, $capabilityFqcn)) {
$supported = $model === '*'
? $manifest->hasCapability($capabilityFqcn)
: $manifest->hasModelCapability($model, $capabilityFqcn);
if (!$supported) {
throw new \RuntimeException(sprintf(
'Provider "%s" with model "%s" does not support capability "%s".',
$providerIdentifier,
Expand All @@ -169,16 +171,17 @@ public function resolveByString(string $notation, string $capabilityFqcn, ?strin

$configs = $this->configurationRepository->findByProviderIdentifier($providerIdentifier);

// Wildcard model: "openai:*" — use the first enabled config as-is
// Wildcard model "provider:*": the first enabled config whose model supports the capability
if ($model === '*') {
foreach ($configs as $config) {
if (!$config->disabled) {
if (!$config->disabled && $manifest->hasModelCapability($config->model, $capabilityFqcn)) {
return new ResolvedProvider($manifest, $config);
}
}
throw new ProviderNotFoundException(sprintf(
'No enabled configuration found for provider "%s".',
'No enabled configuration found for provider "%s" with a model supporting capability "%s".',
$providerIdentifier,
$capabilityFqcn,
), 1773874275);
}

Expand Down
2 changes: 1 addition & 1 deletion Tests/Functional/Middleware/FallbackDispatchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ public function theRateLimiterCountsOneSlotPerDispatchNotPerFallbackHop(): void
#[Test]
public function aGovernanceDenialIsNotRetriedAgainstTheWholeChain(): void
{
$primary = $this->createConfiguration('primary', ['default' => 1, 'be_groups' => '99']);
$this->createConfiguration('primary', ['default' => 1, 'be_groups' => '99']);
$this->createConfiguration('second');
$this->createConfiguration('third');
$this->createBackendUser();
Expand Down
31 changes: 31 additions & 0 deletions Tests/Unit/DependencyInjection/Fixtures/StaticCatalog/Factory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\StaticCatalog;

/**
* Shaped like a third-party bridge factory: the credential
* is the required argument, and as the package name cannot be read as an
* identifier, the bridge's declared name is used.
*/
final class Factory
{
public static function createProvider(string $apiKey, string $name = 'acme'): object
{
return new class() {
public function getName(): string
{
return 'acme';
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\StaticCatalog;

use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Model;

/**
* A static catalog of a third-party bridge:
* chat, embeddings, speech-to-text and text-to-speech, but no image model.
*/
final class ModelCatalog
{
/**
* @return array<string, array{class: class-string, capabilities: list<Capability>}>
*/
public function getModels(): array
{
return [
'chat-model' => [
'class' => Model::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::INPUT_IMAGE,
Capability::TOOL_CALLING,
],
],
'embedding-model' => [
'class' => Model::class,
'capabilities' => [
Capability::INPUT_TEXT,
Capability::EMBEDDINGS,
],
],
'speech-to-text-model' => [
'class' => Model::class,
'capabilities' => [
Capability::INPUT_AUDIO,
Capability::SPEECH_TO_TEXT,
],
],
'text-to-speech-model' => [
'class' => Model::class,
'capabilities' => [
Capability::INPUT_TEXT,
Capability::TEXT_TO_SPEECH,
],
],
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\UnmappedCatalog;

final class Factory
{
public static function createProvider(string $apiKey, string $name = 'voice'): object
{
return new class() {
public function getName(): string
{
return 'voice';
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\UnmappedCatalog;

use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Model;

/**
* A static catalog of audio models only, none of which maps to an AiM
* capability.
*/
final class ModelCatalog
{
/**
* @return array<string, array{class: class-string, capabilities: list<Capability>}>
*/
public function getModels(): array
{
return [
'speech-to-text-model' => [
'class' => Model::class,
'capabilities' => [
Capability::INPUT_AUDIO,
Capability::SPEECH_TO_TEXT,
],
],
'text-to-speech-model' => [
'class' => Model::class,
'capabilities' => [
Capability::INPUT_TEXT,
Capability::TEXT_TO_SPEECH,
],
],
];
}
}
Loading
Loading