diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt
index 1452e805a824c32a4f6580920ad8b8db67b325fc..a0e21477cbd1e8277ab427467d62b2f747f5cc6c 100644
--- a/.cspell-project-words.txt
+++ b/.cspell-project-words.txt
@@ -124,6 +124,7 @@ octect
 octocat
 oden
 oneshot
+operationtype
 outro
 overriden
 paranthesis
diff --git a/src/AiProviderPluginManager.php b/src/AiProviderPluginManager.php
index ecfbe866199edff7441cdce63a51a58f4d7da29a..917d0c8995e21d85f7bdb0031cca99dd658e3ba4 100644
--- a/src/AiProviderPluginManager.php
+++ b/src/AiProviderPluginManager.php
@@ -275,30 +275,29 @@ final class AiProviderPluginManager extends DefaultPluginManager {
     if (!empty($data->data)) {
       return $data->data;
     }
-    // Look in the OperationType/** directories.
-    $operation_types = [];
-    $base_path = $this->moduleHandler->getModule('ai')->getPath() . '/src/OperationType';
-    $directories = new \RecursiveDirectoryIterator($base_path);
-    $iterator = new \RecursiveIteratorIterator($directories);
-    $regex = new \RegexIterator($iterator, '/^.+\.php$/i', \RecursiveRegexIterator::GET_MATCH);
-    foreach ($regex as $file) {
-      $interface = $this->getInterfaceFromFile($file[0]);
-      if ($interface && $this->doesInterfaceExtend($interface, OperationTypeInterface::class)) {
-        $reflection = new \ReflectionClass($interface);
-        $attributes = $reflection->getAttributes(OperationType::class);
-        foreach ($attributes as $attribute) {
-          $operation_types[] = [
-            'id' => $attribute->newInstance()->id,
-            'label' => $attribute->newInstance()->label->render(),
-          ];
-        }
+
+    // We use a plugin manager only to discover the possible operation types.
+    $manager = new class(
+      'OperationType',
+      $this->namespaces,
+      $this->moduleHandler,
+      OperationTypeInterface::class,
+      OperationType::class
+    ) extends DefaultPluginManager {
+
+      /**
+       * Call protected methods usually called in the constructor.
+       */
+      public function finishSetup(): void {
+        $this->alterInfo('ai_operationtype');
       }
-    }
 
-    // Save to cache.
-    $this->cacheBackend->set('ai_operation_types', $operation_types);
+    };
+    $manager->finishSetup();
+    // The in situ plugin manager uses the same cache backend.
+    $manager->setCacheBackend($this->cacheBackend, 'ai_operation_types');
 
-    return $operation_types;
+    return $manager->getDefinitions();
   }
 
   /**
@@ -369,58 +368,6 @@ final class AiProviderPluginManager extends DefaultPluginManager {
     return TRUE;
   }
 
-  /**
-   * Extracts the fully qualified interface name from a file.
-   *
-   * @param string $file
-   *   The file path.
-   *
-   * @return string|null
-   *   The fully qualified interface name, or NULL if not found.
-   */
-  protected function getInterfaceFromFile($file) {
-    $contents = file_get_contents($file);
-
-    // Match namespace and interface declarations.
-    if (preg_match('/namespace\s+([^;]+);/i', $contents, $matches)) {
-      $namespace = $matches[1];
-    }
-
-    // Match on starts with interface and has extends in it.
-    if (preg_match('/interface\s+([^ ]+)\s+extends\s+([^ ]+)/i', $contents, $matches) && isset($namespace)) {
-      $interface = $matches[1];
-      return $namespace . '\\' . $interface;
-    }
-
-    return NULL;
-  }
-
-  /**
-   * Checks if an interface extends another interface.
-   *
-   * @param string $interface
-   *   The interface name.
-   * @param string $baseInterface
-   *   The base interface name.
-   *
-   * @return bool
-   *   TRUE if the interface extends the base interface, FALSE otherwise.
-   */
-  protected function doesInterfaceExtend($interface, $baseInterface) {
-    try {
-      $reflection = new \ReflectionClass($interface);
-
-      if ($reflection->isInterface() && in_array($baseInterface, $reflection->getInterfaceNames())) {
-        return TRUE;
-      }
-    }
-    catch (\ReflectionException $e) {
-      // Ignore.
-    }
-
-    return FALSE;
-  }
-
   /**
    * Gives notice that a provider is disabled.
    *
diff --git a/tests/modules/ai_test/ai_test.module b/tests/modules/ai_test/ai_test.module
new file mode 100644
index 0000000000000000000000000000000000000000..55ab09338d924717c1e8455f4f6d435207ef8230
--- /dev/null
+++ b/tests/modules/ai_test/ai_test.module
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @file
+ * AI test module file.
+ */
+
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\ai_test\Hook\OperationTypeHook;
+
+/**
+ * Implements hook_ai_operationtype_alter().
+ */
+#[LegacyHook]
+function ai_test_ai_operationtype_alter(array &$operation_types) {
+  \Drupal::service(OperationTypeHook::class)->echoOperationType($operation_types);
+}
diff --git a/tests/modules/ai_test/ai_test.services.yml b/tests/modules/ai_test/ai_test.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d6fa111b46740a96cafa00fbd37570c9f5ecb89c
--- /dev/null
+++ b/tests/modules/ai_test/ai_test.services.yml
@@ -0,0 +1,4 @@
+services:
+  Drupal\ai_test\Hook\OperationTypeHook:
+    class: Drupal\ai_test\Hook\OperationTypeHook
+    autowire: true
diff --git a/tests/modules/ai_test/src/Hook/OperationTypeHook.php b/tests/modules/ai_test/src/Hook/OperationTypeHook.php
new file mode 100644
index 0000000000000000000000000000000000000000..2e5bbca8e84af9d1ef61af1e455e37dfc913dd59
--- /dev/null
+++ b/tests/modules/ai_test/src/Hook/OperationTypeHook.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\ai_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * Hooks to interact with operation types.
+ */
+class OperationTypeHook {
+
+  /**
+   * Implements hook_ai_operationtype_alter().
+   */
+  #[Hook('ai_operationtype_alter')]
+  public function echoOperationType(array &$operation_types) {
+    $operation_types['echo']['label'] = 'Echo altered';
+  }
+
+}
diff --git a/tests/modules/ai_test/src/OperationType/Echo/EchoInput.php b/tests/modules/ai_test/src/OperationType/Echo/EchoInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..26de696dd56cff13dd088daf3c4b278eba7eef9c
--- /dev/null
+++ b/tests/modules/ai_test/src/OperationType/Echo/EchoInput.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\ai_test\OperationType\Echo;
+
+use Drupal\ai\OperationType\InputInterface;
+
+/**
+ * Input object for echo operations.
+ */
+class EchoInput implements InputInterface {
+
+  /**
+   * The constructor.
+   *
+   * @param string $input
+   *   The echo input.
+   */
+  public function __construct(private string $input) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function toString(): string {
+    return $this->input;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDebugData(): array {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setDebugData(array $debugData): void {
+    // Do nothing.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setDebugDataValue(string $key, $value): void {
+    // Do nothing.
+  }
+
+  /**
+   * Return the input as string.
+   *
+   * @return string
+   *   The input as string.
+   */
+  public function __toString(): string {
+    return $this->toString();
+  }
+
+}
diff --git a/tests/modules/ai_test/src/OperationType/Echo/EchoInterface.php b/tests/modules/ai_test/src/OperationType/Echo/EchoInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..e86638a336440f3d121198bd4c5150318c1142e0
--- /dev/null
+++ b/tests/modules/ai_test/src/OperationType/Echo/EchoInterface.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\ai_test\OperationType\Echo;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\ai\Attribute\OperationType;
+use Drupal\ai\OperationType\OperationTypeInterface;
+
+/**
+ * Interface for text translation models.
+ */
+#[OperationType(
+  id: 'echo',
+  label: new TranslatableMarkup('Echo'),
+)]
+interface EchoInterface extends OperationTypeInterface {
+
+  /**
+   * Translate the text.
+   *
+   * @param \Drupal\ai_test\OperationType\Echo\EchoInput|string $input
+   *   The input to echo.
+   * @param string $model_id
+   *   The model id to use.
+   * @param array $options
+   *   Extra tags to set.
+   *
+   * @return \Drupal\ai_test\OperationType\Echo\EchoOutput
+   *   The translation output.
+   */
+  public function echo(string|EchoInput $input, string $model_id, array $options = []): EchoOutput;
+
+}
diff --git a/tests/modules/ai_test/src/OperationType/Echo/EchoOutput.php b/tests/modules/ai_test/src/OperationType/Echo/EchoOutput.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d8efcb9b099ea3937f848f05e25c480ceaf40af
--- /dev/null
+++ b/tests/modules/ai_test/src/OperationType/Echo/EchoOutput.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Drupal\ai_test\OperationType\Echo;
+
+use Drupal\ai\OperationType\OutputInterface;
+
+/**
+ * Data transfer output object text translation output.
+ */
+class EchoOutput implements OutputInterface {
+
+  /**
+   * The normalized translated text.
+   *
+   * @var string
+   */
+  private string $normalized;
+
+  /**
+   * The raw output from the AI provider.
+   *
+   * @var mixed
+   */
+  private mixed $rawOutput;
+
+  /**
+   * The metadata from the AI provider.
+   *
+   * @var mixed
+   */
+  private mixed $metadata;
+
+  /**
+   * The constructor.
+   *
+   * @param string $normalized
+   *   The metadata.
+   * @param mixed $rawOutput
+   *   The raw output.
+   * @param mixed $metadata
+   *   The metadata.
+   */
+  public function __construct(string $normalized, mixed $rawOutput, mixed $metadata) {
+    $this->normalized = $normalized;
+    $this->rawOutput = $rawOutput;
+    $this->metadata = $metadata;
+  }
+
+  /**
+   * Returns the translated text.
+   *
+   * @return string
+   *   The text.
+   */
+  public function getNormalized(): string {
+    return $this->normalized;
+  }
+
+  /**
+   * Gets the raw output from the AI provider.
+   *
+   * @return mixed
+   *   The raw output.
+   */
+  public function getRawOutput(): mixed {
+    return $this->rawOutput;
+  }
+
+  /**
+   * Gets the metadata from the AI provider.
+   *
+   * @return mixed
+   *   The metadata.
+   */
+  public function getMetadata(): mixed {
+    return $this->metadata;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function toArray(): array {
+    return [
+      'normalized' => $this->normalized,
+      'rawOutput' => $this->rawOutput,
+      'metadata' => $this->metadata,
+    ];
+  }
+
+}
diff --git a/tests/modules/ai_test/src/Plugin/AiProvider/EchoProvider.php b/tests/modules/ai_test/src/Plugin/AiProvider/EchoProvider.php
index ad6603de1bbfbed4c366cf5b41b6a652ae3ec5f4..fa199081c2d8162632f0ba06cb186d08641b0f96 100644
--- a/tests/modules/ai_test/src/Plugin/AiProvider/EchoProvider.php
+++ b/tests/modules/ai_test/src/Plugin/AiProvider/EchoProvider.php
@@ -38,6 +38,9 @@ use Drupal\ai\OperationType\TextToImage\TextToImageOutput;
 use Drupal\ai\OperationType\TextToSpeech\TextToSpeechInput;
 use Drupal\ai\OperationType\TextToSpeech\TextToSpeechInterface;
 use Drupal\ai\OperationType\TextToSpeech\TextToSpeechOutput;
+use Drupal\ai_test\OperationType\Echo\EchoInput;
+use Drupal\ai_test\OperationType\Echo\EchoInterface;
+use Drupal\ai_test\OperationType\Echo\EchoOutput;
 use Symfony\Component\Yaml\Yaml;
 
 /**
@@ -54,7 +57,8 @@ class EchoProvider extends AiProviderClientBase implements
   SpeechToTextInterface,
   TextToSpeechInterface,
   ImageClassificationInterface,
-  TextToImageInterface {
+  TextToImageInterface,
+  EchoInterface {
 
   /**
    * {@inheritdoc}
@@ -106,6 +110,7 @@ class EchoProvider extends AiProviderClientBase implements
       'text_to_speech',
       'moderation',
       'image_classification',
+      'echo',
     ];
   }
 
@@ -282,4 +287,14 @@ class EchoProvider extends AiProviderClientBase implements
     return new ImageClassificationOutput($output, $response, []);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function echo(string|EchoInput $input, string $model_id, array $options = []): EchoOutput {
+    if (!$input instanceof EchoInput) {
+      $input = new EchoInput($input);
+    }
+    return new EchoOutput((string) $input, ['echo' => (string) $input], []);
+  }
+
 }
diff --git a/tests/src/Kernel/OperationType/Custom/EchoOperationHookTest.php b/tests/src/Kernel/OperationType/Custom/EchoOperationHookTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..69622d521b11dbfd2e4d87b5c83d6c224973c91d
--- /dev/null
+++ b/tests/src/Kernel/OperationType/Custom/EchoOperationHookTest.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\ai\Kernel\OperationType\Custom;
+
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * This tests the echo calling.
+ *
+ * @coversDefaultClass \Drupal\ai_test\OperationType\Echo\EchoInterface
+ *
+ * @group ai
+ */
+class EchoOperationHookTest extends KernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  protected static $modules = [
+    'ai',
+    'ai_test',
+    'key',
+    'file',
+    'system',
+  ];
+
+  /**
+   * Test adding a provider via a hook.
+   */
+  public function testCustomEchoOperation(): void {
+    /** @var \Drupal\ai\AiProviderPluginManager $manager */
+    $manager = \Drupal::service('ai.provider');
+    $types = $manager->getOperationTypes();
+    self::assertContains('echo', array_keys($types));
+    self::assertEquals('Echo altered', $types['echo']['label']);
+    /** @var \Drupal\ai_test\Plugin\AiProvider\EchoProvider $provider */
+    $provider = $manager->createInstance('echoai');
+
+    $input = $this->randomString();
+    $output = $provider->echo($input, 'test');
+    self::assertEquals($input, $output->getNormalized());
+    self::assertEquals(['echo' => $input], $output->getRawOutput());
+  }
+
+}