You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opennlp.apache.org by rz...@apache.org on 2026/09/17 19:44:13 UTC

(opennlp) branch OPENNLP-1972-provider-spi created (now 5c179f69e)

This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a change to branch OPENNLP-1972-provider-spi
in repository https://gitbox.apache.org/repos/asf/opennlp.git


      at 5c179f69e OPENNLP-1972: Resolve service-loaded components through a generic provider SPI

This branch includes the following new commits:

     new 5c179f69e OPENNLP-1972: Resolve service-loaded components through a generic provider SPI

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(opennlp) 01/01: OPENNLP-1972: Resolve service-loaded components through a generic provider SPI

Posted by rz...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch OPENNLP-1972-provider-spi
in repository https://gitbox.apache.org/repos/asf/opennlp.git

commit 5c179f69e300998c8849b70661433b38061d4708
Author: Richard Zowalla <rz...@apache.org>
AuthorDate: Thu Sep 17 21:39:11 2026 +0200

    OPENNLP-1972: Resolve service-loaded components through a generic provider SPI
    
    Adds Provider, Providers, ProviderSpec and the resolution exceptions to
    opennlp.tools.util.ext, so an implementation shipped in a separate jar is
    found without naming a class.
    
    StemmerAnnotatorProvider in opennlp-runtime is the first example provider,
    registered for the new DocumentAnnotatorProvider.
    
    Based on the ComponentProvider work by Kristian Rickert in apache/opennlp#1290.
    
    Co-authored-by: Kristian Rickert <kr...@apache.org>
---
 .../tools/document/DocumentAnnotatorProvider.java  |   29 +
 .../tools/util/ext/AmbiguousProviderException.java |   36 +
 .../main/java/opennlp/tools/util/ext/Provider.java |   90 ++
 .../util/ext/ProviderResolutionException.java      |   48 +
 .../java/opennlp/tools/util/ext/ProviderSpec.java  |  408 +++++++
 .../java/opennlp/tools/util/ext/Providers.java     | 1033 +++++++++++++++++
 .../util/ext/UnsatisfiedProviderException.java     |   36 +
 .../opennlp/tools/util/ext/ProviderSpecTest.java   |  267 +++++
 .../java/opennlp/tools/util/ext/ProvidersTest.java | 1197 ++++++++++++++++++++
 .../tools/stemmer/StemmerAnnotatorProvider.java    |   94 ++
 ...pennlp.tools.document.DocumentAnnotatorProvider |   15 +
 .../stemmer/StemmerAnnotatorProviderTest.java      |  136 +++
 opennlp-docs/src/docbkx/introduction.xml           |  151 +++
 13 files changed, 3540 insertions(+)

diff --git a/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotatorProvider.java b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotatorProvider.java
new file mode 100644
index 000000000..285e9327e
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotatorProvider.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.document;
+
+import opennlp.tools.util.ext.Provider;
+
+/**
+ * The SPI for {@link DocumentAnnotator} providers. Implementations are listed in
+ * {@code META-INF/services/opennlp.tools.document.DocumentAnnotatorProvider}.
+ *
+ * @see opennlp.tools.util.ext.Providers
+ * @since 3.0.0
+ */
+public interface DocumentAnnotatorProvider extends Provider<DocumentAnnotator> {
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ext/AmbiguousProviderException.java b/opennlp-api/src/main/java/opennlp/tools/util/ext/AmbiguousProviderException.java
new file mode 100644
index 000000000..f7b50c898
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/util/ext/AmbiguousProviderException.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.util.ext;
+
+/**
+ * Thrown if more than one provider has the highest priority.
+ *
+ * @since 3.0.0
+ */
+public class AmbiguousProviderException extends ProviderResolutionException {
+
+  private static final long serialVersionUID = 1L;
+
+  /**
+   * Initializes the exception.
+   *
+   * @param message The detail message.
+   */
+  public AmbiguousProviderException(final String message) {
+    super(message);
+  }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ext/Provider.java b/opennlp-api/src/main/java/opennlp/tools/util/ext/Provider.java
new file mode 100644
index 000000000..e35fe055d
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/util/ext/Provider.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.util.ext;
+
+import java.io.IOException;
+
+/**
+ * Creates instances of one service interface from a {@link ProviderSpec}.
+ * <p>
+ * Every service interface has an SPI of its own that extends this interface, for example
+ * {@code DocumentAnnotatorProvider extends Provider<DocumentAnnotator>}. Implementations are
+ * listed in {@code META-INF/services/<SPI fully qualified name>}, need a public no-argument
+ * constructor and are resolved through {@link Providers}.
+ * <p>
+ * Implementations must be stateless and thread-safe. The constructor, the static initializer of
+ * the implementation class, {@link #isAvailable()} and {@link #supports(ProviderSpec)} must not
+ * load models or initialize native libraries: they run while providers are looked up, before a
+ * caller has chosen one.
+ *
+ * @param <T> The service interface created by this provider.
+ * @see Providers
+ * @since 3.0.0
+ */
+public interface Provider<T> {
+
+  /**
+   * @return The case-sensitive name of this provider: at most 64 characters of ASCII letters,
+   *         digits, {@code .}, {@code _} or {@code -}, since it is used as a configuration
+   *         value. A
+   *         provider with another name is skipped when providers are looked up. Providers of one
+   *         SPI may share a name; {@link #priority()} ranks them.
+   */
+  String name();
+
+  /**
+   * @return The rank of this provider among providers that support the same spec, larger
+   *         wins. The default is {@code 0}.
+   */
+  default int priority() {
+    return 0;
+  }
+
+  /**
+   * Checks whether this provider can be used in the running process, for example whether an
+   * optional dependency is on the class path. Check a class by class literal and catch
+   * {@link LinkageError} instead of loading it by name, which also keeps a native image free of
+   * reflection metadata. The check runs on every resolution, so it must not block.
+   *
+   * @return {@code true} if this provider can be used. The default is {@code true}.
+   */
+  default boolean isAvailable() {
+    return true;
+  }
+
+  /**
+   * Checks whether this provider can create an instance for a spec, based on the spec alone.
+   * The check runs on every resolution, so it must not block.
+   *
+   * @param spec The spec to check. Must not be {@code null}.
+   * @return {@code true} if {@link #create(ProviderSpec)} accepts {@code spec}.
+   * @throws IllegalArgumentException Thrown if {@code spec} is {@code null}.
+   */
+  boolean supports(ProviderSpec spec);
+
+  /**
+   * Creates a new instance. The caller owns it and closes it if it is closeable. The location of
+   * the spec is unvalidated input of the caller: a provider that opens it decides itself which
+   * schemes and which directories it accepts.
+   *
+   * @param spec The spec to create an instance for. Must not be {@code null}.
+   * @return The new instance. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code spec} is {@code null} or not supported.
+   * @throws IOException Thrown if a resource named by {@code spec} could not be read.
+   */
+  T create(ProviderSpec spec) throws IOException;
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ext/ProviderResolutionException.java b/opennlp-api/src/main/java/opennlp/tools/util/ext/ProviderResolutionException.java
new file mode 100644
index 000000000..234bd798e
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/util/ext/ProviderResolutionException.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.util.ext;
+
+/**
+ * Thrown if {@link Providers} cannot resolve exactly one provider.
+ *
+ * @see UnsatisfiedProviderException
+ * @see AmbiguousProviderException
+ * @since 3.0.0
+ */
+public class ProviderResolutionException extends RuntimeException {
+
+  private static final long serialVersionUID = 1L;
+
+  /**
+   * Initializes the exception.
+   *
+   * @param message The detail message.
+   */
+  public ProviderResolutionException(final String message) {
+    super(message);
+  }
+
+  /**
+   * Initializes the exception.
+   *
+   * @param message The detail message.
+   * @param cause The cause.
+   */
+  public ProviderResolutionException(final String message, final Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ext/ProviderSpec.java b/opennlp-api/src/main/java/opennlp/tools/util/ext/ProviderSpec.java
new file mode 100644
index 000000000..ca628ccd4
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/util/ext/ProviderSpec.java
@@ -0,0 +1,408 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.util.ext;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Describes what a {@link Provider} should create: an optional location, such as a model file,
+ * and string options. Instances are immutable.
+ *
+ * @since 3.0.0
+ */
+public final class ProviderSpec {
+
+  private static final String FILE_SCHEME = "file";
+  private static final char PATH_SEPARATOR = '/';
+  private static final char QUERY_SEPARATOR = '?';
+  private static final char USER_INFO_SEPARATOR = '@';
+  private static final char ESCAPE = '%';
+  private static final String AUTHORITY_PREFIX = "//";
+  private static final ProviderSpec EMPTY = new ProviderSpec(null, Map.of());
+
+  private final URI location;
+  private final Map<String, String> options;
+
+  /**
+   * @param location The location, or {@code null} if the spec has none.
+   * @param options The options. Must not be {@code null}, an option name must not be
+   *                {@code null} or blank and an option value must not be {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code options} is invalid.
+   */
+  private ProviderSpec(final URI location, final Map<String, String> options) {
+    if (options == null) {
+      throw new IllegalArgumentException("options must not be null");
+    }
+    final Map<String, String> copy = new LinkedHashMap<>();
+    for (final Map.Entry<String, String> option : options.entrySet()) {
+      if (option.getKey() == null || option.getValue() == null) {
+        throw new IllegalArgumentException("options must not contain a null key or value");
+      }
+      if (option.getKey().isBlank()) {
+        throw new IllegalArgumentException("option names must not be blank");
+      }
+      copy.put(option.getKey(), option.getValue());
+    }
+    this.location = location;
+    this.options = Collections.unmodifiableMap(copy);
+  }
+
+  /**
+   * @return A spec without location and options. Never {@code null}.
+   */
+  public static ProviderSpec empty() {
+    return EMPTY;
+  }
+
+  /**
+   * Creates a spec with options and without location.
+   *
+   * @param options The options. Must not be {@code null}, an option name must not be
+   *                {@code null} or blank and an option value must not be {@code null}.
+   * @return The spec. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code options} is invalid.
+   */
+  public static ProviderSpec of(final Map<String, String> options) {
+    return new ProviderSpec(null, options);
+  }
+
+  /**
+   * Creates a spec with a location and without options.
+   *
+   * @param location The absolute location. Must not be {@code null}.
+   * @return The spec. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code location} is {@code null} or not absolute.
+   */
+  public static ProviderSpec of(final URI location) {
+    return of(location, Map.of());
+  }
+
+  /**
+   * Creates a spec with a location and options.
+   *
+   * @param location The absolute location, which is taken as it is, unlike the location of
+   *                 {@link #of(Path, Map)}. Must not be {@code null}.
+   * @param options The options. Must not be {@code null}, an option name must not be
+   *                {@code null} or blank and an option value must not be {@code null}.
+   * @return The spec. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code location} is {@code null} or not absolute,
+   *                                  or if {@code options} is invalid.
+   */
+  public static ProviderSpec of(final URI location, final Map<String, String> options) {
+    if (location == null) {
+      throw new IllegalArgumentException("location must not be null");
+    }
+    if (!location.isAbsolute()) {
+      throw new IllegalArgumentException("location must be an absolute URI: " + location);
+    }
+    return new ProviderSpec(location, options);
+  }
+
+  /**
+   * Creates a spec for a file or directory without options, see {@link #of(Path, Map)}.
+   *
+   * @param location The file or directory. Must not be {@code null}.
+   * @return The spec. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code location} is {@code null}.
+   */
+  public static ProviderSpec of(final Path location) {
+    return of(location, Map.of());
+  }
+
+  /**
+   * Creates a spec for a file or directory with options. A relative path is resolved against the
+   * working directory of the running process when the spec is created, so a spec that is kept in
+   * a {@code static} field must not be created from a relative path. The file system is not
+   * read, so the location of a directory carries no trailing separator and a path holding
+   * {@code ..} is kept as it is.
+   *
+   * @param location The file or directory. Must not be {@code null}.
+   * @param options The options. Must not be {@code null}, an option name must not be
+   *                {@code null} or blank and an option value must not be {@code null}.
+   * @return The spec. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code location} is {@code null}, or if
+   *                                  {@code options} is invalid.
+   */
+  public static ProviderSpec of(final Path location, final Map<String, String> options) {
+    if (location == null) {
+      throw new IllegalArgumentException("location must not be null");
+    }
+    return new ProviderSpec(uriOf(location), options);
+  }
+
+  /**
+   * Converts a path of the default file system without reading it, unlike {@link Path#toUri()},
+   * which appends a separator to an existing directory and would make the location depend on
+   * what is on disk when the spec is created. The path is made absolute but neither normalized
+   * nor resolved against symbolic links.
+   *
+   * @param location The file or directory. Must not be {@code null}.
+   * @return The location of {@code location}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code location} has no URI.
+   */
+  private static URI uriOf(final Path location) {
+    final Path absolute = location.toAbsolutePath();
+    if (!absolute.getFileSystem().equals(FileSystems.getDefault())) {
+      return absolute.toUri();
+    }
+    final String separated = absolute.toString().replace(File.separatorChar, PATH_SEPARATOR);
+    final String path = separated.charAt(0) == PATH_SEPARATOR ? separated : "/" + separated;
+    // A UNC path starts with the two separators an empty authority would be read as.
+    final String authority = path.startsWith(AUTHORITY_PREFIX) ? null : "";
+    try {
+      return new URI(FILE_SCHEME, authority, path, null, null);
+    } catch (final URISyntaxException e) {
+      throw new IllegalArgumentException("location has no URI: " + location, e);
+    }
+  }
+
+  /**
+   * @return The location, or {@link Optional#empty()} if this spec has none.
+   */
+  public Optional<URI> location() {
+    return Optional.ofNullable(location);
+  }
+
+  /**
+   * @return The location as a path, or {@link Optional#empty()} if this spec has no location or
+   *         the location is not a hierarchical {@code file} URI of the default file system.
+   */
+  public Optional<Path> path() {
+    if (location == null || location.isOpaque() || !FILE_SCHEME.equalsIgnoreCase(location.getScheme())) {
+      return Optional.empty();
+    }
+    try {
+      return Optional.of(Path.of(location));
+    } catch (final IllegalArgumentException e) {
+      return Optional.empty();
+    }
+  }
+
+  /**
+   * @return The options in insertion order. Unmodifiable and never {@code null}.
+   */
+  public Map<String, String> options() {
+    return options;
+  }
+
+  /**
+   * Reads an option.
+   *
+   * @param key The option name. Must not be {@code null}.
+   * @param defaultValue The value to return if the option is absent. May be {@code null}.
+   * @return The option value, or {@code defaultValue}.
+   * @throws IllegalArgumentException Thrown if {@code key} is {@code null}.
+   */
+  public String option(final String key, final String defaultValue) {
+    if (key == null) {
+      throw new IllegalArgumentException("key must not be null");
+    }
+    return options.getOrDefault(key, defaultValue);
+  }
+
+  /**
+   * Checks whether every option of this spec has one of the given names.
+   *
+   * @param names The accepted option names. Must not be {@code null} or contain a
+   *              {@code null} element.
+   * @return {@code true} if this spec has no option with another name.
+   * @throws IllegalArgumentException Thrown if {@code names} is {@code null} or contains a
+   *                                  {@code null} element.
+   */
+  public boolean hasOnlyOptions(final String... names) {
+    if (names == null) {
+      throw new IllegalArgumentException("names must not be null");
+    }
+    for (final String name : names) {
+      if (name == null) {
+        throw new IllegalArgumentException("names must not contain a null element");
+      }
+    }
+    for (final String key : options.keySet()) {
+      if (!contains(names, key)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Checks whether the last path segment of the location ends with a suffix, ignoring case. A
+   * query and a fragment are not part of the comparison. For an opaque URI, such as
+   * {@code jar:file:/models.jar!/model.onnx}, the scheme-specific part is used as path. This is a
+   * convenience for routing a request to a provider, not a validation of the location.
+   *
+   * @param suffix The suffix, for example {@code .onnx}. Must not be {@code null} or empty.
+   * @return {@code true} if this spec has a location and its last path segment ends with
+   *         {@code suffix}.
+   * @throws IllegalArgumentException Thrown if {@code suffix} is {@code null} or empty.
+   */
+  public boolean locationEndsWith(final String suffix) {
+    if (suffix == null || suffix.isEmpty()) {
+      throw new IllegalArgumentException("suffix must not be null or empty");
+    }
+    if (location == null) {
+      return false;
+    }
+    final String raw = location.isOpaque()
+        ? location.getRawSchemeSpecificPart() : location.getRawPath();
+    if (raw == null) {
+      return false;
+    }
+    final String path = decode(cutQuery(raw));
+    if (hasControlCharacter(path)) {
+      return false;
+    }
+    final int nameStart = path.lastIndexOf(PATH_SEPARATOR) + 1;
+    final int suffixStart = path.length() - suffix.length();
+    return suffixStart >= nameStart && path.regionMatches(true, suffixStart, suffix, 0, suffix.length());
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public boolean equals(final Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (!(other instanceof ProviderSpec spec)) {
+      return false;
+    }
+    return Objects.equals(location, spec.location) && options.equals(spec.options);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public int hashCode() {
+    return Objects.hash(location, options);
+  }
+
+  /**
+   * {@inheritDoc}
+   * The option values and the user info, query and fragment of the location are omitted, as they
+   * may hold credentials.
+   */
+  @Override
+  public String toString() {
+    return "ProviderSpec[location=" + printableLocation() + ", options=" + options.keySet() + "]";
+  }
+
+  private boolean contains(final String[] names, final String key) {
+    for (final String name : names) {
+      if (key.equals(name)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private String printableLocation() {
+    return location == null ? null : printable(location);
+  }
+
+  /**
+   * Renders a location without the parts that may hold credentials, for a message or a log entry.
+   *
+   * @param location The location. Must not be {@code null}.
+   * @return The location without its user info, query and fragment. Never {@code null}.
+   */
+  static String printable(final URI location) {
+    if (location.isOpaque()) {
+      return location.getScheme() + ':' + redact(cutQuery(location.getRawSchemeSpecificPart()));
+    }
+    final StringBuilder text = new StringBuilder(location.getScheme()).append(':');
+    if (location.getRawAuthority() != null) {
+      text.append(AUTHORITY_PREFIX).append(withoutUserInfo(location.getRawAuthority()));
+    }
+    return text.append(location.getRawPath() == null ? "" : location.getRawPath()).toString();
+  }
+
+  /**
+   * @return The scheme-specific part of an opaque location, such as
+   *         {@code https://user@host/models.jar!/model.onnx}, without the user info of a nested
+   *         authority.
+   */
+  private static String redact(final String part) {
+    final int prefix = part.indexOf(AUTHORITY_PREFIX);
+    final int start = prefix < 0 ? 0 : prefix + AUTHORITY_PREFIX.length();
+    final int path = part.indexOf(PATH_SEPARATOR, start);
+    final int end = path < 0 ? part.length() : path;
+    return part.substring(0, start) + withoutUserInfo(part.substring(start, end))
+        + part.substring(end);
+  }
+
+  private static String withoutUserInfo(final String authority) {
+    final int userInfo = authority.lastIndexOf(USER_INFO_SEPARATOR);
+    return userInfo < 0 ? authority : authority.substring(userInfo + 1);
+  }
+
+  private boolean hasControlCharacter(final String path) {
+    for (int character = 0; character < path.length(); character++) {
+      if (Character.isISOControl(path.charAt(character))) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Decodes the percent escapes of a raw location, unlike {@link java.net.URLDecoder}, which
+   * would also read {@code +} as a space.
+   *
+   * @return The decoded part.
+   */
+  private String decode(final String part) {
+    if (part.indexOf(ESCAPE) < 0) {
+      return part;
+    }
+    final ByteArrayOutputStream decoded = new ByteArrayOutputStream(part.length());
+    for (int character = 0; character < part.length(); character++) {
+      final int escaped = escapedByte(part, character);
+      if (escaped < 0) {
+        decoded.write(part.charAt(character));
+      } else {
+        decoded.write(escaped);
+        character += 2;
+      }
+    }
+    return decoded.toString(StandardCharsets.UTF_8);
+  }
+
+  private int escapedByte(final String part, final int character) {
+    if (part.charAt(character) != ESCAPE || character + 2 >= part.length()) {
+      return -1;
+    }
+    final int high = Character.digit(part.charAt(character + 1), 16);
+    final int low = Character.digit(part.charAt(character + 2), 16);
+    return high < 0 || low < 0 ? -1 : high << 4 | low;
+  }
+
+  private static String cutQuery(final String part) {
+    final int query = part.indexOf(QUERY_SEPARATOR);
+    return query < 0 ? part : part.substring(0, query);
+  }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ext/Providers.java b/opennlp-api/src/main/java/opennlp/tools/util/ext/Providers.java
new file mode 100644
index 000000000..c0ebe6708
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/util/ext/Providers.java
@@ -0,0 +1,1033 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.util.ext;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.security.ProtectionDomain;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.ServiceConfigurationError;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.function.UnaryOperator;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.util.StringUtil;
+
+/**
+ * Resolves the {@link Provider providers} of one SPI.
+ * <p>
+ * The providers are looked up with {@link ServiceLoader} when an instance is created, so
+ * {@link #of(Class)} belongs into the startup of an application rather than into a request, and a
+ * new instance is needed to see providers added later. Availability, support for a spec and the
+ * configuration are read on every call. Instances are thread-safe; the only state that changes is
+ * the set of failures already reported.
+ * <p>
+ * Keep the instance in a field owned by the application and not in a {@code static} field, since
+ * OpenNLP can be deployed in a class loader shared by several applications.
+ * <p>
+ * Resolution is steered by name through {@link #configurationKey()}, {@link #disabledKey()} and
+ * {@link #allowedKey()}, and by the provider class itself through
+ * {@link #ofTrusted(Class, ClassLoader, Predicate)}.
+ *
+ * @param <P> The SPI, a sub-interface of {@link Provider}.
+ * @since 3.0.0
+ */
+public final class Providers<P extends Provider<?>> {
+
+  /**
+   * The prefix of the configuration key that selects a provider by name for {@link #select}.
+   * The full key is the prefix followed by the fully qualified name of the SPI, see
+   * {@link #configurationKey()}.
+   */
+  public static final String CONFIGURATION_KEY_PREFIX = "opennlp.provider.";
+
+  /**
+   * The suffix of the configuration key that disables providers by name, see
+   * {@link #disabledKey()}.
+   */
+  public static final String DISABLED_KEY_SUFFIX = ".disabled";
+
+  /**
+   * The suffix of the configuration key that limits which provider classes may be used, see
+   * {@link #allowedKey()}.
+   */
+  public static final String ALLOWED_KEY_SUFFIX = ".allowed";
+
+  /**
+   * The resource read for configuration keys, once per lookup and class loader, encoded in
+   * UTF-8. Several files are merged by their {@value #ORDINAL_KEY}, so the highest one wins, and
+   * a system property of the same name wins over all of them. Every jar of the class loader can
+   * carry this file. In a native image it is read only when it is registered as a resource.
+   */
+  public static final String CONFIGURATION_FILE = "META-INF/opennlp/providers.properties";
+
+  /** The key that orders several {@value #CONFIGURATION_FILE} files, {@code 0} by default. */
+  public static final String ORDINAL_KEY = "configuration.ordinal";
+
+  private static final Logger logger = LoggerFactory.getLogger(Providers.class);
+
+  private static final char NAME_SEPARATOR = ',';
+  private static final String SERVICE_PREFIX = "META-INF/services/";
+  private static final char PACKAGE_SEPARATOR_PATH = '/';
+  private static final String CLASS_SUFFIX = ".class";
+  /** Stands for an allow list that holds no entry, which allows no provider at all. */
+  private static final String NOTHING_ALLOWED = "\u0000";
+  private static final String PACKAGE_SEPARATOR = ".";
+  private static final char BYTE_ORDER_MARK = '\uFEFF';
+  private static final int MAX_NAME_LENGTH = 64;
+  private static final String LOOKUP_FAILED = "Cannot look up %s providers with %s";
+  private static final String SKIPPED_ENTRY = "Skipping a {} provider registered with {}: {}";
+  private static final String SKIPPED_CALL = "Skipping {} provider {}: {}() failed: {}";
+  private static final String NAME_METHOD = "name";
+  private static final String PRIORITY_METHOD = "priority";
+  private static final String AVAILABLE_METHOD = "isAvailable";
+  private static final String SUPPORTS_METHOD = "supports";
+  private static final String SAME_PRIORITY = " have the same priority: ";
+  private static final String INVALID_NAME = "Skipping {} provider {}: name() is not usable, it "
+      + "must be at most {} characters of ASCII letters, digits, '.', '_' or '-'";
+
+  /**
+   * Bounds a lookup whose iterator neither yields a provider nor reaches its end, which
+   * {@link ServiceLoader} does when it cannot list the service files at all. The bound is far
+   * above the number of broken registrations a class path can hold, so a lookup that only skips
+   * unusable providers is not cut short.
+   */
+  private static final int MAX_CONSECUTIVE_FAILURES = 1000;
+
+  private final Class<P> spi;
+  private final String configurationKey;
+  private final String disabledKey;
+  private final String allowedKey;
+  private final UnaryOperator<String> configuration;
+  private final Predicate<Class<?>> trusted;
+  private final String loaderName;
+  private final List<Registration<P>> registrations;
+  private final List<P> installed;
+  private final int skipped;
+  private final Set<String> warned = ConcurrentHashMap.newKeySet();
+  private volatile Parsed disabled;
+
+  /**
+   * @param spi The SPI. Must be a sub-interface of {@link Provider}.
+   * @param loader The class loader to look providers up with, or {@code null} to resolve one.
+   * @param configuration The source of the configuration keys, or {@code null} for the default.
+   * @param trusted Accepts the provider classes that may be used, or {@code null} for all.
+   * @throws IllegalArgumentException Thrown if {@code spi} is {@code null} or not a
+   *                                  sub-interface of {@link Provider}.
+   * @throws ProviderResolutionException Thrown if the configuration or the service files cannot
+   *                                     be read, or if {@code trusted} fails.
+   */
+  private Providers(final Class<P> spi, final ClassLoader loader,
+                    final UnaryOperator<String> configuration,
+                    final Predicate<Class<?>> trusted) {
+    if (spi == null) {
+      throw new IllegalArgumentException("spi must not be null");
+    }
+    if (!spi.isInterface() || Provider.class.equals(spi) || !Provider.class.isAssignableFrom(spi)) {
+      throw new IllegalArgumentException("spi must be a sub-interface of "
+          + Provider.class.getName() + ": " + spi.getName());
+    }
+    this.spi = spi;
+    this.configurationKey = CONFIGURATION_KEY_PREFIX + spi.getName();
+    this.disabledKey = this.configurationKey + DISABLED_KEY_SUFFIX;
+    this.allowedKey = this.configurationKey + ALLOWED_KEY_SUFFIX;
+    final ClassLoader lookupLoader = resolveLoader(loader);
+    this.loaderName = lookupLoader.getClass().getName() + '@'
+        + Integer.toHexString(System.identityHashCode(lookupLoader));
+    this.configuration = configuration != null ? configuration : fileConfiguration(lookupLoader);
+    this.trusted = trusted;
+    final Lookup<P> lookup = lookup(lookupLoader);
+    this.registrations = lookup.registrations();
+    this.skipped = lookup.skipped();
+    final List<P> providers = new ArrayList<>(registrations.size());
+    for (final Registration<P> registration : registrations) {
+      providers.add(registration.provider());
+    }
+    this.installed = Collections.unmodifiableList(providers);
+  }
+
+  /**
+   * Looks the providers of an SPI up with the thread context class loader, or with the class
+   * loader of {@code spi} if the thread has none or if it cannot see {@code spi}. On a thread
+   * whose context class loader is not the application's, such as a thread of a pool or of a
+   * container, this finds the providers of the SPI's own class loader only, so a library or a
+   * framework integration captures the class loader at startup and calls
+   * {@link #of(Class, ClassLoader)}.
+   *
+   * @param spi The SPI. Must be a sub-interface of {@link Provider}.
+   * @param <P> The SPI.
+   * @return The providers of {@code spi}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code spi} is {@code null} or not a
+   *                                  sub-interface of {@link Provider}.
+   * @throws ProviderResolutionException Thrown if the configuration or the service files cannot
+   *                                     be read.
+   */
+  public static <P extends Provider<?>> Providers<P> of(final Class<P> spi) {
+    return new Providers<>(spi, null, null, null);
+  }
+
+  /**
+   * Looks the providers of an SPI up with a class loader.
+   *
+   * @param spi The SPI. Must be a sub-interface of {@link Provider}.
+   * @param loader The class loader to look providers up with. Must not be {@code null}.
+   * @param <P> The SPI.
+   * @return The providers of {@code spi}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if an argument is {@code null}, or if {@code spi} is
+   *                                  not a sub-interface of {@link Provider}.
+   * @throws ProviderResolutionException Thrown if the configuration or the service files cannot
+   *                                     be read.
+   */
+  public static <P extends Provider<?>> Providers<P> of(final Class<P> spi,
+                                                        final ClassLoader loader) {
+    if (loader == null) {
+      throw new IllegalArgumentException("loader must not be null");
+    }
+    return new Providers<>(spi, loader, null, null);
+  }
+
+  /**
+   * Looks the providers of an SPI up with a class loader and reads the configuration keys from
+   * an application's own source instead of {@value #CONFIGURATION_FILE} and the system
+   * properties. Applications that share a class loader hierarchy, such as several deployments in
+   * one server, use this to configure resolution per application.
+   *
+   * @param spi The SPI. Must be a sub-interface of {@link Provider}.
+   * @param loader The class loader to look providers up with. Must not be {@code null}.
+   * @param configuration Answers {@link #configurationKey()}, {@link #disabledKey()} and
+   *                      {@link #allowedKey()} with the configured value or {@code null}. Must
+   *                      not be {@code null}, and must be thread-safe, cheap and free of side
+   *                      effects, as it is called on every resolution.
+   * @param <P> The SPI.
+   * @return The providers of {@code spi}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if an argument is {@code null}, or if {@code spi} is
+   *                                  not a sub-interface of {@link Provider}.
+   * @throws ProviderResolutionException Thrown if the service files cannot be read.
+   */
+  public static <P extends Provider<?>> Providers<P> of(final Class<P> spi,
+                                                        final ClassLoader loader,
+                                                        final UnaryOperator<String> configuration) {
+    if (loader == null) {
+      throw new IllegalArgumentException("loader must not be null");
+    }
+    if (configuration == null) {
+      throw new IllegalArgumentException("configuration must not be null");
+    }
+    return new Providers<>(spi, loader, configuration, null);
+  }
+
+  /**
+   * Looks the providers of an SPI up with a class loader, using only the provider classes a
+   * predicate accepts, which lets an application decide by the code source, the module or the
+   * signers of a class instead of by its name.
+   * <p>
+   * The predicate is applied to each provider class the configuration permits, after the class
+   * has been loaded and before it is initialized and instantiated, so a rejected provider runs
+   * neither its static initializer nor its constructor. The predicate must not initialize the
+   * class either. An exception from it ends the lookup.
+   *
+   * @param spi The SPI. Must be a sub-interface of {@link Provider}.
+   * @param loader The class loader to look providers up with. Must not be {@code null}.
+   * @param trusted Accepts the provider classes that may be used. Must not be {@code null}.
+   * @param <P> The SPI.
+   * @return The providers of {@code spi}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if an argument is {@code null}, or if {@code spi} is
+   *                                  not a sub-interface of {@link Provider}.
+   * @throws ProviderResolutionException Thrown if the configuration or the service files cannot
+   *                                     be read, or if {@code trusted} throws.
+   */
+  public static <P extends Provider<?>> Providers<P> ofTrusted(final Class<P> spi,
+                                                               final ClassLoader loader,
+                                                               final Predicate<Class<?>> trusted) {
+    if (loader == null) {
+      throw new IllegalArgumentException("loader must not be null");
+    }
+    if (trusted == null) {
+      throw new IllegalArgumentException("trusted must not be null");
+    }
+    return new Providers<>(spi, loader, null, trusted);
+  }
+
+  /**
+   * Combines {@link #of(Class, ClassLoader, UnaryOperator)} and
+   * {@link #ofTrusted(Class, ClassLoader, Predicate)}.
+   *
+   * @param spi The SPI. Must be a sub-interface of {@link Provider}.
+   * @param loader The class loader to look providers up with. Must not be {@code null}.
+   * @param configuration Answers the configuration keys. Must not be {@code null}.
+   * @param trusted Accepts the provider classes that may be used. Must not be {@code null}.
+   * @param <P> The SPI.
+   * @return The providers of {@code spi}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if an argument is {@code null}, or if {@code spi} is
+   *                                  not a sub-interface of {@link Provider}.
+   * @throws ProviderResolutionException Thrown if the service files cannot be read, or if
+   *                                     {@code trusted} throws.
+   */
+  public static <P extends Provider<?>> Providers<P> of(final Class<P> spi,
+                                                        final ClassLoader loader,
+                                                        final UnaryOperator<String> configuration,
+                                                        final Predicate<Class<?>> trusted) {
+    if (loader == null) {
+      throw new IllegalArgumentException("loader must not be null");
+    }
+    if (configuration == null) {
+      throw new IllegalArgumentException("configuration must not be null");
+    }
+    if (trusted == null) {
+      throw new IllegalArgumentException("trusted must not be null");
+    }
+    return new Providers<>(spi, loader, configuration, trusted);
+  }
+
+  /**
+   * @return The configuration key that selects a provider by name for {@link #select}:
+   *         {@value #CONFIGURATION_KEY_PREFIX} followed by the fully qualified name of the SPI.
+   *         Never {@code null}.
+   */
+  public String configurationKey() {
+    return configurationKey;
+  }
+
+  /**
+   * @return The configuration key that disables providers of this SPI, a comma separated list of
+   *         provider names and provider class names: {@link #configurationKey()} followed by
+   *         {@value #DISABLED_KEY_SUFFIX}. A provider disabled by name is not resolved but stays
+   *         listed by {@link #installed()}. A provider disabled by class name is not instantiated
+   *         at all, which needs the class name to be configured before the providers are looked
+   *         up. Never {@code null}.
+   */
+  public String disabledKey() {
+    return disabledKey;
+  }
+
+  /**
+   * @return The configuration key that limits which provider classes of this SPI may be used:
+   *         {@link #configurationKey()} followed by {@value #ALLOWED_KEY_SUFFIX}. The value is a
+   *         comma separated list of provider class names and of package prefixes, which end with
+   *         a dot; an unset or blank value allows every provider, a value that holds only
+   *         separators allows none. It matches class names, not their origin. Use
+   *         {@link #ofTrusted(Class, ClassLoader, Predicate)} to validate provider classes.
+   *         Unlike the other keys it is read from a system property or from the source an
+   *         application passes only, never from {@value #CONFIGURATION_FILE}, which any jar can carry.
+   *         Providers are checked against it before they are instantiated, so it has to be
+   *         configured before the lookup. Never {@code null}.
+   */
+  public String allowedKey() {
+    return allowedKey;
+  }
+
+  /**
+   * @return The providers in lookup order, including unavailable ones and those disabled by
+   *         name. Unmodifiable and never {@code null}.
+   */
+  public List<P> installed() {
+    return installed;
+  }
+
+  /**
+   * Resolves an available provider by name, unless the name is disabled. If several available
+   * providers have the name, the one with the highest priority is returned. The key from
+   * {@link #configurationKey()} is not applied.
+   *
+   * @param name The case-sensitive provider name. Must not be {@code null} or blank.
+   * @return The provider, or {@link Optional#empty()} if the name is disabled or no available
+   *         provider has it.
+   * @throws IllegalArgumentException Thrown if {@code name} is {@code null} or blank.
+   * @throws AmbiguousProviderException Thrown if several providers of that name have the highest
+   *                                    priority.
+   */
+  public Optional<P> byName(final String name) {
+    if (name == null || name.isBlank()) {
+      throw new IllegalArgumentException("name must not be null or blank");
+    }
+    if (disabledNames().contains(name)) {
+      return Optional.empty();
+    }
+    final List<Registration<P>> named = new ArrayList<>();
+    for (final Registration<P> registration : registrations) {
+      if (registration.name().equals(name) && isAvailable(registration)) {
+        named.add(registration);
+      }
+    }
+    if (named.isEmpty()) {
+      return Optional.empty();
+    }
+    named.sort(Registration.BY_PRIORITY);
+    if (isTied(named)) {
+      throw new AmbiguousProviderException(spi.getName() + " providers named '" + name + "'"
+          + SAME_PRIORITY + tiedClasses(named) + ". " + hint(name));
+    }
+    return Optional.of(named.get(0).provider());
+  }
+
+  /**
+   * Lists the available providers that support a spec, without the disabled ones. The key from
+   * {@link #configurationKey()} is not applied.
+   *
+   * @param spec The spec. Must not be {@code null}.
+   * @return The providers ordered by descending priority, providers of equal priority in lookup
+   *         order. Unmodifiable and never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code spec} is {@code null}.
+   */
+  public List<P> supporting(final ProviderSpec spec) {
+    if (spec == null) {
+      throw new IllegalArgumentException("spec must not be null");
+    }
+    final List<Registration<P>> candidates = candidates(spec, disabledNames(), null);
+    final List<P> providers = new ArrayList<>(candidates.size());
+    for (final Registration<P> candidate : candidates) {
+      providers.add(candidate.provider());
+    }
+    return Collections.unmodifiableList(providers);
+  }
+
+  /**
+   * Resolves the provider for a spec. The candidates are the available providers that support
+   * {@code spec} and are not disabled. If the key from {@link #configurationKey()} holds a
+   * nonblank value, only candidates with that name, surrounding whitespace ignored, remain. The
+   * candidate with the highest priority is returned.
+   *
+   * @param spec The spec. Must not be {@code null}.
+   * @return The provider. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code spec} is {@code null}.
+   * @throws UnsatisfiedProviderException Thrown if no candidate remains.
+   * @throws AmbiguousProviderException Thrown if several candidates have the highest priority.
+   */
+  public P select(final ProviderSpec spec) {
+    if (spec == null) {
+      throw new IllegalArgumentException("spec must not be null");
+    }
+    final Set<String> disabled = disabledNames();
+    final String selected = configuredName();
+    final List<Registration<P>> candidates = candidates(spec, disabled, selected);
+    if (candidates.isEmpty()) {
+      throw new UnsatisfiedProviderException(unsatisfiedMessage(spec, selected, disabled));
+    }
+    if (isTied(candidates)) {
+      throw new AmbiguousProviderException(spi.getName() + " providers for " + spec + SAME_PRIORITY
+          + tiedClasses(candidates) + ". " + hint(selected));
+    }
+    return candidates.get(0).provider();
+  }
+
+  /**
+   * @param spec The spec. Must not be {@code null}.
+   * @param disabled The disabled provider names and class names. Must not be {@code null}.
+   * @param selected The selected name, or {@code null} to keep every name.
+   * @return The available registrations that support {@code spec}, ordered by descending
+   *         priority. A new, modifiable list.
+   */
+  private List<Registration<P>> candidates(final ProviderSpec spec, final Set<String> disabled,
+                                           final String selected) {
+    final List<Registration<P>> candidates = new ArrayList<>();
+    for (final Registration<P> registration : registrations) {
+      if ((selected == null || selected.equals(registration.name()))
+          && !disabled.contains(registration.name()) && isAvailable(registration)
+          && supports(registration, spec)) {
+        candidates.add(registration);
+      }
+    }
+    candidates.sort(Registration.BY_PRIORITY);
+    return candidates;
+  }
+
+  private boolean isAvailable(final Registration<P> registration) {
+    try {
+      return registration.provider().isAvailable();
+    } catch (final VirtualMachineError e) {
+      throw e;
+    } catch (final RuntimeException | Error e) {
+      warn(registration.provider(), AVAILABLE_METHOD, e.toString());
+      return false;
+    }
+  }
+
+  private boolean supports(final Registration<P> registration, final ProviderSpec spec) {
+    try {
+      return registration.provider().supports(spec);
+    } catch (final VirtualMachineError e) {
+      throw e;
+    } catch (final RuntimeException | Error e) {
+      warn(registration.provider(), SUPPORTS_METHOD, e.toString());
+      return false;
+    }
+  }
+
+  private boolean isTied(final List<Registration<P>> sorted) {
+    return sorted.size() > 1 && sorted.get(0).priority() == sorted.get(1).priority();
+  }
+
+  private List<String> tiedClasses(final List<Registration<P>> sorted) {
+    final int highest = sorted.get(0).priority();
+    final List<String> classes = new ArrayList<>();
+    for (final Registration<P> registration : sorted) {
+      if (registration.priority() == highest) {
+        classes.add(registration.provider().getClass().getName());
+      }
+    }
+    return classes;
+  }
+
+  /**
+   * @param selected The selected name, or {@code null} if none is configured.
+   * @return The advice added to an {@link AmbiguousProviderException}.
+   */
+  private String hint(final String selected) {
+    if (selected == null) {
+      return "Set " + configurationKey + "=<name> to select one.";
+    }
+    return "Add one of them to " + disabledKey + ", or give it another priority.";
+  }
+
+  /**
+   * @param spec The spec no provider was resolved for.
+   * @param selected The selected name, or {@code null} if none is configured.
+   * @param disabled The disabled provider names and class names.
+   * @return The message of an {@link UnsatisfiedProviderException}.
+   */
+  private String unsatisfiedMessage(final ProviderSpec spec, final String selected,
+                                    final Set<String> disabled) {
+    final List<String> names = new ArrayList<>(registrations.size());
+    for (final Registration<P> registration : registrations) {
+      names.add(registration.name());
+    }
+    final StringBuilder message = new StringBuilder("No available ").append(spi.getName())
+        .append(" provider");
+    if (selected != null) {
+      message.append(" named '").append(selected).append("' (").append(configurationKey)
+          .append(')');
+    }
+    message.append(" supports ").append(spec).append(". Installed: ").append(names);
+    if (!disabled.isEmpty()) {
+      message.append(". Disabled by ").append(disabledKey).append(": ").append(disabled);
+    }
+    final Set<String> allowed = allowedClasses();
+    if (!allowed.isEmpty()) {
+      message.append(". Allowed by ").append(allowedKey).append(": ").append(allowed);
+    }
+    if (skipped > 0) {
+      message.append(". Skipped during lookup: ").append(skipped).append(" (see the log)");
+    }
+    return message.toString();
+  }
+
+  /**
+   * @return The name the configuration selects, without surrounding whitespace, or {@code null}
+   *         if none is configured.
+   */
+  private String configuredName() {
+    final String value = configured(configurationKey);
+    return value == null || value.isBlank() ? null : value.strip();
+  }
+
+  /**
+   * Reads a configuration key through the source of this instance.
+   *
+   * @param key The key to read. Must not be {@code null}.
+   * @return The configured value, or {@code null} if the key is not configured.
+   * @throws ProviderResolutionException Thrown if the configuration source fails.
+   */
+  private String configured(final String key) {
+    try {
+      return configuration.apply(key);
+    } catch (final VirtualMachineError e) {
+      throw e;
+    } catch (final RuntimeException | Error e) {
+      throw new ProviderResolutionException("Cannot read the configuration key " + key, e);
+    }
+  }
+
+  /**
+   * @return The provider names and class names the configuration disables. Never {@code null}.
+   */
+  private Set<String> disabledNames() {
+    final String value = configured(disabledKey);
+    final Parsed parsed = disabled;
+    if (parsed != null && Objects.equals(parsed.value(), value)) {
+      return parsed.names();
+    }
+    final Set<String> names = names(value);
+    disabled = new Parsed(value, names);
+    return names;
+  }
+
+  private Set<String> names(final String value) {
+    if (value == null || value.isBlank()) {
+      return Set.of();
+    }
+    final Set<String> names = new HashSet<>();
+    for (final String entry : StringUtil.split(value, NAME_SEPARATOR)) {
+      final String name = entry.strip();
+      if (!name.isEmpty()) {
+        names.add(name);
+      }
+    }
+    return names;
+  }
+
+  /**
+   * @return The provider class names and package prefixes the configuration allows, or an empty
+   *         set when every provider is allowed. Never {@code null}.
+   */
+  private Set<String> allowedClasses() {
+    final String value = configured(allowedKey);
+    if (value == null || value.isBlank()) {
+      return Set.of();
+    }
+    final Set<String> allowed = names(value);
+    return allowed.isEmpty() ? Set.of(NOTHING_ALLOWED) : allowed;
+  }
+
+  private boolean isAllowed(final Set<String> allowed, final String provided) {
+    if (allowed.isEmpty()) {
+      return true;
+    }
+    if (allowed.contains(provided)) {
+      return true;
+    }
+    for (final String entry : allowed) {
+      if (entry.endsWith(PACKAGE_SEPARATOR) && provided.startsWith(entry)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Resolves the class loader to look providers up with: the thread context class loader when it
+   * sees this SPI, the class loader of the SPI otherwise. A thread of the common
+   * {@link java.util.concurrent.ForkJoinPool} carries the system class loader, which usually does
+   * not see the SPI of an application.
+   *
+   * @param explicit The class loader an application passed, or {@code null}.
+   * @return The class loader to look providers up with. Never {@code null}.
+   */
+  private ClassLoader resolveLoader(final ClassLoader explicit) {
+    if (explicit != null) {
+      return explicit;
+    }
+    final ClassLoader declaring = spi.getClassLoader() != null
+        ? spi.getClassLoader() : ClassLoader.getSystemClassLoader();
+    final ClassLoader context = Thread.currentThread().getContextClassLoader();
+    if (context == null) {
+      return declaring;
+    }
+    for (ClassLoader ancestor = context; ancestor != null; ancestor = ancestor.getParent()) {
+      if (ancestor == declaring) {
+        return context;
+      }
+    }
+    // A class loader of a module system or of a bundle delegates without a parent chain, so ask
+    // it whether it sees the SPI. Its resource is compared instead of loading the class, which
+    // would run foreign code under that loader's lock.
+    final String resource = spi.getName().replace('.', PACKAGE_SEPARATOR_PATH) + CLASS_SUFFIX;
+    try {
+      final String seen = String.valueOf(context.getResource(resource));
+      return seen.equals(String.valueOf(declaring.getResource(resource))) ? context : declaring;
+    } catch (final VirtualMachineError e) {
+      throw e;
+    } catch (final RuntimeException | Error e) {
+      return declaring;
+    }
+  }
+
+  /**
+   * Reads {@value #CONFIGURATION_FILE} of a class loader, merged in ascending order of
+   * {@value #ORDINAL_KEY}, and lets a system property of the same name win over it.
+   *
+   * @param loader The class loader to read the configuration with. Must not be {@code null}.
+   * @return The configuration source. Never {@code null}.
+   * @throws ProviderResolutionException Thrown if a configuration file cannot be read.
+   */
+  private UnaryOperator<String> fileConfiguration(final ClassLoader loader) {
+    final Map<String, String> configured = readConfiguration(loader);
+    final String allowed = allowedKey;
+    // The allow list decides which classes may run at all, so it is not read from a file that
+    // any jar of the class path can carry.
+    return (final String key) -> allowed.equals(key)
+        ? System.getProperty(key) : System.getProperty(key, configured.get(key));
+  }
+
+  /**
+   * @param loader The class loader to read the configuration with. Must not be {@code null}.
+   * @return The merged content of the configuration files. Never {@code null}.
+   * @throws ProviderResolutionException Thrown if the class loader cannot list the configuration
+   *                                     files or one of them cannot be read.
+   */
+  private Map<String, String> readConfiguration(final ClassLoader loader) {
+    final List<ConfigurationFile> files = new ArrayList<>();
+    try {
+      final Enumeration<URL> resources = loader.getResources(CONFIGURATION_FILE);
+      while (resources.hasMoreElements()) {
+        files.add(read(resources.nextElement()));
+      }
+    } catch (final ProviderResolutionException | VirtualMachineError e) {
+      throw e;
+    } catch (final IOException | RuntimeException | Error e) {
+      throw lookupFailed(e);
+    }
+    files.sort(Comparator.comparingInt(ConfigurationFile::ordinal));
+    final Map<String, String> configured = new LinkedHashMap<>();
+    for (final ConfigurationFile file : files) {
+      for (final String key : file.content().stringPropertyNames()) {
+        configured.put(key, file.content().getProperty(key));
+      }
+      if (file.content().containsKey(configurationKey) || file.content().containsKey(disabledKey)
+          || file.content().containsKey(allowedKey)) {
+        // Any jar on the class path can carry this file, so record where a decision came from.
+        logger.info("{} configures {} providers", printable(file.resource()), spi.getName());
+      }
+    }
+    return configured;
+  }
+
+  /**
+   * Reads one configuration file, without caching its jar, so that an application can be
+   * undeployed and its files replaced.
+   *
+   * @param resource The configuration file to read. Must not be {@code null}.
+   * @return The file. Never {@code null}.
+   * @throws ProviderResolutionException Thrown if {@code resource} cannot be read, is not encoded
+   *                                     in UTF-8 or holds an invalid {@value #ORDINAL_KEY}.
+   */
+  private ConfigurationFile read(final URL resource) {
+    final Properties read = new Properties();
+    try {
+      final URLConnection connection = resource.openConnection();
+      connection.setUseCaches(false);
+      try (InputStream stream = connection.getInputStream();
+           Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8.newDecoder())) {
+        read.load(reader);
+      }
+    } catch (final IOException | IllegalArgumentException e) {
+      throw new ProviderResolutionException("Cannot read " + printable(resource), e);
+    }
+    final Properties content = stripByteOrderMark(read);
+    return new ConfigurationFile(resource, content, ordinal(resource, content));
+  }
+
+  private Properties stripByteOrderMark(final Properties content) {
+    for (final String key : content.stringPropertyNames()) {
+      if (!key.isEmpty() && key.charAt(0) == BYTE_ORDER_MARK) {
+        final String stripped = key.substring(1);
+        if (content.getProperty(stripped) == null) {
+          content.setProperty(stripped, content.getProperty(key));
+        }
+        content.remove(key);
+      }
+    }
+    return content;
+  }
+
+  /** @return The location without the parts that may hold credentials, for a log or a message. */
+  private String printable(final URL resource) {
+    try {
+      return ProviderSpec.printable(resource.toURI());
+    } catch (final URISyntaxException e) {
+      return resource.getProtocol() + ":" + CONFIGURATION_FILE;
+    }
+  }
+
+  /**
+   * @return The value of {@value #ORDINAL_KEY}, or {@code 0} if the file has none.
+   * @throws ProviderResolutionException Thrown if the value is not a number.
+   */
+  private int ordinal(final URL resource, final Properties content) {
+    final String value = content.getProperty(ORDINAL_KEY);
+    if (value == null || value.isBlank()) {
+      return 0;
+    }
+    try {
+      return Integer.parseInt(value.strip());
+    } catch (final NumberFormatException e) {
+      throw new ProviderResolutionException(ORDINAL_KEY + " of " + printable(resource)
+          + " is not a number: " + value, e);
+    }
+  }
+
+  /**
+   * Looks the providers up, skipping the registrations that cannot be used.
+   *
+   * @param loader The class loader to look providers up with. Must not be {@code null}.
+   * @return The registrations in lookup order and the number of skipped ones. Never
+   *         {@code null}.
+   * @throws ProviderResolutionException Thrown if the service files cannot be read or the lookup
+   *                                     does not advance.
+   */
+  private Lookup<P> lookup(final ClassLoader loader) {
+    try {
+      // A class loader that cannot list the service files leaves the iterator of the service
+      // loader where it is, so ask it once instead of reading that from a repeated failure.
+      loader.getResources(SERVICE_PREFIX + spi.getName());
+    } catch (final VirtualMachineError e) {
+      throw e;
+    } catch (final IOException | RuntimeException | Error e) {
+      throw lookupFailed(e);
+    }
+    final Set<String> disabled = disabledNames();
+    final Set<String> allowed = allowedClasses();
+    final List<Registration<P>> found = new ArrayList<>();
+    final Iterator<ServiceLoader.Provider<P>> iterator =
+        ServiceLoader.load(spi, loader).stream().iterator();
+    int failures = 0;
+    int ignored = 0;
+    while (true) {
+      final ServiceLoader.Provider<P> entry;
+      try {
+        if (!iterator.hasNext()) {
+          break;
+        }
+        entry = iterator.next();
+      } catch (final RuntimeException e) {
+        throw lookupFailed(e);
+      } catch (final ServiceConfigurationError | LinkageError e) {
+        // Every registration that fails advances the iterator, so only a failure that repeats
+        // beyond the bound can be one that does not.
+        failures++;
+        if (failures > MAX_CONSECUTIVE_FAILURES) {
+          throw lookupFailed(e);
+        }
+        ignored++;
+        logger.warn(SKIPPED_ENTRY, spi.getName(), loaderName, e.toString());
+        continue;
+      }
+      failures = 0;
+      final String provided = entry.type().getName();
+      if (disabled.contains(provided) || !isAllowed(allowed, provided)) {
+        ignored++;
+        logger.info("Leaving out the {} provider {}, see {} and {}", spi.getName(), provided,
+            disabledKey, allowedKey);
+        continue;
+      }
+      logger.debug("The {} provider {} comes from {}", spi.getName(), provided,
+          codeSource(entry.type()));
+      if (!isTrusted(entry.type())) {
+        ignored++;
+        logger.info("Leaving out the not trusted {} provider {} from {}", spi.getName(), provided,
+            codeSource(entry.type()));
+        continue;
+      }
+      final P provider;
+      try {
+        provider = entry.get();
+      } catch (final VirtualMachineError e) {
+        throw e;
+      } catch (final RuntimeException | Error e) {
+        ignored++;
+        logger.warn(SKIPPED_ENTRY, spi.getName(), loaderName, e.toString());
+        continue;
+      }
+      final Optional<String> name = call(provider, NAME_METHOD, provider::name);
+      if (name.isEmpty()) {
+        ignored++;
+        continue;
+      }
+      if (!isValidName(name.get())) {
+        ignored++;
+        logger.warn(INVALID_NAME, spi.getName(), provided, MAX_NAME_LENGTH);
+        continue;
+      }
+      final Optional<Integer> priority = call(provider, PRIORITY_METHOD, provider::priority);
+      if (priority.isEmpty()) {
+        ignored++;
+        continue;
+      }
+      found.add(new Registration<>(provider, name.get(), priority.get()));
+    }
+    warnAboutSharedNames(found);
+    return new Lookup<>(Collections.unmodifiableList(found), ignored);
+  }
+
+  /** Reports providers that share a name, which can be a replacement or an unwanted override. */
+  private void warnAboutSharedNames(final List<Registration<P>> found) {
+    final Map<String, List<String>> classes = new LinkedHashMap<>();
+    for (final Registration<P> registration : found) {
+      classes.computeIfAbsent(registration.name(), name -> new ArrayList<>())
+          .add(registration.provider().getClass().getName());
+    }
+    for (final Map.Entry<String, List<String>> shared : classes.entrySet()) {
+      if (shared.getValue().size() > 1) {
+        logger.info("{} providers {} share the name '{}'", spi.getName(), shared.getValue(),
+            shared.getKey());
+      }
+    }
+  }
+
+  /**
+   * @return {@code true} if the predicate accepts the class.
+   * @throws ProviderResolutionException Thrown if the predicate throws.
+   */
+  private boolean isTrusted(final Class<?> provided) {
+    if (trusted == null) {
+      return true;
+    }
+    try {
+      return trusted.test(provided);
+    } catch (final VirtualMachineError e) {
+      throw e;
+    } catch (final RuntimeException | Error e) {
+      throw new ProviderResolutionException("The trust check of the " + spi.getName()
+          + " provider " + provided.getName() + " failed", e);
+    }
+  }
+
+  /** @return The location the class was loaded from, or {@code "unknown"}. */
+  private String codeSource(final Class<?> provided) {
+    try {
+      final ProtectionDomain domain = provided.getProtectionDomain();
+      if (domain == null || domain.getCodeSource() == null
+          || domain.getCodeSource().getLocation() == null) {
+        return "unknown";
+      }
+      return printable(domain.getCodeSource().getLocation());
+    } catch (final VirtualMachineError e) {
+      throw e;
+    } catch (final RuntimeException | Error e) {
+      return "unknown";
+    }
+  }
+
+  private ProviderResolutionException lookupFailed(final Throwable cause) {
+    return new ProviderResolutionException(String.format(LOOKUP_FAILED, spi.getName(), loaderName),
+        cause);
+  }
+
+  /**
+   * Calls a provider method and logs a failure instead of throwing it.
+   *
+   * @param provider The provider to call.
+   * @param method The name of the called method, for the log message.
+   * @param call The call.
+   * @param <R> The result type.
+   * @return The result, or {@link Optional#empty()} if the call returned {@code null} or threw.
+   */
+  private <R> Optional<R> call(final P provider, final String method, final Supplier<R> call) {
+    try {
+      final R result = call.get();
+      if (result == null) {
+        warn(provider, method, "returned null");
+      }
+      return Optional.ofNullable(result);
+    } catch (final VirtualMachineError e) {
+      throw e;
+    } catch (final RuntimeException | Error e) {
+      warn(provider, method, e.toString());
+      return Optional.empty();
+    }
+  }
+
+  /**
+   * Reports a provider method that could not be used, once per provider and method at
+   * {@code WARN} and at {@code DEBUG} afterwards, so a resolution on a request path does not
+   * flood the log.
+   *
+   * @param provider The provider that failed.
+   * @param method The name of the called method.
+   * @param failure The failure to report.
+   */
+  private void warn(final P provider, final String method, final String failure) {
+    final String provided = provider.getClass().getName();
+    if (warned.add(provided + '#' + method)) {
+      logger.warn(SKIPPED_CALL, spi.getName(), provided, method, failure);
+    } else {
+      logger.debug(SKIPPED_CALL, spi.getName(), provided, method, failure);
+    }
+  }
+
+  private boolean isValidName(final String name) {
+    if (name.isEmpty() || name.length() > MAX_NAME_LENGTH) {
+      return false;
+    }
+    for (int character = 0; character < name.length(); character++) {
+      final char current = name.charAt(character);
+      final boolean ascii = current >= 'a' && current <= 'z' || current >= 'A' && current <= 'Z'
+          || current >= '0' && current <= '9';
+      if (!ascii && current != '.' && current != '_' && current != '-') {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * A provider with the name and priority read once at lookup.
+   *
+   * @param provider The provider.
+   * @param name The name of the provider.
+   * @param priority The priority of the provider.
+   * @param <P> The SPI.
+   */
+  private record Registration<P>(P provider, String name, int priority) {
+
+    /** Orders registrations by descending priority. */
+    private static final Comparator<Registration<?>> BY_PRIORITY =
+        Comparator.comparingInt((final Registration<?> registration) -> registration.priority())
+            .reversed();
+  }
+
+  /**
+   * The entries of a configuration value, kept to parse a value that does not change only once.
+   *
+   * @param value The configuration value, or {@code null} if the key is not configured.
+   * @param names The entries of {@code value}.
+   */
+  private record Parsed(String value, Set<String> names) {
+  }
+
+  /**
+   * A configuration file of a class loader.
+   *
+   * @param resource The location of the file.
+   * @param content The content of the file.
+   * @param ordinal The value of {@value #ORDINAL_KEY}, which orders several files.
+   */
+  private record ConfigurationFile(URL resource, Properties content, int ordinal) {
+  }
+
+  /**
+   * The result of a lookup.
+   *
+   * @param registrations The usable registrations in lookup order.
+   * @param skipped The number of skipped registrations.
+   * @param <P> The SPI.
+   */
+  private record Lookup<P>(List<Registration<P>> registrations, int skipped) {
+  }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ext/UnsatisfiedProviderException.java b/opennlp-api/src/main/java/opennlp/tools/util/ext/UnsatisfiedProviderException.java
new file mode 100644
index 000000000..74757dd3f
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/util/ext/UnsatisfiedProviderException.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.util.ext;
+
+/**
+ * Thrown if no available provider supports a spec, or none of them has the configured name.
+ *
+ * @since 3.0.0
+ */
+public class UnsatisfiedProviderException extends ProviderResolutionException {
+
+  private static final long serialVersionUID = 1L;
+
+  /**
+   * Initializes the exception.
+   *
+   * @param message The detail message.
+   */
+  public UnsatisfiedProviderException(final String message) {
+    super(message);
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/util/ext/ProviderSpecTest.java b/opennlp-api/src/test/java/opennlp/tools/util/ext/ProviderSpecTest.java
new file mode 100644
index 000000000..b1889ae09
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/util/ext/ProviderSpecTest.java
@@ -0,0 +1,267 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.util.ext;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ProviderSpecTest {
+
+  private static final String ONNX = ".onnx";
+
+  @Test
+  void testEmpty() {
+    final ProviderSpec spec = ProviderSpec.empty();
+    assertSame(spec, ProviderSpec.empty());
+    assertTrue(spec.location().isEmpty());
+    assertTrue(spec.path().isEmpty());
+    assertTrue(spec.options().isEmpty());
+    assertFalse(spec.locationEndsWith(ONNX));
+    assertTrue(spec.hasOnlyOptions());
+  }
+
+  @Test
+  void testOptionsAreCopiedInOrderAndUnmodifiable() {
+    final Map<String, String> options = new LinkedHashMap<>();
+    options.put("b", "2");
+    options.put("a", "1");
+    final ProviderSpec spec = ProviderSpec.of(options);
+    options.put("c", "3");
+    assertEquals(List.of("b", "a"), List.copyOf(spec.options().keySet()));
+    assertThrows(UnsupportedOperationException.class, () -> spec.options().put("c", "3"));
+    assertEquals("1", spec.option("a", null));
+    assertEquals("default", spec.option("c", "default"));
+    assertNull(spec.option("c", null));
+  }
+
+  @Test
+  void testRejectsInvalidArguments() {
+    final Map<String, String> nullKey = new HashMap<>();
+    nullKey.put(null, "value");
+    final Map<String, String> nullValue = new HashMap<>();
+    nullValue.put("key", null);
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of((Map<String, String>) null));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of(nullKey));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of(nullValue));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of(Map.of(" ", "value")));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of(Map.of("", "value")));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of((URI) null));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of((Path) null));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of((Path) null, Map.of()));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of(URI.create("model.onnx")));
+    assertThrows(IllegalArgumentException.class, () -> ProviderSpec.of(Path.of("model.onnx"), null));
+    final ProviderSpec spec = ProviderSpec.empty();
+    assertThrows(IllegalArgumentException.class, () -> spec.option(null, "value"));
+    assertThrows(IllegalArgumentException.class, () -> spec.hasOnlyOptions((String[]) null));
+    assertThrows(IllegalArgumentException.class, () -> spec.hasOnlyOptions("a", null));
+    assertThrows(IllegalArgumentException.class, () -> spec.locationEndsWith(null));
+    assertThrows(IllegalArgumentException.class, () -> spec.locationEndsWith(""));
+  }
+
+  @Test
+  void testRelativePathResolvesAgainstWorkingDirectory() {
+    final ProviderSpec spec = ProviderSpec.of(Path.of("models", "model.onnx"));
+    assertTrue(spec.location().orElseThrow().isAbsolute());
+    assertEquals(Path.of("models", "model.onnx").toAbsolutePath(), spec.path().orElseThrow());
+    assertTrue(spec.locationEndsWith(ONNX));
+  }
+
+  @Test
+  void testPathOnlyForLocalFiles() {
+    final Path file = Path.of("model.onnx").toAbsolutePath();
+    assertEquals(file, ProviderSpec.of(file.toUri()).path().orElseThrow());
+    assertTrue(ProviderSpec.of(URI.create("jar:file:/models.jar!/model.onnx")).path().isEmpty());
+    assertTrue(ProviderSpec.of(URI.create("https://example.org/model.onnx")).path().isEmpty());
+    assertTrue(ProviderSpec.of(URI.create("file:model.onnx")).path().isEmpty());
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "file:/models/model.onnx",
+      "file:/models/MODEL.ONNX",
+      "file:/models/.onnx",
+      "jar:file:/models.jar!/nested/model.onnx",
+      "https://example.org/models/model.onnx?revision=1",
+      "classpath:model.onnx"
+  })
+  void testLocationEndsWith(final String location) {
+    assertTrue(ProviderSpec.of(URI.create(location)).locationEndsWith(ONNX));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "file:/models/model.bin",
+      "file:/models/model.onnx/",
+      "file:/models.onnx/model",
+      "file:/models/onnx",
+      "https://example.org/model.bin#model.onnx",
+      "https://example.org"
+  })
+  void testLocationDoesNotEndWith(final String location) {
+    assertFalse(ProviderSpec.of(URI.create(location)).locationEndsWith(ONNX));
+  }
+
+  @Test
+  void testHasOnlyOptions() {
+    final ProviderSpec spec = ProviderSpec.of(Map.of("vocabulary", "vocab.txt"));
+    assertTrue(spec.hasOnlyOptions("vocabulary"));
+    assertTrue(spec.hasOnlyOptions("lowerCase", "vocabulary"));
+    assertFalse(spec.hasOnlyOptions("lowerCase"));
+    assertFalse(spec.hasOnlyOptions());
+  }
+
+  @Test
+  void testPathIsNotNormalized() {
+    final Path traversing = Path.of("/models/current/../weights/model.onnx");
+    final ProviderSpec spec = ProviderSpec.of(traversing);
+    assertEquals(traversing, spec.path().orElseThrow(),
+        "a path is kept as it is, as its segments may be symbolic links");
+    assertEquals(traversing.toUri(), spec.location().orElseThrow(), "location");
+  }
+
+  @Test
+  void testLocationEndsWithDecodesThePath() {
+    assertTrue(ProviderSpec.of(URI.create("file:/models/a%3Fb.onnx")).locationEndsWith(ONNX),
+        "a question mark in a file name is not a query");
+    assertFalse(ProviderSpec.of(URI.create("file:/models/a.onnx%3Fb")).locationEndsWith(ONNX),
+        "an encoded question mark does not end the name either");
+    assertTrue(ProviderSpec.of(Path.of("/models/gpt+2.onnx")).locationEndsWith("+2.onnx"),
+        "a plus is part of the file name, not a space");
+    assertFalse(ProviderSpec.of(URI.create("file:/models/evil%00.onnx")).locationEndsWith(ONNX),
+        "a control character in the path never matches");
+  }
+
+  @Test
+  @EnabledOnOs(OS.WINDOWS)
+  void testWindowsPaths() {
+    final ProviderSpec drive = ProviderSpec.of(Path.of("C:\\models\\model.onnx"));
+    assertEquals(Path.of("C:\\models\\model.onnx"), drive.path().orElseThrow(), "drive letter");
+    assertTrue(drive.locationEndsWith(ONNX), "drive letter");
+    final ProviderSpec unc = ProviderSpec.of(Path.of("\\\\server\\share\\model.onnx"));
+    assertEquals(Path.of("\\\\server\\share\\model.onnx"), unc.path().orElseThrow(), "UNC path");
+    assertTrue(unc.locationEndsWith(ONNX), "UNC path");
+  }
+
+  @Test
+  void testPathDoesNotDependOnTheFileSystem(@TempDir final Path dir) throws IOException {
+    final Path model = dir.resolve("model.onnx");
+    final ProviderSpec absent = ProviderSpec.of(model);
+    Files.createDirectory(model);
+    final ProviderSpec directory = ProviderSpec.of(model);
+    assertEquals(absent, directory, "a spec must not change when the location is created");
+    assertTrue(directory.locationEndsWith(ONNX), "a model directory keeps its name");
+    assertEquals(model, directory.path().orElseThrow(), "path");
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "jar:file:/models.jar!/model.bin?revision=.onnx",
+      "https://example.org/model.bin?revision=.onnx",
+      "file:/models/model.onnx.bin",
+      "file:/models/onnx"
+  })
+  void testLocationEndsWithIgnoresTheQuery(final String location) {
+    assertFalse(ProviderSpec.of(URI.create(location)).locationEndsWith(ONNX), location);
+  }
+
+  @Test
+  void testLocationEndsWithWholeName() {
+    assertTrue(ProviderSpec.of(URI.create("file:/models/.onnx")).locationEndsWith(ONNX),
+        "a name that is the suffix");
+    assertTrue(ProviderSpec.of(URI.create("file:/models/model.onnx")).locationEndsWith("onnx"),
+        "a suffix without a separator");
+    assertFalse(ProviderSpec.of(URI.create("file:/x.onnx")).locationEndsWith("/models/x.onnx"),
+        "a suffix longer than the path");
+  }
+
+  @Test
+  void testEqualsIgnoresOptionOrder() {
+    final Map<String, String> ordered = new LinkedHashMap<>();
+    ordered.put("b", "2");
+    ordered.put("a", "1");
+    assertEquals(ProviderSpec.of(Map.of("a", "1", "b", "2")), ProviderSpec.of(ordered),
+        "options are equal regardless of their order");
+  }
+
+  @Test
+  void testEqualsAndHashCode() {
+    final URI location = URI.create("file:/models/model.onnx");
+    final ProviderSpec spec = ProviderSpec.of(location, Map.of("a", "1"));
+    assertEquals(spec, ProviderSpec.of(location, Map.of("a", "1")));
+    assertEquals(spec.hashCode(), ProviderSpec.of(location, Map.of("a", "1")).hashCode());
+    assertNotEquals(spec, ProviderSpec.of(location, Map.of("a", "2")));
+    assertNotEquals(spec, ProviderSpec.of(Map.of("a", "1")));
+    assertNotEquals(spec, null);
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "https://user:secret@example.org/models/model.onnx",
+      "https://example.org/models/model.onnx?signature=secret",
+      "jar:file:/models.jar!/model.onnx?signature=secret",
+      "https://user:secret@my_host.internal/models/model.onnx",
+      "jar:https://user:secret@example.org/models.jar!/model.onnx",
+      "https://example.org/models/model.onnx#secret",
+      "ftp:user:secret@example.org/models/model.onnx"
+  })
+  void testToStringOmitsCredentials(final String location) {
+    final String text = ProviderSpec.of(URI.create(location), Map.of("apiKey", "secret")).toString();
+    assertTrue(text.contains("model.onnx"), text);
+    assertTrue(text.contains("apiKey"), text);
+    assertFalse(text.contains("secret"), text);
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "https://user:secret@example.org/models/providers.properties",
+      "jar:https://user:secret@example.org/lib.jar!/META-INF/opennlp/providers.properties",
+      "ftp:user:secret@example.org/providers.properties"
+  })
+  void testPrintableOmitsCredentials(final String location) {
+    final String text = ProviderSpec.printable(URI.create(location));
+    assertTrue(text.contains("providers.properties"), text);
+    assertFalse(text.contains("secret"), text);
+  }
+
+  @Test
+  void testToStringKeepsThePath() {
+    final String text = ProviderSpec.of(URI.create("https://tok@example.org/tok@m/model.onnx"))
+        .toString();
+    assertTrue(text.contains("/tok@m/model.onnx"), text);
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/util/ext/ProvidersTest.java b/opennlp-api/src/test/java/opennlp/tools/util/ext/ProvidersTest.java
new file mode 100644
index 000000000..a386cc926
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/util/ext/ProvidersTest.java
@@ -0,0 +1,1197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.util.ext;
+
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.UnaryOperator;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ProvidersTest {
+
+  private static final String META_INF = "META-INF/";
+  private static final String SERVICES = META_INF + "services/";
+  private static final String SOURCE_KEY =
+      Providers.CONFIGURATION_KEY_PREFIX + SourceProvider.class.getName();
+  private static final String SINK_KEY =
+      Providers.CONFIGURATION_KEY_PREFIX + SinkProvider.class.getName();
+  private static final String SOURCE_DISABLED_KEY = SOURCE_KEY + Providers.DISABLED_KEY_SUFFIX;
+  private static final String SOURCE_ALLOWED_KEY = SOURCE_KEY + Providers.ALLOWED_KEY_SUFFIX;
+  private static final String KIND_OPTION = "kind";
+  private static final String TEXT_KIND = "text";
+  private static final ProviderSpec ANY = ProviderSpec.empty();
+  private static final ProviderSpec TEXT = ProviderSpec.of(Map.of(KIND_OPTION, TEXT_KIND));
+  private static final ProviderSpec BINARY = ProviderSpec.of(Map.of(KIND_OPTION, "binary"));
+
+  @TempDir
+  private Path dir;
+
+  @AfterEach
+  void clearConfiguration() {
+    System.clearProperty(SOURCE_KEY);
+    System.clearProperty(SINK_KEY);
+    System.clearProperty(SOURCE_DISABLED_KEY);
+    System.clearProperty(SOURCE_ALLOWED_KEY);
+    System.clearProperty(SINK_KEY + Providers.DISABLED_KEY_SUFFIX);
+  }
+
+  @Test
+  void testConfigurationKeysUseFullyQualifiedSpiName() throws IOException {
+    try (URLClassLoader loader = loader(Map.of())) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertEquals("opennlp.provider.opennlp.tools.util.ext.ProvidersTest$SourceProvider",
+          providers.configurationKey(), "configuration key");
+      assertEquals(providers.configurationKey() + ".disabled", providers.disabledKey(),
+          "disabled key");
+      assertNotEquals(Providers.of(First.SameName.class, loader).configurationKey(),
+          Providers.of(Second.SameName.class, loader).configurationKey(),
+          "SPIs sharing a simple name must not share a key");
+    }
+  }
+
+  @Test
+  @SuppressWarnings({"unchecked", "rawtypes"})
+  void testRejectsInvalidArguments() throws IOException {
+    assertThrows(IllegalArgumentException.class, () -> Providers.of(null), "null spi");
+    assertThrows(IllegalArgumentException.class, () -> Providers.of((Class) Provider.class),
+        "Provider itself");
+    assertThrows(IllegalArgumentException.class, () -> Providers.of((Class) Alpha.class),
+        "an implementation class");
+    assertThrows(IllegalArgumentException.class, () -> Providers.of((Class) Source.class),
+        "a service interface");
+    assertThrows(IllegalArgumentException.class,
+        () -> Providers.of(SourceProvider.class, null), "null loader");
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class, List.of(Alpha.class)))) {
+      assertThrows(IllegalArgumentException.class,
+          () -> Providers.of(SourceProvider.class, loader, null), "null configuration");
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertThrows(IllegalArgumentException.class, () -> providers.select(null), "null spec");
+      assertThrows(IllegalArgumentException.class, () -> providers.supporting(null), "null spec");
+      assertThrows(IllegalArgumentException.class, () -> providers.byName(null), "null name");
+      assertThrows(IllegalArgumentException.class, () -> providers.byName(" "), "blank name");
+    }
+  }
+
+  @Test
+  void testInstalledListsOnlyProvidersOfTheSpiInLookupOrder() throws IOException {
+    CountingSink.INSTANCES.set(0);
+    try (URLClassLoader loader = loader(Map.of(
+        SourceProvider.class, List.of(Beta.class, Alpha.class),
+        SinkProvider.class, List.of(CountingSink.class)))) {
+      final List<SourceProvider> installed = Providers.of(SourceProvider.class, loader).installed();
+      assertEquals(2, installed.size(), "installed sources");
+      assertInstanceOf(Beta.class, installed.get(0), "lookup order");
+      assertInstanceOf(Alpha.class, installed.get(1), "lookup order");
+      assertThrows(UnsupportedOperationException.class, () -> installed.remove(0), "unmodifiable");
+      assertEquals(0, CountingSink.INSTANCES.get(), "a sink provider was instantiated");
+      assertEquals(1, Providers.of(SinkProvider.class, loader).installed().size(), "installed sinks");
+      assertEquals(1, CountingSink.INSTANCES.get(), "sink provider instances");
+    }
+  }
+
+  @Test
+  void testSelectsHighestPrioritySupportingAvailableProvider() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class, Unavailable.class, AvailabilityThrows.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertEquals(4, providers.installed().size(), "installed");
+      final List<SourceProvider> forText = providers.supporting(TEXT);
+      assertEquals(2, forText.size(), "supporting text");
+      assertInstanceOf(TextOnly.class, forText.get(0), "highest priority first");
+      assertInstanceOf(Alpha.class, forText.get(1), "lower priority second");
+      assertThrows(UnsupportedOperationException.class, () -> forText.remove(0), "unmodifiable");
+      assertInstanceOf(TextOnly.class, providers.select(TEXT), "selected for text");
+      assertEquals(1, providers.supporting(BINARY).size(), "supporting binary");
+      assertInstanceOf(Alpha.class, providers.select(BINARY), "selected for binary");
+    }
+  }
+
+  @Test
+  void testEqualPriorityKeepsLookupOrder() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Beta.class, Alpha.class, TextOnly.class)))) {
+      final List<SourceProvider> supporting =
+          Providers.of(SourceProvider.class, loader).supporting(TEXT);
+      assertEquals(List.of("text", "beta", "alpha"), names(supporting),
+          "equal priority keeps lookup order below the higher priority");
+    }
+  }
+
+  @Test
+  void testUnsatisfiedSelectionNamesInstalledProviders() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class, List.of(TextOnly.class)))) {
+      final UnsatisfiedProviderException e = assertThrows(UnsatisfiedProviderException.class,
+          () -> Providers.of(SourceProvider.class, loader).select(BINARY));
+      assertTrue(e.getMessage().contains("Installed: [text]"), e.getMessage());
+    }
+  }
+
+  @Test
+  void testTiedPriorityIsAmbiguous() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, Beta.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      final AmbiguousProviderException e =
+          assertThrows(AmbiguousProviderException.class, () -> providers.select(ANY));
+      assertTrue(e.getMessage().contains(Alpha.class.getName()), e.getMessage());
+      assertTrue(e.getMessage().contains(Beta.class.getName()), e.getMessage());
+      assertTrue(e.getMessage().contains(SOURCE_KEY + "=<name>"), e.getMessage());
+      assertEquals(2, providers.supporting(ANY).size(), "both remain candidates");
+    }
+  }
+
+  @Test
+  void testAmbiguityUnderASelectedNameAdvisesDisabling() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, AlphaDuplicate.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      System.setProperty(SOURCE_KEY, "alpha");
+      final AmbiguousProviderException e =
+          assertThrows(AmbiguousProviderException.class, () -> providers.select(ANY));
+      assertTrue(e.getMessage().contains(SOURCE_DISABLED_KEY), e.getMessage());
+      assertFalse(e.getMessage().contains(SOURCE_KEY + "=<name>"),
+          "selecting by name cannot break a tie between providers of that name");
+    }
+  }
+
+  @Test
+  void testByNameRanksProvidersOfTheSameName() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, AlphaReplacement.class, Unavailable.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertInstanceOf(AlphaReplacement.class, providers.byName("alpha").orElseThrow(),
+          "the higher priority replaces the other provider of that name");
+      assertInstanceOf(AlphaReplacement.class, providers.select(ANY), "selected");
+      assertTrue(providers.byName("unavailable").isEmpty(), "unavailable");
+      assertTrue(providers.byName("missing").isEmpty(), "not installed");
+      assertTrue(providers.byName("ALPHA").isEmpty(), "names are case-sensitive");
+    }
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, AlphaDuplicate.class)))) {
+      final AmbiguousProviderException e = assertThrows(AmbiguousProviderException.class,
+          () -> Providers.of(SourceProvider.class, loader).byName("alpha"));
+      assertTrue(e.getMessage().contains(AlphaDuplicate.class.getName()), e.getMessage());
+      assertTrue(e.getMessage().contains(SOURCE_DISABLED_KEY), e.getMessage());
+    }
+  }
+
+  @Test
+  void testSelectedNameNarrowsCandidates() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      System.setProperty(SOURCE_KEY, "alpha");
+      assertInstanceOf(Alpha.class, providers.select(TEXT), "the selected name wins");
+      assertEquals(2, providers.supporting(TEXT).size(), "supporting ignores the selection");
+      System.setProperty(SOURCE_KEY, " text ");
+      assertInstanceOf(TextOnly.class, providers.select(TEXT), "surrounding whitespace is ignored");
+      System.setProperty(SOURCE_KEY, " ");
+      assertInstanceOf(TextOnly.class, providers.select(TEXT), "a blank value selects nothing");
+    }
+  }
+
+  @Test
+  void testSelectedNameMustSupportTheSpec() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      System.setProperty(SOURCE_KEY, "text");
+      final UnsatisfiedProviderException unsupported =
+          assertThrows(UnsatisfiedProviderException.class, () -> providers.select(BINARY));
+      assertTrue(unsupported.getMessage().contains("named 'text'"), unsupported.getMessage());
+      assertTrue(unsupported.getMessage().contains(SOURCE_KEY), unsupported.getMessage());
+      System.setProperty(SOURCE_KEY, "missing");
+      assertThrows(UnsatisfiedProviderException.class, () -> providers.select(TEXT),
+          "a name nobody has");
+    }
+  }
+
+  @Test
+  void testConfigurationAppliesToItsSpiOnly() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(
+        SourceProvider.class, List.of(Alpha.class),
+        SinkProvider.class, List.of(CountingSink.class)))) {
+      System.setProperty(SINK_KEY + Providers.DISABLED_KEY_SUFFIX, "sink");
+      assertInstanceOf(Alpha.class, Providers.of(SourceProvider.class, loader).select(ANY),
+          "another SPI is unaffected");
+      assertThrows(UnsatisfiedProviderException.class,
+          () -> Providers.of(SinkProvider.class, loader).select(ANY), "disabled sink");
+    }
+  }
+
+  @Test
+  void testDisabledNamesAreNotResolved() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      System.setProperty(SOURCE_DISABLED_KEY, " text , , beta ");
+      assertEquals(2, providers.installed().size(), "a disabled name stays installed");
+      assertTrue(providers.byName("text").isEmpty(), "disabled by name");
+      assertInstanceOf(Alpha.class, providers.byName("alpha").orElseThrow(), "not disabled");
+      assertEquals(1, providers.supporting(TEXT).size(), "supporting without the disabled one");
+      assertInstanceOf(Alpha.class, providers.select(TEXT), "selected");
+      System.setProperty(SOURCE_KEY, "text");
+      final UnsatisfiedProviderException e =
+          assertThrows(UnsatisfiedProviderException.class, () -> providers.select(TEXT));
+      assertTrue(e.getMessage().contains(SOURCE_DISABLED_KEY), e.getMessage());
+      System.clearProperty(SOURCE_KEY);
+      System.setProperty(SOURCE_DISABLED_KEY, " ");
+      assertInstanceOf(TextOnly.class, providers.select(TEXT), "a blank value disables nothing");
+    }
+  }
+
+  /** A provider disabled by class name must not be loaded at all. */
+  @Test
+  void testDisabledClassIsNotLoaded() throws IOException {
+    NotLoaded.INSTANCES.set(0);
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(NotLoaded.class, Alpha.class)))) {
+      System.setProperty(SOURCE_DISABLED_KEY, NotLoaded.class.getName());
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertEquals(1, providers.installed().size(), "only the enabled provider is installed");
+      assertEquals(0, NotLoaded.INSTANCES.get(), "the disabled class was instantiated");
+      assertInstanceOf(Alpha.class, providers.select(ANY), "selected");
+    }
+  }
+
+  /** Only the allowed provider classes are used, and the others are not even instantiated. */
+  @Test
+  void testAllowedProviderClasses() throws IOException {
+    NotLoaded.INSTANCES.set(0);
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(NotLoaded.class, Alpha.class, TextOnly.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertEquals(SOURCE_KEY + ".allowed", providers.allowedKey(), "allowed key");
+      assertEquals(3, providers.installed().size(), "an unset key allows every provider");
+
+      NotLoaded.INSTANCES.set(0);
+      System.setProperty(SOURCE_ALLOWED_KEY, " " + Alpha.class.getName() + " , "
+          + TextOnly.class.getName() + " ");
+      final Providers<SourceProvider> byClass = Providers.of(SourceProvider.class, loader);
+      assertEquals(List.of("alpha", "text"), names(byClass.installed()), "allowed by class name");
+      assertEquals(0, NotLoaded.INSTANCES.get(), "a provider outside the list was instantiated");
+      assertInstanceOf(TextOnly.class, byClass.select(TEXT), "selected");
+
+      System.setProperty(SOURCE_ALLOWED_KEY, "opennlp.tools.util.ext.Nothing.");
+      assertTrue(Providers.of(SourceProvider.class, loader).installed().isEmpty(),
+          "a package prefix nobody matches");
+
+      System.setProperty(SOURCE_ALLOWED_KEY, ProvidersTest.class.getName() + "$");
+      assertEquals(0, Providers.of(SourceProvider.class, loader).installed().size(),
+          "a prefix that does not end with a dot is a class name");
+
+      System.setProperty(SOURCE_ALLOWED_KEY, "opennlp.tools.util.ext.");
+      assertEquals(3, Providers.of(SourceProvider.class, loader).installed().size(),
+          "allowed by package prefix");
+
+      System.setProperty(SOURCE_ALLOWED_KEY, "  ");
+      assertEquals(3, Providers.of(SourceProvider.class, loader).installed().size(),
+          "a blank key allows every provider");
+    }
+  }
+
+  @Test
+  void testAllowedProviderClassesAreReported() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class, List.of(Alpha.class)))) {
+      System.setProperty(SOURCE_ALLOWED_KEY, "com.example.");
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      final UnsatisfiedProviderException e =
+          assertThrows(UnsatisfiedProviderException.class, () -> providers.select(ANY));
+      assertTrue(e.getMessage().contains(SOURCE_ALLOWED_KEY), e.getMessage());
+      assertTrue(e.getMessage().contains("Skipped during lookup: 1"), e.getMessage());
+    }
+  }
+
+  /** The allow list decides what may run, so a file of the class path must not set it. */
+  @Test
+  void testAllowedProviderClassesAreNotReadFromTheConfigurationFile() throws IOException {
+    register(dir, SourceProvider.class, List.of(Alpha.class, TextOnly.class));
+    configure(dir, SOURCE_ALLOWED_KEY + " = " + Alpha.class.getName() + "\n"
+        + SOURCE_DISABLED_KEY + " = text\n");
+    try (URLClassLoader loader = isolated(dir)) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertEquals(2, providers.installed().size(), "the allow list of a file is ignored");
+      assertTrue(providers.byName("text").isEmpty(), "the disabled key of a file is honoured");
+    }
+  }
+
+  @Test
+  void testAllowedProviderClassesWithoutEntriesAllowNothing() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class, List.of(Alpha.class)))) {
+      System.setProperty(SOURCE_ALLOWED_KEY, " , , ");
+      assertTrue(Providers.of(SourceProvider.class, loader).installed().isEmpty(),
+          "a value that holds only separators allows no provider");
+    }
+  }
+
+  /** A provider the trust predicate rejects is neither initialized nor instantiated. */
+  @Test
+  void testNotTrustedProvidersAreNotUsed() throws IOException {
+    Tracker.INITIALIZED.set(false);
+    Tracker.INSTANCES.set(0);
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Initializing.class, Alpha.class)))) {
+      final Providers<SourceProvider> providers = Providers.ofTrusted(SourceProvider.class, loader,
+          (final Class<?> provided) -> Alpha.class.equals(provided));
+      assertEquals(List.of("alpha"), names(providers.installed()), "only the trusted provider");
+      assertFalse(Tracker.INITIALIZED.get(), "the rejected class was initialized");
+      assertEquals(0, Tracker.INSTANCES.get(), "the rejected class was instantiated");
+      final Providers<SourceProvider> rejecting = Providers.ofTrusted(SourceProvider.class, loader,
+          (final Class<?> provided) -> false);
+      assertTrue(rejecting.installed().isEmpty(), "no provider is trusted");
+      final UnsatisfiedProviderException e = assertThrows(UnsatisfiedProviderException.class,
+          () -> rejecting.select(TEXT));
+      assertTrue(e.getMessage().contains("Skipped during lookup: 2"), e.getMessage());
+      assertFalse(Tracker.INITIALIZED.get(), "the rejected class was initialized");
+    }
+  }
+
+  @Test
+  void testTrustedProvidersOfACodeSource() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class)))) {
+      final URL application = getClass().getProtectionDomain().getCodeSource().getLocation();
+      final Providers<SourceProvider> providers = Providers.ofTrusted(SourceProvider.class, loader,
+          (final Class<?> provided) -> application.equals(
+              provided.getProtectionDomain().getCodeSource().getLocation()));
+      assertEquals(2, providers.installed().size(), "both providers share the code source");
+      final Providers<SourceProvider> foreign = Providers.ofTrusted(SourceProvider.class, loader,
+          (final Class<?> provided) -> !application.equals(
+              provided.getProtectionDomain().getCodeSource().getLocation()));
+      assertTrue(foreign.installed().isEmpty(), "no provider comes from another code source");
+    }
+  }
+
+  /** The name based keys filter first, so the predicate only sees what they leave. */
+  @Test
+  void testTrustIsCheckedAfterTheNameBasedKeys() throws IOException {
+    final List<String> asked = Collections.synchronizedList(new ArrayList<>());
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class, Beta.class)))) {
+      System.setProperty(SOURCE_ALLOWED_KEY, Alpha.class.getName() + "," + Beta.class.getName());
+      System.setProperty(SOURCE_DISABLED_KEY, Beta.class.getName());
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertEquals(List.of("alpha"), names(providers.installed()), "the keys leave one provider");
+      final Providers<SourceProvider> trusting = Providers.ofTrusted(SourceProvider.class, loader,
+          (final Class<?> provided) -> {
+            asked.add(provided.getName());
+            return true;
+          });
+      assertEquals(List.of(Alpha.class.getName()), asked,
+          "the predicate is asked for the classes the keys leave, and only for those");
+      assertEquals(1, trusting.installed().size(), "installed");
+    }
+  }
+
+  @Test
+  void testFailingTrustCheckEndsTheLookup() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class, List.of(Alpha.class)))) {
+      final ProviderResolutionException e = assertThrows(ProviderResolutionException.class,
+          () -> Providers.ofTrusted(SourceProvider.class, loader,
+              (final Class<?> provided) -> {
+                throw new IllegalStateException("cannot decide");
+              }), "a trust check that fails must not widen the policy");
+      assertTrue(e.getMessage().contains(Alpha.class.getName()), e.getMessage());
+      assertInstanceOf(IllegalStateException.class, e.getCause(), "cause");
+    }
+  }
+
+  @Test
+  void testTrustedFactoriesRejectInvalidArguments() throws IOException {
+    try (URLClassLoader loader = loader(Map.of())) {
+      assertThrows(IllegalArgumentException.class,
+          () -> Providers.ofTrusted(SourceProvider.class, null, provided -> true), "null loader");
+      assertThrows(IllegalArgumentException.class,
+          () -> Providers.ofTrusted(SourceProvider.class, loader, null), "null predicate");
+      assertThrows(IllegalArgumentException.class,
+          () -> Providers.of(SourceProvider.class, loader, null, provided -> true),
+          "null configuration");
+      assertThrows(IllegalArgumentException.class,
+          () -> Providers.of(SourceProvider.class, loader, key -> null, null), "null predicate");
+    }
+  }
+
+  @Test
+  void testConfigurationOfAnApplicationReplacesTheSystemProperties() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class)))) {
+      final Map<String, String> application = Map.of(SOURCE_KEY, "alpha");
+      final UnaryOperator<String> configuration = application::get;
+      System.setProperty(SOURCE_KEY, "text");
+      final Providers<SourceProvider> providers =
+          Providers.of(SourceProvider.class, loader, configuration);
+      assertInstanceOf(Alpha.class, providers.select(TEXT), "the application's own selection");
+      assertInstanceOf(TextOnly.class, Providers.of(SourceProvider.class, loader).select(TEXT),
+          "another application is configured on its own");
+    }
+  }
+
+  @Test
+  void testConfigurationFileOfTheClassLoader() throws IOException {
+    final Path application = Files.createDirectories(dir.resolve("application"));
+    final Path library = Files.createDirectories(dir.resolve("library"));
+    register(library, SourceProvider.class, List.of(Alpha.class, TextOnly.class));
+    configure(library, Providers.ORDINAL_KEY + " = 10\n" + SOURCE_KEY + " = text\n");
+    configure(application, SOURCE_KEY + " = alpha\n");
+    try (URLClassLoader loader = isolated(library)) {
+      assertInstanceOf(TextOnly.class, Providers.of(SourceProvider.class, loader).select(TEXT),
+          "the value of the configuration file");
+      System.setProperty(SOURCE_KEY, "alpha");
+      assertInstanceOf(Alpha.class, Providers.of(SourceProvider.class, loader).select(TEXT),
+          "a system property wins over the file");
+    }
+    System.clearProperty(SOURCE_KEY);
+    try (URLClassLoader loader = isolated(library, application)) {
+      assertInstanceOf(TextOnly.class, Providers.of(SourceProvider.class, loader).select(TEXT),
+          "the higher ordinal wins");
+    }
+    configure(application, Providers.ORDINAL_KEY + " = 20\n" + SOURCE_KEY + " = alpha\n");
+    try (URLClassLoader loader = isolated(library, application)) {
+      assertInstanceOf(Alpha.class, Providers.of(SourceProvider.class, loader).select(TEXT),
+          "the higher ordinal wins");
+    }
+    configure(application, Providers.ORDINAL_KEY + " = x\n");
+    try (URLClassLoader loader = isolated(library, application)) {
+      assertThrows(ProviderResolutionException.class,
+          () -> Providers.of(SourceProvider.class, loader), "an ordinal that is not a number");
+    }
+  }
+
+  @Test
+  void testBrokenProvidersAreSkipped() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class, List.of(
+        NotAProvider.class, Alpha.class, NoPublicConstructor.class, ConstructorThrows.class,
+        NameThrows.class, NameErrors.class, NullName.class, BlankName.class, CommaName.class,
+        PriorityThrows.class, SupportsErrors.class, TextOnly.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertEquals(List.of("alpha", "supports", "text"), names(providers.installed()),
+          "only the usable providers are installed");
+      assertInstanceOf(TextOnly.class, providers.select(TEXT), "selected for text");
+      assertInstanceOf(Alpha.class, providers.select(BINARY),
+          "a provider whose supports() throws must not hide the others");
+      assertTrue(providers.byName("a,b").isEmpty(), "a name with the separator is skipped");
+    }
+  }
+
+  @Test
+  void testUnsatisfiedSelectionReportsSkippedProviders() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(NotAProvider.class, TextOnly.class)))) {
+      final UnsatisfiedProviderException e = assertThrows(UnsatisfiedProviderException.class,
+          () -> Providers.of(SourceProvider.class, loader).select(BINARY));
+      assertTrue(e.getMessage().contains("Skipped during lookup: 1"), e.getMessage());
+    }
+  }
+
+  @Test
+  void testMissingProviderClassIsSkipped() throws IOException {
+    write(dir, SERVICES + SourceProvider.class.getName(),
+        "opennlp.tools.util.ext.DoesNotExist\n" + Alpha.class.getName() + "\n");
+    try (URLClassLoader loader = isolated(dir)) {
+      assertEquals(1, Providers.of(SourceProvider.class, loader).installed().size(),
+          "the provider behind the missing class");
+    }
+  }
+
+  @Test
+  @Timeout(30)
+  void testLookupFailsIfServiceFilesCannotBeRead() throws IOException {
+    try (URLClassLoader loader = failing(new IOException("unreadable"))) {
+      final ProviderResolutionException e = assertThrows(ProviderResolutionException.class,
+          () -> Providers.of(SourceProvider.class, loader));
+      assertTrue(e.getMessage().contains(SourceProvider.class.getName()), e.getMessage());
+    }
+  }
+
+  /** A class loader that keeps failing without a cause must not be retried forever. */
+  @Test
+  @Timeout(30)
+  void testLookupFailsIfItDoesNotAdvance() throws IOException {
+    try (URLClassLoader loader = failing(new NoClassDefFoundError("class loader is stopped"))) {
+      assertThrows(ProviderResolutionException.class,
+          () -> Providers.of(SourceProvider.class, loader), "a repeated error without a cause");
+    }
+    try (URLClassLoader loader = failing(new IllegalStateException("web application stopped"))) {
+      final ProviderResolutionException e = assertThrows(ProviderResolutionException.class,
+          () -> Providers.of(SourceProvider.class, loader), "a stopped web application");
+      assertInstanceOf(IllegalStateException.class, e.getCause(), "cause");
+    }
+  }
+
+  /** A module or bundle class loader delegates without a parent chain and must still be used. */
+  @Test
+  void testUsesADelegatingContextClassLoader() throws IOException {
+    final Thread thread = Thread.currentThread();
+    final ClassLoader previous = thread.getContextClassLoader();
+    final ClassLoader application = getClass().getClassLoader();
+    register(dir, SourceProvider.class, List.of(Alpha.class));
+    final URL services = dir.toUri().toURL();
+    try (URLClassLoader delegating = new URLClassLoader(new URL[] {services}, null) {
+      @Override
+      public Class<?> loadClass(final String name) throws ClassNotFoundException {
+        return application.loadClass(name);
+      }
+
+      @Override
+      public URL getResource(final String name) {
+        final URL own = findResource(name);
+        return own != null ? own : application.getResource(name);
+      }
+    }) {
+      thread.setContextClassLoader(delegating);
+      assertInstanceOf(Alpha.class, Providers.of(SourceProvider.class).select(ANY),
+          "a context class loader that delegates without a parent chain");
+    } finally {
+      thread.setContextClassLoader(previous);
+    }
+  }
+
+  @Test
+  void testSkippedRegistrationsDoNotHideAProviderBehindThem() throws IOException {
+    final StringBuilder registrations = new StringBuilder();
+    for (int broken = 0; broken < 100; broken++) {
+      registrations.append("opennlp.tools.util.ext.Missing").append(broken).append('\n');
+    }
+    registrations.append(Alpha.class.getName()).append('\n');
+    write(dir, SERVICES + SourceProvider.class.getName(), registrations.toString());
+    try (URLClassLoader loader = isolated(dir)) {
+      assertEquals(1, Providers.of(SourceProvider.class, loader).installed().size(),
+          "a provider listed after a hundred missing ones");
+    }
+  }
+
+  /** Two service files that cannot be read must not hide the providers of a third one. */
+  @Test
+  @Timeout(30)
+  void testUnreadableServiceFilesDoNotHideAProvider() throws IOException {
+    final List<URL> torn = new ArrayList<>();
+    for (int file = 0; file < 2; file++) {
+      final Path missing = dir.resolve("torn" + file).resolve(SERVICES
+          + SourceProvider.class.getName());
+      Files.createDirectories(missing.getParent());
+      torn.add(missing.toUri().toURL());
+    }
+    register(dir, SourceProvider.class, List.of(Alpha.class));
+    try (URLClassLoader loader = new URLClassLoader(new URL[] {dir.toUri().toURL()},
+        getClass().getClassLoader()) {
+      @Override
+      public Enumeration<URL> getResources(final String name) throws IOException {
+        if (!name.startsWith(SERVICES)) {
+          return name.startsWith(META_INF) ? findResources(name) : super.getResources(name);
+        }
+        final List<URL> resources = new ArrayList<>(torn);
+        resources.addAll(Collections.list(findResources(name)));
+        return Collections.enumeration(resources);
+      }
+    }) {
+      assertEquals(1, Providers.of(SourceProvider.class, loader).installed().size(),
+          "the provider of the readable service file");
+    }
+  }
+
+  @Test
+  void testRejectsUnusableNames() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(ControlCharacterName.class, LongName.class, Alpha.class)))) {
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader);
+      assertEquals(List.of("alpha"), names(providers.installed()),
+          "a name with a control character or above the length limit is rejected");
+    }
+  }
+
+  @Test
+  void testConfigurationFailureIsReported() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class, List.of(Alpha.class)))) {
+      final ProviderResolutionException duringLookup = assertThrows(
+          ProviderResolutionException.class,
+          () -> Providers.of(SourceProvider.class, loader, (final String key) -> {
+            throw new IllegalStateException("context destroyed");
+          }), "a configuration that fails while the providers are looked up");
+      assertInstanceOf(IllegalStateException.class, duringLookup.getCause(), "cause");
+      final Providers<SourceProvider> providers = Providers.of(SourceProvider.class, loader,
+          (final String key) -> {
+            if (SOURCE_KEY.equals(key)) {
+              throw new IllegalStateException("context destroyed");
+            }
+            return null;
+          });
+      final ProviderResolutionException duringSelect =
+          assertThrows(ProviderResolutionException.class, () -> providers.select(ANY),
+              "a configuration that fails while a provider is selected");
+      assertInstanceOf(IllegalStateException.class, duringSelect.getCause(), "cause");
+    }
+  }
+
+  @Test
+  void testDisablingThroughTheConfigurationOfAnApplication() throws IOException {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class)))) {
+      final Map<String, String> application = Map.of(SOURCE_DISABLED_KEY, "text");
+      final Providers<SourceProvider> providers =
+          Providers.of(SourceProvider.class, loader, application::get);
+      assertInstanceOf(Alpha.class, providers.select(TEXT), "the disabled provider is left out");
+      assertTrue(providers.byName("text").isEmpty(), "disabled by the application");
+    }
+  }
+
+  @Test
+  void testConfigurationFileIsValidatedOnItsOwn() throws IOException {
+    register(dir, SourceProvider.class, List.of(Alpha.class));
+    configure(dir, Providers.ORDINAL_KEY + " = twenty\n");
+    try (URLClassLoader loader = isolated(dir)) {
+      final ProviderResolutionException e = assertThrows(ProviderResolutionException.class,
+          () -> Providers.of(SourceProvider.class, loader), "a single malformed file");
+      assertTrue(e.getMessage().contains(Providers.CONFIGURATION_FILE), e.getMessage());
+    }
+  }
+
+  @Test
+  void testConfigurationFileWithByteOrderMark() throws IOException {
+    register(dir, SourceProvider.class, List.of(Alpha.class, TextOnly.class));
+    configure(dir, '\uFEFF' + SOURCE_KEY + " = alpha\n");
+    try (URLClassLoader loader = isolated(dir)) {
+      assertInstanceOf(Alpha.class, Providers.of(SourceProvider.class, loader).select(TEXT),
+          "a byte order mark must not hide the first key");
+    }
+  }
+
+  @Test
+  void testConfigurationFileMustBeUtf8() throws IOException {
+    register(dir, SourceProvider.class, List.of(Alpha.class));
+    final Path file = dir.resolve(Providers.CONFIGURATION_FILE);
+    Files.createDirectories(file.getParent());
+    Files.write(file, (SOURCE_KEY + " = pr\u00fcfer\n").getBytes(StandardCharsets.ISO_8859_1));
+    try (URLClassLoader loader = isolated(dir)) {
+      final ProviderResolutionException e = assertThrows(ProviderResolutionException.class,
+          () -> Providers.of(SourceProvider.class, loader), "a file that is not UTF-8");
+      assertTrue(e.getMessage().contains(Providers.CONFIGURATION_FILE), e.getMessage());
+    }
+  }
+
+  @Test
+  void testUsesContextClassLoaderOnlyIfItSeesTheSpi() throws IOException {
+    final Thread thread = Thread.currentThread();
+    final ClassLoader previous = thread.getContextClassLoader();
+    final AtomicInteger lookups = new AtomicInteger();
+    try (URLClassLoader context = loader(Map.of(SourceProvider.class, List.of(Alpha.class)));
+         URLClassLoader unrelated = counting(lookups)) {
+      thread.setContextClassLoader(context);
+      assertInstanceOf(Alpha.class, Providers.of(SourceProvider.class).select(ANY),
+          "a context class loader that sees the SPI is used");
+      thread.setContextClassLoader(unrelated);
+      assertTrue(Providers.of(SourceProvider.class).installed().isEmpty(),
+          "the class loader of the SPI has no providers registered");
+      assertEquals(0, lookups.get(),
+          "a context class loader that cannot see the SPI must not be used");
+      thread.setContextClassLoader(null);
+      assertTrue(Providers.of(SourceProvider.class).installed().isEmpty(), "without a context");
+    } finally {
+      thread.setContextClassLoader(previous);
+    }
+  }
+
+  /** Nothing static may keep the class loader of a lookup alive, not even a failing one. */
+  @Test
+  void testLookupDoesNotRetainTheClassLoader() throws Exception {
+    assertNull(collect(lookUpAndRelease()), "the class loader of a lookup was retained");
+  }
+
+  /**
+   * @return A reference to the class loader of a lookup that is over when this method returns.
+   */
+  private WeakReference<ClassLoader> lookUpAndRelease() throws IOException {
+    final URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, NameThrows.class)));
+    assertEquals(1, Providers.of(SourceProvider.class, loader).installed().size(),
+        "one provider is usable, the other one is skipped and logged");
+    loader.close();
+    return new WeakReference<>(loader);
+  }
+
+  /** The configuration of an instance is independent of the system properties another one uses. */
+  @Test
+  void testConcurrentSelection() throws Exception {
+    try (URLClassLoader loader = loader(Map.of(SourceProvider.class,
+        List.of(Alpha.class, TextOnly.class)))) {
+      final Map<String, String> application = Map.of(SOURCE_KEY, "alpha");
+      final Providers<SourceProvider> pinned =
+          Providers.of(SourceProvider.class, loader, application::get);
+      final Providers<SourceProvider> configured = Providers.of(SourceProvider.class, loader);
+      final int threads = 8;
+      final ExecutorService executor = Executors.newFixedThreadPool(threads + 1);
+      try {
+        final AtomicBoolean running = new AtomicBoolean(true);
+        final Future<?> toggle = executor.submit(() -> {
+          while (running.get()) {
+            System.setProperty(SOURCE_KEY, "alpha");
+            System.clearProperty(SOURCE_KEY);
+            Thread.onSpinWait();
+          }
+        });
+        final List<Callable<Void>> tasks = new ArrayList<>();
+        for (int task = 0; task < threads; task++) {
+          tasks.add(() -> {
+            for (int i = 0; i < 500; i++) {
+              assertInstanceOf(Alpha.class, pinned.select(TEXT), "the pinned configuration");
+              assertEquals("alpha", pinned.select(TEXT).create(TEXT).id(), "created by alpha");
+              final SourceProvider selected = configured.select(TEXT);
+              assertTrue(selected instanceof Alpha || selected instanceof TextOnly,
+                  "the system property selects one of both");
+              assertInstanceOf(Alpha.class, configured.select(BINARY),
+                  "only alpha supports a binary spec");
+            }
+            return null;
+          });
+        }
+        for (final Future<Void> result : executor.invokeAll(tasks)) {
+          result.get();
+        }
+        running.set(false);
+        toggle.get();
+      } finally {
+        executor.shutdownNow();
+      }
+    }
+  }
+
+  /**
+   * @param reference The reference to clear.
+   * @return The referenced class loader, or {@code null} if it was collected.
+   */
+  private ClassLoader collect(final WeakReference<ClassLoader> reference) throws Exception {
+    for (int attempt = 0; attempt < 50 && reference.get() != null; attempt++) {
+      System.gc();
+      TimeUnit.MILLISECONDS.sleep(50);
+    }
+    return reference.get();
+  }
+
+  /**
+   * @param providers The providers to name.
+   * @return The names of {@code providers} in order.
+   */
+  private List<String> names(final List<SourceProvider> providers) {
+    final List<String> names = new ArrayList<>(providers.size());
+    for (final SourceProvider provider : providers) {
+      names.add(provider.name());
+    }
+    return names;
+  }
+
+  /**
+   * @param registrations The provider classes to register per SPI.
+   * @return A class loader that finds service files only in the temporary directory.
+   */
+  private URLClassLoader loader(final Map<Class<?>, List<Class<?>>> registrations)
+      throws IOException {
+    for (final Map.Entry<Class<?>, List<Class<?>>> registration : registrations.entrySet()) {
+      register(dir, registration.getKey(), registration.getValue());
+    }
+    return isolated(dir);
+  }
+
+  /**
+   * @param root The directory to write the service file into.
+   * @param spi The SPI to register providers for.
+   * @param providers The provider classes to register.
+   */
+  private void register(final Path root, final Class<?> spi, final List<Class<?>> providers)
+      throws IOException {
+    write(root, SERVICES + spi.getName(), lines(providers));
+  }
+
+  /**
+   * @param providers The provider classes to register.
+   * @return The content of a service file listing {@code providers}.
+   */
+  private String lines(final List<Class<?>> providers) {
+    final StringBuilder names = new StringBuilder();
+    for (final Class<?> provider : providers) {
+      names.append(provider.getName()).append('\n');
+    }
+    return names.toString();
+  }
+
+  /**
+   * @param root The directory to write the configuration file into.
+   * @param content The content of the configuration file.
+   */
+  private void configure(final Path root, final String content) throws IOException {
+    write(root, Providers.CONFIGURATION_FILE, content);
+  }
+
+  /**
+   * @param root The directory to write into.
+   * @param resource The path of the resource below {@code root}.
+   * @param content The content of the resource.
+   */
+  private void write(final Path root, final String resource, final String content)
+      throws IOException {
+    final Path file = root.resolve(resource);
+    Files.createDirectories(file.getParent());
+    Files.writeString(file, content);
+  }
+
+  /**
+   * @param roots The directories to find resources in.
+   * @return A class loader that finds the resources of OpenNLP only in {@code roots}, and classes
+   *         in its parent.
+   */
+  private URLClassLoader isolated(final Path... roots) throws IOException {
+    final URL[] urls = new URL[roots.length];
+    for (int root = 0; root < roots.length; root++) {
+      urls[root] = roots[root].toUri().toURL();
+    }
+    return new URLClassLoader(urls, getClass().getClassLoader()) {
+      @Override
+      public Enumeration<URL> getResources(final String name) throws IOException {
+        return name.startsWith(META_INF) ? findResources(name) : super.getResources(name);
+      }
+    };
+  }
+
+  /**
+   * @param failure The failure to raise for every resource lookup.
+   * @return A class loader that cannot list resources.
+   */
+  private URLClassLoader failing(final Throwable failure) {
+    return new URLClassLoader(new URL[0], getClass().getClassLoader()) {
+      @Override
+      public Enumeration<URL> getResources(final String name) throws IOException {
+        if (!name.startsWith(META_INF)) {
+          return super.getResources(name);
+        }
+        if (failure instanceof IOException io) {
+          throw io;
+        }
+        if (failure instanceof Error error) {
+          throw error;
+        }
+        throw (RuntimeException) failure;
+      }
+    };
+  }
+
+  /**
+   * @param lookups Counts the resource lookups of OpenNLP.
+   * @return A class loader that cannot see the SPI, because it has no parent.
+   */
+  private URLClassLoader counting(final AtomicInteger lookups) {
+    return new URLClassLoader(new URL[0], null) {
+      @Override
+      public Enumeration<URL> getResources(final String name) throws IOException {
+        if (name.startsWith(META_INF)) {
+          lookups.incrementAndGet();
+          return Collections.emptyEnumeration();
+        }
+        return super.getResources(name);
+      }
+    };
+  }
+
+  interface Source {
+    String id();
+  }
+
+  interface Sink {
+  }
+
+  public interface SourceProvider extends Provider<Source> {
+  }
+
+  public interface SinkProvider extends Provider<Sink> {
+  }
+
+  static final class First {
+    public interface SameName extends Provider<Source> {
+    }
+  }
+
+  static final class Second {
+    public interface SameName extends Provider<Source> {
+    }
+  }
+
+  public static class Alpha implements SourceProvider {
+    @Override
+    public String name() {
+      return "alpha";
+    }
+
+    @Override
+    public boolean supports(final ProviderSpec spec) {
+      return true;
+    }
+
+    @Override
+    public Source create(final ProviderSpec spec) {
+      final String id = name();
+      return () -> id;
+    }
+  }
+
+  public static class Beta extends Alpha {
+    @Override
+    public String name() {
+      return "beta";
+    }
+  }
+
+  /** The same name and priority as {@link Alpha}. */
+  public static class AlphaDuplicate extends Alpha {
+  }
+
+  /** The same name as {@link Alpha} with a higher priority. */
+  public static class AlphaReplacement extends Alpha {
+    @Override
+    public int priority() {
+      return 10;
+    }
+  }
+
+  /** Supports only a text spec, ranked above {@link Alpha}. */
+  public static class TextOnly extends Alpha {
+    @Override
+    public String name() {
+      return "text";
+    }
+
+    @Override
+    public int priority() {
+      return 5;
+    }
+
+    @Override
+    public boolean supports(final ProviderSpec spec) {
+      return TEXT_KIND.equals(spec.option(KIND_OPTION, TEXT_KIND));
+    }
+  }
+
+  public static class Unavailable extends Alpha {
+    @Override
+    public String name() {
+      return "unavailable";
+    }
+
+    @Override
+    public int priority() {
+      return 100;
+    }
+
+    @Override
+    public boolean isAvailable() {
+      return false;
+    }
+  }
+
+  public static class AvailabilityThrows extends Unavailable {
+    @Override
+    public boolean isAvailable() {
+      throw new NoClassDefFoundError("missing runtime");
+    }
+  }
+
+  public static class NameThrows extends Alpha {
+    @Override
+    public String name() {
+      throw new IllegalStateException("no name");
+    }
+  }
+
+  public static class NameErrors extends Alpha {
+    @Override
+    public String name() {
+      throw new AssertionError("no name");
+    }
+  }
+
+  public static class NullName extends Alpha {
+    @Override
+    public String name() {
+      return null;
+    }
+  }
+
+  public static class BlankName extends Alpha {
+    @Override
+    public String name() {
+      return " ";
+    }
+  }
+
+  /** A name holding the separator of the disabled names. */
+  public static class CommaName extends Alpha {
+    @Override
+    public String name() {
+      return "a,b";
+    }
+  }
+
+  /** A name that would forge a log line. */
+  public static class ControlCharacterName extends Alpha {
+    @Override
+    public String name() {
+      return "acme\r\nforged entry";
+    }
+  }
+
+  /** A name beyond the length a configuration value may hold. */
+  public static class LongName extends Alpha {
+    @Override
+    public String name() {
+      return "n".repeat(200);
+    }
+  }
+
+  public static class PriorityThrows extends Alpha {
+    @Override
+    public int priority() {
+      throw new IllegalStateException("no priority");
+    }
+  }
+
+  /** Available and ranked highest, but cannot decide whether it supports a spec. */
+  public static class SupportsErrors extends Unavailable {
+    @Override
+    public String name() {
+      return "supports";
+    }
+
+    @Override
+    public boolean isAvailable() {
+      return true;
+    }
+
+    @Override
+    public boolean supports(final ProviderSpec spec) {
+      throw new AssertionError("cannot decide");
+    }
+  }
+
+  public static class ConstructorThrows extends Alpha {
+    public ConstructorThrows() {
+      throw new IllegalStateException("cannot construct");
+    }
+  }
+
+  public static class NoPublicConstructor extends Alpha {
+    private NoPublicConstructor() {
+    }
+  }
+
+  public static class NotAProvider {
+  }
+
+  /**
+   * Tracks {@link Initializing}. A type of its own, so a test can read the counters without
+   * initializing {@link Initializing}, which happens only once per class loader.
+   */
+  static final class Tracker {
+
+    static final AtomicBoolean INITIALIZED = new AtomicBoolean();
+    static final AtomicInteger INSTANCES = new AtomicInteger();
+
+    private Tracker() {
+    }
+  }
+
+  /** Tracks its initialization and its instances through {@link Tracker}. */
+  public static class Initializing extends Alpha {
+
+    static {
+      Tracker.INITIALIZED.set(true);
+    }
+
+    public Initializing() {
+      Tracker.INSTANCES.incrementAndGet();
+    }
+
+    @Override
+    public String name() {
+      return "initializing";
+    }
+  }
+
+  /** Counts its instances, to show that a disabled class is not loaded. */
+  public static class NotLoaded extends Alpha {
+
+    static final AtomicInteger INSTANCES = new AtomicInteger();
+
+    public NotLoaded() {
+      INSTANCES.incrementAndGet();
+    }
+
+    @Override
+    public String name() {
+      return "notloaded";
+    }
+  }
+
+  public static class CountingSink implements SinkProvider {
+
+    static final AtomicInteger INSTANCES = new AtomicInteger();
+
+    public CountingSink() {
+      INSTANCES.incrementAndGet();
+    }
+
+    @Override
+    public String name() {
+      return "sink";
+    }
+
+    @Override
+    public boolean supports(final ProviderSpec spec) {
+      return true;
+    }
+
+    @Override
+    public Sink create(final ProviderSpec spec) {
+      return new Sink() {
+      };
+    }
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotatorProvider.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotatorProvider.java
new file mode 100644
index 000000000..2fc8c8e11
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotatorProvider.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.stemmer;
+
+import java.util.List;
+
+import opennlp.tools.document.DocumentAnnotator;
+import opennlp.tools.document.DocumentAnnotatorProvider;
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+import opennlp.tools.util.ext.ProviderSpec;
+
+/**
+ * Provides a {@link StemmerAnnotator} under the name {@value #NAME}. It supports a spec without
+ * location whose only option is {@value #ALGORITHM_OPTION}: {@value #PORTER} or absent for the
+ * {@link PorterStemmer}, otherwise the name of a {@link SnowballStemmer.ALGORITHM}. Values are
+ * compared ignoring case, so {@value #PORTER} always means the {@link PorterStemmer} and the
+ * Snowball algorithm of that name is not reachable through this provider.
+ *
+ * @since 3.0.0
+ */
+public final class StemmerAnnotatorProvider implements DocumentAnnotatorProvider {
+
+  /** The name of this provider. */
+  public static final String NAME = "stemmer";
+
+  /** The option that names the stemming algorithm. */
+  public static final String ALGORITHM_OPTION = "algorithm";
+
+  /** The {@value #ALGORITHM_OPTION} value for the {@link PorterStemmer}. */
+  public static final String PORTER = "porter";
+
+  private final List<SnowballStemmer.ALGORITHM> algorithms =
+      List.of(SnowballStemmer.ALGORITHM.values());
+
+  @Override
+  public String name() {
+    return NAME;
+  }
+
+  @Override
+  public boolean supports(final ProviderSpec spec) {
+    if (spec == null) {
+      throw new IllegalArgumentException("spec must not be null");
+    }
+    return spec.location().isEmpty() && spec.hasOnlyOptions(ALGORITHM_OPTION)
+        && isKnown(spec.option(ALGORITHM_OPTION, PORTER));
+  }
+
+  private boolean isKnown(final String algorithm) {
+    return PORTER.equalsIgnoreCase(algorithm) || snowballAlgorithm(algorithm) != null;
+  }
+
+  @Override
+  public DocumentAnnotator create(final ProviderSpec spec) {
+    if (spec == null) {
+      throw new IllegalArgumentException("spec must not be null");
+    }
+    if (!supports(spec)) {
+      throw new IllegalArgumentException("Unsupported spec: " + spec);
+    }
+    final String algorithm = spec.option(ALGORITHM_OPTION, PORTER);
+    if (PORTER.equalsIgnoreCase(algorithm)) {
+      return new StemmerAnnotator(new PorterStemmer());
+    }
+    final SnowballStemmer.ALGORITHM snowball = snowballAlgorithm(algorithm);
+    if (snowball == null) {
+      throw new IllegalArgumentException("Unsupported spec: " + spec);
+    }
+    return new StemmerAnnotator(new SnowballStemmer(snowball));
+  }
+
+  private SnowballStemmer.ALGORITHM snowballAlgorithm(final String algorithm) {
+    for (final SnowballStemmer.ALGORITHM candidate : algorithms) {
+      if (candidate.name().equalsIgnoreCase(algorithm)) {
+        return candidate;
+      }
+    }
+    return null;
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/resources/META-INF/services/opennlp.tools.document.DocumentAnnotatorProvider b/opennlp-core/opennlp-runtime/src/main/resources/META-INF/services/opennlp.tools.document.DocumentAnnotatorProvider
new file mode 100644
index 000000000..e28949a4f
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/resources/META-INF/services/opennlp.tools.document.DocumentAnnotatorProvider
@@ -0,0 +1,15 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+opennlp.tools.stemmer.StemmerAnnotatorProvider
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorProviderTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorProviderTest.java
new file mode 100644
index 000000000..cba372302
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorProviderTest.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.stemmer;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.document.Annotation;
+import opennlp.tools.document.Document;
+import opennlp.tools.document.DocumentAnnotator;
+import opennlp.tools.document.DocumentAnnotatorProvider;
+import opennlp.tools.document.Layers;
+import opennlp.tools.util.Span;
+import opennlp.tools.util.ext.ProviderSpec;
+import opennlp.tools.util.ext.Providers;
+import opennlp.tools.util.ext.UnsatisfiedProviderException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StemmerAnnotatorProviderTest {
+
+  private final StemmerAnnotatorProvider provider = new StemmerAnnotatorProvider();
+
+  @Test
+  void testName() {
+    assertEquals("stemmer", provider.name(), "provider name");
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"porter", "PORTER", "english", "English", "GERMAN", "portuguese"})
+  void testSupportsKnownAlgorithm(final String algorithm) {
+    assertTrue(provider.supports(algorithm(algorithm)), algorithm);
+  }
+
+  @Test
+  void testSupportsAbsentAlgorithm() {
+    assertTrue(provider.supports(ProviderSpec.empty()), "an absent algorithm means porter");
+  }
+
+  @Test
+  void testRejectsOtherSpecs() {
+    assertFalse(provider.supports(algorithm("klingon")), "an unknown algorithm");
+    assertFalse(provider.supports(algorithm(" ")), "a blank algorithm");
+    assertFalse(provider.supports(ProviderSpec.of(Map.of("language", "en"))), "another option");
+    assertFalse(provider.supports(ProviderSpec.of(Map.of("Algorithm", "english"))),
+        "option names are case-sensitive");
+    assertFalse(provider.supports(ProviderSpec.of(Path.of("model.bin"))), "a location");
+    assertThrows(IllegalArgumentException.class, () -> provider.supports(null), "null spec");
+    assertThrows(IllegalArgumentException.class, () -> provider.create(null), "null spec");
+    assertThrows(IllegalArgumentException.class, () -> provider.create(algorithm("klingon")),
+        "an unsupported spec");
+  }
+
+  /** {@code porter} always means the Porter stemmer, not the Snowball algorithm of that name. */
+  @Test
+  void testPorterIsNotTheSnowballAlgorithm() {
+    final DocumentAnnotator porter = provider.create(algorithm("PORTER"));
+    final Document document = Document.of("generously").with(Layers.TOKENS,
+        List.of(new Annotation<>(new Span(0, 10), "generously")));
+    assertEquals("gener", porter.annotate(document).get(StemmerAnnotator.STEMS).get(0).value(),
+        "the Porter stemmer");
+    final DocumentAnnotator snowball = provider.create(algorithm("english"));
+    assertEquals("generous", snowball.annotate(document).get(StemmerAnnotator.STEMS).get(0).value(),
+        "the Snowball English stemmer");
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"porter", "english"})
+  void testCreatesStemmingAnnotator(final String algorithm) {
+    final Document document = Document.of("running dogs").with(Layers.TOKENS, List.of(
+        new Annotation<>(new Span(0, 7), "running"), new Annotation<>(new Span(8, 12), "dogs")));
+    final ProviderSpec spec = algorithm(algorithm);
+    final DocumentAnnotator annotator = provider.create(spec);
+    assertInstanceOf(StemmerAnnotator.class, annotator, "annotator");
+    assertNotSame(annotator, provider.create(spec), "each call creates its own annotator");
+    final List<Annotation<String>> stems = annotator.annotate(document).get(StemmerAnnotator.STEMS);
+    assertEquals(List.of("run", "dog"), List.of(stems.get(0).value(), stems.get(1).value()),
+        algorithm);
+  }
+
+  @Test
+  void testRegistered() {
+    final Providers<DocumentAnnotatorProvider> providers =
+        Providers.of(DocumentAnnotatorProvider.class);
+    assertInstanceOf(StemmerAnnotatorProvider.class,
+        providers.byName(StemmerAnnotatorProvider.NAME).orElseThrow(), "registered by name");
+    assertInstanceOf(StemmerAnnotatorProvider.class, providers.select(algorithm("english")),
+        "selected for a stemmer request");
+    assertTrue(providers.supporting(ProviderSpec.of(Path.of("model.bin"))).isEmpty(),
+        "a model file is not a stemmer request");
+  }
+
+  @Test
+  void testDisabledThroughTheConfiguration() {
+    final Map<String, String> configuration =
+        Map.of(Providers.CONFIGURATION_KEY_PREFIX + DocumentAnnotatorProvider.class.getName()
+            + Providers.DISABLED_KEY_SUFFIX, StemmerAnnotatorProvider.NAME);
+    final Providers<DocumentAnnotatorProvider> providers = Providers.of(
+        DocumentAnnotatorProvider.class, getClass().getClassLoader(), configuration::get);
+    assertTrue(providers.byName(StemmerAnnotatorProvider.NAME).isEmpty(), "disabled by name");
+    assertThrows(UnsatisfiedProviderException.class, () -> providers.select(algorithm("english")),
+        "no provider remains");
+  }
+
+  /**
+   * @param algorithm The algorithm option value.
+   * @return A spec with only the algorithm option.
+   */
+  private ProviderSpec algorithm(final String algorithm) {
+    return ProviderSpec.of(Map.of(StemmerAnnotatorProvider.ALGORITHM_OPTION, algorithm));
+  }
+}
diff --git a/opennlp-docs/src/docbkx/introduction.xml b/opennlp-docs/src/docbkx/introduction.xml
index 5182283f1..4b90d1a7b 100644
--- a/opennlp-docs/src/docbkx/introduction.xml
+++ b/opennlp-docs/src/docbkx/introduction.xml
@@ -404,4 +404,155 @@ Arguments description:
         </section>
     </section>
 
+    <section xml:id="intro.providers">
+        <title>Service providers</title>
+        <para>
+            OpenNLP has two ways to reach an implementation that core does not know.
+            <code>ExtensionLoader</code> instantiates a class whose name comes from a model
+            manifest, an XML descriptor or a training parameter, so the caller must already know
+            that name. The provider SPI in <code>opennlp.tools.util.ext</code> works the other way
+            around: a jar on the class path announces what it can do, and OpenNLP picks an
+            implementation for the request at hand. It is meant for optional implementations that
+            ship separately, such as an annotator, a stemmer or an embedder.
+        </para>
+        <para>
+            Each service interface has a provider interface of its own that extends
+            <code>Provider</code>, for example
+            <code>DocumentAnnotatorProvider extends Provider&lt;DocumentAnnotator&gt;</code>. An
+            implementation needs a public no-argument constructor and is listed in
+            <code>META-INF/services/</code> under the fully qualified name of that provider
+            interface, the usual <code>java.util.ServiceLoader</code> layout. A provider states
+            its <code>name()</code>, a <code>priority()</code>, whether it
+            <code>isAvailable()</code> in the running process, for example whether an optional
+            dependency is present, and whether it <code>supports</code> a request. The request is
+            a <code>ProviderSpec</code>: an optional location, such as a model file, and string
+            options. Neither the constructor nor the static initializer, the availability check or
+            <code>supports</code> may load a model or initialize a native library, because they
+            run while the candidates are collected, before one of them is chosen.
+        </para>
+        <programlisting language="java"><![CDATA[
+DocumentAnnotator createAnnotator() throws IOException {
+  ProviderSpec spec = ProviderSpec.of(Map.of("algorithm", "english"));
+  Providers<DocumentAnnotatorProvider> providers = Providers.of(DocumentAnnotatorProvider.class);
+  return providers.select(spec).create(spec);
+}]]></programlisting>
+        <para>
+            <code>Providers.of</code> scans the class path once, so an application creates its
+            instance at startup and keeps it, rather than resolving per request. The instance is
+            immutable and thread-safe. Keep it in a field owned by the application, for example a
+            servlet context attribute or an application scoped bean; a <code>static</code> field
+            is wrong when OpenNLP is deployed in a class loader shared by several applications,
+            because it would keep one application's providers and their class loader alive. The
+            lookup uses the thread context class loader when that loader sees the provider
+            interface, and the class loader of the provider interface otherwise. On a thread whose
+            context class loader is not the application's, such as a thread of a pool, only the
+            providers of the provider interface's own class loader are found, so a library or a
+            framework integration captures the class loader at startup and passes it to
+            <code>Providers.of(spi, loader)</code>. Resolving is not free either: keep what
+            <code>select</code> returned instead of resolving per request, and keep in mind that a
+            provider blocking in <code>isAvailable()</code> or <code>supports()</code> blocks every
+            resolution of that provider interface.
+        </para>
+        <para>
+            <code>select</code> returns the available provider with the highest priority that
+            supports the request. If several share the highest priority, an
+            <code>AmbiguousProviderException</code> names them; if none supports the request, an
+            <code>UnsatisfiedProviderException</code> names the installed providers.
+            <code>supporting</code> returns every matching provider, best first, and
+            <code>byName</code> resolves one by name. Providers may share a name, in which case
+            the higher priority wins, which is how a module replaces the provider of another
+            module. What <code>create</code> returns belongs to the caller, who also closes it if
+            it is closeable.
+        </para>
+        <para>
+            Three keys configure resolution, all named after the provider interface.
+            <code>opennlp.provider.&lt;interface&gt;</code> selects a provider by name, for example
+            <code>opennlp.provider.opennlp.tools.document.DocumentAnnotatorProvider=stemmer</code>.
+            It narrows the providers that support the request, so a selected provider that does
+            not support the request is reported as unsatisfied rather than used.
+            <code>opennlp.provider.&lt;interface&gt;.disabled</code> holds a comma separated list
+            of provider names and provider class names that are not resolved; a name stays listed
+            by <code>installed()</code>, while a class name is never instantiated, which keeps a
+            provider out of the process entirely and therefore has to be configured before the
+            providers are looked up. The third key,
+            <code>opennlp.provider.&lt;interface&gt;.allowed</code>, is described below. The first
+            two are read from
+            <code>META-INF/opennlp/providers.properties</code>, encoded in UTF-8, of the class
+            loader the providers were looked up with; where several files carry the same key, the
+            one with the highest <code>configuration.ordinal</code> wins, and a system property of
+            the same name wins over all files. In a native image the file is read only when it is
+            registered as a resource.
+        </para>
+        <para>
+            Adding a jar to the class path is a decision to trust its code: every provider of the
+            provider interface is instantiated when the providers are looked up, and any jar can
+            carry a <code>providers.properties</code> that selects or disables a provider, which
+            OpenNLP logs with the file it came from. Providers may also share a name, so a name is
+            a convenient handle and not a guarantee of which implementation answers. An
+            application that must not be reconfigured by a library on its class path passes its
+            own source, which is also how two deployments in one application server configure
+            themselves independently:
+            <code>Providers.of(spi, loader, key -&gt; myConfiguration.get(key))</code>.
+        </para>
+        <para>
+            An operator who wants to decide which implementations may run at all uses
+            <code>opennlp.provider.&lt;interface&gt;.allowed</code>, a comma separated list of
+            provider class names and of package prefixes, which have to end with a dot. Only those
+            classes are instantiated, every other provider of that interface is left out whatever
+            it is named, an unset or blank value allows all of them and a value that holds only
+            separators allows none. It is read from a system property or from the source an
+            application passes, never from a <code>providers.properties</code> that any jar could
+            carry, and it is checked before a provider class is instantiated, so it has to be
+            configured before the providers are looked up. A provider left out this way is counted
+            in the message of an <code>UnsatisfiedProviderException</code> together with the key.
+        </para>
+        <para>
+            What the allowed key decides is which class names may be instantiated, not where their
+            code comes from. Two jars can carry the same class name, and which one is loaded is the
+            class loader's decision, so a package prefix widens that further. A provider name is
+            weaker still: names may be shared between jars, so
+            <code>opennlp.provider.&lt;interface&gt;</code> is a convenience for choosing between
+            implementations, not a security control.
+        </para>
+        <para>
+            Where the origin of an implementation matters, decide by the class instead of by its
+            name:
+        </para>
+        <programlisting language="java"><![CDATA[
+Providers<DocumentAnnotatorProvider> providers = Providers.ofTrusted(
+    DocumentAnnotatorProvider.class, loader,
+    type -> expected.equals(type.getProtectionDomain().getCodeSource().getLocation()));]]></programlisting>
+        <para>
+            The predicate is asked for every provider class the configuration keys leave, before an
+            instance of it is created, and it can look at the code source, the module or the
+            signers of the class. The class is loaded and linked before it is asked, including
+            whatever a custom class loader does while loading it, but it is not initialized: a
+            rejected provider runs neither its static initializer nor its constructor, and is
+            counted among the skipped ones. The predicate must therefore not initialize the class
+            itself, for instance by reading a static field, and must be deterministic, thread-safe
+            and free of blocking calls. A predicate that throws ends the lookup, so a failure
+            never widens the policy. The strongest boundary remains the class path itself: look the
+            providers up with a class loader that sees only the jars you trust. The lookup also
+            logs at debug level which location each provider class was loaded from, for auditing.
+        </para>
+        <para>
+            A provider that cannot be loaded, whose name is unusable, or that throws from
+            <code>name()</code> or <code>priority()</code> is skipped while the providers are
+            looked up, so one broken jar does not hide the others, and the number of skipped
+            providers appears in the message of an <code>UnsatisfiedProviderException</code>. A
+            provider that throws from <code>isAvailable()</code> or <code>supports()</code> is
+            left out of that one resolution instead. Both are reported once per provider and
+            method as a warning and at debug level afterwards. A configuration file that cannot be
+            read, and a class loader that cannot list its files at all, fail the lookup with a
+            <code>ProviderResolutionException</code>. Discovery works on the class path; OpenNLP
+            declares no JPMS <code>provides</code> directives for the module path.
+        </para>
+        <para>
+            The runtime registers one provider, <code>stemmer</code>, which creates a
+            <code>StemmerAnnotator</code> for a request without location whose only option is
+            <code>algorithm</code>: <code>porter</code>, which is also the default when the
+            option is absent, for the Porter stemmer, or the name of a Snowball algorithm.
+        </para>
+    </section>
+
 </chapter>