Repository: explosion/spacy-llm (language: python)

Issue:
Sync `develop` with `master`
<!--- Provide a general summary of your changes in the title. -->

## Description
<!--- Use this section to describe your changes. If your changes required
testing, include information about the testing environment and the tests you
ran. If your test fixes a bug reported in an issue, don't forget to include the
issue number. If your PR is still a work in progress, that's totally fine – just
include a note to let us know. -->
Sync `develop` with `master`.

### Types of change
<!-- What type of change does your PR cover? Is it a bug fix, an enhancement
or new feature, or a change to the documentation? -->
Chore.

## Checklist
<!--- Before you submit the PR, go over this checklist and make sure you can
tick off all the boxes. [] -> [x] -->
- [x] I confirm that I have the right to submit this contribution under the project's MIT license.
- [x] I ran all tests in `tests` and `usage_examples/tests`, and all new and existing tests passed. This includes
  - all external tests (i. e. `pytest` ran with `--external`)
  - all tests requiring a GPU
- [x] My changes don't require a change to the documentation, or if they do, I've added all required information.



Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index ff5a9d4b..9a36a8ff 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,9 @@
 This package integrates Large Language Models (LLMs) into [spaCy](https://spacy.io), featuring a modular system for **fast prototyping** and **prompting**, and turning unstructured responses into **robust outputs** for various NLP tasks, **no training data** required.
 
 - Serializable `llm` **component** to integrate prompts into your pipeline
-- **Modular functions** to define the [**task**](#tasks) (prompting and parsing) and [**backend**](#backends) (model to use)
+- **Modular functions** to define the [**task**](#tasks) (prompting and parsing) and [**model**](#models)
 - Support for **hosted APIs** and self-hosted **open-source models**
-- Integration with [`MiniChain`](https://github.com/srush/MiniChain) and [`LangChain`](https://github.com/hwchase17/langchain)
+- Integration with [`LangChain`](https://github.com/hwchase17/langchain)
 - Access to **[OpenAI API](https://platform.openai.com/docs/api-reference/introduction)**, including GPT-4 and various GPT-3 models
 - Built-in support for **open-source [Dolly](https://huggingface.co/databricks)** models hosted on Hugging Face
 - Usage examples for **Named Entity Recognition** and **Text Classification**
@@ -41,7 +41,7 @@ python -m pip install spacy-llm
 
 ## 🐍 Usage
 
-The task and the backend have to be supplied to the `llm` pipeline component using [spaCy's config
+The task and the model have to be supplied to the `llm` pipeline component using [spaCy's config
 system](https://spacy.io/api/data-formats#config). This package provides various built-in
 functionality, as detailed in the [API](#-api) documentation.
 
@@ -67,10 +67,9 @@ factory = "llm"
 @llm_tasks = "spacy.TextCat.v2"
 labels = ["COMPLIMENT", "INSULT"]
 
-[components.llm.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
-config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
+[components.llm.model]
+@llm_models = "spacy.GPT-3-5.v1"
+config = {"temperature": 0.3}
 ```
 
 Now run:
@@ -105,10 +104,10 @@ factory = "llm"
 @llm_tasks = "spacy.NER.v2"
 labels = ["PERSON", "ORGANISATION", "LOCATION"]
 
-[components.llm.backend]
-@llm_backends = "spacy.Dolly_HF.v1"
-# For better performance, use databricks/dolly-v2-12b instead
-model = "databricks/dolly-v2-3b"
+[components.llm.model]
+@llm_models = "spacy.Dolly.v1"
+# For better performance, use dolly-v2-12b instead
+name = "dolly-v2-3b"
 ```
 
 Now run:
@@ -142,10 +141,8 @@ nlp.add_pipe(
             "@llm_tasks": "spacy.NER.v2",
             "labels": ["PERSON", "ORGANISATION", "LOCATION"]
         },
-        "backend": {
-            "@llm_backends": "spacy.REST.v1",
-            "api": "OpenAI",
-            "config": {"model": "gpt-3.5-turbo"},
+        "model": {
+            "@llm_models": "spacy.gpt-3.5.v1",
         },
     },
 )
@@ -254,7 +251,7 @@ Text:
 You look gorgeous!
 '''
 
-Backend response for doc: You look gorgeous!
+Model response for doc: You look gorgeous!
 COMPLIMENT
 ```
 
@@ -271,16 +268,16 @@ COMPLIMENT
 | Argument         | Type                                        | Description                                                                         |
 | ---------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- |
 | `task`           | `Optional[LLMTask]`                         | An LLMTask can generate prompts and parse LLM responses. See [docs](#tasks).        |
-| `backend`        | `Callable[[Iterable[Any]], Iterable[Any]]]` | Callable querying a specific LLM API. See [docs](#backends).                        |
+| `model`          | `Callable[[Iterable[Any]], Iterable[Any]]]` | Callable querying a specific LLM API. See [docs](#models).                          |
 | `cache`          | `Cache`                                     | Cache to use for caching prompts and responses per doc (batch). See [docs](#cache). |
 | `save_io`        | `bool`                                      | Whether to save prompts/responses within `Doc.user_data["llm_io"]`.                 |
-| `validate_types` | `bool`                                      | Whether to check if signatures of configured backend and task are consistent.       |
+| `validate_types` | `bool`                                      | Whether to check if signatures of configured model and task are consistent.         |
 
 An `llm` component is defined by two main settings:
 
 - A [**task**](#tasks), defining the prompt to send to the LLM as well as the functionality to parse the resulting response
   back into structured fields on spaCy's [Doc](https://spacy.io/api/doc) objects.
-- A [**backend**](#backends) defining the model to use and how to connect to it. Note that `spacy-llm` supports both access to external
+- A [**model**](#models) defining the model and how to connect to it. Note that `spacy-llm` supports both access to external
   APIs (such as OpenAI) as well as access to self-hosted open-source LLMs (such as using Dolly through Hugging Face).
 
 Moreover, `spacy-llm` exposes a customizable [**caching**](#cache) functionality to avoid running
@@ -292,7 +289,7 @@ within the `Doc.user_data["llm_io"]` attribute by setting `save_io` to `True`.
 within the spaCy pipeline. Each entry is itself a dictionary, with two keys:
 `prompt` and `response`.
 
-A note on `validate_types`: by default, `spacy-llm` checks whether the signatures of the `backend` and `task` callables
+A note on `validate_types`: by default, `spacy-llm` checks whether the signatures of the `model` and `task` callables
 are consistent with each other and emits a warning if they don't. `validate_types` can be set to `False` if you want to
 disable this behavior.
 
@@ -308,6 +305,39 @@ Moreover, the task may define an optional [`scorer` method](https://spacy.io/api
 It should accept an iterable of `Example`s as input and return a score dictionary.
 If the `scorer` method is defined, `spacy-llm` will call it to evaluate the component.
 
+#### Providing examples for few-shot prompts
+
+All built-in tasks support few-shot prompts, i. e. including examples in a prompt. Examples can be supplied in two ways:
+(1) as a separate file containing only examples or (2) by initializing `llm` with a `get_examples()` callback (like any
+other spaCy pipeline component).
+
+##### (1) Few-shot example file
+
+A file containing examples for few-shot prompting can be configured like this:
+
+```ini
+[components.llm.task]
+@llm_tasks = "spacy.NER.v2"
+labels = PERSON,ORGANISATION,LOCATION
+[components.llm.task.examples]
+@misc = "spacy.FewShotReader.v1"
+path = "ner_examples.yml"
+```
+
+The supplied file has to conform to the format expected by the required task (see the task documentation further down).
+
+##### (2) Initializing the `llm` component with a `get_examples()` callback
+
+Alternatively, you can initialize your `nlp` pipeline by providing a `get_examples` callback for
+[`nlp.initialize`](https://spacy.io/api/language#initialize) and setting `n_prompt_examples` to a positive number to
+automatically fetch a few examples for few-shot learning. Set `n_prompt_examples` to `-1` to use all examples as
+part of the few-shot learning prompt.
+
+```ini
+[initialize.components.llm]
+n_prompt_examples = 3
+```
+
 #### <kbd>function</kbd> `task.generate_prompts`
 
 Takes a collection of documents, and returns a collection of "prompts", which can be of type `Any`.
@@ -325,7 +355,7 @@ and sets the annotations on the documents. The `parse_responses` function is fre
 including `Doc` fields like `ents`, `spans` or `cats`, or using custom defined fields.
 
 The `responses` are of type `Iterable[Any]`, though they will often be `str` objects. This depends on the
-return type of the [backend](#backends).
+return type of the [model](#models).
 
 | Argument    | Type            | Description              |
 | ----------- | --------------- | ------------------------ |
@@ -333,6 +363,53 @@ return type of the [backend](#backends).
 | `responses` | `Iterable[Any]` | The generated prompts.   |
 | **RETURNS** | `Iterable[Doc]` | The annotated documents. |
 
+
+#### spacy.Summarization.v1
+
+The `spacy.Summarization.v1` task supports both zero-shot and few-shot prompting.
+
+```ini
+[components.llm.task]
+@llm_tasks = "spacy.Summarization.v1"
+examples = null
+max_n_words = null
+```
+
+| Argument      | Type                                    | Default                                                                | Description                                                                                                                              |
+|---------------|-----------------------------------------|------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| `template`    | `str`                                   | [summarization.jinja](./spacy_llm/tasks/templates/summarization.jinja) | Custom prompt template to send to LLM backend. Default templates for each task are located in the `spacy_llm/tasks/templates` directory. |
+| `examples`    | `Optional[Callable[[], Iterable[Any]]]` | `None`                                                                 | Optional function that generates examples for few-shot learning.                                                                         |
+| `max_n_words` | `Optional[int]`                         | `None`                                                                 | Maximum number of words to be used in summary. Note that this should not expected to work exactly.                                       |
+| `field`       | `str`                                   | `summary`                                                              | Name of extension attribute to store summary in (i. e. the summary will be available in `doc._.{field}`).                                |
+
+The summarization task prompts the model for a concise summary of the provided text. It optionally allows to limit the 
+response to a certain number of tokens - note that this requirement will be included in the prompt, but the task doesn't
+perform a hard cut-off. It's hence possible that your summary exceeds `max_n_words`.
+
+To perform few-shot learning, you can write down a few examples in a separate file, and provide these to be injected into the prompt to the LLM.
+The default reader `spacy.FewShotReader.v1` supports `.yml`, `.yaml`, `.json` and `.jsonl`.
+
+```yaml
+- text: >
+    The United Nations, referred to informally as the UN, is an intergovernmental organization whose stated purposes are 
+    to maintain international peace and security, develop friendly relations among nations, achieve international 
+    cooperation, and serve as a centre for harmonizing the actions of nations. It is the world's largest international 
+    organization. The UN is headquartered on international territory in New York City, and the organization has other 
+    offices in Geneva, Nairobi, Vienna, and The Hague, where the International Court of Justice is headquartered.\n\n
+    The UN was established after World War II with the aim of preventing future world wars, and succeeded the League of 
+    Nations, which was characterized as ineffective. 
+  summary: "The UN is an international organization that promotes global peace, cooperation, and harmony. Established after WWII, its purpose is to prevent future world wars."
+```
+
+```ini
+[components.llm.task]
+@llm_tasks = "spacy.summarization.v1"
+max_n_words = 20
+[components.llm.task.examples]
+@misc = "spacy.FewShotReader.v1"
+path = "summarization_examples.yml"
+```
+
 #### spacy.NER.v2
 
 The built-in NER task supports both zero-shot and few-shot prompting. This version also supports explicitly defining the provided labels with custom descriptions.
@@ -347,7 +424,7 @@ examples = null
 | Argument                  | Type                                    | Default                                                  | Description                                                                                                                                           |
 | ------------------------- | --------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
 | `labels`                  | `Union[List[str], str]`                 |                                                          | List of labels or str of comma-separated list of labels.                                                                                              |
-| `template`                | `str`                                   | [ner.v2.jinja](./spacy_llm/tasks/templates/ner.v2.jinja) | Custom prompt template to send to LLM backend. Default templates for each task are located in the `spacy_llm/tasks/templates` directory.              |
+| `template`                | `str`                                   | [ner.v2.jinja](./spacy_llm/tasks/templates/ner.v2.jinja) | Custom prompt template to send to LLM model. Default templates for each task are located in the `spacy_llm/tasks/templates` directory.                |
 | `label_definitions`       | `Optional[Dict[str, str]]`              | `None`                                                   | Optional dict mapping a label to a description of that label. These descriptions are added to the prompt to help instruct the LLM on what to extract. |
 | `examples`                | `Optional[Callable[[], Iterable[Any]]]` | `None`                                                   | Optional function that generates examples for few-shot learning.                                                                                      |
 | `normalizer`              | `Optional[Callable[[str], str]]`        | `None`                                                   | Function that normalizes the labels as returned by the LLM. If `None`, defaults to `spacy.LowercaseNormalizer.v1`.                                    |
@@ -392,7 +469,10 @@ labels = PERSON,ORGANISATION,LOCATION
 path = "ner_examples.yml"
 ```
 
-If you don't have specific examples to provide to the LLM, you can write definitions for each label and provide them via the `label_definitions` argument. This lets you tell the LLM exactly what you're looking for rather than relying on the LLM to interpret its task given just the label name. Label descriptions are freeform so you can write whatever you want here, but through some experiments a brief description along with some examples and counter examples seems to work quite well.
+You can also write definitions for each label and provide them via the `label_definitions` argument. This lets you tell
+the LLM exactly what you're looking for rather than relying on the LLM to interpret its task given just the label name.
+Label descriptions are freeform so you can write whatever you want here, but through some experiments a brief
+description along with some examples and counter examples seems to work quite well.
 
 ```ini
 [components.llm.task]
@@ -403,7 +483,7 @@ PERSON = "Extract any named individual in the text."
 SPORTS_TEAM = "Extract the names of any professional sports team. e.g. Golden State Warriors, LA Lakers, Man City, Real Madrid"
 ```
 
-> Label descriptions can also be used with explicit examples to give as much info to the LLM backend as possible.
+> Label descriptions can also be used with explicit examples to give as much info to the LLM model as possible.
 
 #### spacy.NER.v1
 
@@ -477,7 +557,7 @@ examples = null
 | Argument                  | Type                                    | Default                                                            | Description                                                                                                                                           |
 | ------------------------- | --------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
 | `labels`                  | `Union[List[str], str]`                 |                                                                    | List of labels or str of comma-separated list of labels.                                                                                              |
-| `template`                | `str`                                   | [`spancat.v2.jinja`](./spacy_llm/tasks/templates/spancat.v2.jinja) | Custom prompt template to send to LLM backend. Default templates for each task are located in the `spacy_llm/tasks/templates` directory.              |
+| `template`                | `str`                                   | [`spancat.v2.jinja`](./spacy_llm/tasks/templates/spancat.v2.jinja) | Custom prompt template to send to LLM model. Default templates for each task are located in the `spacy_llm/tasks/templates` directory.                |
 | `label_definitions`       | `Optional[Dict[str, str]]`              | `None`                                                             | Optional dict mapping a label to a description of that label. These descriptions are added to the prompt to help instruct the LLM on what to extract. |
 | `spans_key`               | `str`                                   | `"sc"`                                                             | Key of the `Doc.spans` dict to save the spans under.                                                                                                  |
 | `examples`                | `Optional[Callable[[], Iterable[Any]]]` | `None`                                                             | Optional function that generates examples for few-shot learning.                                                                                      |
@@ -587,7 +667,7 @@ examples = null
 | Argument            | Type                                    | Default                                                      | Description                                                                                                                                      |
 | ------------------- | --------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
 | `labels`            | `Union[List[str], str]`                 |                                                              | List of labels or str of comma-separated list of labels.                                                                                         |
-| `template`          | `str`                                   | [`textcat.jinja`](./spacy_llm/tasks/templates/textcat.jinja) | Custom prompt template to send to LLM backend. Default templates for each task are located in the `spacy_llm/tasks/templates` directory.         |
+| `template`          | `str`                                   | [`textcat.jinja`](./spacy_llm/tasks/templates/textcat.jinja) | Custom prompt template to send to LLM model. Default templates for each task are located in the `spacy_llm/tasks/templates` directory.           |
 | `examples`          | `Optional[Callable[[], Iterable[Any]]]` | `None`                                                       | Optional function that generates examples for few-shot learning.                                                                                 |
 | `normalizer`        | `Optional[Callable[[str], str]]`        | `None`                                                       | Function that normalizes the labels as returned by the LLM. If `None`, falls back to `spacy.LowercaseNormalizer.v1`.                             |
 | `exclusive_classes` | `bool`                                  | `False`                                                      | If set to `True`, only one label per document should be valid. If set to `False`, one document can have multiple labels.                         |
@@ -675,27 +755,23 @@ It relies on an upstream NER component for entities extraction.
 labels = ["LivesIn", "Visits"]
 ```
 
-| Argument            | Type                                    | Default                                              | Description                                                                                                                              |
-| ------------------- | --------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
-| `labels`            | `Union[List[str], str]`                 |                                                      | List of labels or str of comma-separated list of labels.                                                                                 |
-| `template`          | `str`                                   | [`rel.jinja`](./spacy_llm/tasks/templates/rel.jinja) | Custom prompt template to send to LLM backend. Default templates for each task are located in the `spacy_llm/tasks/templates` directory. |
-| `label_description` | `Optional[Dict[str, str]]`              | `None`                                               | Dictionary providing a description for each relation label.                                                                              |
-| `examples`          | `Optional[Callable[[], Iterable[Any]]]` | `None`                                               | Optional function that generates examples for few-shot learning.                                                                         |
-| `normalizer`        | `Optional[Callable[[str], str]]`        | `None`                                               | Function that normalizes the labels as returned by the LLM. If `None`, falls back to `spacy.LowercaseNormalizer.v1`.                     |
-| `verbose`           | `bool`                                  | `False`                                              | If set to `True`, warnings will be generated when the LLM returns invalid responses.                                                     |
+| Argument            | Type                                    | Default                                              | Description                                                                                                                            |
+| ------------------- | --------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `labels`            | `Union[List[str], str]`                 |                                                      | List of labels or str of comma-separated list of labels.                                                                               |
+| `template`          | `str`                                   | [`rel.jinja`](./spacy_llm/tasks/templates/rel.jinja) | Custom prompt template to send to LLM model. Default templates for each task are located in the `spacy_llm/tasks/templates` directory. |
+| `label_description` | `Optional[Dict[str, str]]`              | `None`                                               | Dictionary providing a description for each relation label.                                                                            |
+| `examples`          | `Optional[Callable[[], Iterable[Any]]]` | `None`                                               | Optional function that generates examples for few-shot learning.                                                                       |
+| `normalizer`        | `Optional[Callable[[str], str]]`        | `None`                                               | Function that normalizes the labels as returned by the LLM. If `None`, falls back to `spacy.LowercaseNormalizer.v1`.                   |
+| `verbose`           | `bool`                                  | `False`                                              | If set to `True`, warnings will be generated when the LLM returns invalid responses.                                                   |
 
 To perform few-shot learning, you can write down a few examples in a separate file, and provide these to be injected into the prompt to the LLM.
 The default reader `spacy.FewShotReader.v1` supports `.yml`, `.yaml`, `.json` and `.jsonl`.
 
-```json
+```jsonl
 {"text": "Laura bought a house in Boston with her husband Mark.", "ents": [{"start_char": 0, "end_char": 5, "label": "PERSON"}, {"start_char": 24, "end_char": 30, "label": "GPE"}, {"start_char": 48, "end_char": 52, "label": "PERSON"}], "relations": [{"dep": 0, "dest": 1, "relation": "LivesIn"}, {"dep": 2, "dest": 1, "relation": "LivesIn"}]}
 {"text": "Michael travelled through South America by bike.", "ents": [{"start_char": 0, "end_char": 7, "label": "PERSON"}, {"start_char": 26, "end_char": 39, "label": "LOC"}], "relations": [{"dep": 0, "dest": 1, "relation": "Visits"}]}
 ```
 
-Note: the REL task relies on pre-extracted entities to make its prediction.
-Hence, you'll need to add a component that populates `doc.ents` with recognized
-spans to your spaCy pipeline and put it _before_ the REL component.
-
 ```ini
 [components.llm.task]
 @llm_tasks = "spacy.REL.v1"
@@ -705,6 +781,10 @@ labels = ["LivesIn", "Visits"]
 path = "rel_examples.jsonl"
 ```
 
+Note: the REL task relies on pre-extracted entities to make its prediction.
+Hence, you'll need to add a component that populates `doc.ents` with recognized
+spans to your spaCy pipeline and put it _before_ the REL component.
+
 #### spacy.Lemma.v1
 
 The `Lemma.v1` task lemmatizes the provided text and updates the `lemma_` attribute in the doc's tokens accordingly.
@@ -715,10 +795,10 @@ The `Lemma.v1` task lemmatizes the provided text and updates the `lemma_` attrib
 examples = null
 ```
 
-| Argument   | Type                                    | Default                                                | Description                                                                                                                              |
-| ---------- | --------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
-| `template` | `str`                                   | [lemma.jinja](./spacy_llm/tasks/templates/lemma.jinja) | Custom prompt template to send to LLM backend. Default templates for each task are located in the `spacy_llm/tasks/templates` directory. |
-| `examples` | `Optional[Callable[[], Iterable[Any]]]` | `None`                                                 | Optional function that generates examples for few-shot learning.                                                                         |
+| Argument   | Type                                    | Default                                                | Description                                                                                                                            |
+| ---------- | --------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `template` | `str`                                   | [lemma.jinja](./spacy_llm/tasks/templates/lemma.jinja) | Custom prompt template to send to LLM model. Default templates for each task are located in the `spacy_llm/tasks/templates` directory. |
+| `examples` | `Optional[Callable[[], Iterable[Any]]]` | `None`                                                 | Optional function that generates examples for few-shot learning.                                                                       |
 
 `Lemma.v1` prompts the LLM to lemmatize the passed text and return the lemmatized version as a list of tokens and their
 corresponding lemma. E. g. the text
@@ -771,6 +851,45 @@ The default reader `spacy.FewShotReader.v1` supports `.yml`, `.yaml`, `.json` an
 path = "lemma_examples.yml"
 ```
 
+#### spacy.Sentiment.v1
+
+Performs sentiment analysis on provided texts. Scores between 0 and 1 are stored in `Doc._.sentiment` - the higher, the
+more positive. Note in cases of parsing issues (e. g. in case of unexpected LLM responses) the value might be `None`.
+
+```ini
+[components.llm.task]
+@llm_tasks = "spacy.Sentiment.v1"
+examples = null
+```
+
+| Argument   | Type                                    | Default                                                        | Description                                                                                                                            |
+| ---------- | --------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `template` | `str`                                   | [sentiment.jinja](./spacy_llm/tasks/templates/sentiment.jinja) | Custom prompt template to send to LLM model. Default templates for each task are located in the `spacy_llm/tasks/templates` directory. |
+| `examples` | `Optional[Callable[[], Iterable[Any]]]` | `None`                                                         | Optional function that generates examples for few-shot learning.                                                                       |
+| `field`    | `str`                                   | `sentiment`                                                    | Name of extension attribute to store summary in (i. e. the summary will be available in `doc._.{field}`).                              |
+
+To perform few-shot learning, you can write down a few examples in a separate file, and provide these to be injected into the prompt to the LLM.
+The default reader `spacy.FewShotReader.v1` supports `.yml`, `.yaml`, `.json` and `.jsonl`.
+
+```yaml
+- text: "This is horrifying."
+  score: 0
+- text: "This is underwhelming."
+  score: 0.25
+- text: "This is ok."
+  score: 0.5
+- text: "I'm looking forward to this!"
+  score: 1.0
+```
+
+```ini
+[components.llm.task]
+@llm_tasks = "spacy.Sentiment.v1"
+[components.llm.task.examples]
+@misc = "spacy.FewShotReader.v1"
+path = "sentiment_examples.yml"
+```
+
 #### spacy.NoOp.v1
 
 This task is only useful for testing - it tells the LLM to do nothing, and does not set any fields on the `docs`.
@@ -780,35 +899,43 @@ This task is only useful for testing - it tells the LLM to do nothing, and does
 @llm_tasks = "spacy.NoOp.v1"
 ```
 
-### Backends
+### Models
 
-A _backend_ defines which LLM model to query, and how to query it. It can be a simple function taking a collection
+A _model_ defines which LLM model to query, and how to query it. It can be a simple function taking a collection
 of prompts (consistent with the output type of `task.generate_prompts()`) and returning a collection of responses
 (consistent with the expected input of `parse_responses`). Generally speaking, it's a function of type `Callable[[Iterable[Any]], Iterable[Any]]`,
 but specific implementations can have other signatures, like `Callable[[Iterable[str]], Iterable[str]]`.
 
-All built-in backends are registered in `llm_backends`. If no backend is specified, the repo currently connects to the [`OpenAI` API](#openai) by default,
-using the built-in REST protocol, and accesses the `"gpt-3.5-turbo"` model.
+All built-in models are registered in `llm_models`. If no model is specified, the repo currently connects to the `OpenAI` API by default
+using REST, and accesses the `"gpt-3.5-turbo"` model.
+
+Currently three different approaches to use LLMs are supported:
+
+1. `spacy-llm`s native REST backend. This is the default for all hosted models (e. g. OpenAI, Cohere, Anthropic, ...).
+2. A HuggingFace integration that allows to run a limited set of HF models locally.
+3. A LangChain integration that allows to run any model supported by LangChain (hosted or locally).
+
+Approaches 1. and 2 are the default for hosted model and local models, respectively. Alternatively you can use LangChain
+to access hosted or local models by specifying one of the models registered with the `langchain.` prefix.
 
-> :question: _Why are there backends for third-party libraries in addition to a native REST backend and which should
-> I choose?_
+> :question: Why LangChain if there are also are a native REST and a HuggingFace backend? When should I use what?
 >
-> Third-party libraries like `langchain` or `minichain` focus on prompt management, integration of many different LLM
+> Third-party libraries like `langchain` focus on prompt management, integration of many different LLM
 > APIs, and other related features such as conversational memory or agents. `spacy-llm` on the other hand emphasizes
 > features we consider useful in the context of NLP pipelines utilizing LLMs to process documents (mostly) independent
-> from each other. It makes sense that the feature set of such third-party libraries and `spacy-llm` is not identical -
+> from each other. It makes sense that the feature sets of such third-party libraries and `spacy-llm` aren't identical -
 > and users might want to take advantage of features not available in `spacy-llm`.
 >
-> The advantage of offering our own REST backend is that we can ensure a larger degree of stability of robustness, as
+> The advantage of implementing our own REST and HuggingFace integrations is that we can ensure a larger degree of stability and robustness, as
 > we can guarantee backwards-compatibility and more smoothly integrated error handling.
 >
-> Ultimately we recommend trying to implement your use case using the REST backend first (which is configured as the
-> default backend). If however there are features or APIs not covered by `spacy-llm`, it's trivial to switch to the
-> backend of a third-party library - and easy to customize the prompting mechanism, if so required.
+> If however there are features or APIs not natively covered by `spacy-llm`, it's trivial to utilize LangChain to cover
+> this - and easy to customize the prompting mechanism, if so required.
 
-#### OpenAI
+Note that when using hosted services, you have to ensure that the proper API keys are set as environment variables as
+described by the corresponding provider's documentation.
 
-When the backend uses OpenAI, you have to get an API key from openai.com, and ensure that the keys are set as
+E. g. when using OpenAI, you have to get an API key from openai.com, and ensure that the keys are set as
 environmental variables:
 
 ```shell
@@ -816,109 +943,399 @@ export OPENAI_API_KEY="sk-..."
 export OPENAI_API_ORG="org-..."
 ```
 
-#### spacy.REST.v1
+For Cohere it's
+
+```shell
+export CO_API_KEY="..."
+```
+
+and for Anthropic
+
+```shell
+export ANTHROPIC_API_KEY="..."
+```
+
+#### spacy.GPT-4.v1
+
+OpenAI's `gpt-4` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.GPT-4.v1"
+name = "gpt-4"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                                                            | Default   | Description                                                                                                          |
+| ----------- | --------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["gpt-4", "gpt-4-0314", "gpt-4-32k", "gpt-4-32k-0314"]` | `"gpt-4"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`                                                | `{}`      | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                                                          | `True`    | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                                                           | `3`       | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                                                           | `30`      | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.GPT-3-5.v1
+
+OpenAI's `gpt-3-5` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.GPT-3-5.v1"
+name = "gpt-3.5-turbo"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                                                                                            | Default           | Description                                                                                                          |
+| ----------- | ----------------------------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["gpt-3.5-turbo", "gpt-3.5-turbo-16k", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-0613-16k"]` | `"gpt-3.5-turbo"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`                                                                                | `{}`              | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                                                                                          | `True`            | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                                                                                           | `3`               | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                                                                                           | `30`              | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Text-Davinci.v1
+
+OpenAI's `text-davinci` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Text-Davinci.v1"
+name = "text-davinci-003"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                                              | Default              | Description                                                                                                          |
+| ----------- | ------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["text-davinci-002", "text-davinci-003"]` | `"text-davinci-003"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`                                  | `{}`                 | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                                            | `True`               | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                                             | `3`                  | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                                             | `30`                 | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Code-Davinci.v1
+
+OpenAI's `code-davinci` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Code-Davinci.v1"
+name = "code-davinci-002"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                          | Default              | Description                                                                                                          |
+| ----------- | ----------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["code-davinci-002"]` | `"code-davinci-002"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`              | `{}`                 | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                        | `True`               | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                         | `3`                  | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                         | `30`                 | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Text-Curie.v1
+
+OpenAI's `text-curie` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Text-Curie.v1"
+name = "text-curie-001"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                        | Default            | Description                                                                                                          |
+| ----------- | --------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["text-curie-001"]` | `"text-curie-001"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`            | `{}`               | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                      | `True`             | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                       | `3`                | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                       | `30`               | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Text-Babbage.v1
+
+OpenAI's `text-babbage` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Text-Babbage.v1"
+name = "text-babbage-001"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                          | Default              | Description                                                                                                          |
+| ----------- | ----------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["text-babbage-001"]` | `"text-babbage-001"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`              | `{}`                 | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                        | `True`               | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                         | `3`                  | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                         | `30`                 | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Text-Ada.v1
+
+OpenAI's `text-ada` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Text-Ada.v1"
+name = "text-ada-001"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                      | Default          | Description                                                                                                          |
+| ----------- | ------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["text-ada-001"]` | `"text-ada-001"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`          | `{}`             | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                    | `True`           | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                     | `3`              | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                     | `30`             | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Davinci.v1
 
-This default backend uses `requests` and a simple retry mechanism to access an API.
+OpenAI's `davinci` model family.
+
+Example config:
 
 ```ini
-[components.llm.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
-config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
+[components.llm.model]
+@llm_models = "spacy.Davinci.v1 "
+name = "davinci"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                 | Default     | Description                                                                                                          |
+| ----------- | -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["davinci"]` | `"davinci"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`     | `{}`        | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`               | `True`      | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                | `3`         | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                | `30`        | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Curie.v1
+
+OpenAI's `curie` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Curie.v1 "
+name = "curie"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type               | Default   | Description                                                                                                          |
+| ----------- | ------------------ | --------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["curie"]` | `"curie"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`   | `{}`      | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`             | `True`    | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`              | `3`       | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`              | `30`      | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Babbage.v1
+
+OpenAI's `babbage` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Babbage.v1 "
+name = "babbage"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                 | Default     | Description                                                                                                          |
+| ----------- | -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["babbage"]` | `"babbage"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`     | `{}`        | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`               | `True`      | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                | `3`         | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                | `30`        | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Ada.v1
+
+OpenAI's `ada` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Ada.v1 "
+name = "ada"
+config = {"temperature": 0.3}
 ```
 
 | Argument    | Type             | Default | Description                                                                                                          |
 | ----------- | ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
-| `api`       | `str`            |         | The name of a supported API. In v.0.1.0, only "OpenAI" is supported.                                                 |
-| `config`    | `Dict[Any, Any]` | `{}`    | Further configuration passed on to the backend.                                                                      |
+| `name`      | `Literal["ada"]` | `"ada"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]` | `{}`    | Further configuration passed on to the model.                                                                        |
 | `strict`    | `bool`           | `True`  | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
 | `max_tries` | `int`            | `3`     | Max. number of tries for API request.                                                                                |
 | `timeout`   | `int`            | `30`    | Timeout for API request in seconds.                                                                                  |
 
-When `api` is set to `OpenAI`, the following settings can be defined in the `config` dictionary:
-
-- `model`: one of the following list of supported models:
-  - `"gpt-4"`
-  - `"gpt-4-0314"`
-  - `"gpt-4-32k"`
-  - `"gpt-4-32k-0314"`
-  - `"gpt-3.5-turbo"`
-  - `"gpt-3.5-turbo-0301"`
-  - `"text-davinci-003"`
-  - `"text-davinci-002"`
-  - `"text-curie-001"`
-  - `"text-babbage-001"`
-  - `"text-ada-001"`
-  - `"davinci"`
-  - `"curie"`
-  - `"babbage"`
-  - `"ada"`
-- `url`: By default, this is `https://api.openai.com/v1/completions`. For models requiring the chat endpoint, use `https://api.openai.com/v1/chat/completions`.
-
-#### spacy.MiniChain.v1
-
-To use [MiniChain](https://github.com/srush/MiniChain) for the API retrieval part, make sure you have installed it first:
+#### spacy.Command.v1
 
-```shell
-python -m pip install "minichain>=0.3,<0.4"
-# Or install with spacy-llm directly
-python -m pip install "spacy-llm[minichain]"
+Cohere's `command` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Command.v1 "
+name = "command"
+config = {"temperature": 0.3}
 ```
 
-Note that MiniChain currently only supports Python 3.8, 3.9 and 3.10.
+| Argument    | Type                                                                              | Default     | Description                                                                                                          |
+| ----------- | --------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["command", "command-light", "command-light-nightly", "command-nightly"]` | `"command"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`                                                                  | `{}`        | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                                                                            | `True`      | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                                                                             | `3`         | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                                                                             | `30`        | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Claude-1.v1
 
-Example config blocks:
+Anthropic's `claude-1` model family.
+
+Example config:
 
 ```ini
-[components.llm.backend]
-@llm_backends = "spacy.MiniChain.v1"
-api = "OpenAI"
+[components.llm.model]
+@llm_models = "spacy.Claude-1.v1 "
+name = "claude-1"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                                   | Default      | Description                                                                                                          |
+| ----------- | -------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["claude-1", "claude-1-100k"]` | `"claude-1"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`                       | `{}`         | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                                 | `True`       | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                                  | `3`          | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                                  | `30`         | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Claude-instant-1.v1
+
+Anthropic's `claude-instant-1` model family.
 
-[components.llm.backend.query]
-@llm_queries = "spacy.RunMiniChain.v1"
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Claude-instant-1.v1 "
+name = "claude-instant-1"
+config = {"temperature": 0.3}
 ```
 
-| Argument | Type                                                                              | Default | Description                                                                         |
-| -------- | --------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------- |
-| `api`    | `str`                                                                             |         | The name of an API supported by MiniChain, e.g. "OpenAI".                           |
-| `config` | `Dict[Any, Any]`                                                                  | `{}`    | Further configuration passed on to the backend.                                     |
-| `query`  | `Optional[Callable[["minichain.backend.Backend", Iterable[str]], Iterable[str]]]` | `None`  | Function that executes the prompts. If `None`, defaults to `spacy.RunMiniChain.v1`. |
+| Argument    | Type                                                   | Default              | Description                                                                                                          |
+| ----------- | ------------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["claude-instant-1", "claude-instant-1-100k"]` | `"claude-instant-1"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`                                       | `{}`                 | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                                                 | `True`               | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                                                  | `3`                  | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                                                  | `30`                 | Timeout for API request in seconds.                                                                                  |
 
-The default `query` (`spacy.RunMiniChain.v1`) executes the prompts by running `model(text).run()` for each given textual prompt.
+#### spacy.Claude-instant-1-1.v1
 
-#### spacy.LangChain.v1
+Anthropic's `claude-instant-1.1` model family.
 
-To use [LangChain](https://github.com/hwchase17/langchain) for the API retrieval part, make sure you have installed it first:
+Example config:
 
-```shell
-python -m pip install "langchain>=0.0.144,<0.1"
-# Or install with spacy-llm directly
-python -m pip install "spacy-llm[langchain]"
+```ini
+[components.llm.model]
+@llm_models = "spacy.Claude-instant-1-1.v1 "
+name = "claude-instant-1.1"
+config = {"temperature": 0.3}
 ```
 
-Note that LangChain currently only supports Python 3.9 and beyond.
+| Argument    | Type                                                       | Default                | Description                                                                                                          |
+| ----------- | ---------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["claude-instant-1.1", "claude-instant-1.1-100k"]` | `"claude-instant-1.1"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`                                           | `{}`                   | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                                                     | `True`                 | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                                                      | `3`                    | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                                                      | `30`                   | Timeout for API request in seconds.                                                                                  |
 
-Example config block:
+#### spacy.Claude-1-0.v1
+
+Anthropic's `claude-1.0` model family.
+
+Example config:
 
 ```ini
-[components.llm.backend]
-@llm_backends = "spacy.LangChain.v1"
-api = "OpenAI"
-query = {"@llm_queries": "spacy.CallLangChain.v1"}
+[components.llm.model]
+@llm_models = "spacy.Claude-1-0.v1 "
+name = "claude-1.0"
 config = {"temperature": 0.3}
 ```
 
-| Argument | Type                                                                           | Default | Description                                                                          |
-| -------- | ------------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------ |
-| `api`    | `str`                                                                          |         | The name of an API supported by LangChain, e.g. "OpenAI".                            |
-| `config` | `Dict[Any, Any]`                                                               | `{}`    | Further configuration passed on to the backend.                                      |
-| `query`  | `Optional[Callable[["langchain.llms.BaseLLM", Iterable[Any]], Iterable[Any]]]` | `None`  | Function that executes the prompts. If `None`, defaults to `spacy.CallLangChain.v1`. |
+| Argument    | Type                    | Default        | Description                                                                                                          |
+| ----------- | ----------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["claude-1.0"]` | `"claude-1.0"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`        | `{}`           | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                  | `True`         | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                   | `3`            | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                   | `30`           | Timeout for API request in seconds.                                                                                  |
 
-The default `query` (`spacy.CallLangChain.v1`) executes the prompts by running `model(text)` for each given textual prompt.
+#### spacy.Claude-1-2.v1
 
-#### spacy.Dolly_HF.v1
+Anthropic's `claude-1.2` model family.
 
-To use this backend, ideally you have a GPU enabled and have installed `transformers`, `torch` and CUDA in your virtual environment.
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Claude-1-2.v1 "
+name = "claude-1.2"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                    | Default        | Description                                                                                                          |
+| ----------- | ----------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["claude-1.2"]` | `"claude-1.2"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`        | `{}`           | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                  | `True`         | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                   | `3`            | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                   | `30`           | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Claude-1-3.v1
+
+Anthropic's `claude-1.3` model family.
+
+Example config:
+
+```ini
+[components.llm.model]
+@llm_models = "spacy.Claude-1-3.v1 "
+name = "claude-1.3"
+config = {"temperature": 0.3}
+```
+
+| Argument    | Type                                       | Default        | Description                                                                                                          |
+| ----------- | ------------------------------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `name`      | `Literal["claude-1.3", "claude-1.3-100k"]` | `"claude-1.3"` | Model name, i. e. any supported variant for this particular model.                                                   |
+| `config`    | `Dict[Any, Any]`                           | `{}`           | Further configuration passed on to the model.                                                                        |
+| `strict`    | `bool`                                     | `True`         | If `True`, raises an error if the LLM API returns a malformed response. Otherwise, return the error responses as is. |
+| `max_tries` | `int`                                      | `3`            | Max. number of tries for API request.                                                                                |
+| `timeout`   | `int`                                      | `30`           | Timeout for API request in seconds.                                                                                  |
+
+#### spacy.Dolly.v1
+
+To use this model, ideally you have a GPU enabled and have installed `transformers`, `torch` and CUDA in your virtual environment.
 This allows you to have the setting `device=cuda:0` in your config, which ensures that the model is loaded entirely on the GPU (and fails otherwise).
 
 You can do so with
@@ -937,16 +1354,16 @@ python -m pip install "accelerate>=0.16.0,<1.0"
 Example config block:
 
 ```ini
-[components.llm.backend]
-@llm_backends = "spacy.Dolly_HF.v1"
-model = "databricks/dolly-v2-3b"
+[components.llm.model]
+@llm_models = "spacy.Dolly.v1"
+name = "dolly-v2-3b"
 ```
 
-| Argument      | Type             | Default | Description                                                                                      |
-| ------------- | ---------------- | ------- | ------------------------------------------------------------------------------------------------ |
-| `model`       | `str`            |         | The name of a Dolly model that is supported.                                                     |
-| `config_init` | `Dict[str, Any]` | `{}`    | Further configuration passed on to the construction of the model with `transformers.pipeline()`. |
-| `config_run`  | `Dict[str, Any]` | `{}`    | Further configuration used during model inference.                                               |
+| Argument      | Type                                                    | Default | Description                                                                                      |
+| ------------- | ------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------ |
+| `name`        | `Literal["dolly-v2-3b", "dolly-v2-7b", "dolly-v2-12b"]` |         | The name of a Dolly model that is supported (e. g. "dolly-v2-3b" or "dolly-v2-12b").             |
+| `config_init` | `Dict[str, Any]`                                        | `{}`    | Further configuration passed on to the construction of the model with `transformers.pipeline()`. |
+| `config_run`  | `Dict[str, Any]`                                        | `{}`    | Further configuration used during model inference.                                               |
 
 Supported models (see the [Databricks models page](https://huggingface.co/databricks) on Hugging Face for details):
 
@@ -958,9 +1375,10 @@ Note that Hugging Face will download this model the first time you use it - you
 [define the cached directory](https://huggingface.co/docs/huggingface_hub/main/en/guides/manage-cache)
 by setting the environmental variable `HF_HOME`.
 
-#### spacy.StableLM_HF.v1
+#### spacy.Falcon.v1
 
 To use this backend, ideally you have a GPU enabled and have installed `transformers`, `torch` and CUDA in your virtual environment.
+This allows you to have the setting `device=cuda:0` in your config, which ensures that the model is loaded entirely on the GPU (and fails otherwise).
 
 You can do so with
 
@@ -978,31 +1396,61 @@ python -m pip install "accelerate>=0.16.0,<1.0"
 Example config block:
 
 ```ini
-[components.llm.backend]
-@llm_backends = "spacy.StableLM_HF.v1"
-model = "stabilityai/stablelm-tuned-alpha-7b"
+[components.llm.model]
+@llm_models = "spacy.Falcon.v1"
+name = "falcon-7b"
+```
+
+| Argument      | Type                                                                                | Default         | Description                                                                                      |
+| ------------- | ----------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------ |
+| `name`        | `Literal["falcon-rw-1b", "falcon-7b", "falcon-7b-instruct", "falcon-40b-instruct"]` | `"7b-instruct"` | The name of a Falcon model variant that is supported.                                            |
+| `config_init` | `Dict[str, Any]`                                                                    | `{}`            | Further configuration passed on to the construction of the model with `transformers.pipeline()`. |
+| `config_run`  | `Dict[str, Any]`                                                                    | `{}`            | Further configuration used during model inference.                                               |
+
+Note that Hugging Face will download this model the first time you use it - you can
+[define the cache directory](https://huggingface.co/docs/huggingface_hub/main/en/guides/manage-cache)
+by setting the environmental variable `HF_HOME`.
+
+#### spacy.StableLM.v1
+
+To use this model, ideally you have a GPU enabled and have installed `transformers`, `torch` and CUDA in your virtual environment.
+
+You can do so with
+
+```shell
+python -m pip install "spacy-llm[transformers]" "transformers[sentencepiece]"
+```
+
+If you don't have access to a GPU, you can install `accelerate` and set`device_map=auto` instead, but be aware that this may result in some layers getting distributed to the CPU or even the hard drive,
+which may ultimately result in extremely slow queries.
+
+```shell
+python -m pip install "accelerate>=0.16.0,<1.0"
 ```
 
-| Argument      | Type             | Default | Description                                                                                                                  |
-| ------------- | ---------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
-| `model`       | `str`            |         | The name of a StableLM model that is supported.                                                                              |
-| `config_init` | `Dict[str, Any]` | `{}`    | Further configuration passed on to the construction of the model with `transformers.AutoModelForCausalLM.from_pretrained()`. |
-| `config_run`  | `Dict[str, Any]` | `{}`    | Further configuration used during model inference.                                                                           |
+Example config block:
 
-Supported models (see the [Stability AI StableLM GitHub repo](https://github.com/Stability-AI/StableLM/#stablelm-alpha) for details):
+```ini
+[components.llm.model]
+@llm_models = "spacy.StableLM.v1"
+name = "stablelm-tuned-alpha-7b"
+```
 
-- `"stabilityai/stablelm-base-alpha-3b"`
-- `"stabilityai/stablelm-base-alpha-7b"`
-- `"stabilityai/stablelm-tuned-alpha-3b"`
-- `"stabilityai/stablelm-tuned-alpha-7b"`
+| Argument      | Type                                                                                                                | Default | Description                                                                                                                  |
+| ------------- | ------------------------------------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `name`        | `Literal["stablelm-base-alpha-3b", "stablelm-base-alpha-7b", "stablelm-tuned-alpha-3b", "stablelm-tuned-alpha-7b"]` |         | The name of a StableLM model that is supported (e. g. "stablelm-tuned-alpha-7b").                                            |
+| `config_init` | `Dict[str, Any]`                                                                                                    | `{}`    | Further configuration passed on to the construction of the model with `transformers.AutoModelForCausalLM.from_pretrained()`. |
+| `config_run`  | `Dict[str, Any]`                                                                                                    | `{}`    | Further configuration used during model inference.                                                                           |
+
+See the [Stability AI StableLM GitHub repo](https://github.com/Stability-AI/StableLM/#stablelm-alpha) for details.
 
 Note that Hugging Face will download this model the first time you use it - you can
 [define the cached directory](https://huggingface.co/docs/huggingface_hub/main/en/guides/manage-cache)
 by setting the environmental variable `HF_HOME`.
 
-#### spacy.OpenLLaMaHF.v1
+#### spacy.OpenLLaMA.v1
 
-To use this backend, ideally you have a GPU enabled and have installed
+To use this model, ideally you have a GPU enabled and have installed
 
 - `transformers[sentencepiece]`
 - `torch`
@@ -1025,34 +1473,76 @@ python -m pip install "accelerate>=0.16.0,<1.0"
 Example config block:
 
 ```ini
-[components.llm.backend]
-@llm_backends = "spacy.OpenLLaMaHF.v1"
-model = "openlm-research/open_llama_3b_350bt_preview"
+[components.llm.model]
+@llm_models = "spacy.OpenLLaMA.v1"
+name = "open_llama_3b_350bt_preview"
 ```
 
-| Argument      | Type             | Default | Description                                                                                                                  |
-| ------------- | ---------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
-| `model`       | `str`            |         | The name of a OpenLLaMa model that is supported.                                                                             |
-| `config_init` | `Dict[str, Any]` | `{}`    | Further configuration passed on to the construction of the model with `transformers.AutoModelForCausalLM.from_pretrained()`. |
-| `config_run`  | `Dict[str, Any]` | `{}`    | Further configuration used during model inference.                                                                           |
-
-Supported models (see the [OpenLM Research OpenLLaMa GitHub repo](https://github.com/openlm-research/open_llama) for details):
+| Argument      | Type                                                                                                                                  | Default | Description                                                                                                                  |
+| ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `name`        | `Literal["open_llama_3b_350bt_preview", "open_llama_3b_600bt_preview", "open_llama_7b_400bt_preview", "open_llama_7b_600bt_preview"]` |         | The name of a OpenLLaMA model that is supported (e. g. "open_llama_3b_350bt_preview").                                       |
+| `config_init` | `Dict[str, Any]`                                                                                                                      | `{}`    | Further configuration passed on to the construction of the model with `transformers.AutoModelForCausalLM.from_pretrained()`. |
+| `config_run`  | `Dict[str, Any]`                                                                                                                      | `{}`    | Further configuration used during model inference.                                                                           |
 
-- `"openlm-research/open_llama_3b_350bt_preview"`
-- `"openlm-research/open_llama_3b_600bt_preview"`
-- `"openlm-research/open_llama_7b_400bt_preview"`
-- `"openlm-research/open_llama_7b_700bt_preview"`
+See the [OpenLM Research OpenLLaMA GitHub repo](https://github.com/openlm-research/open_llama) for details.
 
 Note that Hugging Face will download this model the first time you use it - you can
 [define the cached directory](https://huggingface.co/docs/huggingface_hub/main/en/guides/manage-cache)
 by setting the environmental variable `HF_HOME`.
 
+#### LangChain models
+
+To use [LangChain](https://github.com/hwchase17/langchain) for the API retrieval part, make sure you have installed it first:
+
+```shell
+python -m pip install "langchain==0.0.191"
+# Or install with spacy-llm directly
+python -m pip install "spacy-llm[extras]"
+```
+
+Note that LangChain currently only supports Python 3.9 and beyond.
+
+LangChain models in `spacy-llm` work slightly differently. `langchain`'s models are parsed automatically, each
+LLM class in `langchain` has one entry in `spacy-llm`'s registry. As `langchain`'s design has one class per API and
+not per model, this results in registry entries like `langchain.OpenAI.v1` - i. e. there is one registry entry per API
+and not per model (family), as for the REST- and HuggingFace-based entries.
+
+The name of the model to be used has to be passed in via the `name` attribute.
+
+Example config block:
+
+```ini
+[components.llm.model]
+@llm_models = "langchain.OpenAI.v1"
+name = "gpt-3.5-turbo"
+query = {"@llm_queries": "spacy.CallLangChain.v1"}
+config = {"temperature": 0.3}
+```
+
+| Argument | Type                                                                           | Default | Description                                                                          |
+| -------- | ------------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------ |
+| `name`   | `str`                                                                          |         | The name of a mdodel supported by LangChain for this API.                            |
+| `config` | `Dict[Any, Any]`                                                               | `{}`    | Configuration passed on to the LangChain model.                                      |
+| `query`  | `Optional[Callable[["langchain.llms.BaseLLM", Iterable[Any]], Iterable[Any]]]` | `None`  | Function that executes the prompts. If `None`, defaults to `spacy.CallLangChain.v1`. |
+
+The default `query` (`spacy.CallLangChain.v1`) executes the prompts by running `model(text)` for each given textual prompt.
+
 ### Cache
 
 Interacting with LLMs, either through an external API or a local instance, is costly.
 Since developing an NLP pipeline generally means a lot of exploration and prototyping,
 `spacy-llm` implements a built-in cache to avoid reprocessing the same documents at each run
-that keeps batches of documents stored on disk.
+that keeps batches of documents stored on disk. 
+
+The cache implementation also ensures that documents in one cache directory were all produced using the same prompt 
+template. This is only possible however if the specified task implements 
+```python
+@property
+def prompt_template() -> str:
+    ...
+``` 
+which returns the raw prompt template as string. If `prompt_template()` isn't implemented, the cache will emit a warning
+and not check for prompt template consistency.
 
 Example config block:
 
@@ -1067,11 +1557,11 @@ max_batches_in_mem = 4
 | Argument             | Type                         | Default | Description                                                                                                               |
 | -------------------- | ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
 | `path`               | `Optional[Union[str, Path]]` | `None`  | Cache directory. If `None`, no caching is performed, and this component will act as a NoOp.                               |
-| `batch_size`         | `int`                        | 64      | Number of docs in one batch (file). Once a batch is full, it will be peristed to disk.                                    |
+| `batch_size`         | `int`                        | 64      | Number of docs in one batch (file). Once a batch is full, it will be persisted to disk.                                   |
 | `max_batches_in_mem` | `int`                        | 4       | Max. number of batches to hold in memory. Allows you to limit the effect on your memory if you're handling a lot of docs. |
 
 When retrieving a document, the `BatchCache` will first figure out what batch the document belongs to. If the batch
-isn't in memory it will try to load the batch from disk and then move it into memory.
+isn't in memory it will try to load the batch from disk and then move it into memory. 
 
 Note that since the cache is generated by a registered function, you can also provide your own registered function
 returning your own cache implementation. If you wish to do so, ensure that your cache object adheres to the
@@ -1135,3 +1625,7 @@ PRs are always welcome!
 If you have questions regarding the usage of `spacy-llm`, or want to give us feedback after giving it a spin, please use the
 [discussion board](https://github.com/explosion/spaCy/discussions).
 Bug reports can be filed on the [spaCy issue tracker](https://github.com/explosion/spaCy/issues). Thank you!
+
+## Migration guides
+
+Please refer to our [migration guide](migration_guide.md).
diff --git a/migration_guide.md b/migration_guide.md
new file mode 100644
index 00000000..b1f173f4
--- /dev/null
+++ b/migration_guide.md
@@ -0,0 +1,85 @@
+# Migration guides
+
+<details open>
+  <summary>0.3.x to 0.4.x</summary>
+
+## `0.3.x` to `0.4.x`
+
+`0.4.x` significantly refactors the code to make it more robust and the config more intuitive. 0.4.0 changes the config 
+paradigm from `backend`- to `model`-centric. This is reflected in the external API in a different config structure.
+
+Remember that there are three different types of models: the first uses the native REST implementation to communicate
+with hosted LLMs, the second builds on HuggingFace's `transformers` model to run models locally and the third leverages
+`langchain` to operate on hosted or local models. While the config for all three is rather similar (especially in 
+0.4.x), there are differences in how these models have to be configured. We show how to migrate your config from 0.3.x
+to 0.4.x for each of these model types.
+
+### All model types 
+- The registry name has changed - instead of `@llm_backends`, use `@llm_models`.
+- The `api` attribute has been removed.
+
+### Models using REST
+
+This is the default method to communicate with hosted models. Whenever you don't explicitly use LangChain models
+(see section at the bottom) or run models locally, you are using this kind of model.
+
+In `0.3.x`:
+```ini
+[components.llm.backend]
+@llm_backends = "spacy.REST.v1"
+api = "OpenAI"
+config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
+```
+In `0.4.x`:
+```ini
+[components.llm.model]
+@llm_models = "spacy.GPT-3-5.v1"
+name = "gpt-3-5-turbo"
+config = {"temperature": 0.3}
+```
+Note that the factory function (marked with `@`) refers to the name of the model. Variants of the same model can be 
+specified with the `name` attribute - for `gpt-3.5` this could be `"gpt-3-5-turbo"` or `"gpt-3-5-turbo-16k"`.
+
+### Models using HuggingFace
+
+On top of the changes described in the section above, HF models like `spacy.Dolly.v1` now accept `config_init` and 
+`config_run` to reflect that differerent arguments can be passed at init or run time.
+
+In `0.3.x`:
+```ini
+[components.llm.backend]
+@llm_backends = "spacy.Dolly_HF.v1"
+model = "databricks/dolly-v2-3b"
+config = {}
+```
+In `0.4.x`:
+```ini
+[components.llm.model]
+@llm_models = "spacy.Dolly.v1"
+name = "dolly-v2-3b"  # or databricks/dolly-v2-3b - the prefix is optional
+config_init = {}  # Arguments passed to HF model at initialization time
+config_run = {}  # Arguments passed to HF model at inference time 
+```
+
+### Models using LangChain
+
+LangChain models are now accessible via `langchain.[API].[version]`, e. g. `langchain.OpenAI.v1`. Other than that the
+changes from 0.3.x to 0.4.x are identical with REST-based models.
+
+In `0.3.x`:
+```ini
+[components.llm.backend]
+@llm_backends = "spacy.LangChain.v1"
+api = "OpenAI"
+config = {"temperature": 0.3}
+```
+
+In `0.4.x`:
+```ini
+[components.llm.model]
+@llm_models = "langchain.OpenAI.v1"
+name = "gpt-3-5-turbo"
+config = {"temperature": 0.3}
+```
+
+</details>
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 7d3651fd..6be2a20f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,8 @@ filterwarnings = [
     "ignore:^.*pkg_resources.*:DeprecationWarning",
     "ignore:.*function is now available as sqlalchemy.orm.declarative_base().*:",
     "ignore:^.*You are trying to use a chat model. This way of initializing it is no longer supported. Instead, please use.*:UserWarning",
+    "ignore:^.*Xformers is not installed correctly.*:",
+    "ignore:^.*The 'warn' method is deprecated, use 'warning' instead.*:DeprecationWarning"
 ]
 markers = [
     "external: interacts with a (potentially cost-incurring) third-party API"
diff --git a/requirements-dev.txt b/requirements-dev.txt
index a98b37e8..bb69363d 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -7,12 +7,13 @@ mypy>=0.990,<1.1.0; platform_machine != "aarch64" and python_version >= "3.7"
 black==22.3.0
 types-requests==2.28.11.16
 # Prompting libraries needed for testing
-langchain==0.0.191; python_version>="3.9"
-minichain>=0.3,<0.4; python_version>="3.8" and python_version<"3.11"
+langchain==0.0.219; python_version>="3.9"
+openai>=0.27; python_version>="3.9"
 
 # Necessary for running all local models on GPU.
 transformers[sentencepiece]>=4.0.0,<4.30
 torch
+einops>=0.4
 
 # Necessary for pytest checks and ignores.
 sqlalchemy; python_version<"3.9"
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index e732eaf3..99095199 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [metadata]
-version = 0.3.2
+version = 0.4.0
 description = Integrating LLMs into structured NLP pipelines
 author = Explosion
 author_email = contact@explosion.ai
@@ -43,13 +43,13 @@ spacy_misc =
     spacy.FileReader.v1 = spacy_llm.registry:file_reader
 
 [options.extras_require]
-minichain =
-    minichain>=0.3,<0.4
 langchain =
     langchain==0.0.191
 transformers =
     torch>=1.13.1,<2.0
     transformers>=4.28.1,<5.0
+    einops>=0.4
+    xformers
 
 [bdist_wheel]
 universal = true
diff --git a/spacy_llm/__init__.py b/spacy_llm/__init__.py
index d3984b36..9e0bc07b 100644
--- a/spacy_llm/__init__.py
+++ b/spacy_llm/__init__.py
@@ -1,5 +1,5 @@
-from . import backends  # noqa: F401
 from . import cache  # noqa: F401
+from . import models  # noqa: F401
 from . import registry  # noqa: F401
 from . import tasks  # noqa: F401
 from .pipeline import llm
diff --git a/spacy_llm/backends/__init__.py b/spacy_llm/backends/__init__.py
deleted file mode 100644
index d630dc06..00000000
--- a/spacy_llm/backends/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from .integration import backend_dolly_hf
-from .integration import backend_langchain, query_langchain
-from .integration import backend_minichain, query_minichain
-from .rest import backend_rest
-
-__all__ = [
-    "query_minichain",
-    "query_langchain",
-    "backend_minichain",
-    "backend_langchain",
-    "backend_rest",
-    "backend_dolly_hf",
-]
diff --git a/spacy_llm/backends/integration/__init__.py b/spacy_llm/backends/integration/__init__.py
deleted file mode 100644
index 5bac6072..00000000
--- a/spacy_llm/backends/integration/__init__.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from .hf.base import HuggingFaceBackend
-from .hf.dolly import backend_dolly_hf
-from .hf.stablelm import backend_stablelm_hf
-from .remote.base import RemoteBackend
-from .remote.langchain import backend_langchain, query_langchain
-from .remote.minichain import backend_minichain, query_minichain
-
-__all__ = [
-    "RemoteBackend",
-    "HuggingFaceBackend",
-    "query_minichain",
-    "query_langchain",
-    "backend_minichain",
-    "backend_langchain",
-    "backend_dolly_hf",
-    "backend_stablelm_hf",
-]
diff --git a/spacy_llm/backends/integration/hf/__init__.py b/spacy_llm/backends/integration/hf/__init__.py
deleted file mode 100644
index 89ce47c7..00000000
--- a/spacy_llm/backends/integration/hf/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from .base import HuggingFaceBackend
-from .dolly import backend_dolly_hf
-from .openllama import backend_openllama_hf
-from .stablelm import backend_stablelm_hf
-
-__all__ = [
-    "HuggingFaceBackend",
-    "backend_dolly_hf",
-    "backend_openllama_hf",
-    "backend_stablelm_hf",
-]
diff --git a/spacy_llm/backends/integration/remote/__init__.py b/spacy_llm/backends/integration/remote/__init__.py
deleted file mode 100644
index c4ce7f39..00000000
--- a/spacy_llm/backends/integration/remote/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from .base import RemoteBackend
-from .langchain import backend_langchain, query_langchain
-from .minichain import backend_minichain, query_minichain
-
-__all__ = [
-    "RemoteBackend",
-    "query_minichain",
-    "query_langchain",
-    "backend_minichain",
-    "backend_langchain",
-]
diff --git a/spacy_llm/backends/integration/remote/base.py b/spacy_llm/backends/integration/remote/base.py
deleted file mode 100644
index 8e3478b3..00000000
--- a/spacy_llm/backends/integration/remote/base.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from typing import Any, Callable, Iterable, TypeVar
-
-# Type of prompts returned from Task.generate_prompts().
-_PromptType = TypeVar("_PromptType")
-# Type of responses returned from query function.
-_ResponseType = TypeVar("_ResponseType")
-
-
-class RemoteBackend:
-    def __init__(
-        self,
-        integration: Any,
-        query: Callable[[Any, Iterable[_PromptType]], Iterable[_ResponseType]],
-    ):
-        """Initializes Backend instance for remote APIs.
-        integration (Any): Object/Callable enabling LLM calls through third-party libraries. This can be a HuggingFace
-            model, a LangChain API class, or anything else able to execute LLM prompts directly or indirectly.
-        query (Callable[[Any, Iterable[_PromptType]], Iterable[_ResponseType]]): Callable executing LLM prompts when
-            supplied with the `integration` object.
-        """
-        self._integration = integration
-        self.query = query
-
-    def __call__(self, prompts: Iterable[_PromptType]) -> Iterable[_ResponseType]:
-        """Executes prompts on specified API.
-        prompts (Iterable[_PromptType]): Prompts to execute.
-        RETURNS (Iterable[_ResponseType]): API responses.
-        """
-        return self.query(self._integration, prompts)
diff --git a/spacy_llm/backends/integration/remote/langchain.py b/spacy_llm/backends/integration/remote/langchain.py
deleted file mode 100644
index aa6f521e..00000000
--- a/spacy_llm/backends/integration/remote/langchain.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from typing import Any, Callable, Dict, Iterable, Optional, Type
-
-from spacy.util import SimpleFrozenDict
-
-from ....compat import has_langchain, langchain
-from ....registry import registry
-from . import RemoteBackend
-
-
-def _check_installation() -> None:
-    """Checks whether `langchain` is installed. Raises an error otherwise."""
-    if not has_langchain:
-        raise ValueError(
-            "The LangChain backend requires `langchain` to be installed, which it is not. See "
-            "https://github.com/hwchase17/langchain for installation instructions."
-        )
-
-
-@registry.llm_queries("spacy.CallLangChain.v1")
-def query_langchain() -> (
-    Callable[["langchain.llms.base.BaseLLM", Iterable[Any]], Iterable[Any]]
-):
-    """Returns query Callable for LangChain.
-    RETURNS (Callable[["langchain.llms.BaseLLM", Iterable[Any]], Iterable[Any]]:): Callable executing simple prompts on
-        the specified LangChain backend.
-    """
-    return _prompt_langchain
-
-
-def _prompt_langchain(
-    backend: "langchain.llms.base.BaseLLM", prompts: Iterable[Any]
-) -> Iterable[Any]:
-    return [backend(pr) for pr in prompts]
-
-
-@registry.llm_backends("spacy.LangChain.v1")
-def backend_langchain(
-    api: str,
-    query: Optional[
-        Callable[["langchain.llms.base.BaseLLM", Iterable[str]], Iterable[str]]
-    ] = None,
-    config: Dict[Any, Any] = SimpleFrozenDict(),
-) -> Callable[[Iterable[Any]], Iterable[Any]]:
-    """Returns Callable using MiniChain backend to prompt specified API.
-    api (str): Name of any API/class in langchain.llms.type_to_cls_dict, e. g. "openai".
-    query (Callable[["langchain.llms.BaseLLM", Iterable[str]], Iterable[str]]): Callable implementing querying this
-        API.
-    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the langchain.llms.BaseLLM
-        instance.
-    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Callable using the querying the specified API using the
-        specified backend.
-    """
-    _check_installation()
-
-    if "model" not in config:
-        raise ValueError("The LLM model must be specified in the config.")
-
-    # langchain.llms.type_to_cls_dict contains all API names in lowercase, so this allows to be more forgiving and make
-    # "OpenAI" work as well "openai".
-    api = api.lower()
-    type_to_cls_dict: Dict[
-        str, Type[langchain.llms.base.BaseLLM]
-    ] = langchain.llms.type_to_cls_dict
-
-    if api in type_to_cls_dict:
-        model = config.pop("model")
-        return RemoteBackend(
-            integration=type_to_cls_dict[api](model_name=model, **config),
-            query=query_langchain() if query is None else query,
-        )
-    else:
-        raise KeyError(
-            f"The requested API {api} is not available in `langchain.llms.type_to_cls_dict`."
-        )
diff --git a/spacy_llm/backends/integration/remote/minichain.py b/spacy_llm/backends/integration/remote/minichain.py
deleted file mode 100644
index bd325082..00000000
--- a/spacy_llm/backends/integration/remote/minichain.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from typing import Any, Callable, Dict, Iterable, Optional
-
-from spacy.util import SimpleFrozenDict
-
-from ....compat import has_minichain, minichain
-from ....registry import registry
-from .base import RemoteBackend
-
-
-def _check_installation() -> None:
-    """Checks whether `minichain` is installed. Raises an error otherwise."""
-    if not has_minichain:
-        raise ValueError(
-            "The MiniChain backend requires `minichain` to be installed, which it is not. See "
-            "https://github.com/srush/MiniChain for installation instructions."
-        )
-
-
-@registry.llm_queries("spacy.RunMiniChain.v1")
-def query_minichain() -> (
-    Callable[["minichain.backend.Backend", Iterable[str]], Iterable[str]]
-):
-    """Returns query Callable for MiniChain.
-    RETURNS (Callable[["minichain.backend.Backend", Iterable[str]], Iterable[str]]): Callable executing simple prompts
-        on the specified MiniChain backend.
-    """
-
-    def prompt(
-        backend: "minichain.backend.Backend", prompts: Iterable[str]
-    ) -> Iterable[str]:
-        @minichain.prompt(backend)
-        def _prompt(model: "minichain.base.Prompt.Model", prompt_text: str) -> str:
-            return model(prompt_text)
-
-        return [_prompt(pr).run() for pr in prompts]
-
-    return prompt
-
-
-@registry.llm_backends("spacy.MiniChain.v1")
-def backend_minichain(
-    api: str,
-    query: Optional[
-        Callable[["minichain.backend.Backend", Iterable[str]], Iterable[str]]
-    ] = None,
-    config: Dict[Any, Any] = SimpleFrozenDict(),
-) -> Callable[[Iterable[str]], Iterable[str]]:
-    """Returns Callable using MiniChain backend to prompt specified API.
-    api (str): Name of any API/class in minichain.backend, e. g. "OpenAI".
-    query (Optional[Callable[["minichain.backend.Backend", Iterable[str]], Iterable[str]]]): Callable implementing querying this
-        API.
-    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the minichain.Backend
-        instance.
-    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Callable querying the specified API using the
-        specified backend.
-    """
-    _check_installation()
-
-    if "model" not in config:
-        raise ValueError("The LLM model must be specified in the config.")
-
-    if hasattr(minichain.backend, api):
-        return RemoteBackend(
-            integration=getattr(minichain.backend, api)(**config),
-            query=query_minichain() if query is None else query,
-        )
-    else:
-        raise KeyError(
-            f"The requested API {api} is not available in `minichain.backend`."
-        )
diff --git a/spacy_llm/backends/rest/noop.py b/spacy_llm/backends/rest/noop.py
deleted file mode 100644
index cf2bcdee..00000000
--- a/spacy_llm/backends/rest/noop.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import time
-from typing import Dict, Iterable
-
-from .base import Backend
-
-_NOOP_RESPONSE = ""
-
-
-class NoOpBackend(Backend):
-    """NoOp backend. Used for tests."""
-
-    _CALL_TIMEOUT = 0.01
-
-    @property
-    def supported_models(self) -> Dict[str, str]:
-        return {"NoOp": ""}
-
-    @property
-    def credentials(self) -> Dict[str, str]:
-        return {}
-
-    def __call__(self, prompts: Iterable[str]) -> Iterable[str]:
-        # Assume time penalty for API calls.
-        time.sleep(NoOpBackend._CALL_TIMEOUT)
-        return [_NOOP_RESPONSE] * len(list(prompts))
diff --git a/spacy_llm/cache.py b/spacy_llm/cache.py
index 94d60015..3e1559e1 100644
--- a/spacy_llm/cache.py
+++ b/spacy_llm/cache.py
@@ -1,3 +1,4 @@
+import warnings
 from pathlib import Path
 from typing import Dict, Iterable, List, Optional, Union
 
@@ -7,6 +8,7 @@
 from spacy.vocab import Vocab
 
 from .registry import registry
+from .ty import LLMTask, PromptTemplateProvider
 
 
 @registry.llm_misc("spacy.BatchCache.v1")
@@ -31,7 +33,7 @@ def __init__(
         batch_size: int,
         max_batches_in_mem: int,
     ):
-        """Initialize Cache instance.
+        """Initialize Cache instance. Acts as NoOp if path is None.
         path (Optional[Union[str,Path]]): Cache directory.
         batch_size (int): Number of docs in one batch (file).
         max_batches_in_mem (int): Max. number of batches to hold in memory.
@@ -43,6 +45,8 @@ def __init__(
         # Max. number of batches to keep in memory.
         self.max_batches_in_mem = max_batches_in_mem
         self._vocab: Optional[Vocab] = None
+        self._prompt_template: Optional[str] = None
+        self._prompt_template_checked: bool = False
 
         # Stores doc hash -> batch hash to allow efficient lookup of available Docs.
         self._doc2batch: Dict[int, int] = {}
@@ -62,24 +66,63 @@ def __init__(
             "persisted": 0,
         }
 
-        self._init_cache_index()
+        self._init_cache_dir()
+
+    def initialize(self, vocab: Vocab, task: LLMTask) -> None:
+        """
+        Initialize cache with data not available at construction time.
+        vocab (Vocab): Vocab object.
+        task (LLMTask): Task.
+        """
+        self._vocab = vocab
+        if isinstance(task, PromptTemplateProvider):
+            self.prompt_template = task.prompt_template
+        else:
+            self.prompt_template = ""
+            if self._path:
+                warnings.warn(
+                    "The specified task does not provide its prompt template via `prompt_template()`. This means that "
+                    "the cache cannot verify whether all cached documents were generated using the same prompt "
+                    "template."
+                )
 
     @property
-    def vocab(self) -> Optional[Vocab]:
-        """Vocab used for deserializing docs.
-        RETURNS (Vocab): Vocab used for deserializing docs.
+    def prompt_template(self) -> Optional[str]:
+        """Get prompt template.
+        RETURNS (Optional[str]): Prompt template string used for docs to cache/cached docs.
         """
-        return self._vocab
+        return self._prompt_template
 
-    @vocab.setter
-    def vocab(self, vocab: Vocab) -> None:
-        """Set vocab to use for deserializing docs.
-        vocab (Vocab): Vocab to use for deserializing docs.
+    @prompt_template.setter
+    def prompt_template(self, prompt_template: str) -> None:
+        """Set prompt template.
+        prompt_template (str): Prompt template string used for docs to cache/cached docs.
         """
-        self._vocab = vocab
+        self._prompt_template = prompt_template
+        if not self._path:
+            return
+
+        # Store prompt template as file.
+        prompt_template_path = self._path / "prompt_template.txt"
+        # If no file exists: store in plain text for easier debugging.
+        if not prompt_template_path.exists():
+            with open(prompt_template_path, "w") as file:
+                file.write(self._prompt_template)
+
+        else:
+            # If file exists and prompt template is not equal to new prompt template: raise, as this indicates we are
+            # trying to reuse a cache directory with a changed prompt.
+            with open(prompt_template_path, "r") as file:
+                existing_prompt_template = "".join(file.readlines())
+            if hash(existing_prompt_template) != hash(self._prompt_template):
+                raise ValueError(
+                    f"Prompt template in cache directory ({prompt_template_path}) is not equal with "
+                    f"current prompt template. Reset your cache if you are using a new prompt "
+                    f"template."
+                )
 
-    def _init_cache_index(self) -> None:
-        """Init cache index and directory."""
+    def _init_cache_dir(self) -> None:
+        """Init cache directory with index and prompt template file."""
         if self._path is None:
             return
 
@@ -87,6 +130,7 @@ def _init_cache_index(self) -> None:
             raise ValueError("Cache directory exists and is not a directory.")
         self._path.mkdir(parents=True, exist_ok=True)
 
+        # Read from index file, if it exists.
         index_path = self._index_path
         if index_path.exists():
             for rec in srsly.read_jsonl(index_path):
@@ -136,6 +180,13 @@ def add(self, doc: Doc) -> None:
         """
         if self._path is None:
             return
+        if not self._prompt_template_checked and self._prompt_template is None:
+            warnings.warn(
+                "No prompt template set for Cache object, entailing that consistency of prompt template used "
+                "to generate docs cannot be checked. Be mindful to reset your cache whenever you change your "
+                "prompt template."
+            )
+            self._prompt_template_checked = True
 
         self._cache_queue.append(doc)
         self._stats["added"] += 1
diff --git a/spacy_llm/compat.py b/spacy_llm/compat.py
index dcb7f798..e63e33b5 100644
--- a/spacy_llm/compat.py
+++ b/spacy_llm/compat.py
@@ -2,9 +2,9 @@
 import sys
 
 if sys.version_info[:2] >= (3, 8):  # Python 3.8+
-    from typing import Protocol, runtime_checkable, Literal
+    from typing import Literal, Protocol, runtime_checkable
 else:
-    from typing_extensions import Protocol, runtime_checkable, Literal  # noqa: F401
+    from typing_extensions import Literal, Protocol, runtime_checkable  # noqa: F401
 
 if sys.version_info[:2] >= (3, 9):  # Python 3.8+
     from typing import TypedDict  # noqa: F401
@@ -19,14 +19,6 @@
     langchain = None
     has_langchain = False
 
-try:
-    import minichain
-
-    has_minichain = True
-except (ImportError, AttributeError):
-    minichain = None
-    has_minichain = False
-
 try:
     import torch
 
diff --git a/spacy_llm/models/__init__.py b/spacy_llm/models/__init__.py
new file mode 100644
index 00000000..7e252d49
--- /dev/null
+++ b/spacy_llm/models/__init__.py
@@ -0,0 +1,14 @@
+from .hf import dolly_hf, openllama_hf, stablelm_hf
+from .langchain import query_langchain
+from .rest import anthropic, cohere, noop, openai
+
+__all__ = [
+    "anthropic",
+    "cohere",
+    "openai",
+    "dolly_hf",
+    "noop",
+    "stablelm_hf",
+    "openllama_hf",
+    "query_langchain",
+]
diff --git a/spacy_llm/models/hf/__init__.py b/spacy_llm/models/hf/__init__.py
new file mode 100644
index 00000000..5f40c71d
--- /dev/null
+++ b/spacy_llm/models/hf/__init__.py
@@ -0,0 +1,13 @@
+from .base import HuggingFace
+from .dolly import dolly_hf
+from .falcon import falcon_hf
+from .openllama import openllama_hf
+from .stablelm import stablelm_hf
+
+__all__ = [
+    "HuggingFace",
+    "dolly_hf",
+    "falcon_hf",
+    "openllama_hf",
+    "stablelm_hf",
+]
diff --git a/spacy_llm/backends/integration/hf/base.py b/spacy_llm/models/hf/base.py
similarity index 66%
rename from spacy_llm/backends/integration/hf/base.py
rename to spacy_llm/models/hf/base.py
index 492822bf..71fdc074 100644
--- a/spacy_llm/backends/integration/hf/base.py
+++ b/spacy_llm/models/hf/base.py
@@ -1,35 +1,32 @@
 import abc
 import warnings
-from typing import Any, Dict, Iterable, Optional, Tuple, TypeVar
+from typing import Any, Dict, Iterable, Optional, Tuple
 
 from thinc.compat import has_torch_cuda_gpu
 
-from ....compat import has_accelerate, has_torch, has_transformers, torch
+from ...compat import Literal, has_accelerate, has_torch, has_transformers, torch
 
-# Type of prompts returned from Task.generate_prompts().
-_PromptType = TypeVar("_PromptType")
-# Type of responses returned from query function.
-_ResponseType = TypeVar("_ResponseType")
 
+class HuggingFace(abc.ABC):
+    """Base class for HuggingFace model classes."""
 
-class HuggingFaceBackend(abc.ABC):
-    """Backend for HuggingFace models."""
+    MODEL_NAMES = Literal[None]  # noqa: F722
 
     def __init__(
         self,
-        model: str,
+        name: str,
         config_init: Optional[Dict[str, Any]],
         config_run: Optional[Dict[str, Any]],
     ):
-        """Initializes Backend instance.
-        query (Callable[[Any, Iterable[_PromptType]], Iterable[_ResponseType]]): Callable executing LLM prompts when
+        """Initializes HF model instance.
+        query (Callable[[Any, Iterable[Any]], Iterable[Any]): Callable executing LLM prompts when
             supplied with the `integration` object.
-        model (str): Name of HF model to load.
+        name (str): Name of HF model to load (without account name).
         config_init (Optional[Dict[str, Any]]): HF config for initializing the model.
         config_run (Optional[Dict[str, Any]]): HF config for running the model.
         inference_config (Dict[Any, Any]): HF config for model run.
         """
-        self._model_name = model
+        self._name = name if self.hf_account in name else f"{self.hf_account}/{name}"
         self._config_init, self._config_run = self.compile_default_configs()
         if config_init:
             self._config_init = {**self._config_init, **config_init}
@@ -37,22 +34,36 @@ def __init__(
             self._config_run = {**self._config_run, **config_run}
 
         # Init HF model.
-        HuggingFaceBackend.check_installation()
-        self.check_model()
+        HuggingFace.check_installation()
+        self._check_model()
         self._model = self.init_model()
 
     @abc.abstractmethod
-    def __call__(self, prompts: Iterable[_PromptType]) -> Iterable[_ResponseType]:
+    def __call__(self, prompts: Iterable[Any]) -> Iterable[Any]:
         """Executes prompts on specified API.
-        prompts (Iterable[_PromptType]): Prompts to execute.
-        RETURNS (Iterable[_ResponseType]): API responses.
+        prompts (Iterable[Any]): Prompts to execute.
+        RETURNS (Iterable[Any]): API responses.
         """
 
+    def _check_model(self) -> None:
+        """Checks whether model is supported. Raises if it isn't."""
+        if self._name.replace(f"{self.hf_account}/", "") not in self.get_model_names():
+            raise ValueError(
+                f"Model '{self._name}' is not supported - select one of {self.get_model_names()} instead"
+            )
+
+    @classmethod
+    def get_model_names(cls) -> Tuple[str, ...]:
+        """Names of supported models for this HF model implementation.
+        RETURNS (Tuple[str]): Names of supported models.
+        """
+        return tuple(str(arg) for arg in cls.MODEL_NAMES.__args__)  # type: ignore[attr-defined]
+
     @property
     @abc.abstractmethod
-    def supported_models(self) -> Iterable[str]:
-        """Get names of supported models.
-        RETURNS (Iterable[str]): Names of supported models.
+    def hf_account(self) -> str:
+        """Name of HF account for this model.
+        RETURNS (str): Name of HF account.
         """
 
     @staticmethod
@@ -60,22 +71,15 @@ def check_installation() -> None:
         """Checks whether the required external libraries are installed. Raises an error otherwise."""
         if not has_torch:
             raise ValueError(
-                "The HF backend requires `torch` to be installed, which it is not. See "
+                "The HF model requires `torch` to be installed, which it is not. See "
                 "https://pytorch.org/ for installation instructions."
             )
         if not has_transformers:
             raise ValueError(
-                "The HF backend requires `transformers` to be installed, which it is not. See "
+                "The HF model requires `transformers` to be installed, which it is not. See "
                 "https://huggingface.co/docs/transformers/installation for installation instructions."
             )
 
-    def check_model(self) -> None:
-        """Checks whether model is supported. Raises if it isn't."""
-        if self._model_name not in self.supported_models:
-            raise ValueError(
-                f"Model '{self._model_name}' is not supported - select one of {self.supported_models} instead"
-            )
-
     @staticmethod
     def compile_default_configs() -> Tuple[Dict[str, Any], Dict[str, Any]]:
         """Compiles default init and run configs for HF model.
diff --git a/spacy_llm/backends/integration/hf/dolly.py b/spacy_llm/models/hf/dolly.py
similarity index 57%
rename from spacy_llm/backends/integration/hf/dolly.py
rename to spacy_llm/models/hf/dolly.py
index 9a19db3e..f622e3c7 100644
--- a/spacy_llm/backends/integration/hf/dolly.py
+++ b/spacy_llm/models/hf/dolly.py
@@ -1,28 +1,20 @@
 from typing import Any, Callable, Dict, Iterable, Optional, Tuple
 
-from spacy.util import SimpleFrozenDict, SimpleFrozenList
+from spacy.util import SimpleFrozenDict
 
-from ....compat import transformers
-from ....registry.util import registry
-from . import HuggingFaceBackend
+from ...compat import Literal, transformers
+from ...registry.util import registry
+from . import HuggingFace
 
 
-class DollyBackend(HuggingFaceBackend):
+class Dolly(HuggingFace):
+    MODEL_NAMES = Literal["dolly-v2-3b", "dolly-v2-7b", "dolly-v2-12b"]  # noqa: F722
+
     def init_model(self) -> Any:
         """Sets up HF model and needed utilities.
         RETURNS (Any): HF model.
         """
-        return transformers.pipeline(model=self._model_name, **self._config_init)
-
-    @property
-    def supported_models(self) -> Iterable[str]:
-        return SimpleFrozenList(
-            [
-                "databricks/dolly-v2-3b",
-                "databricks/dolly-v2-7b",
-                "databricks/dolly-v2-12b",
-            ]
-        )
+        return transformers.pipeline(model=self._name, **self._config_init)
 
     def __call__(self, prompts: Iterable[str]) -> Iterable[str]:  # type: ignore[override]
         """Queries Dolly HF model.
@@ -34,9 +26,13 @@ def __call__(self, prompts: Iterable[str]) -> Iterable[str]:  # type: ignore[ove
             self._model(pr, **self._config_run)[0]["generated_text"] for pr in prompts
         ]
 
+    @property
+    def hf_account(self) -> str:
+        return "databricks"
+
     @staticmethod
     def compile_default_configs() -> Tuple[Dict[str, Any], Dict[str, Any]]:
-        default_cfg_init, default_cfg_run = HuggingFaceBackend.compile_default_configs()
+        default_cfg_init, default_cfg_run = HuggingFace.compile_default_configs()
         return (
             {
                 **default_cfg_init,
@@ -49,21 +45,17 @@ def compile_default_configs() -> Tuple[Dict[str, Any], Dict[str, Any]]:
         )
 
 
-@registry.llm_backends("spacy.DollyHF.v1")
-@registry.llm_backends("spacy.Dolly_HF.v1")
-def backend_dolly_hf(
-    model: str,
+@registry.llm_models("spacy.Dolly.v1")
+def dolly_hf(
+    name: Dolly.MODEL_NAMES,
     config_init: Optional[Dict[str, Any]] = SimpleFrozenDict(),
     config_run: Optional[Dict[str, Any]] = SimpleFrozenDict(),
 ) -> Callable[[Iterable[str]], Iterable[str]]:
-    """Returns Callable that can execute a set of prompts and return the raw responses.
-    model (str): Name of the HF model.
+    """Generates Dolly instance that can execute a set of prompts and return the raw responses.
+    name (Literal): Name of the Dolly model. Has to be one of Dolly.get_model_names().
     config_init (Optional[Dict[str, Any]]): HF config for initializing the model.
     config_run (Optional[Dict[str, Any]]): HF config for running the model.
-    RETURNS (Callable[[Iterable[str]], Iterable[str]]): Callable executing the prompts and returning raw responses.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]): Dolly instance that can execute a set of prompts and return
+        the raw responses.
     """
-    return DollyBackend(
-        model=model,
-        config_init=config_init,
-        config_run=config_run,
-    )
+    return Dolly(name=name, config_init=config_init, config_run=config_run)
diff --git a/spacy_llm/models/hf/falcon.py b/spacy_llm/models/hf/falcon.py
new file mode 100644
index 00000000..3cffcb4d
--- /dev/null
+++ b/spacy_llm/models/hf/falcon.py
@@ -0,0 +1,76 @@
+from typing import Any, Callable, Dict, Iterable, Optional, Tuple
+
+from spacy.util import SimpleFrozenDict
+
+from ...compat import Literal, transformers
+from ...registry.util import registry
+from .base import HuggingFace
+
+
+class Falcon(HuggingFace):
+    MODEL_NAMES = Literal[
+        "falcon-rw-1b", "falcon-7b", "falcon-7b-instruct", "falcon-40b-instruct"
+    ]  # noqa: F722
+
+    def __init__(
+        self,
+        name: MODEL_NAMES,
+        config_init: Optional[Dict[str, Any]],
+        config_run: Optional[Dict[str, Any]],
+    ):
+        self._tokenizer: Optional["transformers.AutoTokenizer"] = None
+        self._device: Optional[str] = None
+        super().__init__(name=name, config_init=config_init, config_run=config_run)
+        assert isinstance(self._tokenizer, transformers.PreTrainedTokenizerBase)
+        self._config_run["eos_token_id"] = self._tokenizer.eos_token_id
+
+    def init_model(self) -> Any:
+        self._tokenizer = transformers.AutoTokenizer.from_pretrained(self._name)
+        return transformers.pipeline(
+            "text-generation",
+            model=self._name,
+            tokenizer=self._tokenizer,
+            **self._config_init,
+        )
+
+    @property
+    def hf_account(self) -> str:
+        return "tiiuae"
+
+    def __call__(self, prompts: Iterable[str]) -> Iterable[str]:  # type: ignore[override]
+        return [
+            self._model(pr, **self._config_run)[0]["generated_text"] for pr in prompts
+        ]
+
+    @staticmethod
+    def compile_default_configs() -> Tuple[Dict[str, Any], Dict[str, Any]]:
+        default_cfg_init, default_cfg_run = HuggingFace.compile_default_configs()
+        return (
+            {
+                **default_cfg_init,
+                "trust_remote_code": True,
+            },
+            {
+                **default_cfg_run,
+                "max_length": 200,
+                "do_sample": True,
+                "top_k": 10,
+                "num_return_sequences": 1,
+            },
+        )
+
+
+@registry.llm_models("spacy.Falcon.v1")
+def falcon_hf(
+    name: Falcon.MODEL_NAMES,
+    config_init: Optional[Dict[str, Any]] = SimpleFrozenDict(),
+    config_run: Optional[Dict[str, Any]] = SimpleFrozenDict(),
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Generates Falcon instance that can execute a set of prompts and return the raw responses.
+    name (Literal): Name of the Falcon model. Has to be one of Falcon.get_model_names().
+    config_init (Optional[Dict[str, Any]]): HF config for initializing the model.
+    config_run (Optional[Dict[str, Any]]): HF config for running the model.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]): Falcon instance that can execute a set of prompts and return
+        the raw responses.
+    """
+    return Falcon(name=name, config_init=config_init, config_run=config_run)
diff --git a/spacy_llm/backends/integration/hf/openllama.py b/spacy_llm/models/hf/openllama.py
similarity index 62%
rename from spacy_llm/backends/integration/hf/openllama.py
rename to spacy_llm/models/hf/openllama.py
index 9c112a06..a077f60f 100644
--- a/spacy_llm/backends/integration/hf/openllama.py
+++ b/spacy_llm/models/hf/openllama.py
@@ -1,34 +1,41 @@
 from typing import Any, Callable, Dict, Iterable, Optional, Tuple
 
-from spacy.util import SimpleFrozenDict, SimpleFrozenList
+from spacy.util import SimpleFrozenDict
 
-from ....compat import torch, transformers
-from ....registry.util import registry
-from .base import HuggingFaceBackend
+from ...compat import Literal, torch, transformers
+from ...registry.util import registry
+from .base import HuggingFace
 
 
-class OpenLLaMaBackend(HuggingFaceBackend):
+class OpenLLaMA(HuggingFace):
+    MODEL_NAMES = Literal[
+        "open_llama_3b_350bt_preview",  # noqa: F722
+        "open_llama_3b_600bt_preview",  # noqa: F722
+        "open_llama_7b_400bt_preview",  # noqa: F722
+        "open_llama_7b_600bt_preview",  # noqa: F722
+    ]
+
     def __init__(
         self,
-        model: str,
+        name: str,
         config_init: Optional[Dict[str, Any]],
         config_run: Optional[Dict[str, Any]],
     ):
         self._tokenizer: Optional["transformers.AutoTokenizer"] = None
         self._device: Optional[str] = None
-        super().__init__(model=model, config_init=config_init, config_run=config_run)
+        super().__init__(name=name, config_init=config_init, config_run=config_run)
 
     def init_model(self) -> "transformers.AutoModelForCausalLM":
         """Sets up HF model and needed utilities.
         RETURNS (Any): HF model.
         """
         # Initialize tokenizer and model.
-        self._tokenizer = transformers.AutoTokenizer.from_pretrained(self._model_name)
+        self._tokenizer = transformers.AutoTokenizer.from_pretrained(self._name)
         init_cfg = self._config_init
         if "device" in init_cfg:
             self._device = init_cfg.pop("device")
         model = transformers.AutoModelForCausalLM.from_pretrained(
-            self._model_name, **init_cfg
+            self._name, **init_cfg
         )
 
         if self._device:
@@ -53,19 +60,12 @@ def __call__(self, prompts: Iterable[str]) -> Iterable[str]:  # type: ignore[ove
         ]
 
     @property
-    def supported_models(self) -> Iterable[str]:
-        return SimpleFrozenList(
-            [
-                "openlm-research/open_llama_3b_350bt_preview",
-                "openlm-research/open_llama_3b_600bt_preview",
-                "openlm-research/open_llama_7b_400bt_preview",
-                "openlm-research/open_llama_7b_700bt_preview",
-            ]
-        )
+    def hf_account(self) -> str:
+        return "openlm-research"
 
     @staticmethod
     def compile_default_configs() -> Tuple[Dict[str, Any], Dict[str, Any]]:
-        default_cfg_init, default_cfg_run = HuggingFaceBackend.compile_default_configs()
+        default_cfg_init, default_cfg_run = HuggingFace.compile_default_configs()
         return (
             {
                 **default_cfg_init,
@@ -75,20 +75,17 @@ def compile_default_configs() -> Tuple[Dict[str, Any], Dict[str, Any]]:
         )
 
 
-@registry.llm_backends("spacy.OpenLLaMa_HF.v1")
-def backend_openllama_hf(
-    model: str,
+@registry.llm_models("spacy.OpenLLaMA.v1")
+def openllama_hf(
+    name: OpenLLaMA.MODEL_NAMES,
     config_init: Optional[Dict[str, Any]] = SimpleFrozenDict(),
     config_run: Optional[Dict[str, Any]] = SimpleFrozenDict(),
 ) -> Callable[[Iterable[str]], Iterable[str]]:
-    """Returns Callable that can execute a set of prompts and return the raw responses.
-    model (str): Name of the HF model.
+    """Generates OpenLLaMA instance that can execute a set of prompts and return the raw responses.
+    name (Literal): Name of the OpenLLaMA model. Has to be one of OpenLLaMA.get_model_names().
     config_init (Optional[Dict[str, Any]]): HF config for initializing the model.
     config_run (Optional[Dict[str, Any]]): HF config for running the model.
-    RETURNS (Callable[[Iterable[str]], Iterable[str]]): Callable executing the prompts and returning raw responses.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]): OpenLLaMA instance that can execute a set of prompts and return
+        the raw responses.
     """
-    return OpenLLaMaBackend(
-        model=model,
-        config_init=config_init,
-        config_run=config_run,
-    )
+    return OpenLLaMA(name=name, config_init=config_init, config_run=config_run)
diff --git a/spacy_llm/backends/integration/hf/stablelm.py b/spacy_llm/models/hf/stablelm.py
similarity index 69%
rename from spacy_llm/backends/integration/hf/stablelm.py
rename to spacy_llm/models/hf/stablelm.py
index e97b2c14..3eb7cf7f 100644
--- a/spacy_llm/backends/integration/hf/stablelm.py
+++ b/spacy_llm/models/hf/stablelm.py
@@ -1,10 +1,10 @@
 from typing import Any, Callable, Dict, Iterable, Optional, Tuple
 
-from spacy.util import SimpleFrozenDict, SimpleFrozenList
+from spacy.util import SimpleFrozenDict
 
-from ....compat import has_transformers, torch, transformers
-from ....registry.util import registry
-from .base import HuggingFaceBackend
+from ...compat import Literal, has_transformers, torch, transformers
+from ...registry.util import registry
+from .base import HuggingFace
 
 if has_transformers:
 
@@ -19,7 +19,13 @@ def __call__(
             return False
 
 
-class StableLMBackend(HuggingFaceBackend):
+class StableLM(HuggingFace):
+    MODEL_NAMES = Literal[
+        "stablelm-base-alpha-3b",  # noqa: F722
+        "stablelm-base-alpha-7b",  # noqa: F722
+        "stablelm-tuned-alpha-3b",  # noqa: F722
+        "stablelm-tuned-alpha-7b",  # noqa: F722
+    ]
     _SYSTEM_PROMPT = """
 
# StableLM Tuned (Alpha version)
 - StableLM is a helpful and harmless open-source AI language model developed by StabilityAI.
@@ -30,26 +36,25 @@ class StableLMBackend(HuggingFaceBackend):
 
     def __init__(
         self,
-        model: str,
+        name: str,
         config_init: Optional[Dict[str, Any]],
         config_run: Optional[Dict[str, Any]],
     ):
         self._tokenizer: Optional["transformers.AutoTokenizer"] = None
-        self._is_tuned = "tuned" in model
+        self._is_tuned = "tuned" in name
         self._device: Optional[str] = None
-        super().__init__(model=model, config_init=config_init, config_run=config_run)
+        super().__init__(name=name, config_init=config_init, config_run=config_run)
 
     def init_model(self) -> "transformers.AutoModelForCausalLM":
         """Sets up HF model and needed utilities.
         RETURNS (Any): HF model.
         """
-        # Initialize tokenizer and model.
-        self._tokenizer = transformers.AutoTokenizer.from_pretrained(self._model_name)
+        self._tokenizer = transformers.AutoTokenizer.from_pretrained(self._name)
         init_cfg = self._config_init
         if "device" in init_cfg:
             self._device = init_cfg.pop("device")
         model = transformers.AutoModelForCausalLM.from_pretrained(
-            self._model_name, **init_cfg
+            self._name, **init_cfg
         )
 
         if self._device:
@@ -57,6 +62,10 @@ def init_model(self) -> "transformers.AutoModelForCausalLM":
 
         return model
 
+    @property
+    def hf_account(self) -> str:
+        return "stabilityai"
+
     def __call__(self, prompts: Iterable[str]) -> Iterable[str]:  # type: ignore[override]
         assert callable(self._tokenizer)
         tokenized_prompts = [
@@ -66,7 +75,7 @@ def __call__(self, prompts: Iterable[str]) -> Iterable[str]:  # type: ignore[ove
                 prompts
                 if not self._is_tuned
                 else [
-                    f"{StableLMBackend._SYSTEM_PROMPT}
{prompt}
"
+                    f"{StableLM._SYSTEM_PROMPT}
{prompt}
"
                     for prompt in prompts
                 ]
             )
@@ -83,20 +92,9 @@ def __call__(self, prompts: Iterable[str]) -> Iterable[str]:  # type: ignore[ove
             for prompt in tokenized_prompts
         ]
 
-    @property
-    def supported_models(self) -> Iterable[str]:
-        return SimpleFrozenList(
-            [
-                "stabilityai/stablelm-base-alpha-3b",
-                "stabilityai/stablelm-base-alpha-7b",
-                "stabilityai/stablelm-tuned-alpha-3b",
-                "stabilityai/stablelm-tuned-alpha-7b",
-            ]
-        )
-
     @staticmethod
     def compile_default_configs() -> Tuple[Dict[str, Any], Dict[str, Any]]:
-        default_cfg_init, default_cfg_run = HuggingFaceBackend.compile_default_configs()
+        default_cfg_init, default_cfg_run = HuggingFace.compile_default_configs()
         return (
             default_cfg_init,
             {
@@ -108,20 +106,25 @@ def compile_default_configs() -> Tuple[Dict[str, Any], Dict[str, Any]]:
         )
 
 
-@registry.llm_backends("spacy.StableLM_HF.v1")
-def backend_stablelm_hf(
-    model: str,
+@registry.llm_models("spacy.StableLM.v1")
+def stablelm_hf(
+    name: StableLM.MODEL_NAMES,
     config_init: Optional[Dict[str, Any]] = SimpleFrozenDict(),
     config_run: Optional[Dict[str, Any]] = SimpleFrozenDict(),
 ) -> Callable[[Iterable[str]], Iterable[str]]:
-    """Returns Callable that can execute a set of prompts and return the raw responses.
-    model (str): Name of the HF model.
+    """Generates StableLM instance that can execute a set of prompts and return the raw responses.
+    name (Literal): Name of the StableLM model. Has to be one of StableLM.get_model_names().
     config_init (Optional[Dict[str, Any]]): HF config for initializing the model.
     config_run (Optional[Dict[str, Any]]): HF config for running the model.
-    RETURNS (Callable[[Iterable[str]], Iterable[str]]): Callable executing the prompts and returning raw responses.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]): StableLM instance that can execute a set of prompts and return
+        the raw responses.
     """
-    return StableLMBackend(
-        model=model,
+    if name not in StableLM.get_model_names():
+        raise ValueError(
+            f"Expected one of {StableLM.get_model_names()}, but received {name}."
+        )
+    return StableLM(
+        name=name,
         config_init=config_init,
         config_run=config_run,
     )
diff --git a/spacy_llm/models/langchain/__init__.py b/spacy_llm/models/langchain/__init__.py
new file mode 100644
index 00000000..2a348b4c
--- /dev/null
+++ b/spacy_llm/models/langchain/__init__.py
@@ -0,0 +1,9 @@
+from .model import LangChain, query_langchain
+
+# Dynamically register LangChain API classes as individual models, if this hasn't been done yet.
+LangChain.register_models()
+
+__all__ = [
+    "LangChain",
+    "query_langchain",
+]
diff --git a/spacy_llm/models/langchain/model.py b/spacy_llm/models/langchain/model.py
new file mode 100644
index 00000000..57e80a44
--- /dev/null
+++ b/spacy_llm/models/langchain/model.py
@@ -0,0 +1,121 @@
+from typing import Any, Callable, Dict, Iterable, Optional, Type
+
+from spacy.util import SimpleFrozenDict
+
+from ...compat import has_langchain, langchain
+from ...registry import registry
+
+
+class LangChain:
+    def __init__(
+        self,
+        name: str,
+        api: str,
+        config: Dict[Any, Any],
+        query: Callable[
+            ["langchain.llms.base.BaseLLM", Iterable[Any]],
+            Iterable[Any],
+        ],
+    ):
+        """Initializes model instance for integration APIs.
+        name (str): Name of LangChain model to instantiate.
+        api (str): Name of class/API.
+        config (Dict[Any, Any]): Config passed on to LangChain model.
+        query (Callable[[Any, Iterable[_PromptType]], Iterable[_ResponseType]]): Callable executing LLM prompts when
+            supplied with the `integration` object.
+        """
+        self._langchain_model = LangChain.get_type_to_cls_dict()[api](
+            model_name=name, **config
+        )
+        self.query = query
+        self._check_installation()
+
+    @staticmethod
+    def get_type_to_cls_dict() -> Dict[str, Type["langchain.llms.base.BaseLLM"]]:
+        """Returns langchain.llms.type_to_cls_dict.
+        RETURNS (Dict[str, Type[langchain.llms.base.BaseLLM]]): langchain.llms.type_to_cls_dict.
+        """
+        return langchain.llms.type_to_cls_dict
+
+    def __call__(self, prompts: Iterable[Any]) -> Iterable[Any]:
+        """Executes prompts on specified API.
+        prompts (Iterable[Any]): Prompts to execute.
+        RETURNS (Iterable[Any]): API responses.
+        """
+        return self.query(self._langchain_model, prompts)
+
+    @staticmethod
+    def query_langchain(
+        model: "langchain.llms.base.BaseLLM", prompts: Iterable[Any]
+    ) -> Iterable[Any]:
+        """Query LangChain model naively.
+        model (langchain.llms.base.BaseLLM): LangChain model.
+        prompts (Iterable[Any]): Prompts to execute.
+        RETURNS (Iterable[Any]): LLM responses.
+        """
+        return [model(pr) for pr in prompts]
+
+    @staticmethod
+    def _check_installation() -> None:
+        """Checks whether `langchain` is installed. Raises an error otherwise."""
+        if not has_langchain:
+            raise ValueError(
+                "The LangChain model requires `langchain` to be installed, which it is not. See "
+                "https://github.com/hwchase17/langchain for installation instructions."
+            )
+
+    @staticmethod
+    def _langchain_model_maker(class_id: str):
+        def langchain_model(
+            name: str,
+            query: Optional[
+                Callable[["langchain.llms.base.BaseLLM", Iterable[str]], Iterable[str]]
+            ] = None,
+            config: Dict[Any, Any] = SimpleFrozenDict(),
+            langchain_class_id: str = class_id,
+        ) -> Optional[Callable[[Iterable[Any]], Iterable[Any]]]:
+            try:
+                return LangChain(
+                    name=name,
+                    api=langchain_class_id,
+                    config=config,
+                    query=query_langchain() if query is None else query,
+                )
+            except ImportError as err:
+                raise ValueError(
+                    f"Failed to instantiate LangChain model {langchain_class_id}. Ensure all necessary dependencies "
+                    f"are installed."
+                ) from err
+
+        return langchain_model
+
+    @staticmethod
+    def register_models() -> None:
+        """Registers APIs supported by langchain (one API is registered as one model).
+        Doesn't attempt to register anything if LangChain isn't installed or at least one LangChain model has been
+        registered already.
+        """
+        if not has_langchain or any(
+            [
+                (handle.startswith("langchain"))
+                for handle in registry.llm_models.get_all()
+            ]
+        ):
+            return
+
+        for class_id, cls in LangChain.get_type_to_cls_dict().items():
+            registry.llm_models.register(
+                f"langchain.{cls.__name__}.v1",
+                func=LangChain._langchain_model_maker(class_id=class_id),
+            )
+
+
+@registry.llm_queries("spacy.CallLangChain.v1")
+def query_langchain() -> (
+    Callable[["langchain.llms.base.BaseLLM", Iterable[Any]], Iterable[Any]]
+):
+    """Returns query Callable for LangChain.
+    RETURNS (Callable[["langchain.llms.BaseLLM", Iterable[Any]], Iterable[Any]]:): Callable executing simple prompts on
+        the specified LangChain model.
+    """
+    return LangChain.query_langchain
diff --git a/spacy_llm/backends/rest/__init__.py b/spacy_llm/models/rest/__init__.py
similarity index 59%
rename from spacy_llm/backends/rest/__init__.py
rename to spacy_llm/models/rest/__init__.py
index 01cb981c..4f72ffa5 100644
--- a/spacy_llm/backends/rest/__init__.py
+++ b/spacy_llm/models/rest/__init__.py
@@ -1,5 +1,4 @@
 from . import anthropic, base, cohere, noop, openai
-from .registry import backend_rest, supported_apis
 
 __all__ = [
     "anthropic",
@@ -7,6 +6,4 @@
     "cohere",
     "openai",
     "noop",
-    "supported_apis",
-    "backend_rest",
 ]
diff --git a/spacy_llm/models/rest/anthropic/__init__.py b/spacy_llm/models/rest/anthropic/__init__.py
new file mode 100644
index 00000000..745a0fbe
--- /dev/null
+++ b/spacy_llm/models/rest/anthropic/__init__.py
@@ -0,0 +1,15 @@
+from .model import Anthropic, Endpoints
+from .registry import anthropic_claude_1, anthropic_claude_1_0, anthropic_claude_1_2
+from .registry import anthropic_claude_1_3, anthropic_claude_instant_1
+from .registry import anthropic_claude_instant_1_1
+
+__all__ = [
+    "Anthropic",
+    "Endpoints",
+    "anthropic_claude_1",
+    "anthropic_claude_1_0",
+    "anthropic_claude_1_2",
+    "anthropic_claude_1_3",
+    "anthropic_claude_instant_1",
+    "anthropic_claude_instant_1_1",
+]
diff --git a/spacy_llm/backends/rest/anthropic.py b/spacy_llm/models/rest/anthropic/model.py
similarity index 64%
rename from spacy_llm/backends/rest/anthropic.py
rename to spacy_llm/models/rest/anthropic/model.py
index c508c6a9..169f1077 100644
--- a/spacy_llm/backends/rest/anthropic.py
+++ b/spacy_llm/models/rest/anthropic/model.py
@@ -1,12 +1,13 @@
 import os
 from enum import Enum
-from typing import Any, Dict, Iterable, List, Sized
+from typing import Any, Dict, Iterable, List, Sized, Tuple
 
 import requests  # type: ignore[import]
 import srsly  # type: ignore[import]
 from requests import HTTPError
 
-from .base import Backend
+from ....compat import Literal
+from ..base import REST
 
 
 class Endpoints(str, Enum):
@@ -22,27 +23,10 @@ class SystemPrompt(str, Enum):
     ASST = "\n\nAssistant:"
 
 
-class AnthropicBackend(Backend):
-    @property
-    def supported_models(self) -> Dict[str, str]:
-        """Returns supported models with their endpoints.
-        Based on https://console.anthropic.com/docs/api/reference
-        RETURNS (Dict[str, str]): Supported models with their endpoints.
-        """
-        return {
-            "claude-1": Endpoints.COMPLETIONS.value,
-            "claude-1-100k": Endpoints.COMPLETIONS.value,
-            "claude-instant-1": Endpoints.COMPLETIONS.value,
-            "claude-instant-1-100k": Endpoints.COMPLETIONS.value,
-            # sub-versions of the models
-            "claude-1.3": Endpoints.COMPLETIONS.value,
-            "claude-1.3-100k": Endpoints.COMPLETIONS.value,
-            "claude-1.2": Endpoints.COMPLETIONS.value,
-            "claude-1.0": Endpoints.COMPLETIONS.value,
-            "claude-instant-1.1": Endpoints.COMPLETIONS.value,
-            "claude-instant-1.1-100k": Endpoints.COMPLETIONS.value,
-            "claude-instant-1.0": Endpoints.COMPLETIONS.value,
-        }
+class Anthropic(REST):
+    MODEL_NAMES = {
+        "claude-1": Literal["claude-1", "claude-1-100k"],
+    }
 
     @property
     def credentials(self) -> Dict[str, str]:
@@ -55,15 +39,6 @@ def credentials(self) -> Dict[str, str]:
                 "an environment variable 'ANTHROPIC_API_KEY."
             )
 
-        # Ensure model is supported
-        model = self._config["model"]
-        if model not in self.supported_models:
-            raise ValueError(
-                f"The specified model '{model}' is not supported by the /v1/complete endpoint. "
-                f"Choices are: {sorted(list(self.supported_models))} ."
-                "(See the Anthropic API documentation: https://console.anthropic.com/docs/api/reference)"
-            )
-
         # Set-up headers
         headers = {"X-API-Key": api_key}
         assert api_key is not None
@@ -72,19 +47,19 @@ def credentials(self) -> Dict[str, str]:
     def __call__(self, prompts: Iterable[str]) -> Iterable[str]:
         headers = {
             **self._credentials,
+            "model": self._name,
             "Content-Type": "application/json",
         }
 
         api_responses: List[str] = []
         prompts = list(prompts)
-        url = self._url if self._url else self.supported_models[self._config["model"]]
 
         def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
             r = self.retry(
                 call_method=requests.post,
-                url=url,
+                url=self._endpoint,
                 headers=headers,
-                json={**json_data, **self._config},
+                json={**json_data, **self._config, "model": self._name},
                 timeout=self._max_request_time,
             )
             try:
@@ -123,3 +98,23 @@ def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
 
         assert len(api_responses) == len(prompts)
         return api_responses
+
+    @classmethod
+    def get_model_names(cls) -> Tuple[str, ...]:
+        return (
+            # claude-1
+            "claude-1",
+            "claude-1-100k",
+            # claude-instant-1
+            "claude-instant-1",
+            "claude-instant-1-100k",
+            # claude-instant-1.1
+            "claude-instant-1.1",
+            "claude-instant-1.1-100k",
+            # claude-1.3
+            "claude-1.3",
+            "claude-1.3-100k",
+            # others
+            "claude-1.0",
+            "claude-1.2",
+        )
diff --git a/spacy_llm/models/rest/anthropic/registry.py b/spacy_llm/models/rest/anthropic/registry.py
new file mode 100644
index 00000000..812f401d
--- /dev/null
+++ b/spacy_llm/models/rest/anthropic/registry.py
@@ -0,0 +1,215 @@
+from typing import Any, Callable, Dict, Iterable
+
+from spacy.util import SimpleFrozenDict
+
+from ....compat import Literal
+from ....registry import registry
+from .model import Anthropic, Endpoints
+
+
+@registry.llm_models("spacy.Claude-1.v1")
+def anthropic_claude_1(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["claude-1", "claude-1-100k"] = "claude-1",  # noqa: F722
+    strict: bool = Anthropic.DEFAULT_STRICT,
+    max_tries: int = Anthropic.DEFAULT_MAX_TRIES,
+    interval: float = Anthropic.DEFAULT_INTERVAL,
+    max_request_time: float = Anthropic.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns Anthropic instance for 'claude-1' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Literal["claude-1", "claude-1-100k"]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1' model using REST to
+        prompt API.
+    """
+    return Anthropic(
+        name=name,
+        endpoint=Endpoints.COMPLETIONS,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Claude-instant-1.v1")
+def anthropic_claude_instant_1(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal[
+        "claude-instant-1", "claude-instant-1-100k"
+    ] = "claude-instant-1",  # noqa: F722
+    strict: bool = Anthropic.DEFAULT_STRICT,
+    max_tries: int = Anthropic.DEFAULT_MAX_TRIES,
+    interval: float = Anthropic.DEFAULT_INTERVAL,
+    max_request_time: float = Anthropic.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns Anthropic instance for 'claude-instant-1' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Literal["claude-instant-1", "claude-instant-1-100k"]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-instant-1' model using REST to
+        prompt API.
+    """
+    return Anthropic(
+        name=name,
+        endpoint=Endpoints.COMPLETIONS.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Claude-instant-1-1.v1")
+def anthropic_claude_instant_1_1(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal[
+        "claude-instant-1.1", "claude-instant-1.1-100k"
+    ] = "claude-instant-1.1",  # noqa: F722
+    strict: bool = Anthropic.DEFAULT_STRICT,
+    max_tries: int = Anthropic.DEFAULT_MAX_TRIES,
+    interval: float = Anthropic.DEFAULT_INTERVAL,
+    max_request_time: float = Anthropic.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns Anthropic instance for 'claude-instant-1.1' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Literal["claude-instant-1.1", "claude-instant-1.1-100k"]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-instant-1.1' model using REST to
+        prompt API.
+    """
+    return Anthropic(
+        name=name,
+        endpoint=Endpoints.COMPLETIONS,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Claude-1-0.v1")
+def anthropic_claude_1_0(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["claude-1.0"] = "claude-1.0",  # noqa: F722
+    strict: bool = Anthropic.DEFAULT_STRICT,
+    max_tries: int = Anthropic.DEFAULT_MAX_TRIES,
+    interval: float = Anthropic.DEFAULT_INTERVAL,
+    max_request_time: float = Anthropic.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns Anthropic instance for 'claude-1.0' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Literal["claude-1.0"]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1.0' model using REST to prompt
+        API.
+    """
+    return Anthropic(
+        name=name,
+        endpoint=Endpoints.COMPLETIONS,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Claude-1-2.v1")
+def anthropic_claude_1_2(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["claude-1.2"] = "claude-1.2",  # noqa: F722
+    strict: bool = Anthropic.DEFAULT_STRICT,
+    max_tries: int = Anthropic.DEFAULT_MAX_TRIES,
+    interval: float = Anthropic.DEFAULT_INTERVAL,
+    max_request_time: float = Anthropic.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns Anthropic instance for 'claude-1.2' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Literal["claude-1.2"]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1.2' model using REST to prompt
+        API.
+    """
+    return Anthropic(
+        name=name,
+        endpoint=Endpoints.COMPLETIONS,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Claude-1-3.v1")
+def anthropic_claude_1_3(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["claude-1.3", "claude-1.3-100k"] = "claude-1.3",  # noqa: F722
+    strict: bool = Anthropic.DEFAULT_STRICT,
+    max_tries: int = Anthropic.DEFAULT_MAX_TRIES,
+    interval: float = Anthropic.DEFAULT_INTERVAL,
+    max_request_time: float = Anthropic.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns Anthropic instance for 'claude-1.3' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Literal["claude-1.3", "claude-1.3-100k"]): Model variant to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1.3' model using REST to prompt
+        API.
+    """
+    return Anthropic(
+        name=name,
+        endpoint=Endpoints.COMPLETIONS,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
diff --git a/spacy_llm/backends/rest/base.py b/spacy_llm/models/rest/base.py
similarity index 78%
rename from spacy_llm/backends/rest/base.py
rename to spacy_llm/models/rest/base.py
index fcdc6f15..0fe4e85e 100644
--- a/spacy_llm/backends/rest/base.py
+++ b/spacy_llm/models/rest/base.py
@@ -1,8 +1,7 @@
 import abc
 import time
-import warnings
 from enum import Enum
-from typing import Any, Dict, Iterable, Callable, Optional
+from typing import Any, Callable, Dict, Iterable, Optional, Tuple
 
 import requests  # type: ignore
 from requests import ConnectTimeout, ReadTimeout
@@ -17,18 +16,27 @@ def has(cls, item: int):
         return item in set(item.value for item in cls)
 
 
-class Backend(abc.ABC):
+class REST(abc.ABC):
     """Queries LLMs via their REST APIs."""
 
+    DEFAULT_STRICT = True
+    DEFAULT_MAX_TRIES = 5
+    DEFAULT_INTERVAL = 1.0
+    DEFAULT_MAX_REQUEST_TIME = 30
+
     def __init__(
         self,
+        name: str,
+        endpoint: str,
         config: Dict[Any, Any],
         strict: bool,
         max_tries: int,
         interval: float,
         max_request_time: float,
     ):
-        """Initializes new Backend instance.
+        """Initializes new instance of REST-based model.
+        name (str): Model name.
+        endpoint (str): URL of API endpoint.
         config (Dict[Any, Any]): Config passed on to LLM API.
         strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
             or other response object that does not conform to the expectation of how a well-formed response object from
@@ -40,22 +48,28 @@ def __init__(
             backoff at each retry.
         max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
         """
+        self._name = name
+        self._endpoint = endpoint
         self._config = config
         self._strict = strict
         self._max_tries = max_tries
         self._interval = interval
         self._max_request_time = max_request_time
-        self._url = self._config.pop("url") if "url" in self._config else None
         self._credentials = self.credentials
 
-        if "model" not in config:
-            raise ValueError("The LLM model must be specified in the config.")
-        self._check_api_endpoint_compatibility()
-
         assert self._max_tries >= 1
         assert self._interval > 0
         assert self._max_request_time > 0
 
+        self._check_model()
+
+    def _check_model(self) -> None:
+        """Checks whether model is supported. Raises if it isn't."""
+        if self._name not in self.get_model_names():
+            raise ValueError(
+                f"Model '{self._name}' is not supported - select one of {self.get_model_names()} instead"
+            )
+
     @abc.abstractmethod
     def __call__(self, prompts: Iterable[str]) -> Iterable[str]:
         """Executes prompts on specified API.
@@ -63,11 +77,11 @@ def __call__(self, prompts: Iterable[str]) -> Iterable[str]:
         RETURNS (Iterable[str]): API responses.
         """
 
-    @property
+    @classmethod
     @abc.abstractmethod
-    def supported_models(self) -> Dict[str, str]:
-        """Returns supported models with their endpoints.
-        RETURNS (Dict[str, str]): Supported models with their endpoints.
+    def get_model_names(cls) -> Tuple[str, ...]:
+        """Names of supported models.
+        RETURNS (Tuple[str]): Names of supported models.
         """
 
     @property
@@ -132,19 +146,3 @@ def _call_api(attempt: int) -> Optional[requests.Response]:
             )
 
         return response
-
-    def _check_api_endpoint_compatibility(self):
-        """Checks whether specified model supports the supported API endpoint."""
-        supported_models = self.supported_models
-        if self._config["model"] not in supported_models:
-            raise ValueError(
-                f"Requested model '{self._config['model']}' is not one of the supported models: "
-                f"{', '.join(sorted(list(supported_models.keys())))}."
-            )
-
-        model_endpoint = supported_models[self._config["model"]]
-        if self._url and self._url != model_endpoint:
-            warnings.warn(
-                f"Configured endpoint {self._url} diverges from expected endpoint {model_endpoint} for selected "
-                f"model '{self._config['model']}'. Please ensure that this endpoint supports your model."
-            )
diff --git a/spacy_llm/models/rest/cohere/__init__.py b/spacy_llm/models/rest/cohere/__init__.py
new file mode 100644
index 00000000..f5319ec4
--- /dev/null
+++ b/spacy_llm/models/rest/cohere/__init__.py
@@ -0,0 +1,4 @@
+from .model import Cohere, Endpoints
+from .registry import cohere_command
+
+__all__ = ["Cohere", "Endpoints", "cohere_command"]
diff --git a/spacy_llm/backends/rest/cohere.py b/spacy_llm/models/rest/cohere/model.py
similarity index 74%
rename from spacy_llm/backends/rest/cohere.py
rename to spacy_llm/models/rest/cohere/model.py
index 27a74c45..12636c05 100644
--- a/spacy_llm/backends/rest/cohere.py
+++ b/spacy_llm/models/rest/cohere/model.py
@@ -1,31 +1,19 @@
 import os
 from enum import Enum
-from typing import Any, Dict, Iterable, List, Sized
+from typing import Any, Dict, Iterable, List, Sized, Tuple
 
 import requests  # type: ignore[import]
 import srsly  # type: ignore[import]
 from requests import HTTPError
 
-from .base import Backend
+from ..base import REST
 
 
 class Endpoints(str, Enum):
     COMPLETION = "https://api.cohere.ai/v1/generate"
 
 
-class CohereBackend(Backend):
-    @property
-    def supported_models(self) -> Dict[str, str]:
-        """Returns supported models with their endpoints.
-        RETURNS (Dict[str, str]): Supported models with their endpoints.
-        """
-        return {
-            "command": Endpoints.COMPLETION.value,
-            "command-nightly": Endpoints.COMPLETION.value,
-            "command-light": Endpoints.COMPLETION.value,
-            "command-light-nightly": Endpoints.COMPLETION.value,
-        }
-
+class Cohere(REST):
     @property
     def credentials(self) -> Dict[str, str]:
         api_key = os.getenv("CO_API_KEY")
@@ -35,15 +23,6 @@ def credentials(self) -> Dict[str, str]:
                 "set up via https://dashboard.cohere.ai/api-keys, then make it available as "
                 "an environment variable 'CO_API_KEY'."
             )
-
-        model = self._config["model"]
-        if model not in self.supported_models:
-            raise ValueError(
-                f"The specified model '{model}' is not supported by the /v1/generate endpoint. "
-                f"Choices are: {sorted(list(self.supported_models))}. "
-                "(See Cohere API documentation: https://docs.cohere.com/reference/generate)"
-            )
-
         headers = {"Authorization": f"Bearer {api_key}"}
         assert api_key is not None
         return headers
@@ -57,12 +36,11 @@ def __call__(self, prompts: Iterable[str]) -> Iterable[str]:
 
         api_responses: List[str] = []
         prompts = list(prompts)
-        url = self._url if self._url else self.supported_models[self._config["model"]]
 
         def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
             r = self.retry(
                 call_method=requests.post,
-                url=url,
+                url=self._endpoint,
                 headers=headers,
                 json={**json_data, **self._config},
                 timeout=self._max_request_time,
@@ -104,3 +82,7 @@ def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
                     api_responses.append(srsly.json_dumps(response))
 
         return api_responses
+
+    @classmethod
+    def get_model_names(cls) -> Tuple[str, ...]:
+        return "command", "command-light", "command-light-nightly", "command-nightly"
diff --git a/spacy_llm/backends/rest/registry.py b/spacy_llm/models/rest/cohere/registry.py
similarity index 51%
rename from spacy_llm/backends/rest/registry.py
rename to spacy_llm/models/rest/cohere/registry.py
index 07691bcf..d657f40d 100644
--- a/spacy_llm/backends/rest/registry.py
+++ b/spacy_llm/models/rest/cohere/registry.py
@@ -1,31 +1,26 @@
-from typing import Any, Callable, Dict, Iterable, Type
-
+from typing import Any, Callable, Dict, Iterable
 
 from spacy.util import SimpleFrozenDict
 
-from . import anthropic, base, openai, noop
-from ...registry import registry
-
+from ....compat import Literal
+from ....registry import registry
+from .model import Cohere, Endpoints
 
-supported_apis: Dict[str, Type[base.Backend]] = {
-    "OpenAI": openai.OpenAIBackend,
-    "Anthropic": anthropic.AnthropicBackend,
-    "NoOp": noop.NoOpBackend,
-}
 
-
-@registry.llm_backends("spacy.REST.v1")
-def backend_rest(
-    api: str,
+@registry.llm_models("spacy.Command.v1")
+def cohere_command(
     config: Dict[Any, Any] = SimpleFrozenDict(),
-    strict: bool = True,
-    max_tries: int = 5,
-    interval: float = 1.0,
-    max_request_time: float = 30,
+    name: Literal[
+        "command", "command-light", "command-light-nightly", "command-nightly"
+    ] = "command",  # noqa: F821
+    strict: bool = Cohere.DEFAULT_STRICT,
+    max_tries: int = Cohere.DEFAULT_MAX_TRIES,
+    interval: float = Cohere.DEFAULT_INTERVAL,
+    max_request_time: float = Cohere.DEFAULT_MAX_REQUEST_TIME,
 ) -> Callable[[Iterable[str]], Iterable[str]]:
-    """Returns Callable using minimal REST backend to prompt specified API.
-    api (str): Name of any API. Currently supported: "OpenAI".
-    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the Backend instance.
+    """Returns Cohere instance for 'command' model using REST to prompt API.
+    name (Literal["command", "command-light", "command-light-nightly", "command-nightly"]): Model  to use.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
     strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
         or other response object that does not conform to the expectation of how a well-formed response object from
         this API should look like). If False, the API error responses are returned by __call__(), but no error will
@@ -34,15 +29,14 @@ def backend_rest(
     interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
         at each retry.
     max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
-    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Callable using the querying the specified API using a
-        Backend instance.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Cohere instance for 'command' model using REST to prompt API.
     """
-
-    backend = supported_apis[api](
+    return Cohere(
+        name=name,
+        endpoint=Endpoints.COMPLETION.value,
         config=config,
         strict=strict,
         max_tries=max_tries,
         interval=interval,
         max_request_time=max_request_time,
     )
-    return backend
diff --git a/spacy_llm/models/rest/noop/__init__.py b/spacy_llm/models/rest/noop/__init__.py
new file mode 100644
index 00000000..a8cc0bd5
--- /dev/null
+++ b/spacy_llm/models/rest/noop/__init__.py
@@ -0,0 +1,4 @@
+from .model import _NOOP_RESPONSE, NoOpModel
+from .registry import noop
+
+__all__ = ["noop", "NoOpModel", "_NOOP_RESPONSE"]
diff --git a/spacy_llm/models/rest/noop/model.py b/spacy_llm/models/rest/noop/model.py
new file mode 100644
index 00000000..db39f2ec
--- /dev/null
+++ b/spacy_llm/models/rest/noop/model.py
@@ -0,0 +1,36 @@
+import time
+from typing import Dict, Iterable, Tuple
+
+from ..base import REST
+
+_NOOP_RESPONSE = ""
+
+
+class NoOpModel(REST):
+    """NoOp model. Used for tests only."""
+
+    _CALL_TIMEOUT = 0.01
+
+    def __init__(self):
+        super().__init__(
+            name="NoOp",
+            endpoint="NoOp",
+            config={},
+            strict=True,
+            max_tries=1,
+            interval=1,
+            max_request_time=1,
+        )
+
+    @property
+    def credentials(self) -> Dict[str, str]:
+        return {}
+
+    def __call__(self, prompts: Iterable[str]) -> Iterable[str]:
+        # Assume time penalty for API calls.
+        time.sleep(NoOpModel._CALL_TIMEOUT)
+        return [_NOOP_RESPONSE] * len(list(prompts))
+
+    @classmethod
+    def get_model_names(cls) -> Tuple[str, ...]:
+        return ("NoOp",)
diff --git a/spacy_llm/models/rest/noop/registry.py b/spacy_llm/models/rest/noop/registry.py
new file mode 100644
index 00000000..bd393776
--- /dev/null
+++ b/spacy_llm/models/rest/noop/registry.py
@@ -0,0 +1,12 @@
+from typing import Callable, Iterable
+
+from ....registry import registry
+from .model import NoOpModel
+
+
+@registry.llm_models("spacy.NoOp.v1")
+def noop() -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns NoOpModel.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): NoOp model instance for test purposes.
+    """
+    return NoOpModel()
diff --git a/spacy_llm/models/rest/openai/__init__.py b/spacy_llm/models/rest/openai/__init__.py
new file mode 100644
index 00000000..5671c910
--- /dev/null
+++ b/spacy_llm/models/rest/openai/__init__.py
@@ -0,0 +1,19 @@
+from .model import Endpoints, OpenAI
+from .registry import openai_ada, openai_babbage, openai_curie, openai_davinci
+from .registry import openai_gpt_3_5, openai_gpt_4, openai_text_ada
+from .registry import openai_text_babbage, openai_text_curie, openai_text_davinci
+
+__all__ = [
+    "OpenAI",
+    "Endpoints",
+    "openai_ada",
+    "openai_babbage",
+    "openai_curie",
+    "openai_davinci",
+    "openai_gpt_3_5",
+    "openai_gpt_4",
+    "openai_text_ada",
+    "openai_text_babbage",
+    "openai_text_curie",
+    "openai_text_davinci",
+]
diff --git a/spacy_llm/backends/rest/openai.py b/spacy_llm/models/rest/openai/model.py
similarity index 67%
rename from spacy_llm/backends/rest/openai.py
rename to spacy_llm/models/rest/openai/model.py
index 30a7df9a..a362927a 100644
--- a/spacy_llm/backends/rest/openai.py
+++ b/spacy_llm/models/rest/openai/model.py
@@ -1,12 +1,12 @@
 import os
 from enum import Enum
-from typing import Any, Dict, Iterable, List, Sized
+from typing import Any, Dict, Iterable, List, Sized, Tuple
 
 import requests  # type: ignore[import]
 import srsly  # type: ignore[import]
 from requests import HTTPError
 
-from .base import Backend
+from ..base import REST
 
 
 class Endpoints(str, Enum):
@@ -14,33 +14,9 @@ class Endpoints(str, Enum):
     NON_CHAT = "https://api.openai.com/v1/completions"
 
 
-class OpenAIBackend(Backend):
-    @property
-    def supported_models(self) -> Dict[str, str]:
-        """Returns supported models with their endpoints.
-        RETURNS (Dict[str, str]): Supported models with their endpoints.
-        """
-        return {
-            "gpt-4": Endpoints.CHAT.value,
-            "gpt-4-0314": Endpoints.CHAT.value,
-            "gpt-4-32k": Endpoints.CHAT.value,
-            "gpt-4-32k-0314": Endpoints.CHAT.value,
-            "gpt-3.5-turbo": Endpoints.CHAT.value,
-            "gpt-3.5-turbo-0301": Endpoints.CHAT.value,
-            "text-davinci-003": Endpoints.NON_CHAT.value,
-            "text-davinci-002": Endpoints.NON_CHAT.value,
-            "text-curie-001": Endpoints.NON_CHAT.value,
-            "text-babbage-001": Endpoints.NON_CHAT.value,
-            "text-ada-001": Endpoints.NON_CHAT.value,
-            "davinci": Endpoints.NON_CHAT.value,
-            "curie": Endpoints.NON_CHAT.value,
-            "babbage": Endpoints.NON_CHAT.value,
-            "ada": Endpoints.NON_CHAT.value,
-        }
-
+class OpenAI(REST):
     @property
     def credentials(self) -> Dict[str, str]:
-        model = self._config["model"]
         # Fetch and check the key
         api_key = os.getenv("OPENAI_API_KEY")
         api_org = os.getenv("OPENAI_API_ORG")
@@ -76,23 +52,15 @@ def credentials(self) -> Dict[str, str]:
 
         response = r.json()["data"]
         models = [response[i]["id"] for i in range(len(response))]
-        if model not in models:
-            raise ValueError(
-                f"The specified model '{model}' is not available. Choices are: {sorted(set(models))}"
-            )
-
-        if model not in self.supported_models:
+        if self._name not in models:
             raise ValueError(
-                f"The specified model '{model}' is not supported by the /v1/completions endpoint. "
-                f"Choices are: {sorted(list(self.supported_models))} ."
-                "(See OpenAI API documentation: https://platform.openai.com/docs/models/)"
+                f"The specified model '{self._name}' is not available. Choices are: {sorted(set(models))}"
             )
 
         # Ensure endpoint is supported.
-        url = self._url if self._url else self.supported_models[self._config["model"]]
-        if url not in (Endpoints.NON_CHAT, Endpoints.CHAT):
+        if self._endpoint not in (Endpoints.NON_CHAT, Endpoints.CHAT):
             raise ValueError(
-                f"Endpoint {url} isn't supported. Please use one of: {Endpoints.CHAT}, {Endpoints.NON_CHAT}."
+                f"Endpoint {self._endpoint} isn't supported. Please use one of: {Endpoints.CHAT}, {Endpoints.NON_CHAT}."
             )
 
         assert api_key is not None
@@ -105,14 +73,13 @@ def __call__(self, prompts: Iterable[str]) -> Iterable[str]:
         }
         api_responses: List[str] = []
         prompts = list(prompts)
-        url = self._url if self._url else self.supported_models[self._config["model"]]
 
         def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
             r = self.retry(
                 call_method=requests.post,
-                url=url,
+                url=self._endpoint,
                 headers=headers,
-                json={**json_data, **self._config},
+                json={**json_data, **self._config, "model": self._name},
                 timeout=self._max_request_time,
             )
             try:
@@ -134,7 +101,7 @@ def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
 
             return responses
 
-        if url == Endpoints.CHAT:
+        if self._endpoint == Endpoints.CHAT:
             # The OpenAI API doesn't support batching for /chat/completions yet, so we have to send individual requests.
             for prompt in prompts:
                 responses = _request(
@@ -152,7 +119,7 @@ def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
                     )
                 )
 
-        elif url == Endpoints.NON_CHAT:
+        elif self._endpoint == Endpoints.NON_CHAT:
             responses = _request({"prompt": prompts})
             if "error" in responses:
                 return responses["error"]
@@ -165,3 +132,30 @@ def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
                     api_responses.append(srsly.json_dumps(response))
 
         return api_responses
+
+    @classmethod
+    def get_model_names(cls) -> Tuple[str, ...]:
+        return (
+            # gpt-4
+            "gpt-4",
+            "gpt-4-0314",
+            "gpt-4-32k",
+            "gpt-4-32k-0314",
+            # gpt-3.5
+            "gpt-3.5-turbo",
+            "gpt-3.5-turbo-16k",
+            "gpt-3.5-turbo-0613",
+            "gpt-3.5-turbo-0613-16k",
+            # text-davinci
+            "text-davinci-002",
+            "text-davinci-003",
+            # others
+            "code-davinci-002",
+            "text-curie-001",
+            "text-babbage-001",
+            "text-ada-001",
+            "davinci",
+            "curie",
+            "babbage",
+            "ada",
+        )
diff --git a/spacy_llm/models/rest/openai/registry.py b/spacy_llm/models/rest/openai/registry.py
new file mode 100644
index 00000000..d8b500b0
--- /dev/null
+++ b/spacy_llm/models/rest/openai/registry.py
@@ -0,0 +1,390 @@
+from typing import Any, Callable, Dict, Iterable
+
+from spacy.util import SimpleFrozenDict
+
+from ....compat import Literal
+from ....registry import registry
+from .model import Endpoints, OpenAI
+
+
+@registry.llm_models("spacy.GPT-4.v1")
+def openai_gpt_4(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal[
+        "gpt-4", "gpt-4-0314", "gpt-4-32k", "gpt-4-32k-0314"
+    ] = "gpt-4",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'gpt-4' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["0314", "32k", "32k-0314"]]): Model to use. Base 'gpt-4' model by default.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): OpenAI instance for 'gpt-4' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.GPT-3-5.v1")
+def openai_gpt_3_5(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal[
+        "gpt-3.5-turbo",
+        "gpt-3.5-turbo-16k",
+        "gpt-3.5-turbo-0613",
+        "gpt-3.5-turbo-0613-16k",
+    ] = "gpt-3.5-turbo",  # noqa: F722,F821
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'gpt-3.5' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Literal["gpt-3.5-turbo", "gpt-3.5-turbo-16k", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-0613-16k"]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): OpenAI instance for 'gpt-3.5' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Text-Davinci.v1")
+def openai_text_davinci(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal[
+        "text-davinci-002", "text-davinci-003"
+    ] = "text-davinci-003",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'text-davinci' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["text-davinci-002", "text-davinci-003"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): OpenAI instance for 'text-davinci' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Code-Davinci.v1")
+def openai_code_davinci(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["code-davinci-002"] = "code-davinci-002",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'code-davinci' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["code-davinci-002"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): OpenAI instance for 'code-davinci' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Text-Curie.v1")
+def openai_text_curie(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["text-curie-001"] = "text-curie-001",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'text-curie' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["text-curie-001"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): OpenAI instance for 'text-curie' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Text-Babbage.v1")
+def openai_text_babbage(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["text-babbage-001"] = "text-babbage-001",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'text-babbage' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["text-babbage-001"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): OpenAI instance for 'text-babbage' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Text-Ada.v1")
+def openai_text_ada(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["text-ada-001"] = "text-ada-001",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'text-ada' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["text-ada-001"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Davinci.v1")
+def openai_davinci(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["davinci"] = "davinci",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'davinci' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["davinci"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Curie.v1")
+def openai_curie(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["curie"] = "curie",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'curie' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["curie"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Babbage.v1")
+def openai_babbage(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["babbage"] = "babbage",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'babbage' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["babbage"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
+
+
+@registry.llm_models("spacy.Ada.v1")
+def openai_ada(
+    config: Dict[Any, Any] = SimpleFrozenDict(),
+    name: Literal["ada"] = "ada",  # noqa: F722
+    strict: bool = OpenAI.DEFAULT_STRICT,
+    max_tries: int = OpenAI.DEFAULT_MAX_TRIES,
+    interval: float = OpenAI.DEFAULT_INTERVAL,
+    max_request_time: float = OpenAI.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns OpenAI instance for 'ada' model using REST to prompt API.
+    config (Dict[Any, Any]): LLM config arguments passed on to the initialization of the model instance.
+    name (Optional[Literal["ada"]]): Model to use.
+    strict (bool): If True, ValueError is raised if the LLM API returns a malformed response (i. e. any kind of JSON
+        or other response object that does not conform to the expectation of how a well-formed response object from
+        this API should look like). If False, the API error responses are returned by __call__(), but no error will
+        be raised.
+    max_tries (int): Max. number of tries for API request.
+    interval (float): Time interval (in seconds) for API retries in seconds. We implement a base 2 exponential backoff
+        at each retry.
+    max_request_time (float): Max. time (in seconds) to wait for request to terminate before raising an exception.
+    RETURNS (Callable[[Iterable[str]], Iterable[str]]]): Anthropic instance for 'claude-1' model using REST to
+        prompt API.
+    """
+    return OpenAI(
+        name=name,
+        endpoint=Endpoints.NON_CHAT.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )
diff --git a/spacy_llm/pipeline/llm.py b/spacy_llm/pipeline/llm.py
index 567703dc..cdc448cc 100644
--- a/spacy_llm/pipeline/llm.py
+++ b/spacy_llm/pipeline/llm.py
@@ -34,10 +34,8 @@ class CacheConfigType(TypedDict):
     assigns=[],
     default_config={
         "task": None,
-        "backend": {
-            "@llm_backends": "spacy.REST.v1",
-            "api": "OpenAI",
-            "config": {"model": "gpt-3.5-turbo"},
+        "model": {
+            "@llm_models": "spacy.GPT-3-5.v1",
             "strict": True,
         },
         "cache": {
@@ -54,7 +52,7 @@ def make_llm(
     nlp: Language,
     name: str,
     task: Optional[LLMTask],
-    backend: PromptExecutor,
+    model: PromptExecutor,
     cache: Cache,
     save_io: bool,
     validate_types: bool,
@@ -66,7 +64,7 @@ def make_llm(
         losses during training.
     task (Optional[LLMTask]): An LLMTask can generate prompts for given docs, and can parse the LLM's responses into
         structured information and set that back on the docs.
-    backend (Callable[[Iterable[Any]], Iterable[Any]]]): Callable querying the specified LLM API.
+    model (Callable[[Iterable[Any]], Iterable[Any]]]): Callable querying the specified LLM API.
     cache (Cache): Cache to use for caching prompts and responses per doc (batch).
     save_io (bool): Whether to save LLM I/O (prompts and responses) in the `Doc._.llm_io` custom extension.
     validate_types (bool): Whether to check if signatures of configured backend and task are consistent.
@@ -77,13 +75,13 @@ def make_llm(
             "'spacy.NER.v2'})."
         )
     if validate_types:
-        validate_type_consistency(task, backend)
+        validate_type_consistency(task, model)
 
     return LLMWrapper(
         name=name,
         task=task,
         save_io=save_io,
-        backend=backend,
+        model=model,
         cache=cache,
         vocab=nlp.vocab,
     )
@@ -98,7 +96,7 @@ def __init__(
         *,
         vocab: Vocab,
         task: LLMTask,
-        backend: PromptExecutor,
+        model: PromptExecutor,
         cache: Cache,
         save_io: bool,
     ) -> None:
@@ -110,16 +108,16 @@ def __init__(
         vocab (Vocab): Pipeline vocabulary.
         task (Optional[LLMTask]): An LLMTask can generate prompts for given docs, and can parse the LLM's responses into
             structured information and set that back on the docs.
-        backend (Callable[[Iterable[Any]], Iterable[Any]]]): Callable querying the specified LLM API.
+        model (Callable[[Iterable[Any]], Iterable[Any]]]): Callable querying the specified LLM API.
         cache (Cache): Cache to use for caching prompts and responses per doc (batch).
         save_io (bool): Whether to save LLM I/O (prompts and responses) in the `Doc._.llm_io` custom extension.
         """
         self._name = name
         self._task = task
-        self._backend = backend
+        self._model = model
         self._cache = cache
-        self._cache.vocab = vocab
         self._save_io = save_io
+        self._cache.initialize(vocab, self._task)
 
         # This is done this way because spaCy's `validate_init_settings` function
         # does not support `**kwargs: Any`.
@@ -177,8 +175,8 @@ def pipe(self, stream: Iterable[Doc], *, batch_size: int = 128) -> Iterator[Doc]
                 error_handler(self._name, self, doc_batch, e)
 
     def _process_docs(self, docs: List[Doc]) -> List[Doc]:
-        """Process a batch of docs with the configured LLM backend and task.
-        If a cache is configured, only sends prompts to backend for docs not found in cache.
+        """Process a batch of docs with the configured LLM model and task.
+        If a cache is configured, only sends prompts to model for docs not found in cache.
 
         docs (List[Doc]): Input batch of docs
         RETURNS (List[Doc]): Processed batch of docs with task annotations set
@@ -198,7 +196,7 @@ def _process_docs(self, docs: List[Doc]) -> List[Doc]:
             prompts_iters = tee(
                 self._task.generate_prompts(noncached_doc_batch), n_iters
             )
-            responses_iters = tee(self._backend(prompts_iters[0]), n_iters)
+            responses_iters = tee(self._model(prompts_iters[0]), n_iters)
             for prompt, response, doc in zip(
                 prompts_iters[1], responses_iters[1], noncached_doc_batch
             ):
@@ -248,8 +246,8 @@ def to_bytes(
 
         if isinstance(self._task, Serializable):
             serialize["task"] = lambda: self._task.to_bytes(exclude=exclude)  # type: ignore[attr-defined]
-        if isinstance(self._backend, Serializable):
-            serialize["backend"] = lambda: self._backend.to_bytes(exclude=exclude)  # type: ignore[attr-defined]
+        if isinstance(self._model, Serializable):
+            serialize["model"] = lambda: self._model.to_bytes(exclude=exclude)  # type: ignore[attr-defined]
 
         return util.to_bytes(serialize, exclude)
 
@@ -270,8 +268,8 @@ def from_bytes(
 
         if isinstance(self._task, Serializable):
             deserialize["task"] = lambda b: self._task.from_bytes(b, exclude=exclude)  # type: ignore[attr-defined]
-        if isinstance(self._backend, Serializable):
-            deserialize["backend"] = lambda b: self._backend.from_bytes(b, exclude=exclude)  # type: ignore[attr-defined]
+        if isinstance(self._model, Serializable):
+            deserialize["model"] = lambda b: self._model.from_bytes(b, exclude=exclude)  # type: ignore[attr-defined]
 
         util.from_bytes(bytes_data, deserialize, exclude)
         return self
@@ -288,10 +286,10 @@ def to_disk(
 
         if isinstance(self._task, Serializable):
             serialize["task"] = lambda p: self._task.to_disk(p, exclude=exclude)  # type: ignore[attr-defined]
-        if isinstance(self._backend, Serializable):
-            serialize["backend"] = lambda p: self._backend.to_disk(p, exclude=exclude)  # type: ignore[attr-defined]
+        if isinstance(self._model, Serializable):
+            serialize["model"] = lambda p: self._model.to_disk(p, exclude=exclude)  # type: ignore[attr-defined]
 
-        return util.to_disk(path, serialize, exclude)
+        util.to_disk(path, serialize, exclude)
 
     def from_disk(
         self, path: Path, *, exclude: Tuple[str] = cast(Tuple[str], tuple())
@@ -306,8 +304,8 @@ def from_disk(
 
         if isinstance(self._task, Serializable):
             serialize["task"] = lambda p: self._task.from_disk(p, exclude=exclude)  # type: ignore[attr-defined]
-        if isinstance(self._backend, Serializable):
-            serialize["backend"] = lambda p: self._backend.from_disk(p, exclude=exclude)  # type: ignore[attr-defined]
+        if isinstance(self._model, Serializable):
+            serialize["model"] = lambda p: self._model.from_disk(p, exclude=exclude)  # type: ignore[attr-defined]
 
         util.from_disk(path, serialize, exclude)
         return self
diff --git a/spacy_llm/registry/reader.py b/spacy_llm/registry/reader.py
index e6e2e3e6..45b3d13a 100644
--- a/spacy_llm/registry/reader.py
+++ b/spacy_llm/registry/reader.py
@@ -1,6 +1,6 @@
 import functools
 from pathlib import Path
-from typing import Any, Callable, Dict, Iterable, Union, cast
+from typing import Any, Callable, Dict, Iterable, List, Optional, Union, cast
 
 import srsly
 
@@ -30,22 +30,57 @@ def fewshot_reader(path: Union[str, Path]) -> Callable[[], Iterable[Dict[str, An
     return functools.partial(_fewshot_reader, eg_path=eg_path)
 
 
-def _fewshot_reader(eg_path: Path) -> Iterable[Any]:
+def _fewshot_reader(eg_path: Path) -> Iterable[Dict[str, Any]]:
+    data: Optional[List] = None
+
     if eg_path is None:
         data = []
-    elif eg_path.suffix in (".yml", ".yaml"):
-        data = srsly.read_yaml(eg_path)
-    elif eg_path.suffix == ".json":
-        data = srsly.read_json(eg_path)
-    elif eg_path.suffix == ".jsonl":
-        data = list(srsly.read_jsonl(eg_path))
+
     else:
-        raise ValueError(
-            "The examples file expects a .yml, .yaml, .json, or .jsonl file type."
-        )
+        if not eg_path.exists():
+            raise ValueError(
+                "Specified file path doesn't exist. Please ensure to provide a valid file path."
+            )
+
+        suffix = eg_path.suffix.replace("yaml", "yml")
+        readers = {
+            ".yml": srsly.read_yaml,
+            ".json": srsly.read_json,
+            ".jsonl": lambda path: list(srsly.read_jsonl(eg_path)),
+        }
+
+        # Try to read in indicated format.
+        success = False
+        if suffix in readers:
+            try:
+                data = readers[suffix](eg_path)
+                success = True
+            except Exception:
+                pass
+        if not success:
+            # Try to read file in all supported formats.
+            for file_format, reader in readers.items():
+                if file_format == suffix:
+                    continue
+                try:
+                    data = reader(eg_path)
+                    success = True
+                    break
+                except Exception:
+                    pass
+
+        # Raise error if reading file didn't work.
+        if not success:
+            raise ValueError(
+                "The examples file expects a .yml, .yaml, .json, or .jsonl file type. Ensure that your file "
+                "corresponds to one of these file formats."
+            )
 
     if not isinstance(data, list) or not all(isinstance(d, dict) for d in data):
         raise ValueError(
-            f"Cannot interpret prompt examples from {str(eg_path)}. Please check your formatting"
+            f"Cannot interpret prompt examples from {str(eg_path)}. Please check your formatting to ensure that the "
+            f"examples specified in {eg_path} are described as list of dictionaries that fit the structure described by"
+            f" the prompt example class for the corresponding class."
         )
+
     return cast(Iterable[Dict[str, Any]], data)
diff --git a/spacy_llm/registry/util.py b/spacy_llm/registry/util.py
index eeea9c34..f3c8092a 100644
--- a/spacy_llm/registry/util.py
+++ b/spacy_llm/registry/util.py
@@ -1,13 +1,13 @@
 import catalogue  # type: ignore[import]
 import spacy
 
-for registry_name in ("queries", "backends", "tasks", "misc"):
+for registry_name in ("queries", "models", "tasks", "misc"):
     if f"llm_{registry_name}" not in spacy.util.registry.get_registry_names():
         spacy.util.registry.create(f"llm_{registry_name}", entry_points=True)
 
 
 class registry(spacy.util.registry):
-    llm_backends: catalogue.Registry
+    llm_models: catalogue.Registry
     llm_queries: catalogue.Registry
     llm_tasks: catalogue.Registry
     llm_misc: catalogue.Registry
diff --git a/spacy_llm/tasks/__init__.py b/spacy_llm/tasks/__init__.py
index 9d0649a2..24f7609d 100644
--- a/spacy_llm/tasks/__init__.py
+++ b/spacy_llm/tasks/__init__.py
@@ -2,7 +2,9 @@
 from .ner import NERTask, make_ner_task, make_ner_task_v2
 from .noop import NoopTask, make_noop_task
 from .rel import RELTask, make_rel_task
+from .sentiment import SentimentTask, make_sentiment_task
 from .spancat import SpanCatTask, make_spancat_task, make_spancat_task_v2
+from .summarization import SummarizationTask, make_summarization_task
 from .textcat import TextCatTask, make_textcat_task
 
 __all__ = [
@@ -11,13 +13,17 @@
     "make_ner_task_v2",
     "make_noop_task",
     "make_rel_task",
+    "make_sentiment_task",
     "make_spancat_task",
     "make_spancat_task_v2",
+    "make_summarization_task",
     "make_textcat_task",
     "LemmaTask",
     "NERTask",
     "NoopTask",
     "RELTask",
+    "SentimentTask",
     "SpanCatTask",
+    "SummarizationTask",
     "TextCatTask",
 ]
diff --git a/spacy_llm/tasks/lemma.py b/spacy_llm/tasks/lemma.py
index 36c49d5a..19ee2cc1 100644
--- a/spacy_llm/tasks/lemma.py
+++ b/spacy_llm/tasks/lemma.py
@@ -1,6 +1,7 @@
 from typing import Any, Callable, Dict, Iterable, List, Optional, Type
 
 import jinja2
+from pydantic import BaseModel
 from spacy.language import Language
 from spacy.scorer import Scorer
 from spacy.tokens import Doc
@@ -10,12 +11,15 @@
 from ..ty import ExamplesConfigType
 from .templates import read_template
 from .util import SerializableTask
-from .util.examples import LemmaExample
-from .util.serialization import ExampleType
 
 _DEFAULT_LEMMA_TEMPLATE_V1 = read_template("lemma")
 
 
+class LemmaExample(BaseModel):
+    text: str
+    lemmas: List[Dict[str, str]]
+
+
 @registry.llm_tasks("spacy.Lemma.v1")
 def make_lemma_task(
     template: str = _DEFAULT_LEMMA_TEMPLATE_V1,
@@ -29,11 +33,11 @@ def make_lemma_task(
         passed, then zero-shot learning will be used.
     """
     raw_examples = examples() if callable(examples) else examples
-    span_examples = (
+    lemma_examples = (
         [LemmaExample(**eg) for eg in raw_examples] if raw_examples else None
     )
 
-    return LemmaTask(template=template, examples=span_examples)
+    return LemmaTask(template=template, examples=lemma_examples)
 
 
 class LemmaTask(SerializableTask[LemmaExample]):
@@ -50,20 +54,29 @@ def __init__(
             passed, then zero-shot learning will be used.
         """
         self._template = template
-        self._examples = examples
+        self._prompt_examples = examples or []
 
     def initialize(
         self,
         get_examples: Callable[[], Iterable["Example"]],
         nlp: Language,
+        n_prompt_examples: int = 0,
         **kwargs: Any,
     ) -> None:
-        """Nothing to initialize for the LEMMA task.
+        """Initializes prompt examples from Doc examples.
         get_examples (Callable[[], Iterable["Example"]]): Callable that provides examples
             for initialization.
         nlp (Language): Language instance.
-        labels (List[str]): Optional list of labels.
+        n_prompt_examples (int): How many prompt examples to infer from the provided Example objects.
+            0 by default. Takes all examples if set to -1.
         """
+        for eg in get_examples():
+            if n_prompt_examples < 0 or len(self._prompt_examples) < n_prompt_examples:
+                self._prompt_examples.append(self._create_prompt_example(eg))
+
+    @property
+    def prompt_template(self) -> str:
+        return self._template
 
     def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
         environment = jinja2.Environment()
@@ -71,7 +84,7 @@ def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
         for doc in docs:
             prompt = _template.render(
                 text=doc.text,
-                examples=self._examples,
+                examples=self._prompt_examples,
             )
             yield prompt
 
@@ -114,5 +127,10 @@ def _cfg_keys(self) -> List[str]:
         return ["_template"]
 
     @property
-    def _Example(self) -> Type[ExampleType]:
+    def _Example(self) -> Type[LemmaExample]:
         return LemmaExample
+
+    def _create_prompt_example(self, example: Example) -> LemmaExample:
+        """Create a lemma prompt example from a spaCy example."""
+        lemma_dict = [{t.text: t.lemma_} for t in example.reference]
+        return LemmaExample(text=example.reference.text, lemmas=lemma_dict)
diff --git a/spacy_llm/tasks/ner.py b/spacy_llm/tasks/ner.py
index 26cbdcc2..365aba98 100644
--- a/spacy_llm/tasks/ner.py
+++ b/spacy_llm/tasks/ner.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 from typing import Any, Callable, Dict, Iterable, List, Optional, Union
 
 from spacy.language import Language
@@ -10,8 +11,8 @@
 from ..registry import registry
 from ..ty import ExamplesConfigType
 from ..util import split_labels
+from .span import SpanExample, SpanTask
 from .templates import read_template
-from .util import SpanExample, SpanTask
 
 _DEFAULT_NER_TEMPLATE_V1 = read_template("ner")
 _DEFAULT_NER_TEMPLATE_V2 = read_template("ner.v2")
@@ -52,7 +53,7 @@ def make_ner_task(
     return NERTask(
         labels=labels_list,
         template=_DEFAULT_NER_TEMPLATE_V1,
-        examples=span_examples,
+        prompt_examples=span_examples,
         normalizer=normalizer,
         alignment_mode=alignment_mode,
         case_sensitive_matching=case_sensitive_matching,
@@ -98,7 +99,7 @@ def make_ner_task_v2(
         labels=labels_list,
         template=template,
         label_definitions=label_definitions,
-        examples=span_examples,
+        prompt_examples=span_examples,
         normalizer=normalizer,
         alignment_mode=alignment_mode,
         case_sensitive_matching=case_sensitive_matching,
@@ -112,7 +113,7 @@ def __init__(
         labels: List[str] = [],
         template: str = _DEFAULT_NER_TEMPLATE_V2,
         label_definitions: Optional[Dict[str, str]] = None,
-        examples: Optional[List[SpanExample]] = None,
+        prompt_examples: Optional[List[SpanExample]] = None,
         normalizer: Optional[Callable[[str], str]] = None,
         alignment_mode: Literal["strict", "contract", "expand"] = "contract",
         case_sensitive_matching: bool = False,
@@ -140,7 +141,7 @@ def __init__(
             labels=labels,
             template=template,
             label_definitions=label_definitions,
-            examples=examples,
+            prompt_examples=prompt_examples,
             normalizer=normalizer,
             alignment_mode=alignment_mode,
             case_sensitive_matching=case_sensitive_matching,
@@ -152,6 +153,7 @@ def initialize(
         get_examples: Callable[[], Iterable["Example"]],
         nlp: Language,
         labels: List[str] = [],
+        n_prompt_examples: int = 0,
         **kwargs: Any,
     ) -> None:
         """Initialize the NER task, by auto-discovering labels.
@@ -166,22 +168,26 @@ def initialize(
             for initialization.
         nlp (Language): Language instance.
         labels (List[str]): Optional list of labels.
+        n_prompt_examples (int): How many prompt examples to infer from the Example objects.
+            0 by default. Takes all examples if set to -1.
         """
-
-        examples = get_examples()
-
         if not labels:
             labels = list(self._label_dict.values())
+        infer_labels = not labels
 
-        if not labels:
-            label_set = set()
+        if infer_labels:
+            labels = []
 
-            for eg in examples:
+        for eg in get_examples():
+            if infer_labels:
                 for ent in eg.reference.ents:
-                    label_set.add(ent.label_)
-            labels = list(label_set)
+                    labels.append(ent.label_)
+            if n_prompt_examples < 0 or len(self._prompt_examples) < n_prompt_examples:
+                self._prompt_examples.append(self._create_prompt_example(eg))
 
-        self._label_dict = {self._normalizer(label): label for label in labels}
+        self._label_dict = {
+            self._normalizer(label): label for label in sorted(set(labels))
+        }
 
     def assign_spans(
         self,
@@ -196,3 +202,11 @@ def scorer(
         examples: Iterable[Example],
     ) -> Dict[str, Any]:
         return get_ner_prf(examples)
+
+    def _create_prompt_example(self, example: Example) -> SpanExample:
+        """Create an NER prompt example from a spaCy example."""
+        entities = defaultdict(list)
+        for ent in example.reference.ents:
+            entities[ent.label_].append(ent.text)
+
+        return SpanExample(text=example.reference.text, entities=entities)
diff --git a/spacy_llm/tasks/noop.py b/spacy_llm/tasks/noop.py
index c62abd29..044ca5ac 100644
--- a/spacy_llm/tasks/noop.py
+++ b/spacy_llm/tasks/noop.py
@@ -22,3 +22,10 @@ def parse_responses(
     ) -> Iterable[Doc]:
         # Not doing anything
         return docs
+
+    @property
+    def prompt_template(self) -> str:
+        return """
+        This is the NoOp
+        prompt template
+        """
diff --git a/spacy_llm/tasks/rel.py b/spacy_llm/tasks/rel.py
index c921a328..12ac7bff 100644
--- a/spacy_llm/tasks/rel.py
+++ b/spacy_llm/tasks/rel.py
@@ -93,7 +93,7 @@ def make_rel_task(
         labels=labels_list,
         template=template,
         label_definitions=label_definitions,
-        examples=rel_examples,
+        prompt_examples=rel_examples,
         normalizer=normalizer,
         verbose=verbose,
     )
@@ -105,7 +105,7 @@ def __init__(
         labels: List[str] = [],
         template: str = _DEFAULT_REL_TEMPLATE,
         label_definitions: Optional[Dict[str, str]] = None,
-        examples: Optional[List[RELExample]] = None,
+        prompt_examples: Optional[List[RELExample]] = None,
         normalizer: Optional[Callable[[str], str]] = None,
         verbose: bool = False,
     ):
@@ -118,21 +118,23 @@ def __init__(
             of the label to help the language model output the entities wanted.
             It is usually easier to provide these definitions rather than
             full examples, although both can be provided.
-        examples (Optional[Callable[[], List[RELExample]]]): Optional callable that
+        prompt_examples (Optional[Callable[[], List[RELExample]]]): Optional callable that
             reads a file containing task examples for few-shot learning. If None is
             passed, then zero-shot learning will be used.
         normalizer (Optional[Callable[[str], str]]): Optional normalizer function.
         verbose (bool): Controls the verbosity of the task.
         """
         self._normalizer = normalizer if normalizer else lowercase_normalizer()
-        self._label_dict = {self._normalizer(label): label for label in labels}
+        self._label_dict = {
+            self._normalizer(label): label for label in sorted(set(labels))
+        }
         self._template = template
         self._label_definitions = label_definitions
-        self._examples = examples
+        self._prompt_examples = prompt_examples or []
         self._verbose = verbose
 
     @classmethod
-    def _check_rel_extention(cls):
+    def _check_rel_extension(cls):
         """Add `rel` extension if need be."""
         if not Doc.has_extension("rel"):
             Doc.set_extension("rel", default=[])
@@ -141,6 +143,10 @@ def _check_rel_extention(cls):
     def labels(self) -> Tuple[str, ...]:
         return tuple(self._label_dict.values())
 
+    @property
+    def prompt_template(self) -> str:
+        return self._template
+
     def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
         environment = jinja2.Environment()
         _template = environment.from_string(self._template)
@@ -149,7 +155,7 @@ def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
                 text=_preannotate(doc),
                 labels=list(self._label_dict.values()),
                 label_definitions=self._label_definitions,
-                examples=self._examples,
+                examples=self._prompt_examples,
                 preannotate=_preannotate,
             )
             yield prompt
@@ -171,7 +177,7 @@ def _format_response(self, response: str) -> List[RelationItem]:
     def parse_responses(
         self, docs: Iterable[Doc], responses: Iterable[str]
     ) -> Iterable[Doc]:
-        self._check_rel_extention()
+        self._check_rel_extension()
 
         for doc, prompt_response in zip(docs, responses):
             rels = self._format_response(prompt_response)
@@ -183,6 +189,7 @@ def initialize(
         get_examples: Callable[[], Iterable["Example"]],
         nlp: Language,
         labels: List[str] = [],
+        n_prompt_examples: int = 0,
     ) -> None:
         """Initialize the SpanCat task, by auto-discovering labels.
 
@@ -196,24 +203,29 @@ def initialize(
             for initialization.
         nlp (Language): Language instance.
         labels (List[str]): Optional list of labels.
+        n_prompt_examples (int): How many prompt examples to infer from the Example objects.
+            0 by default. Takes all examples if set to -1.
         """
-        self._check_rel_extention()
-
-        examples = get_examples()
+        self._check_rel_extension()
 
         if not labels:
             labels = list(self._label_dict.values())
+        infer_labels = not labels
 
-        if not labels:
-            label_set = set()
+        if infer_labels:
+            labels = []
 
-            for eg in examples:
+        for eg in get_examples():
+            if infer_labels:
                 rels: List[RelationItem] = eg.reference._.rel
                 for rel in rels:
-                    label_set.add(rel.relation)
-            labels = list(label_set)
+                    labels.append(rel.relation)
+            if n_prompt_examples < 0 or len(self._prompt_examples) < n_prompt_examples:
+                self._prompt_examples.append(self._create_prompt_example(eg))
 
-        self._label_dict = {self._normalizer(label): label for label in labels}
+        self._label_dict = {
+            self._normalizer(label): label for label in sorted(set(labels))
+        }
 
     @property
     def _cfg_keys(self) -> List[str]:
@@ -227,3 +239,21 @@ def _cfg_keys(self) -> List[str]:
     @property
     def _Example(self) -> Type[RELExample]:
         return RELExample
+
+    def _create_prompt_example(self, example: Example) -> RELExample:
+        """Create a REL prompt example from a spaCy example."""
+        entities = [
+            EntityItem(
+                start_char=ent.start_char,
+                end_char=ent.end_char,
+                label=ent.label_,
+            )
+            for ent in example.reference.ents
+        ]
+
+        rel_example = RELExample(
+            text=example.reference.text,
+            ents=entities,
+            relations=example.reference._.rel,
+        )
+        return rel_example
diff --git a/spacy_llm/tasks/sentiment.py b/spacy_llm/tasks/sentiment.py
new file mode 100644
index 00000000..07d63d71
--- /dev/null
+++ b/spacy_llm/tasks/sentiment.py
@@ -0,0 +1,142 @@
+from typing import Any, Callable, Dict, Iterable, List, Optional, Type
+
+import jinja2
+from pydantic import BaseModel
+from spacy.language import Language
+from spacy.tokens import Doc
+from spacy.training import Example
+
+from ..registry import registry
+from ..ty import ExamplesConfigType
+from .templates import read_template
+from .util import SerializableTask
+from .util.serialization import ExampleType
+
+_DEFAULT_SENTIMENT_TEMPLATE_V1 = read_template("sentiment")
+
+
+class SentimentExample(BaseModel):
+    text: str
+    score: float
+
+
+@registry.llm_tasks("spacy.Sentiment.v1")
+def make_sentiment_task(
+    template: str = _DEFAULT_SENTIMENT_TEMPLATE_V1,
+    examples: ExamplesConfigType = None,
+    field: str = "sentiment",
+):
+    """Sentiment.v1 task factory.
+
+    template (str): Prompt template passed to the model.
+    examples (Optional[Callable[[], Iterable[Any]]]): Optional callable that
+        reads a file containing task examples for few-shot learning. If None is
+        passed, then zero-shot learning will be used.
+    field (str): The name of the doc extension in which to store the summary.
+    """
+    raw_examples = examples() if callable(examples) else examples
+    sentiment_examples = (
+        [SentimentExample(**eg) for eg in raw_examples] if raw_examples else None
+    )
+
+    return SentimentTask(template=template, examples=sentiment_examples, field=field)
+
+
+class SentimentTask(SerializableTask[SentimentExample]):
+    def __init__(
+        self,
+        template: str = _DEFAULT_SENTIMENT_TEMPLATE_V1,
+        examples: Optional[List[SentimentExample]] = None,
+        field: str = "sentiment",
+    ):
+        """Sentiment analysis task.
+
+        template (str): Prompt template passed to the model.
+        examples (Optional[Callable[[], Iterable[Any]]]): Optional callable that
+            reads a file containing task examples for few-shot learning. If None is
+            passed, then zero-shot learning will be used.
+        field (str): The name of the doc extension in which to store the summary.
+        """
+        self._template = template
+        self._examples = examples
+        self._prompt_examples = examples or []
+        self._field = field
+        self._check_doc_extension()
+
+    def _check_doc_extension(self):
+        """Add extension if need be."""
+        if not Doc.has_extension(self._field):
+            Doc.set_extension(self._field, default=None)
+
+    def initialize(
+        self,
+        get_examples: Callable[[], Iterable["Example"]],
+        nlp: Language,
+        n_prompt_examples: int = 0,
+        **kwargs: Any,
+    ) -> None:
+        """Initialize sentiment task.
+        get_examples (Callable[[], Iterable["Example"]]): Callable that provides examples
+            for initialization.
+        nlp (Language): Language instance.
+        labels (List[str]): Optional list of labels.
+        n_prompt_examples (int): How many prompt examples to infer from the provided Example objects.
+            0 by default. Takes all examples if set to -1.
+        """
+        self._check_doc_extension()
+
+        for eg in get_examples():
+            if n_prompt_examples < 0 or len(self._prompt_examples) < n_prompt_examples:
+                self._prompt_examples.append(
+                    SentimentExample(
+                        text=eg.reference.text,
+                        score=getattr(eg.reference._, self._field),
+                    )
+                )
+
+    def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
+        environment = jinja2.Environment()
+        _template = environment.from_string(self._template)
+        for doc in docs:
+            prompt = _template.render(
+                text=doc.text,
+                examples=self._examples,
+            )
+            yield prompt
+
+    def parse_responses(
+        self, docs: Iterable[Doc], responses: Iterable[str]
+    ) -> Iterable[Doc]:
+        self._check_doc_extension()
+
+        for doc, prompt_response in zip(docs, responses):
+            try:
+                setattr(
+                    doc._,
+                    self._field,
+                    float(
+                        "".join(prompt_response.replace("Answer:", "").strip().split())
+                    ),
+                )
+            except ValueError:
+                setattr(doc._, self._field, None)
+
+            yield doc
+
+    def scorer(
+        self,
+        examples: Iterable[Example],
+    ) -> Dict[str, Any]:
+        """Scores lemmatization accuracy on provided examples.
+        examples (Iterable[Example]): Examples to determine score against.
+        """
+        # todo
+        return {"sentiment_diff": 0}
+
+    @property
+    def _cfg_keys(self) -> List[str]:
+        return ["_template"]
+
+    @property
+    def _Example(self) -> Type[ExampleType]:
+        return SentimentExample
diff --git a/spacy_llm/tasks/util/span.py b/spacy_llm/tasks/span.py
similarity index 63%
rename from spacy_llm/tasks/util/span.py
rename to spacy_llm/tasks/span.py
index b337f80a..708e1565 100644
--- a/spacy_llm/tasks/util/span.py
+++ b/spacy_llm/tasks/span.py
@@ -1,13 +1,19 @@
+import warnings
 from typing import Callable, Dict, Iterable, List, Optional, Tuple, Type
 
 import jinja2
+from pydantic import BaseModel
 from spacy.tokens import Doc, Span
 
-from ...compat import Literal
-from ...registry import lowercase_normalizer
-from .examples import SpanExample
-from .parsing import find_substrings
-from .serialization import SerializableTask
+from ..compat import Literal
+from ..registry import lowercase_normalizer
+from .util.parsing import find_substrings
+from .util.serialization import SerializableTask
+
+
+class SpanExample(BaseModel):
+    text: str
+    entities: Dict[str, List[str]]
 
 
 class SpanTask(SerializableTask[SpanExample]):
@@ -18,7 +24,7 @@ def __init__(
         labels: List[str],
         template: str,
         label_definitions: Optional[Dict[str, str]] = {},
-        examples: Optional[List[SpanExample]] = None,
+        prompt_examples: Optional[List[SpanExample]] = None,
         normalizer: Optional[Callable[[str], str]] = None,
         alignment_mode: Literal[
             "strict", "contract", "expand"  # noqa: F821
@@ -27,19 +33,67 @@ def __init__(
         single_match: bool = False,
     ):
         self._normalizer = normalizer if normalizer else lowercase_normalizer()
-        self._label_dict = {self._normalizer(label): label for label in labels}
+        self._label_dict = {
+            self._normalizer(label): label for label in sorted(set(labels))
+        }
         self._template = template
         self._label_definitions = label_definitions
-        self._examples = examples
+        self._prompt_examples = prompt_examples or []
         self._validate_alignment(alignment_mode)
         self._alignment_mode = alignment_mode
         self._case_sensitive_matching = case_sensitive_matching
         self._single_match = single_match
 
+        if self._prompt_examples:
+            self._prompt_examples = self._check_label_consistency()
+
+    def _check_label_consistency(self) -> List[SpanExample]:
+        """Checks consistency of labels between examples and defined labels. Emits warning on inconsistency.
+        RETURNS (List[SpanExample]): List of SpanExamples with valid labels.
+        """
+        assert self._prompt_examples
+        example_labels = {
+            self._normalizer(key): key
+            for example in self._prompt_examples
+            for key in example.entities
+        }
+        unspecified_labels = {
+            example_labels[key]
+            for key in (set(example_labels.keys()) - set(self._label_dict.keys()))
+        }
+        if not set(example_labels.keys()) <= set(self._label_dict.keys()):
+            warnings.warn(
+                f"Examples contain labels that are not specified in the task configuration. The latter contains the "
+                f"following labels: {sorted(list(set(self._label_dict.values())))}. Labels in examples missing from "
+                f"the task configuration: {sorted(list(unspecified_labels))}. Please ensure your label specification "
+                f"and example labels are consistent."
+            )
+
+        # Return examples without non-declared labels. If an example only has undeclared labels, it is discarded.
+        return [
+            example
+            for example in [
+                SpanExample(
+                    text=example.text,
+                    entities={
+                        label: entities
+                        for label, entities in example.entities.items()
+                        if self._normalizer(label) in self._label_dict
+                    },
+                )
+                for example in self._prompt_examples
+            ]
+            if len(example.entities)
+        ]
+
     @property
     def labels(self) -> Tuple[str, ...]:
         return tuple(self._label_dict.values())
 
+    @property
+    def prompt_template(self) -> str:
+        return self._template
+
     def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
         environment = jinja2.Environment()
         _template = environment.from_string(self._template)
@@ -48,7 +102,7 @@ def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
                 text=doc.text,
                 labels=list(self._label_dict.values()),
                 label_definitions=self._label_definitions,
-                examples=self._examples,
+                examples=self._prompt_examples,
             )
             yield prompt
 
diff --git a/spacy_llm/tasks/spancat.py b/spacy_llm/tasks/spancat.py
index 82738b93..a1cf74fe 100644
--- a/spacy_llm/tasks/spancat.py
+++ b/spacy_llm/tasks/spancat.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 from typing import Any, Callable, Dict, Iterable, List, Optional, Union
 
 from spacy.language import Language
@@ -9,8 +10,8 @@
 from ..registry import registry
 from ..ty import ExamplesConfigType
 from ..util import split_labels
+from .span import SpanExample, SpanTask
 from .templates import read_template
-from .util import SpanExample, SpanTask
 
 _DEFAULT_SPANCAT_TEMPLATE_V1 = read_template("spancat")
 _DEFAULT_SPANCAT_TEMPLATE_V2 = read_template("spancat.v2")
@@ -51,7 +52,7 @@ def make_spancat_task(
     return SpanCatTask(
         labels=labels_list,
         template=_DEFAULT_SPANCAT_TEMPLATE_V1,
-        examples=span_examples,
+        prompt_examples=span_examples,
         normalizer=normalizer,
         alignment_mode=alignment_mode,
         case_sensitive_matching=case_sensitive_matching,
@@ -97,7 +98,7 @@ def make_spancat_task_v2(
         labels=labels_list,
         template=template,
         label_definitions=label_definitions,
-        examples=span_examples,
+        prompt_examples=span_examples,
         normalizer=normalizer,
         alignment_mode=alignment_mode,
         case_sensitive_matching=case_sensitive_matching,
@@ -112,7 +113,7 @@ def __init__(
         template: str = _DEFAULT_SPANCAT_TEMPLATE_V2,
         label_definitions: Optional[Dict[str, str]] = None,
         spans_key: str = "sc",
-        examples: Optional[List[SpanExample]] = None,
+        prompt_examples: Optional[List[SpanExample]] = None,
         normalizer: Optional[Callable[[str], str]] = None,
         alignment_mode: Literal["strict", "contract", "expand"] = "contract",
         case_sensitive_matching: bool = False,
@@ -128,7 +129,7 @@ def __init__(
             It is usually easier to provide these definitions rather than
             full examples, although both can be provided.
         spans_key (str): Key of the `Doc.spans` dict to save under.
-        examples (Optional[Callable[[], Iterable[Any]]]): Optional callable that
+        prompt_examples (Optional[Callable[[], Iterable[Any]]]): Optional callable that
             reads a file containing task examples for few-shot learning. If None is
             passed, then zero-shot learning will be used.
         normalizer (Optional[Callable[[str], str]]): optional normalizer function.
@@ -141,7 +142,7 @@ def __init__(
             labels=labels,
             template=template,
             label_definitions=label_definitions,
-            examples=examples,
+            prompt_examples=prompt_examples,
             normalizer=normalizer,
             alignment_mode=alignment_mode,
             case_sensitive_matching=case_sensitive_matching,
@@ -172,6 +173,7 @@ def initialize(
         get_examples: Callable[[], Iterable["Example"]],
         nlp: Language,
         labels: List[str] = [],
+        n_prompt_examples: int = 0,
         **kwargs: Any,
     ) -> None:
         """Initialize the SpanCat task, by auto-discovering labels.
@@ -186,21 +188,23 @@ def initialize(
             for initialization.
         nlp (Language): Language instance.
         labels (List[str]): Optional list of labels.
+        n_prompt_examples (int): How many prompt examples to infer from the Example objects.
+            0 by default. Takes all examples if set to -1.
         """
-        examples = get_examples()
-
         if not labels:
             labels = list(self._label_dict.values())
+        infer_labels = not labels
 
-        if not labels:
-            label_set = set()
-
-            for eg in examples:
+        for eg in get_examples():
+            if infer_labels:
                 for span in eg.reference.spans.get(self._spans_key, []):
-                    label_set.add(span.label_)
-            labels = list(label_set)
+                    labels.append(span.label_)
+            if n_prompt_examples < 0 or len(self._prompt_examples) < n_prompt_examples:
+                self._prompt_examples.append(self._create_prompt_example(eg))
 
-        self._label_dict = {self._normalizer(label): label for label in labels}
+        self._label_dict = {
+            self._normalizer(label): label for label in sorted(set(labels))
+        }
 
     @property
     def _cfg_keys(self) -> List[str]:
@@ -213,3 +217,11 @@ def _cfg_keys(self) -> List[str]:
             "_case_sensitive_matching",
             "_single_match",
         ]
+
+    def _create_prompt_example(self, example: Example) -> SpanExample:
+        """Create a spancat prompt example from a spaCy example."""
+        entities = defaultdict(list)
+        for span in example.reference.spans[self._spans_key]:
+            entities[span.label_].append(span.text)
+
+        return SpanExample(text=example.reference.text, entities=entities)
diff --git a/spacy_llm/tasks/summarization.py b/spacy_llm/tasks/summarization.py
new file mode 100644
index 00000000..c6a585ac
--- /dev/null
+++ b/spacy_llm/tasks/summarization.py
@@ -0,0 +1,146 @@
+import warnings
+from typing import Any, Callable, Iterable, List, Optional, Type
+
+import jinja2
+from pydantic import BaseModel
+from spacy.language import Language
+from spacy.tokens import Doc
+from spacy.training import Example
+
+from ..registry import registry
+from ..ty import ExamplesConfigType
+from .templates import read_template
+from .util import SerializableTask
+from .util.serialization import ExampleType
+
+_DEFAULT_SUMMARIZATION_TEMPLATE_V1 = read_template("summarization")
+
+
+class SummarizationExample(BaseModel):
+    text: str
+    summary: str
+
+
+@registry.llm_tasks("spacy.Summarization.v1")
+def make_summarization_task(
+    template: str = _DEFAULT_SUMMARIZATION_TEMPLATE_V1,
+    examples: ExamplesConfigType = None,
+    max_n_words: Optional[int] = None,
+    field: str = "summary",
+):
+    """Summarization.v1 task factory.
+
+    template (str): Prompt template passed to the model.
+    examples (Optional[Callable[[], Iterable[Any]]]): Optional callable that
+        reads a file containing task examples for few-shot learning. If None is
+        passed, then zero-shot learning will be used.
+    max_n_words (int): Max. number of words to use in summary.
+    field (str): The name of the doc extension in which to store the summary.
+    """
+    raw_examples = examples() if callable(examples) else examples
+    span_examples = (
+        [SummarizationExample(**eg) for eg in raw_examples] if raw_examples else None
+    )
+
+    return SummarizationTask(
+        template=template, examples=span_examples, max_n_words=max_n_words, field=field
+    )
+
+
+class SummarizationTask(SerializableTask[SummarizationExample]):
+    def __init__(
+        self,
+        template: str = _DEFAULT_SUMMARIZATION_TEMPLATE_V1,
+        examples: Optional[List[SummarizationExample]] = None,
+        max_n_words: Optional[int] = None,
+        field: str = "summary",
+    ):
+        """Default summarization task.
+
+        template (str): Prompt template passed to the model.
+        examples (Optional[Callable[[], Iterable[Any]]]): Optional callable that
+            reads a file containing task examples for few-shot learning. If None is
+            passed, then zero-shot learning will be used.
+        max_n_words (int): Max. number of words to use in summary.
+        field (str): The name of the doc extension in which to store the summary.
+        """
+        self._template = template
+        self._examples = examples
+        self._max_n_words = max_n_words
+        self._field = field
+        self._prompt_examples = examples or []
+        self._check_example_summaries = True
+        if not Doc.has_extension(field):
+            Doc.set_extension(field, default=None)
+
+    def initialize(
+        self,
+        get_examples: Callable[[], Iterable["Example"]],
+        nlp: Language,
+        n_prompt_examples: int = 0,
+        **kwargs: Any,
+    ) -> None:
+        """Initializes prompt examples from Doc examples.
+        get_examples (Callable[[], Iterable["Example"]]): Callable that provides examples
+            for initialization.
+        nlp (Language): Language instance.
+        n_prompt_examples (int): How many prompt examples to infer from the provided Example objects.
+            0 by default. Takes all examples if set to -1.
+        """
+        for eg in get_examples():
+            if n_prompt_examples < 0 or len(self._prompt_examples) < n_prompt_examples:
+                self._prompt_examples.append(
+                    SummarizationExample(
+                        text=eg.reference.text,
+                        summary=getattr(eg.reference._, self._field),
+                    )
+                )
+
+    def _check_prompt_example_summary_len(self) -> None:
+        """Checks whether summaries of prompt examples are of expected lengths. Warns if they aren't."""
+        if self._max_n_words is None:
+            return
+
+        for pr_ex in self._prompt_examples:
+            len_summary = len(pr_ex.summary.split())
+            len_text = len(pr_ex.text.split())
+            if len_summary >= len_text * 1.2:
+                warnings.warn(
+                    f"The provided example '{pr_ex.text[:30]}...' has a summary of token length {len_summary} and a text "
+                    f"of token length {len_text}. Ensure that your examples' summaries are shorter than their original "
+                    f"texts."
+                )
+            if len_summary > self._max_n_words * 1.2:
+                warnings.warn(
+                    f"The provided example '{pr_ex.text[:20]}...' has a summary of length {len_summary}, but "
+                    f"`max_n_words` == {self._max_n_words}. If your examples are longer than they should be, the "
+                    f"LLM will likely produce responses that are too long."
+                )
+
+    def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
+        if self._check_example_summaries:
+            self._check_prompt_example_summary_len()
+            self._check_example_summaries = False
+
+        environment = jinja2.Environment()
+        _template = environment.from_string(self._template)
+        for doc in docs:
+            prompt = _template.render(
+                text=doc.text, examples=self._examples, max_n_words=self._max_n_words
+            )
+            yield prompt
+
+    def parse_responses(
+        self, docs: Iterable[Doc], responses: Iterable[str]
+    ) -> Iterable[Doc]:
+        for doc, prompt_response in zip(docs, responses):
+            setattr(doc._, self._field, prompt_response.replace("'''", "").strip())
+            yield doc
+
+    @property
+    def _cfg_keys(self) -> List[str]:
+        return ["_template"]
+
+    @property
+    def _Example(self) -> Type[ExampleType]:
+        return SummarizationExample
diff --git a/spacy_llm/tasks/templates/sentiment.jinja b/spacy_llm/tasks/templates/sentiment.jinja
new file mode 100644
index 00000000..6763dda3
--- /dev/null
+++ b/spacy_llm/tasks/templates/sentiment.jinja
@@ -0,0 +1,22 @@
+Analyse whether the text surrounded by ''' is positive or negative. Respond with a float value between 0 and 1. 1 represents an exclusively positive sentiment, 0 an exclusively negative sentiment.
+{# whitespace #}
+{%- if examples -%}
+Below are some examples (only use these as a guide):
+{# whitespace #}
+{%- for example in examples -%}
+{# whitespace #}
+Text:
+'''
+{{ example.text }}
+'''
+Answer: {{ example.score }}
+{# whitespace #}
+{%- endfor -%}
+{# whitespace #}
+{%- endif -%}
+{# whitespace #}
+Text:
+'''
+{{ text }}
+'''
+Answer:
\ No newline at end of file
diff --git a/spacy_llm/tasks/templates/summarization.jinja b/spacy_llm/tasks/templates/summarization.jinja
new file mode 100644
index 00000000..8b60060c
--- /dev/null
+++ b/spacy_llm/tasks/templates/summarization.jinja
@@ -0,0 +1,30 @@
+You are an expert summarization system. Your task is to accept Text as input and summarize the Text in a concise way.
+{%- if max_n_words -%}
+{# whitespace #}
+The summary must not, under any circumstances, contain more than {{ max_n_words }} words.
+{%- endif -%}
+{# whitespace #}
+{%- if examples -%}
+{# whitespace #}
+Below are some examples (only use these as a guide):
+{# whitespace #}
+{%- for example in examples -%}
+{# whitespace #}
+Text:
+'''
+{{ example.text }}
+'''
+Summary:
+'''
+{{ example.summary }}
+'''
+{# whitespace #}
+{%- endfor -%}
+{# whitespace #}
+{%- endif -%}
+{# whitespace #}
+Here is the Text that needs to be summarized:
+'''
+{{ text }}
+'''
+Summary:
diff --git a/spacy_llm/tasks/textcat.py b/spacy_llm/tasks/textcat.py
index 4ff5a2d6..9fe35ae9 100644
--- a/spacy_llm/tasks/textcat.py
+++ b/spacy_llm/tasks/textcat.py
@@ -68,7 +68,7 @@ def make_textcat_task(
     return TextCatTask(
         labels=labels_list,
         template=_DEFAULT_TEXTCAT_TEMPLATE_V1,
-        examples=textcat_examples,
+        prompt_examples=textcat_examples,
         normalizer=normalizer,
         exclusive_classes=exclusive_classes,
         allow_none=allow_none,
@@ -122,7 +122,7 @@ def make_textcat_task_v2(
     return TextCatTask(
         labels=labels_list,
         template=template,
-        examples=textcat_examples,
+        prompt_examples=textcat_examples,
         normalizer=normalizer,
         exclusive_classes=exclusive_classes,
         allow_none=allow_none,
@@ -182,7 +182,7 @@ def make_textcat_task_v3(
         labels=labels_list,
         template=template,
         label_definitions=label_definitions,
-        examples=textcat_examples,
+        prompt_examples=textcat_examples,
         normalizer=normalizer,
         exclusive_classes=exclusive_classes,
         allow_none=allow_none,
@@ -196,7 +196,7 @@ def __init__(
         labels: List[str] = [],
         template: str = _DEFAULT_TEXTCAT_TEMPLATE_V3,
         label_definitions: Optional[Dict[str, str]] = None,
-        examples: Optional[List[TextCatExample]] = None,
+        prompt_examples: Optional[List[TextCatExample]] = None,
         normalizer: Optional[Callable[[str], str]] = None,
         exclusive_classes: bool = False,
         allow_none: bool = True,
@@ -222,7 +222,7 @@ def __init__(
         template (str): Prompt template passed to the model.
         label_definitions (Optional[Dict[str, str]]): Optional dict mapping a label to a description of that label.
             These descriptions are added to the prompt to help instruct the LLM on what to extract.
-        examples (Optional[Callable[[], Iterable[Any]]]): Optional callable that
+        prompt_examples (Optional[Callable[[], Iterable[Any]]]): Optional callable that
             reads a file containing task examples for few-shot learning. If None is
             passed, then zero-shot learning will be used.
         normalizer (Optional[Callable[[str], str]]): Optional normalizer function.
@@ -233,9 +233,11 @@ def __init__(
         """
         self._template = template
         self._normalizer = normalizer if normalizer else lowercase_normalizer()
-        self._label_dict = {self._normalizer(label): label for label in labels}
+        self._label_dict = {
+            self._normalizer(label): label for label in sorted(set(labels))
+        }
         self._label_definitions = label_definitions
-        self._examples = examples
+        self._prompt_examples = prompt_examples or []
         # Textcat configuration
         self._use_binary = True if len(self._label_dict) == 1 else False
         self._exclusive_classes = exclusive_classes
@@ -253,6 +255,10 @@ def __init__(
     def labels(self) -> Tuple[str, ...]:
         return tuple(self._label_dict.values())
 
+    @property
+    def prompt_template(self) -> str:
+        return self._template
+
     def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
         environment = jinja2.Environment()
         _template = environment.from_string(self._template)
@@ -261,7 +267,7 @@ def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
                 text=doc.text,
                 labels=list(self._label_dict.values()),
                 label_definitions=self._label_definitions,
-                examples=self._examples,
+                examples=self._prompt_examples,
                 exclusive_classes=self._exclusive_classes,
                 allow_none=self._allow_none,
             )
@@ -324,6 +330,7 @@ def initialize(
         get_examples: Callable[[], Iterable["Example"]],
         nlp: Language,
         labels: List[str] = [],
+        n_prompt_examples: int = 0,
         **kwargs: Any,
     ) -> None:
         """Initialize the TextCat task, by auto-discovering labels.
@@ -338,21 +345,26 @@ def initialize(
             for initialization.
         nlp (Language): Language instance.
         labels (List[str]): Optional list of labels.
+        n_prompt_examples (int): How many prompt examples to infer from the Example objects.
+            0 by default. Takes all examples if set to -1.
         """
-        examples = get_examples()
-
         if not labels:
             labels = list(self._label_dict.values())
+        infer_labels = not labels
 
-        if not labels:
-            label_set = set()
+        if infer_labels:
+            labels = []
 
-            for eg in examples:
+        for eg in get_examples():
+            if infer_labels:
                 for cat in eg.reference.cats.keys():
-                    label_set.add(cat)
-            labels = list(label_set)
+                    labels.append(cat)
+            if n_prompt_examples < 0 or len(self._prompt_examples) < n_prompt_examples:
+                self._prompt_examples.append(self._create_prompt_example(eg))
 
-        self._label_dict = {self._normalizer(label): label for label in labels}
+        self._label_dict = {
+            self._normalizer(label): label for label in sorted(set(labels))
+        }
 
     @property
     def _cfg_keys(self) -> List[str]:
@@ -369,3 +381,26 @@ def _cfg_keys(self) -> List[str]:
     @property
     def _Example(self) -> Type[TextCatExample]:
         return TextCatExample
+
+    def _create_prompt_example(self, example: Example) -> TextCatExample:
+        """Create a textcat prompt example from a spaCy example."""
+        if self._use_binary:
+            answer = (
+                "POS"
+                if example.reference.cats[list(self._label_dict.values())[0]] == 1.0
+                else "NEG"
+            )
+        else:
+            answer = ",".join(
+                [
+                    label
+                    for label, score in example.reference.cats.items()
+                    if score == 1.0
+                ]
+            )
+
+        textcat_example = TextCatExample(
+            text=example.reference.text,
+            answer=answer,
+        )
+        return textcat_example
diff --git a/spacy_llm/tasks/util/__init__.py b/spacy_llm/tasks/util/__init__.py
index 41e03ab6..924f6eaa 100644
--- a/spacy_llm/tasks/util/__init__.py
+++ b/spacy_llm/tasks/util/__init__.py
@@ -1,6 +1,4 @@
-from .examples import SpanExample
 from .parsing import find_substrings
 from .serialization import SerializableTask
-from .span import SpanTask
 
-__all__ = ["SpanExample", "find_substrings", "SpanTask", "SerializableTask"]
+__all__ = ["find_substrings", "SerializableTask"]
diff --git a/spacy_llm/tasks/util/examples.py b/spacy_llm/tasks/util/examples.py
deleted file mode 100644
index c139a03a..00000000
--- a/spacy_llm/tasks/util/examples.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from typing import Dict, List
-
-from pydantic import BaseModel
-
-
-class SpanExample(BaseModel):
-    text: str
-    entities: Dict[str, List[str]]
-
-
-class LemmaExample(BaseModel):
-    text: str
-    lemmas: List[Dict[str, str]]
diff --git a/spacy_llm/tasks/util/serialization.py b/spacy_llm/tasks/util/serialization.py
index e220e644..75fdc642 100644
--- a/spacy_llm/tasks/util/serialization.py
+++ b/spacy_llm/tasks/util/serialization.py
@@ -1,6 +1,6 @@
 import abc
 from pathlib import Path
-from typing import Any, Dict, Generic, List, Optional, Tuple, Type, TypeVar, cast
+from typing import Any, Dict, Generic, List, Tuple, Type, TypeVar, cast
 
 import srsly
 from pydantic import BaseModel
@@ -12,7 +12,7 @@
 class SerializableTask(abc.ABC, Generic[ExampleType]):
     """A task that can be serialized and deserialized."""
 
-    _examples: Optional[List[ExampleType]]
+    _prompt_examples: List[ExampleType]
 
     @property
     @abc.abstractmethod
@@ -39,20 +39,17 @@ def set_cfg(self, cfg: Dict[str, Any]) -> None:
         for key, value in cfg.items():
             setattr(self, key, value)
 
-    def get_examples(self) -> Optional[List[Dict[str, Any]]]:
+    def _get_prompt_examples(self) -> List[Dict[str, Any]]:
         """Serialize examples."""
-        if self._examples is not None:
-            examples = [eg.dict() for eg in self._examples]
-            return examples
-        return None
+        examples = [eg.dict() for eg in self._prompt_examples]
+        return examples
 
-    def set_examples(self, examples: Optional[List[Dict[str, Any]]]) -> None:
+    def _set_prompt_examples(self, examples: List[Dict[str, Any]]) -> None:
         """Deserialize examples from bytes.
 
-        examples (Optional[List[Dict[str, Any]]]): serialized examples.
+        examples (List[Dict[str, Any]]): serialized examples.
         """
-        if examples is not None:
-            self._examples = [self._Example.parse_obj(eg) for eg in examples]
+        self._prompt_examples = [self._Example.parse_obj(eg) for eg in examples]
 
     def to_bytes(
         self,
@@ -67,7 +64,7 @@ def to_bytes(
 
         serialize = {
             "cfg": lambda: srsly.json_dumps(self.get_cfg()),
-            "examples": lambda: srsly.msgpack_dumps(self.get_examples()),
+            "prompt_examples": lambda: srsly.msgpack_dumps(self._get_prompt_examples()),
         }
 
         return util.to_bytes(serialize, exclude)
@@ -87,7 +84,9 @@ def from_bytes(
 
         deserialize = {
             "cfg": lambda b: self.set_cfg(srsly.json_loads(b)),
-            "examples": lambda b: self.set_examples(srsly.msgpack_loads(b)),
+            "prompt_examples": lambda b: self._set_prompt_examples(
+                srsly.msgpack_loads(b)
+            ),
         }
 
         util.from_bytes(bytes_data, deserialize, exclude)
@@ -107,7 +106,9 @@ def to_disk(
 
         serialize = {
             "cfg": lambda p: srsly.write_json(p, self.get_cfg()),
-            "examples": lambda p: srsly.write_msgpack(p, self.get_examples()),
+            "prompt_examples": lambda p: srsly.write_msgpack(
+                p, self._get_prompt_examples()
+            ),
         }
 
         util.to_disk(path, serialize, exclude)
@@ -126,7 +127,9 @@ def from_disk(
 
         deserialize = {
             "cfg": lambda p: self.set_cfg(srsly.read_json(p)),
-            "examples": lambda p: self.set_examples(srsly.read_msgpack(p)),
+            "prompt_examples": lambda p: self._set_prompt_examples(
+                srsly.read_msgpack(p)
+            ),
         }
 
         util.from_disk(path, deserialize, exclude)
diff --git a/spacy_llm/ty.py b/spacy_llm/ty.py
index 696c401f..e31a272b 100644
--- a/spacy_llm/ty.py
+++ b/spacy_llm/ty.py
@@ -9,8 +9,8 @@
 from spacy.training.example import Example
 from spacy.vocab import Vocab
 
-from .backends import integration
 from .compat import Protocol, runtime_checkable
+from .models import langchain
 
 _Prompt = Any
 _Response = Any
@@ -76,6 +76,13 @@ def parse_responses(
         ...
 
 
+@runtime_checkable
+class PromptTemplateProvider(Protocol):
+    @property
+    def prompt_template(self) -> str:
+        ...
+
+
 @runtime_checkable
 class Labeled(Protocol):
     @property
@@ -87,16 +94,11 @@ def labels(self) -> Tuple[str, ...]:
 class Cache(Protocol):
     """Defines minimal set of operations a cache implementiation needs to support."""
 
-    @property
-    def vocab(self) -> Optional[Vocab]:
-        """Vocab used for deserializing docs.
-        RETURNS (Vocab): Vocab used for deserializing docs.
+    def initialize(self, vocab: Vocab, task: LLMTask) -> None:
         """
-
-    @vocab.setter
-    def vocab(self, vocab: Vocab) -> None:
-        """Set vocab to use for deserializing docs.
-        vocab (Vocab): Vocab to use for deserializing docs.
+        Initialize cache with data not available at construction time.
+        vocab (Vocab): Vocab object.
+        task (LLMTask): Task.
         """
 
     def add(self, doc: Doc) -> None:
@@ -104,6 +106,18 @@ def add(self, doc: Doc) -> None:
         doc (Doc): Doc to add to persistence queue.
         """
 
+    @property
+    def prompt_template(self) -> Optional[str]:
+        """Get prompt template.
+        RETURNS (Optional[str]): Prompt template string used for docs to cache/cached docs.
+        """
+
+    @prompt_template.setter
+    def prompt_template(self, prompt_template: str) -> None:
+        """Set prompt template.
+        prompt_template (str): Prompt template string used for docs to cache/cached docs.
+        """
+
     def __contains__(self, doc: Doc) -> bool:
         """Checks whether doc has been processed and cached.
         doc (Doc): Doc to check for.
@@ -140,25 +154,25 @@ def _do_args_match(out_arg: Iterable, in_arg: Iterable) -> bool:
     )
 
 
-def _extract_backend_call_signature(backend: PromptExecutor) -> Dict[str, Any]:
-    """Extract call signature from backend object.
-    backend (PromptExecutor): Backend object to extract call signature from.
+def _extract_model_call_signature(model: PromptExecutor) -> Dict[str, Any]:
+    """Extract call signature from model object.
+    model (PromptExecutor): Model object to extract call signature from.
     RETURNS (Dict[str, Any]): Type per argument name.
     """
-    if inspect.isfunction(backend):
-        return typing.get_type_hints(backend)
+    if inspect.isfunction(model):
+        return typing.get_type_hints(model)
 
-    if not hasattr(backend, "__call__"):
-        raise ValueError("The object supplied as backend must implement `__call__()`.")
+    if not hasattr(model, "__call__"):
+        raise ValueError("The object supplied as model must implement `__call__()`.")
 
-    # Assume that __call__() has the necessary type info - except in the case of integration.Backend, for which
+    # Assume that __call__() has the necessary type info - except in the case of integration.Model, for which
     # we know this is not the case.
-    if not isinstance(backend, integration.RemoteBackend):
-        return typing.get_type_hints(backend.__call__)
+    if not isinstance(model, langchain.LangChain):
+        return typing.get_type_hints(model.__call__)
 
-    # If this is an instance of integrations.RemoteBackend: read type information from .query() instead, only keep
+    # If this is an instance of integrations.Model: read type information from .query() instead, only keep
     # information on Iterable args.
-    signature = typing.get_type_hints(backend.query).items()
+    signature = typing.get_type_hints(model.query).items()
     to_ignore: List[str] = []
     for k, v in signature:
         if not (hasattr(v, "__origin__") and issubclass(v.__origin__, Iterable)):
@@ -166,7 +180,7 @@ def _extract_backend_call_signature(backend: PromptExecutor) -> Dict[str, Any]:
 
     signature = {
         k: v
-        for k, v in typing.get_type_hints(backend.query).items()
+        for k, v in typing.get_type_hints(model.query).items()
         # In Python 3.8+ (or 3.6+ if typing_utils is installed) the check for the origin class should be done using
         # typing.get_origin().
         if k not in to_ignore
@@ -176,10 +190,10 @@ def _extract_backend_call_signature(backend: PromptExecutor) -> Dict[str, Any]:
     return signature
 
 
-def validate_type_consistency(task: LLMTask, backend: PromptExecutor) -> None:
-    """Check whether the types of the task and backend signatures match.
+def validate_type_consistency(task: LLMTask, model: PromptExecutor) -> None:
+    """Check whether the types of the task and model signatures match.
     task (LLMTask): Specified task.
-    backend (PromptExecutor): Specified backend.
+    backend (PromptExecutor): Specified model.
     """
     # Raises an error or prints a warning if something looks wrong/odd.
     if not isinstance(task, LLMTask):
@@ -199,23 +213,23 @@ def validate_type_consistency(task: LLMTask, backend: PromptExecutor) -> None:
     type_hints = {
         "template": typing.get_type_hints(task.generate_prompts),
         "parse": typing.get_type_hints(task.parse_responses),
-        "backend": _extract_backend_call_signature(backend),
+        "model": _extract_model_call_signature(model),
     }
 
     parse_input: Optional[Type] = None
-    backend_input: Optional[Type] = None
-    backend_output: Optional[Type] = None
+    model_input: Optional[Type] = None
+    model_output: Optional[Type] = None
 
-    # Validate the 'backend' object
-    if not (len(type_hints["backend"]) == 2 and "return" in type_hints["backend"]):
+    # Validate the 'model' object
+    if not (len(type_hints["model"]) == 2 and "return" in type_hints["model"]):
         raise ValueError(
-            "The 'backend' Callable should have one input argument and one return value."
+            "The 'model' Callable should have one input argument and one return value."
         )
-    for k in type_hints["backend"]:
+    for k in type_hints["model"]:
         if k == "return":
-            backend_output = type_hints["backend"][k]
+            model_output = type_hints["model"][k]
         else:
-            backend_input = type_hints["backend"][k]
+            model_input = type_hints["model"][k]
 
     # validate the 'parse' object
     if not (len(type_hints["parse"]) == 3 and "return" in type_hints["parse"]):
@@ -234,10 +248,10 @@ def validate_type_consistency(task: LLMTask, backend: PromptExecutor) -> None:
     for var, msg in (
         (template_output, "`task.generate_prompts()` needs to return an `Iterable`."),
         (
-            backend_input,
-            "The prompts variable in the 'backend' needs to be an `Iterable`.",
+            model_input,
+            "The prompts variable in the 'model' needs to be an `Iterable`.",
         ),
-        (backend_output, "The `backend` function needs to return an `Iterable`."),
+        (model_output, "The `model` function needs to return an `Iterable`."),
         (
             parse_input,
             "`responses` in `task.parse_responses()` needs to be an `Iterable`.",
@@ -246,16 +260,16 @@ def validate_type_consistency(task: LLMTask, backend: PromptExecutor) -> None:
         if not var != Iterable:
             raise ValueError(msg)
 
-    # Ensure that the template returns the same type as expected by the backend
-    if not _do_args_match(template_output, backend_input):  # type: ignore[arg-type]
+    # Ensure that the template returns the same type as expected by the model
+    if not _do_args_match(template_output, model_input):  # type: ignore[arg-type]
         warnings.warn(
             f"Type returned from `task.generate_prompts()` (`{template_output}`) doesn't match type expected by "
-            f"`backend` (`{backend_input}`)."
+            f"`model` (`{model_input}`)."
         )
 
-    # Ensure that the parser expects the same type as returned by the backend
-    if not _do_args_match(backend_output, parse_input):  # type: ignore[arg-type]
+    # Ensure that the parser expects the same type as returned by the model
+    if not _do_args_match(model_output, parse_input):  # type: ignore[arg-type]
         warnings.warn(
-            f"Type returned from `backend` (`{backend_output}`) doesn't match type expected by "
+            f"Type returned from `model` (`{model_output}`) doesn't match type expected by "
             f"`task.parse_responses()` (`{parse_input}`)."
         )
diff --git a/usage_examples/README.md b/usage_examples/README.md
index 75724e2d..aa316d4d 100644
--- a/usage_examples/README.md
+++ b/usage_examples/README.md
@@ -7,8 +7,8 @@ configuration and an optional `examples.yml` file for few-shot annotation.
 ## The configuration file
 
 Each configuration file contains an `llm` component that takes in a `task` and a
-`backend` as its parameters. The `task` defines how the prompt is structured and
-how the corresponding LLM output will be parsed whereas the `backend` defines
+`model` as its parameters. `task` defines how the prompt is structured and
+how the corresponding LLM output will be parsed whereas `model` defines
 which model to use and how to connect to it.
 
 ```ini
@@ -24,8 +24,8 @@ factory = "llm"
 ...
 
 # Defines which model to use (open-source or third-party API) and how to connect
-# to it (e.g., REST, MiniChain, LangChain).
-[components.llm.backend]
+# to it (e.g., REST, LangChain, locally via HuggingFace, ...).
+[components.llm.model]
 ...
 ```
 
@@ -53,14 +53,13 @@ need to implement two functions:
 - **`generate_prompts(docs: Iterable[Doc]) -> Iterable[str]`**: a function that
   takes in a list of spaCy [`Doc`](https://spacy.io/api/doc) objects and transforms
   them into a list of prompts. These prompts will then be sent to the LLM in the
-  `backend`.
+  `model`.
 - **`parse_responses(docs: Iterable[Doc], responses: Iterable[str]) -> Iterable[Doc]`**: a function for parsing the LLM's outputs into spaCy
   [`Doc`](https://spacy.io/api/doc) objects. You also have access to the input
   `Doc` objects so you can store the outputs into one of its attributes.
 
 The `spacy-llm` library requires tasks to be defined as a class and registered in the `llm_tasks` registry:
 
-
 ```python
 from spacy_llm.registry import registry
 from spacy_llm.util import split_labels
@@ -97,52 +96,43 @@ You can check sample tasks for Named Entity Recognition and text categorization
 in the `spacy_llm/tasks/` directory. We also recommend checking out the
 `spacy.NoOp.v1` task for a barebones implementation to pattern your task from.
 
-## Using LangChain, MiniChain and other integrated third-party prompting libraries
-
-`spacy-llm` integrates bindings to a number of libraries centered on prompt management and LLM usage to allow users
-to leverage their functionality in their spaCy workflows. This currently includes
+## Using LangChain
 
-- [LangChain](https://github.com/hwchase17/langchain)
-- [MiniChain](https://github.com/srush/MiniChain)
+`spacy-llm` integrates [LangChain](https://github.com/hwchase17/langchain) to allow users to leverage its features for
+prompt management and LLM usage in their spaCy workflows.
 
-An integrated third-party library can be used by configuring the `llm` component to use the respective backend, e. g.:
+LangChain can be used like so:
 
 ```ini
-[components.llm.backend]
-@llm_backends = "spacy.LangChain.v1"
-```
-
-or
-
-```ini
-[components.llm.backend]
-@llm_backends = "spacy.MiniChain.v1"
+[components.llm.model]
+@llm_models = "langchain.OpenAI.v1"
+name = "gpt-3.5-turbo"
 ```
 
 <!-- The `usage_examples` directory contains example for all integrated third-party -->
 
-## Writing your own backend
+## Writing your own model
 
-In `spacy-llm`, the [**backend**](../README.md#backend) is responsible for the
+In `spacy-llm`, the [**model**](../README.md#models) is responsible for the
 interaction with the actual LLM model. The latter can be an
 [API-based service](../README.md#spacyrestv1), or a local model - whether
 you [downloaded it from the Hugging Face Hub](../README.md#spacydollyhfv1)
 directly or finetuned it with proprietary data.
 
-`spacy-llm` lets you implement your own custom backend so you can try out the
+`spacy-llm` lets you implement your own custom model so you can try out the
 latest LLM interface out there. Bear in mind that tasks are responsible for
 creating the prompt and parsing the response – and both can be arbitrary objects.
-Hence, a backend's call signature should be consistent with that of the task you'd like it to run.
+Hence, a model's call signature should be consistent with that of the task you'd like it to run.
 
 In other words, `spacy-llm` roughly performs the following pseudo-code behind the scenes:
 
 ```python
 prompts = task.generate_prompts(docs)
-responses = backend(prompts)
+responses = model(prompts)
 docs = task.parse_responses(docs, responses)
 ```
 
-Let's write a dummy backend that provides a random output for the
+Let's write a dummy model that provides a random output for the
 [text classification task](../README.md#spacytextcatv1).
 
 ```python
@@ -150,13 +140,13 @@ from spacy_llm.registry import registry
 import random
 from typing import Iterable
 
-@registry.llm_backends("RandomClassification.v1")
+@registry.llm_models("RandomClassification.v1")
 def random_textcat(labels: str):
     labels = labels.split(",")
     def _classify(prompts: Iterable[str]) -> Iterable[str]:
         for _ in prompts:
             yield random.choice(labels)
-    
+
     return _classify
 ```
 
@@ -167,18 +157,18 @@ def random_textcat(labels: str):
 labels = LABEL1,LABEL2,LABEL3
 
 
-[components.llm.backend]
-@llm_backends = "RandomClassification.v1"
+[components.llm.model]
+@llm_models = "RandomClassification.v1"
 labels = ${components.llm.task.labels}  # Make sure to use the same label
 ...
 ```
 
-Of course, this particular backend is not very realistic
+Of course, this particular model is not very realistic
 (it does not even interact with an actual LLM model!).
 But it does show how you would go about writing custom
 and arbitrary logic to interact with any LLM implementation.
 
-Note that in all built-in tasks prompts and responses are expected to be of type `str`, while all built-in backends
-support `str` (or `Any`) types. All built-in tasks and backends are therefore inter-operable. It's possible to work with 
+Note that in all built-in tasks prompts and responses are expected to be of type `str`, while all built-in model
+support `str` (or `Any`) types. All built-in tasks and models are therefore inter-operable. It's possible to work with
 arbitrary objects instead of `str` though - which might be useful if you want some third-party abstractions for prompts
 or responses.
diff --git a/usage_examples/multitask_openai/fewshot.cfg b/usage_examples/multitask_openai/fewshot.cfg
index 17b3c583..726fdd39 100644
--- a/usage_examples/multitask_openai/fewshot.cfg
+++ b/usage_examples/multitask_openai/fewshot.cfg
@@ -18,10 +18,10 @@ labels = SIZE,TYPE,TOPPING,PRODUCT
 @misc = "spacy.FewShotReader.v1"
 path = ${paths.examples}
 
-[components.llm_ner.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
-config = {"model": "text-davinci-003", "temperature": 0.3}
+[components.llm_ner.model]
+@llm_models = "spacy.Text-Davinci.v1"
+name = "text-davinci-003"
+config = {"temperature": 0.3}
 
 [components.llm_textcat]
 factory = "llm"
@@ -35,7 +35,7 @@ exclusive_classes=True
 @misc = "spacy.FewShotReader.v1"
 path = ${paths.examples}
 
-[components.llm_textcat.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
-config = {"model": "text-davinci-003", "temperature": 0.3}
+[components.llm_textcat.model]
+@llm_models = "spacy.Text-Davinci.v1"
+name = "text-davinci-003"
+config = {"temperature": 0.3}
diff --git a/usage_examples/multitask_openai/zeroshot.cfg b/usage_examples/multitask_openai/zeroshot.cfg
index 9d810cd5..fc91a507 100644
--- a/usage_examples/multitask_openai/zeroshot.cfg
+++ b/usage_examples/multitask_openai/zeroshot.cfg
@@ -11,10 +11,10 @@ factory = "llm"
 @llm_tasks = "spacy.NER.v2"
 labels = SIZE,TYPE,TOPPING,PRODUCT
 
-[components.llm_ner.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
-config = {"model": "text-davinci-003", "temperature": 0.3}
+[components.llm_ner.model]
+@llm_models = "spacy.Text-Davinci.v1"
+name = "text-davinci-003"
+config = {"temperature": 0.3}
 
 [components.llm_textcat]
 factory = "llm"
@@ -24,7 +24,7 @@ factory = "llm"
 labels = INFORMATION,ORDER
 exclusive_classes=True
 
-[components.llm_textcat.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
-config = {"model": "text-davinci-003", "temperature": 0.3}
+[components.llm_textcat.model]
+@llm_models = "spacy.Text-Davinci.v1"
+name = "text-davinci-003"
+config = {"temperature": 0.3}
diff --git a/usage_examples/ner_dolly/fewshot.cfg b/usage_examples/ner_dolly/fewshot.cfg
index cd8984c0..46590e6e 100644
--- a/usage_examples/ner_dolly/fewshot.cfg
+++ b/usage_examples/ner_dolly/fewshot.cfg
@@ -11,9 +11,9 @@ batch_size = 128
 [components.llm]
 factory = "llm"
 
-[components.llm.backend]
-@llm_backends = "spacy.Dolly_HF.v1"
-model = "databricks/dolly-v2-3b"
+[components.llm.model]
+@llm_models = "spacy.Dolly.v1"
+name = "dolly-v2-3b"
 
 [components.llm.task]
 @llm_tasks = "spacy.NER.v2"
diff --git a/usage_examples/ner_dolly/zeroshot.cfg b/usage_examples/ner_dolly/zeroshot.cfg
index f08f7a8d..abf825af 100644
--- a/usage_examples/ner_dolly/zeroshot.cfg
+++ b/usage_examples/ner_dolly/zeroshot.cfg
@@ -8,9 +8,9 @@ batch_size = 128
 [components.llm]
 factory = "llm"
 
-[components.llm.backend]
-@llm_backends = "spacy.Dolly_HF.v1"
-model = "databricks/dolly-v2-3b"
+[components.llm.model]
+@llm_models = "spacy.Dolly.v1"
+name = "dolly-v2-3b"
 
 [components.llm.task]
 @llm_tasks = "spacy.NER.v2"
diff --git a/usage_examples/ner_langchain_openai/ner.cfg b/usage_examples/ner_langchain_openai/ner.cfg
index 02b3b48b..039609d8 100644
--- a/usage_examples/ner_langchain_openai/ner.cfg
+++ b/usage_examples/ner_langchain_openai/ner.cfg
@@ -12,7 +12,7 @@ factory = "llm"
 labels = PERSON,LOCATION
 examples = null
 
-[components.llm.backend]
-@llm_backends = "spacy.LangChain.v1"
-api = "OpenAI"
-config = {"model": "text-davinci-002"}
+[components.llm.model]
+@llm_models = "langchain.OpenAI.v1"
+name = "text-davinci-002"
+config = {}
diff --git a/usage_examples/ner_minichain_openai/README.md b/usage_examples/ner_minichain_openai/README.md
deleted file mode 100644
index 0b0dc84f..00000000
--- a/usage_examples/ner_minichain_openai/README.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Using GPT models from OpenAI
-
-This example shows how you can use a model from OpenAI to recognize named entities using the MiniChain backend.
-
-First, create a new API key from
-[openai.com](https://platform.openai.com/account/api-keys) or fetch an existing
-one. Record the secret key and make sure this is available as an environmental
-variable:
-
-```sh
-export OPENAI_API_KEY="sk-..."
-export OPENAI_API_ORG="org-..."
-```
-
-Then, you can run the pipeline on a sample text via:
-
-```sh
-python run_pipeline.py [TEXT] [PATH TO CONFIG]
-```
-
-For example:
-
-```sh
-python run_pipeline.py "Jack and Jill went up the hill." ./ner.cfg
-```
diff --git a/usage_examples/ner_minichain_openai/__init__.py b/usage_examples/ner_minichain_openai/__init__.py
deleted file mode 100644
index 06fab2f6..00000000
--- a/usage_examples/ner_minichain_openai/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .run_pipeline import run_pipeline
-
-__all__ = ["run_pipeline"]
diff --git a/usage_examples/ner_minichain_openai/ner.cfg b/usage_examples/ner_minichain_openai/ner.cfg
deleted file mode 100644
index a066ddda..00000000
--- a/usage_examples/ner_minichain_openai/ner.cfg
+++ /dev/null
@@ -1,18 +0,0 @@
-[nlp]
-lang = "en"
-pipeline = ["llm"]
-
-[components]
-
-[components.llm]
-factory = "llm"
-
-[components.llm.task]
-@llm_tasks = "spacy.NER.v2"
-labels = PERSON,LOCATION
-examples = null
-
-[components.llm.backend]
-@llm_backends = "spacy.MiniChain.v1"
-api = "OpenAI"
-config = {"model": "gpt-3.5-turbo"}
diff --git a/usage_examples/ner_minichain_openai/run_pipeline.py b/usage_examples/ner_minichain_openai/run_pipeline.py
deleted file mode 100644
index 6315080f..00000000
--- a/usage_examples/ner_minichain_openai/run_pipeline.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import os
-from pathlib import Path
-
-import typer
-from wasabi import msg
-
-from spacy_llm.util import assemble
-
-Arg = typer.Argument
-Opt = typer.Option
-
-
-def run_pipeline(
-    # fmt: off
-    text: str = Arg("", help="Text to perform NER on."),
-    config_path: Path = Arg(..., help="Path to the configuration file to use."),
-    verbose: bool = Opt(False, "--verbose", "-v", help="Show extra information."),
-    # fmt: on
-):
-    if not os.getenv("OPENAI_API_KEY", None):
-        msg.fail(
-            "OPENAI_API_KEY env variable was not found. "
-            "Set it by running 'export OPENAI_API_KEY=...' and try again.",
-            exits=1,
-        )
-
-    msg.text(f"Loading config from {config_path}", show=verbose)
-    nlp = assemble(config_path)
-    doc = nlp(text)
-
-    msg.text(f"Text: {doc.text}")
-    msg.text(f"Entities: {doc.ents}")
-
-
-if __name__ == "__main__":
-    typer.run(run_pipeline)
diff --git a/usage_examples/rel_openai/fewshot.cfg b/usage_examples/rel_openai/fewshot.cfg
index ba0397c7..1995e94a 100644
--- a/usage_examples/rel_openai/fewshot.cfg
+++ b/usage_examples/rel_openai/fewshot.cfg
@@ -21,9 +21,8 @@ labels = LivesIn,Visits
 @misc = "spacy.FewShotReader.v1"
 path = ${paths.examples}
 
-[components.llm_rel.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
+[components.llm_rel.model]
+@llm_models = "spacy.GPT-3-5.v1"
 
 [initialize]
 vectors = "en_core_web_md"
diff --git a/usage_examples/rel_openai/zeroshot.cfg b/usage_examples/rel_openai/zeroshot.cfg
index b29ae605..ef0d3f94 100644
--- a/usage_examples/rel_openai/zeroshot.cfg
+++ b/usage_examples/rel_openai/zeroshot.cfg
@@ -17,9 +17,8 @@ factory = "llm"
 @llm_tasks = "spacy.REL.v1"
 labels = LivesIn,Visits
 
-[components.llm_rel.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
+[components.llm_rel.model]
+@llm_models = "spacy.GPT-3-5.v1"
 
 [initialize]
 vectors = "en_core_web_md"
diff --git a/usage_examples/streamlit/streamlit_app.py b/usage_examples/streamlit/streamlit_app.py
index ca0d4fae..4cebf177 100644
--- a/usage_examples/streamlit/streamlit_app.py
+++ b/usage_examples/streamlit/streamlit_app.py
@@ -20,7 +20,7 @@
 factory = "llm"
 
 [components.llm.backend]
-@llm_backends = "spacy.REST.v1"
+@llm_models = "spacy.REST.v1"
 api = "OpenAI"
 config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
 
@@ -45,7 +45,7 @@
 factory = "llm"
 
 [components.llm.backend]
-@llm_backends = "spacy.REST.v1"
+@llm_models = "spacy.REST.v1"
 api = "OpenAI"
 config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
 
diff --git a/usage_examples/textcat_openai/fewshot.cfg b/usage_examples/textcat_openai/fewshot.cfg
index d3f8987e..e7bfd0f9 100644
--- a/usage_examples/textcat_openai/fewshot.cfg
+++ b/usage_examples/textcat_openai/fewshot.cfg
@@ -11,10 +11,9 @@ batch_size = 128
 [components.llm]
 factory = "llm"
 
-[components.llm.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
-config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
+[components.llm.model]
+@llm_models = "spacy.GPT-3-5.v1"
+config = {"temperature": 0.3}
 
 [components.llm.task]
 @llm_tasks = "spacy.TextCat.v2"
diff --git a/usage_examples/textcat_openai/zeroshot.cfg b/usage_examples/textcat_openai/zeroshot.cfg
index 30808132..41bb5c4d 100644
--- a/usage_examples/textcat_openai/zeroshot.cfg
+++ b/usage_examples/textcat_openai/zeroshot.cfg
@@ -8,10 +8,9 @@ batch_size = 128
 [components.llm]
 factory = "llm"
 
-[components.llm.backend]
-@llm_backends = "spacy.REST.v1"
-api = "OpenAI"
-config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
+[components.llm.model]
+@llm_models = "spacy.GPT-3-5.v1"
+config = {"temperature": 0.3}
 
 [components.llm.task]
 @llm_tasks = "spacy.TextCat.v2"


Test patch:
diff --git a/spacy_llm/tests/backends/test_minichain.py b/spacy_llm/tests/backends/test_minichain.py
deleted file mode 100644
index b3c2faf5..00000000
--- a/spacy_llm/tests/backends/test_minichain.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import spacy
-import pytest
-from spacy_llm.compat import has_minichain
-
-PIPE_CFG = {
-    "backend": {
-        "@llm_backends": "spacy.MiniChain.v1",
-        "api": "OpenAI",
-        "config": {"model": "gpt-3.5-turbo"},
-        "query": {"@llm_queries": "spacy.RunMiniChain.v1"},
-    },
-    "task": {"@llm_tasks": "spacy.NoOp.v1"},
-}
-
-
-@pytest.mark.external
-@pytest.mark.skipif(has_minichain is False, reason="MiniChain is not installed")
-def test_initialization():
-    """Test initialization and simple run"""
-    nlp = spacy.blank("en")
-    nlp.add_pipe("llm", config=PIPE_CFG)
-    nlp("This is a test.")
diff --git a/spacy_llm/tests/conftest.py b/spacy_llm/tests/conftest.py
index cd0e1063..32981074 100644
--- a/spacy_llm/tests/conftest.py
+++ b/spacy_llm/tests/conftest.py
@@ -35,7 +35,7 @@ def pytest_collection_modifyitems(config, items):
             item.add_marker(skip_external)
 
 
-@registry.llm_backends("test.NoOpBackend.v1")
+@registry.llm_models("test.NoOpModel.v1")
 def noop_factory(output: str = ""):
     def noop(prompts: Iterable[str]) -> Iterable[str]:
         return [output] * len(list(prompts))
diff --git a/spacy_llm/tests/backends/__init__.py b/spacy_llm/tests/models/__init__.py
similarity index 100%
rename from spacy_llm/tests/backends/__init__.py
rename to spacy_llm/tests/models/__init__.py
diff --git a/spacy_llm/tests/backends/test_anthropic.py b/spacy_llm/tests/models/test_anthropic.py
similarity index 73%
rename from spacy_llm/tests/backends/test_anthropic.py
rename to spacy_llm/tests/models/test_anthropic.py
index a07e846d..72aeef56 100644
--- a/spacy_llm/tests/backends/test_anthropic.py
+++ b/spacy_llm/tests/models/test_anthropic.py
@@ -1,15 +1,21 @@
 # mypy: ignore-errors
+import re
+
 import pytest
-from spacy_llm.backends.rest.anthropic import AnthropicBackend
+
+from spacy_llm.models.rest.anthropic import Anthropic, Endpoints
 
 from ..compat import has_anthropic_key
 
 
+@pytest.mark.external
 @pytest.mark.skipif(has_anthropic_key is False, reason="Anthropic API key unavailable")
 def test_anthropic_api_response_is_correct():
     """Check if we're getting the expected response and we're parsing it properly"""
-    anthropic = AnthropicBackend(
-        config={"model": "claude-instant-1", "max_tokens_to_sample": 10},
+    anthropic = Anthropic(
+        name="claude-instant-1",
+        endpoint=Endpoints.COMPLETIONS.value,
+        config={"max_tokens_to_sample": 10},
         strict=False,
         max_tries=10,
         interval=5.0,
@@ -23,14 +29,16 @@ def test_anthropic_api_response_is_correct():
         assert isinstance(response, str)
 
 
+@pytest.mark.external
 @pytest.mark.skipif(has_anthropic_key is False, reason="Anthropic API key unavailable")
 def test_anthropic_api_response_when_error():
     """Check if error message shows up properly given incorrect config"""
     # Incorrect config c.f. https://console.anthropic.com/docs/api/reference
     incorrect_temperature = "one"  # should be an int
-    anthropic = AnthropicBackend(
+    anthropic = Anthropic(
+        name="claude-instant-1",
+        endpoint=Endpoints.COMPLETIONS.value,
         config={
-            "model": "claude-instant-1",
             "max_tokens_to_sample": 10,
             "temperature": incorrect_temperature,
         },
@@ -47,16 +55,20 @@ def test_anthropic_api_response_when_error():
     assert "temperature: value is not a valid float" in str(err.value)
 
 
+@pytest.mark.external
 @pytest.mark.skipif(has_anthropic_key is False, reason="Anthropic API key unavailable")
 def test_anthropic_error_unsupported_model():
     """Ensure graceful handling of error when model is not supported"""
     incorrect_model = "x-gpt-3.5-turbo"
-    with pytest.raises(ValueError) as err:
-        AnthropicBackend(
-            config={"model": incorrect_model, "max_tokens_to_sample": 10},
+    with pytest.raises(
+        ValueError, match=re.escape("Model 'x-gpt-3.5-turbo' is not supported")
+    ):
+        Anthropic(
+            name=incorrect_model,
+            endpoint=Endpoints.COMPLETIONS.value,
+            config={"max_tokens_to_sample": 10},
             strict=False,
             max_tries=10,
             interval=5.0,
             max_request_time=20,
         )
-    assert "The specified model 'x-gpt-3.5-turbo' is not supported" in str(err.value)
diff --git a/spacy_llm/tests/backends/test_cohere.py b/spacy_llm/tests/models/test_cohere.py
similarity index 71%
rename from spacy_llm/tests/backends/test_cohere.py
rename to spacy_llm/tests/models/test_cohere.py
index 55e3baf8..018f51de 100644
--- a/spacy_llm/tests/backends/test_cohere.py
+++ b/spacy_llm/tests/models/test_cohere.py
@@ -1,6 +1,7 @@
 # mypy: ignore-errors
 import pytest
-from spacy_llm.backends.rest.cohere import CohereBackend
+
+from spacy_llm.models.rest.cohere import Cohere, Endpoints
 
 from ..compat import has_cohere_key
 
@@ -8,8 +9,10 @@
 @pytest.mark.skipif(has_cohere_key is False, reason="Cohere API key not available")
 def test_cohere_api_response_is_correct():
     """Check if we're getting the response from the correct structure"""
-    cohere = CohereBackend(
-        config={"model": "command"},
+    cohere = Cohere(
+        name="command",
+        endpoint=Endpoints.COMPLETION.value,
+        config={},
         strict=False,
         max_tries=10,
         interval=5.0,
@@ -24,15 +27,17 @@ def test_cohere_api_response_is_correct():
 
 @pytest.mark.skipif(has_cohere_key is False, reason="Cohere API key not available")
 def test_cohere_api_response_n_generations():
-    """Test how the backend handles more than 1 generation of output
+    """Test how the model handles more than 1 generation of output
 
     Users can configure Cohere to return more than 1 output for a single prompt
-    The current backend doesn't support that and the implementation only returns
+    The current model doesn't support that and the implementation only returns
     the very first output.
     """
     num_generations = 3
-    cohere = CohereBackend(
-        config={"model": "command", "num_generations": num_generations},
+    cohere = Cohere(
+        name="command",
+        endpoint=Endpoints.COMPLETION.value,
+        config={"num_generations": num_generations},
         strict=False,
         max_tries=10,
         interval=5.0,
@@ -48,12 +53,14 @@ def test_cohere_api_response_n_generations():
 
 @pytest.mark.skipif(has_cohere_key is False, reason="Cohere API key not available")
 def test_cohere_api_response_when_error():
-    """Ensure graceful handling of error in the Cohere backend"""
+    """Ensure graceful handling of error in the Cohere model"""
     # Incorrect config because temperature is in incorrect range [0, 5]
     # c.f. https://docs.cohere.com/reference/generate
     incorrect_temperature = 1000  # must be between 0 and 5
-    cohere = CohereBackend(
-        config={"model": "command", "temperature": incorrect_temperature},
+    cohere = Cohere(
+        name="command",
+        endpoint=Endpoints.COMPLETION.value,
+        config={"temperature": incorrect_temperature},
         strict=False,
         max_tries=10,
         interval=5.0,
@@ -69,12 +76,13 @@ def test_cohere_api_response_when_error():
 def test_cohere_error_unsupported_model():
     """Ensure graceful handling of error when model is not supported"""
     incorrect_model = "x-gpt-3.5-turbo"
-    with pytest.raises(ValueError) as err:
-        CohereBackend(
-            config={"model": incorrect_model},
+    with pytest.raises(ValueError, match="Model 'x-gpt-3.5-turbo' is not supported"):
+        Cohere(
+            name=incorrect_model,
+            config={},
+            endpoint=Endpoints.COMPLETION.value,
             strict=False,
             max_tries=10,
             interval=5.0,
             max_request_time=20,
         )
-    assert "The specified model 'x-gpt-3.5-turbo' is not supported" in str(err.value)
diff --git a/spacy_llm/tests/backends/test_dolly.py b/spacy_llm/tests/models/test_dolly.py
similarity index 65%
rename from spacy_llm/tests/backends/test_dolly.py
rename to spacy_llm/tests/models/test_dolly.py
index da65e287..8a197b2f 100644
--- a/spacy_llm/tests/backends/test_dolly.py
+++ b/spacy_llm/tests/models/test_dolly.py
@@ -6,9 +6,9 @@
 from thinc.compat import has_torch_cuda_gpu
 
 _PIPE_CFG = {
-    "backend": {
-        "@llm_backends": "spacy.Dolly_HF.v1",
-        "model": "databricks/dolly-v2-3b",
+    "model": {
+        "@llm_models": "spacy.Dolly.v1",
+        "name": "dolly-v2-3b",
     },
     "task": {"@llm_tasks": "spacy.NoOp.v1"},
 }
@@ -28,23 +28,21 @@
 [components.llm.task]
 @llm_tasks = "spacy.NoOp.v1"
 
-[components.llm.backend]
-@llm_backends = "spacy.Dolly_HF.v1"
-model = "databricks/dolly-v2-3b"
+[components.llm.model]
+@llm_models = "spacy.Dolly.v1"
+name = "dolly-v2-3b"
 """
 
 
-@pytest.mark.parametrize("backend", ("spacy.Dolly_HF.v1", "spacy.DollyHF.v1"))
 @pytest.mark.skipif(not has_torch_cuda_gpu, reason="needs GPU & CUDA")
-def test_init(backend: str):
-    """Test initialization and simple run.
-    backend (str): Name of backend to use.
-    """
+def test_init():
+    """Test initialization and simple run."""
     nlp = spacy.blank("en")
     cfg = copy.deepcopy(_PIPE_CFG)
-    cfg["backend"]["@llm_backends"] = backend
+    cfg["model"]["@llm_models"] = "spacy.Dolly.v1"
     nlp.add_pipe("llm", config=cfg)
     nlp("This is a test.")
+    nlp.get_pipe("llm")._model.get_model_names()
 
 
 @pytest.mark.skipif(not has_torch_cuda_gpu, reason="needs GPU & CUDA")
@@ -58,6 +56,6 @@ def test_init_from_config():
 def test_invalid_model():
     orig_config = Config().from_str(_NLP_CONFIG)
     config = copy.deepcopy(orig_config)
-    config["components"]["llm"]["backend"]["model"] = "dolly-the-sheep"
-    with pytest.raises(ValueError, match="is not supported"):
+    config["components"]["llm"]["model"]["name"] = "dolly-the-sheep"
+    with pytest.raises(ValueError, match="unexpected value; permitted"):
         spacy.util.load_model_from_config(config, auto_fill=True)
diff --git a/spacy_llm/tests/models/test_falcon.py b/spacy_llm/tests/models/test_falcon.py
new file mode 100644
index 00000000..b4d099b8
--- /dev/null
+++ b/spacy_llm/tests/models/test_falcon.py
@@ -0,0 +1,59 @@
+import copy
+
+import pytest
+import spacy
+from confection import Config  # type: ignore[import]
+from thinc.compat import has_torch_cuda_gpu
+
+_PIPE_CFG = {
+    "model": {
+        "@llm_models": "spacy.Falcon.v1",
+        "name": "falcon-rw-1b",
+    },
+    "task": {"@llm_tasks": "spacy.NoOp.v1"},
+}
+
+_NLP_CONFIG = """
+
+[nlp]
+lang = "en"
+pipeline = ["llm"]
+batch_size = 128
+
+[components]
+
+[components.llm]
+factory = "llm"
+
+[components.llm.task]
+@llm_tasks = "spacy.NoOp.v1"
+
+[components.llm.model]
+@llm_models = "spacy.Falcon.v1"
+name = "falcon-rw-1b"
+"""
+
+
+@pytest.mark.skipif(not has_torch_cuda_gpu, reason="needs GPU & CUDA")
+def test_init():
+    """Test initialization and simple run."""
+    nlp = spacy.blank("en")
+    cfg = copy.deepcopy(_PIPE_CFG)
+    nlp.add_pipe("llm", config=cfg)
+    nlp("This is a test.")
+
+
+@pytest.mark.skipif(not has_torch_cuda_gpu, reason="needs GPU & CUDA")
+def test_init_from_config():
+    orig_config = Config().from_str(_NLP_CONFIG)
+    nlp = spacy.util.load_model_from_config(orig_config, auto_fill=True)
+    assert nlp.pipe_names == ["llm"]
+
+
+@pytest.mark.skipif(not has_torch_cuda_gpu, reason="needs GPU & CUDA")
+def test_invalid_model():
+    orig_config = Config().from_str(_NLP_CONFIG)
+    config = copy.deepcopy(orig_config)
+    config["components"]["llm"]["model"]["name"] = "x"
+    with pytest.raises(ValueError, match="unexpected value; permitted"):
+        spacy.util.load_model_from_config(config, auto_fill=True)
diff --git a/spacy_llm/tests/backends/test_langchain.py b/spacy_llm/tests/models/test_langchain.py
similarity index 76%
rename from spacy_llm/tests/backends/test_langchain.py
rename to spacy_llm/tests/models/test_langchain.py
index cee14004..9f711d85 100644
--- a/spacy_llm/tests/backends/test_langchain.py
+++ b/spacy_llm/tests/models/test_langchain.py
@@ -1,13 +1,13 @@
-import spacy
 import pytest
+import spacy
 
 from spacy_llm.compat import has_langchain
 
 PIPE_CFG = {
-    "backend": {
-        "@llm_backends": "spacy.LangChain.v1",
-        "api": "OpenAI",
-        "config": {"temperature": 0.3, "model": "ada"},
+    "model": {
+        "@llm_models": "langchain.OpenAI.v1",
+        "name": "ada",
+        "config": {"temperature": 0.3},
         "query": {"@llm_queries": "spacy.CallLangChain.v1"},
     },
     "task": {"@llm_tasks": "spacy.NoOp.v1"},
diff --git a/spacy_llm/tests/backends/test_openllama.py b/spacy_llm/tests/models/test_openllama.py
similarity index 76%
rename from spacy_llm/tests/backends/test_openllama.py
rename to spacy_llm/tests/models/test_openllama.py
index 12a65903..105af819 100644
--- a/spacy_llm/tests/backends/test_openllama.py
+++ b/spacy_llm/tests/models/test_openllama.py
@@ -6,9 +6,9 @@
 from thinc.compat import has_torch_cuda_gpu
 
 _PIPE_CFG = {
-    "backend": {
-        "@llm_backends": "spacy.OpenLLaMa_HF.v1",
-        "model": "openlm-research/open_llama_3b_350bt_preview",
+    "model": {
+        "@llm_models": "spacy.OpenLLaMA.v1",
+        "name": "open_llama_3b_350bt_preview",
     },
     "task": {"@llm_tasks": "spacy.NoOp.v1"},
 }
@@ -27,9 +27,9 @@
 [components.llm.task]
 @llm_tasks = "spacy.NoOp.v1"
 
-[components.llm.backend]
-@llm_backends = spacy.OpenLLaMa_HF.v1
-model = "openlm-research/open_llama_3b_350bt_preview"
+[components.llm.model]
+@llm_models = spacy.OpenLLaMA.v1
+name = open_llama_3b_350bt_preview
 """
 
 
@@ -46,7 +46,7 @@ def test_init_with_set_config():
     """Test initialization and simple run with changed config."""
     nlp = spacy.blank("en")
     cfg = copy.deepcopy(_PIPE_CFG)
-    cfg["backend"]["config_run"] = {"max_new_tokens": 32}
+    cfg["model"]["config_run"] = {"max_new_tokens": 32}
     nlp.add_pipe("llm", config=cfg)
     nlp("This is a test.")
 
@@ -62,6 +62,6 @@ def test_init_from_config():
 def test_invalid_model():
     orig_config = Config().from_str(_NLP_CONFIG)
     config = copy.deepcopy(orig_config)
-    config["components"]["llm"]["backend"]["model"] = "anything-else"
-    with pytest.raises(ValueError, match="is not supported"):
+    config["components"]["llm"]["model"]["name"] = "anything-else"
+    with pytest.raises(ValueError, match="unexpected value; permitted"):
         spacy.util.load_model_from_config(config, auto_fill=True)
diff --git a/spacy_llm/tests/backends/test_rest.py b/spacy_llm/tests/models/test_rest.py
similarity index 75%
rename from spacy_llm/tests/backends/test_rest.py
rename to spacy_llm/tests/models/test_rest.py
index 2de49b8b..87af07e8 100644
--- a/spacy_llm/tests/backends/test_rest.py
+++ b/spacy_llm/tests/models/test_rest.py
@@ -11,10 +11,9 @@
 from ..compat import has_openai_key
 
 PIPE_CFG = {
-    "backend": {
-        "@llm_backends": "spacy.REST.v1",
-        "api": "OpenAI",
-        "config": {"temperature": 0.3, "model": "gpt-3.5-turbo"},
+    "model": {
+        "@llm_models": "spacy.GPT-3-5.v1",
+        "config": {"temperature": 0.3},
     },
     "task": {"@llm_tasks": "spacy.TextCat.v1", "labels": "POSITIVE,NEGATIVE"},
 }
@@ -22,22 +21,27 @@
 
 @registry.llm_tasks("spacy.Count.v1")
 class _CountTask:
+    _PROMPT_TEMPLATE = "Count the number of characters in this string: '{text}'."
+
     def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
         for doc in docs:
-            yield f"Count the number of characters in this string: '{doc.text}'."
+            yield _CountTask._PROMPT_TEMPLATE.format(text=doc.text)
 
     def parse_responses(
         self, docs: Iterable[Doc], responses: Iterable[str]
     ) -> Iterable[Doc]:
         return docs
 
+    @property
+    def prompt_template(self) -> str:
+        return _CountTask._PROMPT_TEMPLATE
+
 
 def test_initialization():
     """Test initialization and simple run"""
     nlp = spacy.blank("en")
     cfg = copy.deepcopy(PIPE_CFG)
-    cfg["backend"]["api"] = "NoOp"
-    cfg["backend"]["config"] = {"model": "NoOp"}
+    cfg["model"] = {"@llm_models": "spacy.NoOp.v1"}
     nlp.add_pipe("llm", config=cfg)
     nlp("This is a test.")
 
@@ -47,15 +51,14 @@ def test_initialization():
 def test_model_error_handling():
     """Test error handling for wrong model."""
     nlp = spacy.blank("en")
-    with pytest.raises(ValueError) as err:
+    with pytest.raises(ValueError, match="Could not find function 'spacy.gpt-3.5x.v1'"):
         nlp.add_pipe(
             "llm",
             config={
                 "task": {"@llm_tasks": "spacy.NoOp.v1"},
-                "backend": {"config": {"model": "x-gpt-3.5-turbo"}},
+                "model": {"@llm_models": "spacy.gpt-3.5x.v1"},
             },
         )
-    assert "The specified model 'x-gpt-3.5-turbo' is not available." in str(err.value)
 
 
 @pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
@@ -68,18 +71,18 @@ def test_doc_length_error_handling():
         config={
             # Not using the NoOp task is necessary here, as the NoOp task sends a fixed-size prompt.
             "task": {"@llm_tasks": "spacy.Count.v1"},
-            "backend": {"config": {"model": "ada"}},
+            "model": {"config": {"model": "ada"}},
         },
     )
     # Call with an overly long document to elicit error.
     with pytest.raises(
         ValueError,
         match=re.escape(
-            "Request to OpenAI API failed: This model's maximum context length is 2049 tokens, however you requested "
-            "2527 tokens (2511 in your prompt; 16 for the completion). Please reduce your prompt; or completion length."
+            "Request to OpenAI API failed: This model's maximum context length is 4097 tokens. However, your messages "
+            "resulted in 5018 tokens. Please reduce the length of the messages."
         ),
     ):
-        nlp("n" * 5000)
+        nlp("n" * 10000)
 
 
 @pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
@@ -95,7 +98,7 @@ def test_max_time_error_handling():
             "llm",
             config={
                 "task": {"@llm_tasks": "spacy.Count.v1"},
-                "backend": {
+                "model": {
                     "config": {"model": "ada"},
                     "max_request_time": 0.001,
                     "max_tries": 1,
diff --git a/spacy_llm/tests/backends/test_stablelm.py b/spacy_llm/tests/models/test_stablelm.py
similarity index 69%
rename from spacy_llm/tests/backends/test_stablelm.py
rename to spacy_llm/tests/models/test_stablelm.py
index 4f908449..ba69c778 100644
--- a/spacy_llm/tests/backends/test_stablelm.py
+++ b/spacy_llm/tests/models/test_stablelm.py
@@ -6,9 +6,9 @@
 from thinc.compat import has_torch_cuda_gpu
 
 _PIPE_CFG = {
-    "backend": {
-        "@llm_backends": "spacy.StableLM_HF.v1",
-        "model": "stabilityai/stablelm-base-alpha-3b",
+    "model": {
+        "@llm_models": "spacy.StableLM.v1",
+        "name": "stablelm-base-alpha-3b",
     },
     "task": {"@llm_tasks": "spacy.NoOp.v1"},
 }
@@ -27,24 +27,21 @@
 [components.llm.task]
 @llm_tasks = "spacy.NoOp.v1"
 
-[components.llm.backend]
-@llm_backends = "spacy.StableLM_HF.v1"
-model = "stabilityai/stablelm-base-alpha-3b"
+[components.llm.model]
+@llm_models = "spacy.StableLM.v1"
+name = "stablelm-base-alpha-3b"
 """
 
 
-@pytest.mark.parametrize(
-    "model",
-    ("stabilityai/stablelm-base-alpha-3b", "stabilityai/stablelm-tuned-alpha-3b"),
-)
+@pytest.mark.parametrize("name", ("stablelm-base-alpha-3b", "stablelm-tuned-alpha-3b"))
 @pytest.mark.skipif(not has_torch_cuda_gpu, reason="needs GPU & CUDA")
-def test_init(model: str):
+def test_init(name: str):
     """Test initialization and simple run.
-    model (str): Name of model to run.
+    name (str): Name of model to run.
     """
     nlp = spacy.blank("en")
     cfg = copy.deepcopy(_PIPE_CFG)
-    cfg["backend"]["model"] = model
+    cfg["model"]["name"] = name
     nlp.add_pipe("llm", config=cfg)
     nlp("This is a test.")
 
@@ -61,7 +58,7 @@ def test_init_with_set_config():
     """Test initialization and simple run with changed config."""
     nlp = spacy.blank("en")
     cfg = copy.deepcopy(_PIPE_CFG)
-    cfg["backend"]["config_run"] = {"temperature": 0.3}
+    cfg["model"]["config_run"] = {"temperature": 0.3}
     nlp.add_pipe("llm", config=cfg)
     nlp("This is a test.")
 
@@ -70,6 +67,6 @@ def test_init_with_set_config():
 def test_invalid_model():
     orig_config = Config().from_str(_NLP_CONFIG)
     config = copy.deepcopy(orig_config)
-    config["components"]["llm"]["backend"]["model"] = "anything-else"
-    with pytest.raises(ValueError, match="is not supported"):
+    config["components"]["llm"]["model"]["name"] = "anything-else"
+    with pytest.raises(ValueError, match="unexpected value; permitted:"):
         spacy.util.load_model_from_config(config, auto_fill=True)
diff --git a/spacy_llm/tests/pipeline/test_llm.py b/spacy_llm/tests/pipeline/test_llm.py
index 94780cfb..b2e35dd6 100644
--- a/spacy_llm/tests/pipeline/test_llm.py
+++ b/spacy_llm/tests/pipeline/test_llm.py
@@ -6,19 +6,22 @@
 
 import pytest
 import spacy
+import srsly
 from confection import Config
 from spacy.language import Language
 from spacy.tokens import Doc
+from spacy.util import make_tempdir
 from thinc.api import NumpyOps, get_current_ops
 
 import spacy_llm
-from spacy_llm.backends.rest.noop import _NOOP_RESPONSE
+from spacy_llm.models.rest.noop import _NOOP_RESPONSE
 from spacy_llm.pipeline import LLMWrapper
 from spacy_llm.registry import registry
 from spacy_llm.tasks import make_noop_task
 from spacy_llm.tasks.noop import _NOOP_PROMPT
 
 from ...cache import BatchCache
+from ...registry.reader import fewshot_reader
 from ...util import assemble_from_config
 from ..compat import has_openai_key
 
@@ -31,7 +34,7 @@ def noop_config() -> Dict[str, Any]:
     return {
         "save_io": True,
         "task": {"@llm_tasks": "spacy.NoOp.v1"},
-        "backend": {"api": "NoOp", "config": {"model": "NoOp"}},
+        "model": {"@llm_models": "spacy.NoOp.v1"},
     }
 
 
@@ -76,7 +79,7 @@ def test_llm_pipe_with_cache(tmp_path: Path, n_process: int):
 
     config = {
         "task": {"@llm_tasks": "spacy.NoOp.v1"},
-        "backend": {"api": "NoOp", "config": {"model": "NoOp"}},
+        "model": {"@llm_models": "spacy.NoOp.v1"},
         "cache": {
             "path": str(path),
             "batch_size": 1,  # Eager caching
@@ -113,7 +116,7 @@ def test_llm_serialize_bytes():
     llm = LLMWrapper(
         task=make_noop_task(),
         save_io=False,
-        backend=None,  # type: ignore
+        model=None,  # type: ignore
         cache=BatchCache(path=None, batch_size=0, max_batches_in_mem=0),
         vocab=None,  # type: ignore
     )
@@ -124,7 +127,7 @@ def test_llm_serialize_disk():
     llm = LLMWrapper(
         task=make_noop_task(),
         save_io=False,
-        backend=None,  # type: ignore
+        model=None,  # type: ignore
         cache=BatchCache(path=None, batch_size=0, max_batches_in_mem=0),
         vocab=None,  # type: ignore
     )
@@ -169,11 +172,11 @@ def parse_responses(
     assert (
         str(record[0].message)
         == "Type returned from `task.generate_prompts()` (`typing.Iterable[int]`) doesn't match type "
-        "expected by `backend` (`typing.Iterable[str]`)."
+        "expected by `model` (`typing.Iterable[str]`)."
     )
     assert (
         str(record[1].message)
-        == "Type returned from `backend` (`typing.Iterable[str]`) doesn't match type "
+        == "Type returned from `model` (`typing.Iterable[str]`) doesn't match type "
         "expected by `task.parse_responses()` (`typing.Iterable[float]`)."
     )
 
@@ -211,8 +214,7 @@ def test_llm_logs_at_debug_level(
 
 
 def test_llm_logs_default_null_handler(nlp: Language, capsys: pytest.CaptureFixture):
-
-    doc = nlp("This is a test")
+    nlp("This is a test")
 
     captured = capsys.readouterr()
     assert captured.out == ""
@@ -239,6 +241,24 @@ def test_llm_logs_default_null_handler(nlp: Language, capsys: pytest.CaptureFixt
     assert f"LLM response for doc: {doc.text}" not in captured.out
 
 
+def test_fewshot_reader_file_format_handling():
+    """Test if fewshot reader copes with file formats as expected."""
+    example = [
+        {
+            "text": "Circe lived on Aeaea.",
+            "entities": {"PER": ["Circe"], "LOC": ["Aeaea"]},
+        }
+    ]
+    with make_tempdir() as tmpdir:
+        srsly.write_yaml(tmpdir / "example.yml", example)
+        srsly.write_yaml(tmpdir / "example.json", example)
+        srsly.write_yaml(tmpdir / "example.foo", example)
+
+        fewshot_reader(tmpdir / "example.yml")
+        fewshot_reader(tmpdir / "example.json")
+        fewshot_reader(tmpdir / "example.foo")
+
+
 @pytest.mark.external
 @pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
 def test_pipe_labels():
@@ -258,10 +278,9 @@ def test_pipe_labels():
     @llm_tasks = "spacy.TextCat.v2"
     labels = ["COMPLIMENT", "INSULT"]
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
+    config = {"temperature": 0.3}
     """
 
     config = Config().from_str(cfg_string)
diff --git a/spacy_llm/tests/tasks/examples/lemma_examples.json b/spacy_llm/tests/tasks/examples/lemma.json
similarity index 100%
rename from spacy_llm/tests/tasks/examples/lemma_examples.json
rename to spacy_llm/tests/tasks/examples/lemma.json
diff --git a/spacy_llm/tests/tasks/examples/lemma_examples.jsonl b/spacy_llm/tests/tasks/examples/lemma.jsonl
similarity index 100%
rename from spacy_llm/tests/tasks/examples/lemma_examples.jsonl
rename to spacy_llm/tests/tasks/examples/lemma.jsonl
diff --git a/spacy_llm/tests/tasks/examples/lemma_examples.yml b/spacy_llm/tests/tasks/examples/lemma.yml
similarity index 100%
rename from spacy_llm/tests/tasks/examples/lemma_examples.yml
rename to spacy_llm/tests/tasks/examples/lemma.yml
diff --git a/spacy_llm/tests/tasks/examples/ner_examples.json b/spacy_llm/tests/tasks/examples/ner.json
similarity index 100%
rename from spacy_llm/tests/tasks/examples/ner_examples.json
rename to spacy_llm/tests/tasks/examples/ner.json
diff --git a/spacy_llm/tests/tasks/examples/ner_examples.jsonl b/spacy_llm/tests/tasks/examples/ner.jsonl
similarity index 100%
rename from spacy_llm/tests/tasks/examples/ner_examples.jsonl
rename to spacy_llm/tests/tasks/examples/ner.jsonl
diff --git a/spacy_llm/tests/tasks/examples/ner_examples.yml b/spacy_llm/tests/tasks/examples/ner.yml
similarity index 100%
rename from spacy_llm/tests/tasks/examples/ner_examples.yml
rename to spacy_llm/tests/tasks/examples/ner.yml
diff --git a/spacy_llm/tests/tasks/examples/ner_inconsistent.yml b/spacy_llm/tests/tasks/examples/ner_inconsistent.yml
new file mode 100644
index 00000000..1111e3de
--- /dev/null
+++ b/spacy_llm/tests/tasks/examples/ner_inconsistent.yml
@@ -0,0 +1,20 @@
+- text: Jack and Jill went up the hill.
+  entities:
+    PERSON:
+      - Jack
+      - Jill
+    LOCATION:
+      - hill
+- text: spaCy is a great tool
+  entities:
+    TECH:
+      - spaCy
+- text: Jack and Jill went up the hill and spaCy is a great tool.
+  entities:
+    PERSON:
+      - Jack
+      - Jill
+    LOCATION:
+      - hill
+    TECH:
+      - spaCy
\ No newline at end of file
diff --git a/spacy_llm/tests/tasks/examples/rel_examples.jsonl b/spacy_llm/tests/tasks/examples/rel.jsonl
similarity index 100%
rename from spacy_llm/tests/tasks/examples/rel_examples.jsonl
rename to spacy_llm/tests/tasks/examples/rel.jsonl
diff --git a/spacy_llm/tests/tasks/examples/sentiment.json b/spacy_llm/tests/tasks/examples/sentiment.json
new file mode 100644
index 00000000..1b36d7d0
--- /dev/null
+++ b/spacy_llm/tests/tasks/examples/sentiment.json
@@ -0,0 +1,18 @@
+[
+  {
+    "text": "This is horrifying.",
+    "score": 0
+  },
+  {
+    "text": "This is underwhelming.",
+    "score": 0.25
+  },
+  {
+    "text": "This is ok.",
+    "score": 0.5
+  },
+  {
+    "text": "I'm looking forward to this!",
+    "score": 1.0
+  }
+]
\ No newline at end of file
diff --git a/spacy_llm/tests/tasks/examples/sentiment.jsonl b/spacy_llm/tests/tasks/examples/sentiment.jsonl
new file mode 100644
index 00000000..7cb14dee
--- /dev/null
+++ b/spacy_llm/tests/tasks/examples/sentiment.jsonl
@@ -0,0 +1,4 @@
+{"text": "This is horrifying.", "score": 0}
+{"text": "This is underwhelming.", "score": 0.25}
+{"text": "This is ok.", "score": 0.5}
+{"text": "I'm looking forward to this!", "score": 1.0}
\ No newline at end of file
diff --git a/spacy_llm/tests/tasks/examples/sentiment.yml b/spacy_llm/tests/tasks/examples/sentiment.yml
new file mode 100644
index 00000000..0348064c
--- /dev/null
+++ b/spacy_llm/tests/tasks/examples/sentiment.yml
@@ -0,0 +1,8 @@
+- text: "This is horrifying."
+  score: 0
+- text: "This is underwhelming."
+  score: 0.25
+- text: "This is ok."
+  score: 0.5
+- text: "I'm looking forward to this!"
+  score: 1.0
\ No newline at end of file
diff --git a/spacy_llm/tests/tasks/examples/summarization.json b/spacy_llm/tests/tasks/examples/summarization.json
new file mode 100644
index 00000000..fec10bfc
--- /dev/null
+++ b/spacy_llm/tests/tasks/examples/summarization.json
@@ -0,0 +1,10 @@
+[
+  {
+    "text": "The United Nations, referred to informally as the UN, is an intergovernmental organization whose stated purposes are to maintain international peace and security, develop friendly relations among nations, achieve international cooperation, and serve as a centre for harmonizing the actions of nations. It is the world's largest international organization. The UN is headquartered on international territory in New York City, and the organization has other offices in Geneva, Nairobi, Vienna, and The Hague, where the International Court of Justice is headquartered.\n\nThe UN was established after World War II with the aim of preventing future world wars, and succeeded the League of Nations, which was characterized as ineffective. On 25 April 1945, 50 nations met in San Francisco, California for a conference and started drafting the UN Charter, which was adopted on 25 June 1945. The charter took effect on 24 October 1945, when the UN began operations. The organization's objectives, as defined by its charter, include maintaining international peace and security, protecting human rights, delivering humanitarian aid, promoting sustainable development, and upholding international law. At its founding, the UN had 51 member states; as of 2023, it has 193 – almost all of the world's sovereign states.",
+    "summary": "UN is an intergovernmental organization to foster international peace, security, and cooperation. Established after WW2 with 51 members, now 193."
+  },
+  {
+    "text": "Life is a quality that distinguishes matter that has biological processes, such as signaling and self-sustaining processes, from matter that does not, and is defined by the capacity for growth, reaction to stimuli, metabolism, energy transformation, and reproduction. Various forms of life exist, such as plants, animals, fungi, protists, archaea, and bacteria. Biology is the science that studies life.\n\nThe gene is the unit of heredity, whereas the cell is the structural and functional unit of life. There are two kinds of cells, prokaryotic and eukaryotic, both of which consist of cytoplasm enclosed within a membrane and contain many biomolecules such as proteins and nucleic acids. Cells reproduce through a process of cell division, in which the parent cell divides into two or more daughter cells and passes its genes onto a new generation, sometimes producing genetic variation.\n\nOrganisms, or the individual entities of life, are generally thought to be open systems that maintain homeostasis, are composed of cells, have a life cycle, undergo metabolism, can grow, adapt to their environment, respond to stimuli, reproduce and evolve over multiple generations. Other definitions sometimes include non-cellular life forms such as viruses and viroids, but they are usually excluded because they do not function on their own; rather, they exploit the biological processes of hosts.",
+    "summary": "Life is a quality defined by biological processes, including reproduction, genetics, and metabolism. There are two types of cells and organisms that can grow, respond, reproduce, and evolve."
+  }
+]
\ No newline at end of file
diff --git a/spacy_llm/tests/tasks/examples/summarization.jsonl b/spacy_llm/tests/tasks/examples/summarization.jsonl
new file mode 100644
index 00000000..b4835f16
--- /dev/null
+++ b/spacy_llm/tests/tasks/examples/summarization.jsonl
@@ -0,0 +1,2 @@
+{"text": "The United Nations, referred to informally as the UN, is an intergovernmental organization whose stated purposes are to maintain international peace and security, develop friendly relations among nations, achieve international cooperation, and serve as a centre for harmonizing the actions of nations. It is the world's largest international organization. The UN is headquartered on international territory in New York City, and the organization has other offices in Geneva, Nairobi, Vienna, and The Hague, where the International Court of Justice is headquartered.\n\nThe UN was established after World War II with the aim of preventing future world wars, and succeeded the League of Nations, which was characterized as ineffective. On 25 April 1945, 50 nations met in San Francisco, California for a conference and started drafting the UN Charter, which was adopted on 25 June 1945. The charter took effect on 24 October 1945, when the UN began operations. The organization's objectives, as defined by its charter, include maintaining international peace and security, protecting human rights, delivering humanitarian aid, promoting sustainable development, and upholding international law. At its founding, the UN had 51 member states; as of 2023, it has 193 – almost all of the world's sovereign states.", "summary": "UN is an intergovernmental organization to foster international peace, security, and cooperation. Established after WW2 with 51 members, now 193."}
+{"text": "Life is a quality that distinguishes matter that has biological processes, such as signaling and self-sustaining processes, from matter that does not, and is defined by the capacity for growth, reaction to stimuli, metabolism, energy transformation, and reproduction. Various forms of life exist, such as plants, animals, fungi, protists, archaea, and bacteria. Biology is the science that studies life.\n\nThe gene is the unit of heredity, whereas the cell is the structural and functional unit of life. There are two kinds of cells, prokaryotic and eukaryotic, both of which consist of cytoplasm enclosed within a membrane and contain many biomolecules such as proteins and nucleic acids. Cells reproduce through a process of cell division, in which the parent cell divides into two or more daughter cells and passes its genes onto a new generation, sometimes producing genetic variation.\n\nOrganisms, or the individual entities of life, are generally thought to be open systems that maintain homeostasis, are composed of cells, have a life cycle, undergo metabolism, can grow, adapt to their environment, respond to stimuli, reproduce and evolve over multiple generations. Other definitions sometimes include non-cellular life forms such as viruses and viroids, but they are usually excluded because they do not function on their own; rather, they exploit the biological processes of hosts.", "summary": "Life is a quality defined by biological processes, including reproduction, genetics, and metabolism. There are two types of cells and organisms that can grow, respond, reproduce, and evolve."}
\ No newline at end of file
diff --git a/spacy_llm/tests/tasks/examples/summarization.yml b/spacy_llm/tests/tasks/examples/summarization.yml
new file mode 100644
index 00000000..97940e09
--- /dev/null
+++ b/spacy_llm/tests/tasks/examples/summarization.yml
@@ -0,0 +1,5 @@
+- text: "The United Nations, referred to informally as the UN, is an intergovernmental organization whose stated purposes are to maintain international peace and security, develop friendly relations among nations, achieve international cooperation, and serve as a centre for harmonizing the actions of nations. It is the world's largest international organization. The UN is headquartered on international territory in New York City, and the organization has other offices in Geneva, Nairobi, Vienna, and The Hague, where the International Court of Justice is headquartered.\n\nThe UN was established after World War II with the aim of preventing future world wars, and succeeded the League of Nations, which was characterized as ineffective. On 25 April 1945, 50 nations met in San Francisco, California for a conference and started drafting the UN Charter, which was adopted on 25 June 1945. The charter took effect on 24 October 1945, when the UN began operations. The organization's objectives, as defined by its charter, include maintaining international peace and security, protecting human rights, delivering humanitarian aid, promoting sustainable development, and upholding international law. At its founding, the UN had 51 member states; as of 2023, it has 193 – almost all of the world's sovereign states."
+  summary: "UN is an intergovernmental organization to foster international peace, security, and cooperation. Established after WW2 with 51 members, now 193."
+
+- text: "Life is a quality that distinguishes matter that has biological processes, such as signaling and self-sustaining processes, from matter that does not, and is defined by the capacity for growth, reaction to stimuli, metabolism, energy transformation, and reproduction. Various forms of life exist, such as plants, animals, fungi, protists, archaea, and bacteria. Biology is the science that studies life.\n\nThe gene is the unit of heredity, whereas the cell is the structural and functional unit of life. There are two kinds of cells, prokaryotic and eukaryotic, both of which consist of cytoplasm enclosed within a membrane and contain many biomolecules such as proteins and nucleic acids. Cells reproduce through a process of cell division, in which the parent cell divides into two or more daughter cells and passes its genes onto a new generation, sometimes producing genetic variation.\n\nOrganisms, or the individual entities of life, are generally thought to be open systems that maintain homeostasis, are composed of cells, have a life cycle, undergo metabolism, can grow, adapt to their environment, respond to stimuli, reproduce and evolve over multiple generations. Other definitions sometimes include non-cellular life forms such as viruses and viroids, but they are usually excluded because they do not function on their own; rather, they exploit the biological processes of hosts."
+  summary: "Life is a quality defined by biological processes, including reproduction, genetics, and metabolism. There are two types of cells and organisms that can grow, respond, reproduce, and evolve."
diff --git a/spacy_llm/tests/tasks/examples/textcat_binary_examples.json b/spacy_llm/tests/tasks/examples/textcat_binary.json
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_binary_examples.json
rename to spacy_llm/tests/tasks/examples/textcat_binary.json
diff --git a/spacy_llm/tests/tasks/examples/textcat_binary_examples.jsonl b/spacy_llm/tests/tasks/examples/textcat_binary.jsonl
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_binary_examples.jsonl
rename to spacy_llm/tests/tasks/examples/textcat_binary.jsonl
diff --git a/spacy_llm/tests/tasks/examples/textcat_binary_examples.yml b/spacy_llm/tests/tasks/examples/textcat_binary.yml
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_binary_examples.yml
rename to spacy_llm/tests/tasks/examples/textcat_binary.yml
diff --git a/spacy_llm/tests/tasks/examples/textcat_multi_excl_examples.json b/spacy_llm/tests/tasks/examples/textcat_multi_excl.json
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_multi_excl_examples.json
rename to spacy_llm/tests/tasks/examples/textcat_multi_excl.json
diff --git a/spacy_llm/tests/tasks/examples/textcat_multi_excl_examples.jsonl b/spacy_llm/tests/tasks/examples/textcat_multi_excl.jsonl
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_multi_excl_examples.jsonl
rename to spacy_llm/tests/tasks/examples/textcat_multi_excl.jsonl
diff --git a/spacy_llm/tests/tasks/examples/textcat_multi_excl_examples.yml b/spacy_llm/tests/tasks/examples/textcat_multi_excl.yml
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_multi_excl_examples.yml
rename to spacy_llm/tests/tasks/examples/textcat_multi_excl.yml
diff --git a/spacy_llm/tests/tasks/examples/textcat_multi_nonexcl_examples.json b/spacy_llm/tests/tasks/examples/textcat_multi_nonexcl.json
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_multi_nonexcl_examples.json
rename to spacy_llm/tests/tasks/examples/textcat_multi_nonexcl.json
diff --git a/spacy_llm/tests/tasks/examples/textcat_multi_nonexcl_examples.jsonl b/spacy_llm/tests/tasks/examples/textcat_multi_nonexcl.jsonl
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_multi_nonexcl_examples.jsonl
rename to spacy_llm/tests/tasks/examples/textcat_multi_nonexcl.jsonl
diff --git a/spacy_llm/tests/tasks/examples/textcat_multi_nonexcl_examples.yml b/spacy_llm/tests/tasks/examples/textcat_multi_nonexcl.yml
similarity index 100%
rename from spacy_llm/tests/tasks/examples/textcat_multi_nonexcl_examples.yml
rename to spacy_llm/tests/tasks/examples/textcat_multi_nonexcl.yml
diff --git a/spacy_llm/tests/tasks/templates/lemma_template.jinja2 b/spacy_llm/tests/tasks/templates/lemma.jinja2
similarity index 100%
rename from spacy_llm/tests/tasks/templates/lemma_template.jinja2
rename to spacy_llm/tests/tasks/templates/lemma.jinja2
diff --git a/spacy_llm/tests/tasks/templates/ner_template.jinja2 b/spacy_llm/tests/tasks/templates/ner.jinja2
similarity index 100%
rename from spacy_llm/tests/tasks/templates/ner_template.jinja2
rename to spacy_llm/tests/tasks/templates/ner.jinja2
diff --git a/spacy_llm/tests/tasks/templates/sentiment.jinja2 b/spacy_llm/tests/tasks/templates/sentiment.jinja2
new file mode 100644
index 00000000..270d16a8
--- /dev/null
+++ b/spacy_llm/tests/tasks/templates/sentiment.jinja2
@@ -0,0 +1,2 @@
+Text: {{ text }}
+Sentiment:
\ No newline at end of file
diff --git a/spacy_llm/tests/tasks/templates/summarization.jinja2 b/spacy_llm/tests/tasks/templates/summarization.jinja2
new file mode 100644
index 00000000..ec048632
--- /dev/null
+++ b/spacy_llm/tests/tasks/templates/summarization.jinja2
@@ -0,0 +1,2 @@
+This is a test summarization template.
+Here is the text: {{ text }}
\ No newline at end of file
diff --git a/spacy_llm/tests/tasks/templates/textcat_template.jinja2 b/spacy_llm/tests/tasks/templates/textcat.jinja2
similarity index 100%
rename from spacy_llm/tests/tasks/templates/textcat_template.jinja2
rename to spacy_llm/tests/tasks/templates/textcat.jinja2
diff --git a/spacy_llm/tests/tasks/test_lemma.py b/spacy_llm/tests/tasks/test_lemma.py
index c2e105c2..dafd5ea2 100644
--- a/spacy_llm/tests/tasks/test_lemma.py
+++ b/spacy_llm/tests/tasks/test_lemma.py
@@ -3,9 +3,13 @@
 import pytest
 import spacy
 from confection import Config
+from spacy.tokens import Doc
+from spacy.training import Example
 from spacy.util import make_tempdir
 
 from spacy_llm.registry import fewshot_reader, file_reader
+from spacy_llm.tasks.lemma import LemmaTask
+from spacy_llm.util import assemble_from_config
 
 from ...tasks import make_lemma_task
 from ..compat import has_openai_key
@@ -14,6 +18,27 @@
 TEMPLATES_DIR = Path(__file__).parent / "templates"
 
 
+@pytest.fixture
+def noop_config():
+    return """
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+    batch_size = 128
+
+    [components]
+
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.Lemma.v1"
+
+    [components.llm.model]
+    @llm_models = "test.NoOpModel.v1"
+    """
+
+
 @pytest.fixture
 def zeroshot_cfg_string():
     return """
@@ -30,9 +55,8 @@ def zeroshot_cfg_string():
     [components.llm.task]
     @llm_tasks = "spacy.Lemma.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     config = {"temperature": 0}
     """
 
@@ -55,11 +79,10 @@ def fewshot_cfg_string():
 
     [components.llm.task.examples]
     @misc = "spacy.FewShotReader.v1"
-    path = {str((Path(__file__).parent / "examples" / "lemma_examples.yml"))}
+    path = {str((Path(__file__).parent / "examples" / "lemma.yml"))}
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     config = {{"temperature": 0}}
     """
 
@@ -83,12 +106,11 @@ def ext_template_cfg_string():
 
     [components.llm.task.template]
     @misc = "spacy.FileReader.v1"
-    path = {str((Path(__file__).parent / "templates" / "lemma_template.jinja2"))}
+    path = {str((Path(__file__).parent / "templates" / "lemma.jinja2"))}
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {{}}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
+    config = {{"temperature": 0}}
     """
 
 
@@ -137,7 +159,10 @@ def test_lemma_predict(cfg_string, request):
     lemmas = [str(token.lemma_) for token in nlp("I've watered the plants.")]
     # Compare lemmas for correctness, if we are not using the external dummy template.
     if cfg_string != "ext_template_cfg_string":
-        assert lemmas == ["I", "have", "water", "the", "plant", "."]
+        assert lemmas in (
+            ["-PRON-", "have", "water", "the", "plant", "."],
+            ["I", "have", "water", "the", "plant", "."],
+        )
 
 
 @pytest.mark.external
@@ -160,7 +185,10 @@ def test_lemma_io(cfg_string, request):
     assert nlp2.pipe_names == ["llm"]
     lemmas = [str(token.lemma_) for token in nlp2("I've watered the plants.")]
     if cfg_string != "ext_template_cfg_string":
-        assert lemmas == ["I", "have", "water", "the", "plant", "."]
+        assert lemmas in (
+            ["-PRON-", "have", "water", "the", "plant", "."],
+            ["I", "have", "water", "the", "plant", "."],
+        )
 
 
 def test_jinja_template_rendering_without_examples():
@@ -201,9 +229,9 @@ def test_jinja_template_rendering_without_examples():
 @pytest.mark.parametrize(
     "examples_path",
     [
-        str(EXAMPLES_DIR / "lemma_examples.json"),
-        str(EXAMPLES_DIR / "lemma_examples.yml"),
-        str(EXAMPLES_DIR / "lemma_examples.jsonl"),
+        str(EXAMPLES_DIR / "lemma.json"),
+        str(EXAMPLES_DIR / "lemma.yml"),
+        str(EXAMPLES_DIR / "lemma.jsonl"),
     ],
 )
 def test_jinja_template_rendering_with_examples(examples_path):
@@ -302,7 +330,7 @@ def test_jinja_template_rendering_with_examples(examples_path):
 
 
 def test_external_template_actually_loads():
-    template_path = str(TEMPLATES_DIR / "lemma_template.jinja2")
+    template_path = str(TEMPLATES_DIR / "lemma.jinja2")
     template = file_reader(template_path)
     text = "Alice and Bob went to the supermarket"
     nlp = spacy.blank("xx")
@@ -317,3 +345,37 @@ def test_external_template_actually_loads():
 Here is the text: {text}
 """.strip()
     )
+
+
+@pytest.mark.parametrize("n_prompt_examples", [-1, 0, 1, 2])
+def test_lemma_init(noop_config, n_prompt_examples: int):
+    config = Config().from_str(noop_config)
+    nlp = assemble_from_config(config)
+
+    examples = []
+    pred_words_1 = ["Alice", "works", "all", "evenings"]
+    gold_lemmas_1 = ["Alice", "work", "all", "evening"]
+    pred_1 = Doc(nlp.vocab, words=pred_words_1)
+    gold_1 = Doc(nlp.vocab, words=pred_words_1, lemmas=gold_lemmas_1)
+    examples.append(Example(pred_1, gold_1))
+
+    pred_words_2 = ["Bob", "loves", "living", "cities"]
+    gold_lemmas_2 = ["Bob", "love", "live", "city"]
+    pred_2 = Doc(nlp.vocab, words=pred_words_2)
+    gold_2 = Doc(nlp.vocab, words=pred_words_2, lemmas=gold_lemmas_2)
+    examples.append(Example(pred_2, gold_2))
+
+    _, llm = nlp.pipeline[0]
+    task: LemmaTask = llm._task
+
+    assert not task._prompt_examples
+
+    nlp.config["initialize"]["components"]["llm"] = {
+        "n_prompt_examples": n_prompt_examples
+    }
+    nlp.initialize(lambda: examples)
+
+    if n_prompt_examples >= 0:
+        assert len(task._prompt_examples) == n_prompt_examples
+    else:
+        assert len(task._prompt_examples) == len(examples)
diff --git a/spacy_llm/tests/tasks/test_ner.py b/spacy_llm/tests/tasks/test_ner.py
index b96f9094..b1605c1c 100644
--- a/spacy_llm/tests/tasks/test_ner.py
+++ b/spacy_llm/tests/tasks/test_ner.py
@@ -1,11 +1,11 @@
 import json
+import re
 from pathlib import Path
 
 import pytest
 import spacy
 import srsly
 from confection import Config
-from pydantic import ValidationError
 from spacy.tokens import Span
 from spacy.training import Example
 from spacy.util import make_tempdir
@@ -44,10 +44,8 @@ def zeroshot_cfg_string():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -76,10 +74,8 @@ def zeroshot_cfg_string_v2_lds():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -102,15 +98,13 @@ def fewshot_cfg_string():
 
     [components.llm.task.examples]
     @misc = "spacy.FewShotReader.v1"
-    path = {str((Path(__file__).parent / "examples" / "ner_examples.yml"))}
+    path = {str((Path(__file__).parent / "examples" / "ner.yml"))}
 
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config: {{}}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -133,15 +127,13 @@ def fewshot_cfg_string_v2():
 
     [components.llm.task.examples]
     @misc = "spacy.FewShotReader.v1"
-    path = {str((Path(__file__).parent / "examples" / "ner_examples.yml"))}
+    path = {str((Path(__file__).parent / "examples" / "ner.yml"))}
 
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config: {{}}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -165,15 +157,13 @@ def ext_template_cfg_string():
 
     [components.llm.task.template]
     @misc = "spacy.FileReader.v1"
-    path = {str((Path(__file__).parent / "templates" / "ner_template.jinja2"))}
+    path = {str((Path(__file__).parent / "templates" / "ner.jinja2"))}
 
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {{}}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -211,7 +201,7 @@ def test_ner_config(cfg_string, request):
     labels = split_labels(labels)
     task = pipe.task
     assert isinstance(task, Labeled)
-    assert task.labels == tuple(labels)
+    assert sorted(task.labels) == sorted(tuple(labels))
     assert pipe.labels == task.labels
     assert nlp.pipe_labels["llm"] == list(task.labels)
 
@@ -232,14 +222,17 @@ def test_ner_predict(cfg_string, request):
     """Use OpenAI to get zero-shot NER results.
     Note that this test may fail randomly, as the LLM's output is unguaranteed to be consistent/predictable
     """
+    orig_cfg_string = cfg_string
     cfg_string = request.getfixturevalue(cfg_string)
     orig_config = Config().from_str(cfg_string)
     nlp = spacy.util.load_model_from_config(orig_config, auto_fill=True)
     text = "Marc and Bob both live in Ireland."
     doc = nlp(text)
-    assert len(doc.ents) > 0
-    for ent in doc.ents:
-        assert ent.label_ in ["PER", "ORG", "LOC"]
+
+    if orig_cfg_string != "ext_template_cfg_string":
+        assert len(doc.ents) > 0
+        for ent in doc.ents:
+            assert ent.label_ in ["PER", "ORG", "LOC"]
 
 
 @pytest.mark.external
@@ -519,9 +512,9 @@ def test_jinja_template_rendering_without_examples():
 You are an expert Named Entity Recognition (NER) system. Your task is to accept Text as input and extract named entities for the set of predefined entity labels.
 From the Text input provided, extract named entities for each label in the following format:
 
-PER: <comma delimited list of strings>
-ORG: <comma delimited list of strings>
 LOC: <comma delimited list of strings>
+ORG: <comma delimited list of strings>
+PER: <comma delimited list of strings>
 
 
 Here is the text that needs labeling:
@@ -537,9 +530,9 @@ def test_jinja_template_rendering_without_examples():
 @pytest.mark.parametrize(
     "examples_path",
     [
-        str(EXAMPLES_DIR / "ner_examples.json"),
-        str(EXAMPLES_DIR / "ner_examples.yml"),
-        str(EXAMPLES_DIR / "ner_examples.jsonl"),
+        str(EXAMPLES_DIR / "ner.json"),
+        str(EXAMPLES_DIR / "ner.yml"),
+        str(EXAMPLES_DIR / "ner.jsonl"),
     ],
 )
 def test_jinja_template_rendering_with_examples(examples_path):
@@ -562,9 +555,9 @@ def test_jinja_template_rendering_with_examples(examples_path):
 You are an expert Named Entity Recognition (NER) system. Your task is to accept Text as input and extract named entities for the set of predefined entity labels.
 From the Text input provided, extract named entities for each label in the following format:
 
-PER: <comma delimited list of strings>
-ORG: <comma delimited list of strings>
 LOC: <comma delimited list of strings>
+ORG: <comma delimited list of strings>
+PER: <comma delimited list of strings>
 
 
 Below are some examples (only use these as a guide):
@@ -626,9 +619,9 @@ def test_jinja_template_rendering_with_label_definitions():
 You are an expert Named Entity Recognition (NER) system. Your task is to accept Text as input and extract named entities for the set of predefined entity labels.
 From the Text input provided, extract named entities for each label in the following format:
 
-PER: <comma delimited list of strings>
-ORG: <comma delimited list of strings>
 LOC: <comma delimited list of strings>
+ORG: <comma delimited list of strings>
+PER: <comma delimited list of strings>
 
 Below are definitions of each label to help aid you in what kinds of named entities to extract for each label.
 Assume these definitions are written by an expert and follow them closely.
@@ -659,12 +652,12 @@ def test_example_not_following_basemodel():
         tmp_path = tmpdir / "wrong_example.yml"
         srsly.write_yaml(tmp_path, wrong_example)
 
-        with pytest.raises(ValidationError):
+        with pytest.raises(ValueError):
             make_ner_task_v2(labels="PER,ORG,LOC", examples=fewshot_reader(tmp_path))
 
 
 def test_external_template_actually_loads():
-    template_path = str(TEMPLATES_DIR / "ner_template.jinja2")
+    template_path = str(TEMPLATES_DIR / "ner.jinja2")
     template = file_reader(template_path)
     labels = "PER,ORG,LOC"
     nlp = spacy.blank("xx")
@@ -676,9 +669,9 @@ def test_external_template_actually_loads():
         prompt.strip()
         == """
 This is a test NER template. Here are the labels
-PER
-ORG
 LOC
+ORG
+PER
 
 Here is the text: Alice and Bob went to the supermarket
 """.strip()
@@ -705,15 +698,14 @@ def noop_config():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "test.NoOpBackend.v1"
+    [components.llm.model]
+    @llm_models = "test.NoOpModel.v1"
     output = "PER: Alice,Bob"
     """
 
 
 @pytest.mark.parametrize("n_detections", [0, 1, 2])
 def test_ner_scoring(noop_config, n_detections):
-
     config = Config().from_str(noop_config)
     nlp = assemble_from_config(config)
 
@@ -735,10 +727,11 @@ def test_ner_scoring(noop_config, n_detections):
     assert scores["ents_p"] == n_detections / 2
 
 
-def test_ner_init(noop_config):
-
+@pytest.mark.parametrize("n_prompt_examples", [-1, 0, 1, 2])
+def test_ner_init(noop_config, n_prompt_examples: int):
     config = Config().from_str(noop_config)
     del config["components"]["llm"]["task"]["labels"]
+
     nlp = assemble_from_config(config)
 
     examples = []
@@ -762,12 +755,25 @@ def test_ner_init(noop_config):
     task: NERTask = llm._task
 
     assert set(task._label_dict.values()) == set()
+    assert not task._prompt_examples
+
+    nlp.config["initialize"]["components"]["llm"] = {
+        "n_prompt_examples": n_prompt_examples
+    }
     nlp.initialize(lambda: examples)
+
     assert set(task._label_dict.values()) == {"PER", "LOC"}
+    if n_prompt_examples >= 0:
+        assert len(task._prompt_examples) == n_prompt_examples
+    else:
+        assert len(task._prompt_examples) == len(examples)
 
+    if n_prompt_examples > 0:
+        for eg in task._prompt_examples:
+            assert set(eg.entities.keys()) == {"PER", "LOC"}
 
-def test_ner_serde(noop_config):
 
+def test_ner_serde(noop_config):
     config = Config().from_str(noop_config)
     del config["components"]["llm"]["task"]["labels"]
 
@@ -792,7 +798,6 @@ def test_ner_serde(noop_config):
 
 
 def test_ner_to_disk(noop_config, tmp_path: Path):
-
     config = Config().from_str(noop_config)
     del config["components"]["llm"]["task"]["labels"]
 
@@ -823,3 +828,55 @@ def test_ner_to_disk(noop_config, tmp_path: Path):
     nlp2.from_disk(path)
 
     assert task1._label_dict == task2._label_dict == labels
+
+
+def test_label_inconsistency():
+    """Test whether inconsistency between specified labels and labels in examples is detected."""
+    cfg = f"""
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+
+    [components]
+
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.NER.v2"
+    labels = ["PERSON", "LOCATION"]
+
+    [components.llm.task.examples]
+    @misc = "spacy.FewShotReader.v1"
+    path = {str((Path(__file__).parent / "examples" / "ner_inconsistent.yml"))}
+
+    [components.llm.model]
+    @llm_models = "test.NoOpModel.v1"
+    """
+
+    config = Config().from_str(cfg)
+    with pytest.warns(
+        UserWarning,
+        match=re.escape(
+            "Examples contain labels that are not specified in the task configuration. The latter contains the "
+            "following labels: ['LOCATION', 'PERSON']. Labels in examples missing from the task configuration: "
+            "['TECH']. Please ensure your label specification and example labels are consistent."
+        ),
+    ):
+        nlp = assemble_from_config(config)
+
+    prompt_examples = nlp.get_pipe("llm")._task._prompt_examples
+    assert len(prompt_examples) == 2
+    assert prompt_examples[0].text == "Jack and Jill went up the hill."
+    assert prompt_examples[0].entities == {
+        "LOCATION": ["hill"],
+        "PERSON": ["Jack", "Jill"],
+    }
+    assert (
+        prompt_examples[1].text
+        == "Jack and Jill went up the hill and spaCy is a great tool."
+    )
+    assert prompt_examples[1].entities == {
+        "LOCATION": ["hill"],
+        "PERSON": ["Jack", "Jill"],
+    }
diff --git a/spacy_llm/tests/tasks/test_rel.py b/spacy_llm/tests/tasks/test_rel.py
index 504b7b03..0f581f91 100644
--- a/spacy_llm/tests/tasks/test_rel.py
+++ b/spacy_llm/tests/tasks/test_rel.py
@@ -37,9 +37,8 @@ def zeroshot_cfg_string():
     @llm_tasks = "spacy.REL.v1"
     labels = "LivesIn,Visits"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
 
     [initialize]
     vectors = "en_core_web_md"
@@ -68,11 +67,10 @@ def fewshot_cfg_string():
 
     [components.llm.task.examples]
     @misc = "spacy.FewShotReader.v1"
-    path = {str(EXAMPLES_DIR / "rel_examples.jsonl")}
+    path = {str(EXAMPLES_DIR / "rel.jsonl")}
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
 
     [initialize]
     vectors = "en_core_web_md"
@@ -99,8 +97,8 @@ def noop_config():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "test.NoOpBackend.v1"
+    [components.llm.model]
+    @llm_models = "test.NoOpModel.v1"
     """
 
 
@@ -129,13 +127,13 @@ def test_rel_config(cfg_string, request: FixtureRequest):
     labels = split_labels(labels)
     assert isinstance(task, Labeled)
     assert task.labels == tuple(labels)
-    assert pipe.labels == task.labels
+    assert set(pipe.labels) == set(task.labels)
     assert nlp.pipe_labels["llm"] == list(task.labels)
 
 
 @pytest.mark.external
 @pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
-@pytest.mark.parametrize("cfg_string", ["zeroshot_cfg_string", "fewshot_cfg_string"])
+@pytest.mark.parametrize("cfg_string", ["fewshot_cfg_string"])  # "zeroshot_cfg_string",
 def test_rel_predict(task, cfg_string, request):
     """Use OpenAI to get REL results.
     Note that this test may fail randomly, as the LLM's output is unguaranteed to be consistent/predictable
@@ -151,9 +149,9 @@ def test_rel_predict(task, cfg_string, request):
     assert doc._.rel
 
 
-def test_rel_init(noop_config):
-
-    RELTask._check_rel_extention()
+@pytest.mark.parametrize("n_prompt_examples", [-1, 0, 1, 2])
+def test_rel_init(noop_config, n_prompt_examples: int):
+    RELTask._check_rel_extension()
 
     config = Config().from_str(noop_config)
     del config["components"]["llm"]["task"]["labels"]
@@ -179,15 +177,25 @@ def test_rel_init(noop_config):
         examples.append(Example(predicted, reference))
 
     _, llm = nlp.pipeline[0]
-    task: RELTask = llm._task
+    task: RELTask = llm._task  # type: ignore[annotation-unchecked]
 
     assert set(task._label_dict.values()) == set()
+    assert not task._prompt_examples
+
+    nlp.config["initialize"]["components"]["llm"] = {
+        "n_prompt_examples": n_prompt_examples
+    }
     nlp.initialize(lambda: examples)
+
     assert set(task._label_dict.values()) == {"LivesIn", "Visits"}
 
+    if n_prompt_examples >= 0:
+        assert len(task._prompt_examples) == n_prompt_examples
+    else:
+        assert len(task._prompt_examples) == len(examples)
 
-def test_rel_serde(noop_config, tmp_path: Path):
 
+def test_rel_serde(noop_config, tmp_path: Path):
     config = Config().from_str(noop_config)
     del config["components"]["llm"]["task"]["labels"]
 
diff --git a/spacy_llm/tests/tasks/test_sentiment.py b/spacy_llm/tests/tasks/test_sentiment.py
new file mode 100644
index 00000000..d5435ecd
--- /dev/null
+++ b/spacy_llm/tests/tasks/test_sentiment.py
@@ -0,0 +1,268 @@
+from pathlib import Path
+
+import pytest
+import spacy
+from confection import Config
+from spacy.util import make_tempdir
+
+from spacy_llm.registry import fewshot_reader, file_reader
+
+from ...tasks import make_sentiment_task
+from ..compat import has_openai_key
+
+EXAMPLES_DIR = Path(__file__).parent / "examples"
+TEMPLATES_DIR = Path(__file__).parent / "templates"
+
+
+@pytest.fixture
+def zeroshot_cfg_string():
+    return """
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+    batch_size = 128
+
+    [components]
+
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.Sentiment.v1"
+
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
+    config = {"temperature": 0}
+    """
+
+
+@pytest.fixture
+def fewshot_cfg_string():
+    return f"""
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+    batch_size = 128
+
+    [components]
+
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.Sentiment.v1"
+
+    [components.llm.task.examples]
+    @misc = "spacy.FewShotReader.v1"
+    path = {str((Path(__file__).parent / "examples" / "sentiment.yml"))}
+
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
+    config = {{"temperature": 0}}
+    """
+
+
+@pytest.fixture
+def ext_template_cfg_string():
+    """Simple zero-shot config with an external template"""
+
+    return f"""
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+    batch_size = 128
+
+    [components]
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.Sentiment.v1"
+
+    [components.llm.task.template]
+    @misc = "spacy.FileReader.v1"
+    path = {str((Path(__file__).parent / "templates" / "sentiment.jinja2"))}
+
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
+    config = {{"temperature": 0}}
+    """
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
+@pytest.mark.parametrize(
+    "cfg_string",
+    [
+        "zeroshot_cfg_string",
+        "fewshot_cfg_string",
+        "ext_template_cfg_string",
+    ],
+)
+def test_sentiment_config(cfg_string, request):
+    cfg_string = request.getfixturevalue(cfg_string)
+    orig_config = Config().from_str(cfg_string)
+    nlp = spacy.util.load_model_from_config(orig_config, auto_fill=True)
+    assert nlp.pipe_names == ["llm"]
+
+    # also test nlp config from a dict in add_pipe
+    component_cfg = dict(orig_config["components"]["llm"])
+    component_cfg.pop("factory")
+
+    nlp2 = spacy.blank("en")
+    nlp2.add_pipe("llm", config=component_cfg)
+    assert nlp2.pipe_names == ["llm"]
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
+@pytest.mark.parametrize(
+    "cfg_string",
+    [
+        "zeroshot_cfg_string",
+        "fewshot_cfg_string",
+        "ext_template_cfg_string",
+    ],
+)
+def test_sentiment_predict(cfg_string, request):
+    """Use OpenAI to get zero-shot sentiment results.
+    Note that this test may fail randomly, as the LLM's output is unguaranteed to be consistent/predictable
+    """
+    cfg = request.getfixturevalue(cfg_string)
+    orig_config = Config().from_str(cfg)
+    nlp = spacy.util.load_model_from_config(orig_config, auto_fill=True)
+    if cfg_string != "ext_template_cfg_string":
+        assert nlp("This is horrible.")._.sentiment == 0
+        assert 0 < nlp("This is meh.")._.sentiment <= 0.5
+        assert nlp("This is perfect.")._.sentiment == 1
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
+@pytest.mark.parametrize(
+    "cfg_string_field",
+    [
+        ("zeroshot_cfg_string", None),
+        ("fewshot_cfg_string", None),
+        ("zeroshot_cfg_string", "sentiment_x"),
+    ],
+)
+def test_lemma_io(cfg_string_field, request):
+    cfg_string, field = cfg_string_field
+    cfg = request.getfixturevalue(cfg_string)
+    orig_config = Config().from_str(cfg)
+    if field:
+        orig_config["components"]["llm"]["task"]["field"] = field
+    nlp = spacy.util.load_model_from_config(orig_config, auto_fill=True)
+    assert nlp.pipe_names == ["llm"]
+    # ensure you can save a pipeline to disk and run it after loading
+    with make_tempdir() as tmpdir:
+        nlp.to_disk(tmpdir)
+        nlp2 = spacy.load(tmpdir)
+    assert nlp2.pipe_names == ["llm"]
+    score = getattr(nlp2("This is perfect.")._, field if field else "sentiment")
+    if cfg_string != "ext_template_cfg_string":
+        assert score == 1
+
+
+def test_jinja_template_rendering_without_examples():
+    """Test if jinja template renders as we expected
+
+    We apply the .strip() method for each prompt so that we don't have to deal
+    with annoying newlines and spaces at the edge of the text.
+    """
+    nlp = spacy.blank("xx")
+    text = "They're indifferent."
+    doc = nlp.make_doc(text)
+
+    sentiment_task = make_sentiment_task(examples=None)
+    prompt = list(sentiment_task.generate_prompts([doc]))[0]
+
+    assert (
+        prompt.strip()
+        == f"""
+Analyse whether the text surrounded by ''' is positive or negative. Respond with a float value between 0 and 1. 1 represents an exclusively positive sentiment, 0 an exclusively negative sentiment.
+
+Text:
+'''
+{text}
+'''
+Answer:""".strip()
+    )
+
+
+@pytest.mark.parametrize(
+    "examples_path",
+    [
+        str(EXAMPLES_DIR / "sentiment.json"),
+        str(EXAMPLES_DIR / "sentiment.yml"),
+        str(EXAMPLES_DIR / "sentiment.jsonl"),
+    ],
+)
+def test_jinja_template_rendering_with_examples(examples_path):
+    """Test if jinja2 template renders as expected
+
+    We apply the .strip() method for each prompt so that we don't have to deal
+    with annoying newlines and spaces at the edge of the text.
+    """
+    nlp = spacy.blank("xx")
+    text = "It was the happiest day of her life."
+    doc = nlp.make_doc(text)
+
+    sentiment_task = make_sentiment_task(examples=fewshot_reader(examples_path))
+    prompt = list(sentiment_task.generate_prompts([doc]))[0]
+
+    assert (
+        prompt.strip()
+        == """
+Analyse whether the text surrounded by ''' is positive or negative. Respond with a float value between 0 and 1. 1 represents an exclusively positive sentiment, 0 an exclusively negative sentiment.
+Below are some examples (only use these as a guide):
+
+Text:
+'''
+This is horrifying.
+'''
+Answer: 0.0
+
+Text:
+'''
+This is underwhelming.
+'''
+Answer: 0.25
+
+Text:
+'''
+This is ok.
+'''
+Answer: 0.5
+
+Text:
+'''
+I'm looking forward to this!
+'''
+Answer: 1.0
+
+Text:
+'''
+It was the happiest day of her life.
+'''
+Answer:""".strip()
+    )
+
+
+def test_external_template_actually_loads():
+    template_path = str(TEMPLATES_DIR / "sentiment.jinja2")
+    template = file_reader(template_path)
+    text = "There is a silver lining."
+    nlp = spacy.blank("xx")
+    doc = nlp.make_doc(text)
+
+    sentiment_task = make_sentiment_task(template=template)
+    prompt = list(sentiment_task.generate_prompts([doc]))[0]
+    assert (
+        prompt.strip()
+        == f"""
+Text: {text}
+Sentiment:
+""".strip()
+    )
diff --git a/spacy_llm/tests/tasks/test_spancat.py b/spacy_llm/tests/tasks/test_spancat.py
index 4faa0788..0c6f6487 100644
--- a/spacy_llm/tests/tasks/test_spancat.py
+++ b/spacy_llm/tests/tasks/test_spancat.py
@@ -4,7 +4,6 @@
 import spacy
 import srsly
 from confection import Config
-from pydantic import ValidationError
 from spacy.tokens import Span
 from spacy.training import Example
 from spacy.util import make_tempdir
@@ -42,10 +41,8 @@ def zeroshot_cfg_string():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -68,15 +65,13 @@ def fewshot_cfg_string():
 
     [components.llm.task.examples]
     @misc = "spacy.FewShotReader.v1"
-    path = {str((Path(__file__).parent / "examples" / "ner_examples.yml"))}
+    path = {str((Path(__file__).parent / "examples" / "ner.yml"))}
 
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config: {{}}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -97,7 +92,7 @@ def test_spancat_config(cfg_string, request):
     labels = split_labels(labels)
     task = pipe.task
     assert isinstance(task, Labeled)
-    assert task.labels == tuple(labels)
+    assert sorted(task.labels) == sorted(tuple(labels))
     assert pipe.labels == task.labels
     assert nlp.pipe_labels["llm"] == list(task.labels)
 
@@ -391,9 +386,9 @@ def test_jinja_template_rendering_without_examples():
 The entities you extract for each label can overlap with each other.
 From the Text input provided, extract named entities for each label in the following format:
 
-PER: <comma delimited list of strings>
-ORG: <comma delimited list of strings>
 LOC: <comma delimited list of strings>
+ORG: <comma delimited list of strings>
+PER: <comma delimited list of strings>
 
 
 Here is the text that needs labeling:
@@ -409,9 +404,9 @@ def test_jinja_template_rendering_without_examples():
 @pytest.mark.parametrize(
     "examples_path",
     [
-        str(EXAMPLES_DIR / "ner_examples.json"),
-        str(EXAMPLES_DIR / "ner_examples.yml"),
-        str(EXAMPLES_DIR / "ner_examples.jsonl"),
+        str(EXAMPLES_DIR / "ner.json"),
+        str(EXAMPLES_DIR / "ner.yml"),
+        str(EXAMPLES_DIR / "ner.jsonl"),
     ],
 )
 def test_jinja_template_rendering_with_examples(examples_path):
@@ -435,9 +430,9 @@ def test_jinja_template_rendering_with_examples(examples_path):
 The entities you extract for each label can overlap with each other.
 From the Text input provided, extract named entities for each label in the following format:
 
-PER: <comma delimited list of strings>
-ORG: <comma delimited list of strings>
 LOC: <comma delimited list of strings>
+ORG: <comma delimited list of strings>
+PER: <comma delimited list of strings>
 
 
 Below are some examples (only use these as a guide):
@@ -486,10 +481,8 @@ def test_example_not_following_basemodel():
         tmp_path = tmpdir / "wrong_example.yml"
         srsly.write_yaml(tmp_path, wrong_example)
 
-        with pytest.raises(ValidationError):
-            make_spancat_task_v2(
-                labels="PER,ORG,LOC", examples=fewshot_reader(tmp_path)
-            )
+    with pytest.raises(ValueError):
+        make_spancat_task_v2(labels="PER,ORG,LOC", examples=fewshot_reader(tmp_path))
 
 
 @pytest.fixture
@@ -512,15 +505,14 @@ def noop_config():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "test.NoOpBackend.v1"
+    [components.llm.model]
+    @llm_models = "test.NoOpModel.v1"
     output = "PER: Bob,Alice"
     """
 
 
 @pytest.mark.parametrize("n_detections", [0, 1, 2])
 def test_spancat_scoring(noop_config, n_detections):
-
     config = Config().from_str(noop_config)
     nlp = assemble_from_config(config)
 
@@ -542,8 +534,8 @@ def test_spancat_scoring(noop_config, n_detections):
     assert scores["spans_sc_p"] == n_detections / 2
 
 
-def test_spancat_init(noop_config):
-
+@pytest.mark.parametrize("n_prompt_examples", [-1, 0, 1, 2])
+def test_spancat_init(noop_config, n_prompt_examples: bool):
     config = Config().from_str(noop_config)
     del config["components"]["llm"]["task"]["labels"]
     nlp = assemble_from_config(config)
@@ -569,12 +561,26 @@ def test_spancat_init(noop_config):
     task: SpanCatTask = llm._task
 
     assert set(task._label_dict.values()) == set()
+    assert not task._prompt_examples
+
+    nlp.config["initialize"]["components"]["llm"] = {
+        "n_prompt_examples": n_prompt_examples
+    }
+
     nlp.initialize(lambda: examples)
+
     assert set(task._label_dict.values()) == {"PER", "LOC"}
+    if n_prompt_examples >= 0:
+        assert len(task._prompt_examples) == n_prompt_examples
+    else:
+        assert len(task._prompt_examples) == len(examples)
 
+    if n_prompt_examples > 0:
+        for eg in task._prompt_examples:
+            assert set(eg.entities.keys()) == {"PER", "LOC"}
 
-def test_spancat_serde(noop_config):
 
+def test_spancat_serde(noop_config):
     config = Config().from_str(noop_config)
     del config["components"]["llm"]["task"]["labels"]
 
diff --git a/spacy_llm/tests/tasks/test_summarization.py b/spacy_llm/tests/tasks/test_summarization.py
new file mode 100644
index 00000000..e35071f6
--- /dev/null
+++ b/spacy_llm/tests/tasks/test_summarization.py
@@ -0,0 +1,377 @@
+from pathlib import Path
+
+import pytest
+import regex
+import spacy
+from confection import Config
+from spacy.util import make_tempdir
+
+from spacy_llm.pipeline import LLMWrapper
+from spacy_llm.registry import fewshot_reader, file_reader
+from spacy_llm.ty import LLMTask
+from spacy_llm.util import assemble_from_config
+
+from ...tasks import make_summarization_task
+from ..compat import has_openai_key
+
+EXAMPLES_DIR = Path(__file__).parent / "examples"
+TEMPLATES_DIR = Path(__file__).parent / "templates"
+
+
+@pytest.fixture
+def zeroshot_cfg_string():
+    return """
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+    batch_size = 128
+
+    [components]
+
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.Summarization.v1"
+    max_n_words = 20
+
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
+    config = {"temperature": 0}
+    """
+
+
+@pytest.fixture
+def fewshot_cfg_string():
+    return f"""
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+    batch_size = 128
+
+    [components]
+
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.Summarization.v1"
+    max_n_words = 20
+
+    [components.llm.task.examples]
+    @misc = "spacy.FewShotReader.v1"
+    path = {str((Path(__file__).parent / "examples" / "summarization.yml"))}
+
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
+    config = {{"temperature": 0}}
+    """
+
+
+@pytest.fixture
+def ext_template_cfg_string():
+    """Simple zero-shot config with an external template"""
+
+    return f"""
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+    batch_size = 128
+
+    [components]
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.Summarization.v1"
+    max_n_words = 20
+
+    [components.llm.task.template]
+    @misc = "spacy.FileReader.v1"
+    path = {str((Path(__file__).parent / "templates" / "summarization.jinja2"))}
+
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
+    config = {{"temperature": 0}}
+    """
+
+
+@pytest.fixture
+def noop_config():
+    return """
+    [nlp]
+    lang = "en"
+    pipeline = ["llm"]
+    batch_size = 128
+
+    [components]
+
+    [components.llm]
+    factory = "llm"
+
+    [components.llm.task]
+    @llm_tasks = "spacy.Summarization.v1"
+
+    [components.llm.model]
+    @llm_models = "test.NoOpModel.v1"
+    """
+
+
+@pytest.fixture
+def example_text() -> str:
+    """Returns string to be used as example in tests."""
+    return (
+        "The atmosphere of Earth is the layer of gases, known collectively as air, retained by Earth's gravity "
+        "that surrounds the planet and forms its planetary atmosphere. The atmosphere of Earth creates pressure, "
+        "absorbs most meteoroids and ultraviolet solar radiation, warms the surface through heat retention "
+        "(greenhouse effect), allowing life and liquid water to exist on the Earth's surface, and reduces "
+        "temperature extremes between day and night (the diurnal temperature variation)."
+    )
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
+@pytest.mark.parametrize(
+    "cfg_string",
+    [
+        "zeroshot_cfg_string",
+        "fewshot_cfg_string",
+        "ext_template_cfg_string",
+    ],
+)
+def test_summarization_config(cfg_string, request):
+    cfg_string = request.getfixturevalue(cfg_string)
+    orig_config = Config().from_str(cfg_string)
+    nlp = spacy.util.load_model_from_config(orig_config, auto_fill=True)
+    assert nlp.pipe_names == ["llm"]
+
+    # also test nlp config from a dict in add_pipe
+    component_cfg = dict(orig_config["components"]["llm"])
+    component_cfg.pop("factory")
+
+    nlp2 = spacy.blank("en")
+    nlp2.add_pipe("llm", config=component_cfg)
+    assert nlp2.pipe_names == ["llm"]
+
+    pipe = nlp.get_pipe("llm")
+    assert isinstance(pipe, LLMWrapper)
+    assert isinstance(pipe.task, LLMTask)
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
+@pytest.mark.parametrize(
+    "cfg_string",
+    [
+        "zeroshot_cfg_string",
+        "fewshot_cfg_string",
+        "ext_template_cfg_string",
+    ],
+)
+def test_summarization_predict(cfg_string, example_text, request):
+    """Use OpenAI to get summarize text.
+    Note that this test may fail randomly, as the LLM's output is unguaranteed to be consistent/predictable
+    """
+    orig_cfg_string = cfg_string
+    cfg_string = request.getfixturevalue(cfg_string)
+    orig_config = Config().from_str(cfg_string)
+    nlp = spacy.util.load_model_from_config(orig_config, auto_fill=True)
+
+    # One of the examples exceeds the set max_n_words, so we expect a warning to be emitted.
+    if orig_cfg_string == "fewshot_cfg_string":
+        with pytest.warns(
+            UserWarning,
+            match=regex.escape(
+                "The provided example 'Life is a quality th...' has a summary of length 28, but `max_n_words` == 20."
+            ),
+        ):
+            doc = nlp(example_text)
+    else:
+        doc = nlp(example_text)
+
+    # Check whether a non-empty summary was written and we are somewhat close to the desired upper length limit.
+    assert 0 < len(doc._.summary)
+    if "ext" not in orig_cfg_string:
+        nlp.select_pipes(disable=["llm"])
+        assert (
+            len(nlp(doc._.summary))
+            <= orig_config["components"]["llm"]["task"]["max_n_words"] * 1.5
+        )
+
+
+# @pytest.mark.external
+@pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
+@pytest.mark.parametrize(
+    "cfg_string_and_field",
+    [
+        ("zeroshot_cfg_string", None),
+        ("fewshot_cfg_string", None),
+        ("ext_template_cfg_string", None),
+        ("zeroshot_cfg_string", "summary_x"),
+    ],
+)
+def test_summarization_io(cfg_string_and_field, example_text, request):
+    cfg_string, field = cfg_string_and_field
+    orig_cfg_string = cfg_string
+    cfg_string = request.getfixturevalue(cfg_string)
+    orig_config = Config().from_str(cfg_string)
+    if field:
+        orig_config["components"]["llm"]["task"]["field"] = field
+    nlp = spacy.util.load_model_from_config(orig_config, auto_fill=True)
+    assert nlp.pipe_names == ["llm"]
+    # ensure you can save a pipeline to disk and run it after loading
+    with make_tempdir() as tmpdir:
+        nlp.to_disk(tmpdir)
+        nlp2 = spacy.load(tmpdir)
+    assert nlp2.pipe_names == ["llm"]
+
+    if orig_cfg_string == "fewshot_cfg_string":
+        with pytest.warns(
+            UserWarning,
+            match=regex.escape(
+                "The provided example 'Life is a quality th...' has a summary of length 28, but `max_n_words` == 20."
+            ),
+        ):
+            doc = nlp2(example_text)
+    else:
+        doc = nlp2(example_text)
+
+    field = "summary" if field is None else field
+    nlp2.select_pipes(disable=["llm"])
+    assert 0 < len(nlp2(getattr(doc._, field)))
+    if "ext" not in orig_cfg_string:
+        assert (
+            len(nlp2(getattr(doc._, field)))
+            <= orig_config["components"]["llm"]["task"]["max_n_words"] * 1.5
+        )
+
+
+def test_jinja_template_rendering_without_examples(example_text):
+    """Test if jinja template renders as we expected
+
+    We apply the .strip() method for each prompt so that we don't have to deal
+    with annoying newlines and spaces at the edge of the text.
+    """
+    nlp = spacy.blank("xx")
+    doc = nlp.make_doc(example_text)
+
+    llm_ner = make_summarization_task(examples=None, max_n_words=10)
+    prompt = list(llm_ner.generate_prompts([doc]))[0]
+
+    assert (
+        prompt.strip()
+        == f"""
+You are an expert summarization system. Your task is to accept Text as input and summarize the Text in a concise way.
+The summary must not, under any circumstances, contain more than 10 words.
+Here is the Text that needs to be summarized:
+'''
+{example_text}
+'''
+Summary:""".strip()
+    )
+
+
+@pytest.mark.parametrize(
+    "examples_path",
+    [
+        str(EXAMPLES_DIR / "summarization.json"),
+        str(EXAMPLES_DIR / "summarization.yml"),
+        str(EXAMPLES_DIR / "summarization.jsonl"),
+    ],
+)
+def test_jinja_template_rendering_with_examples(examples_path, example_text):
+    """Test if jinja2 template renders as expected
+
+    We apply the .strip() method for each prompt so that we don't have to deal
+    with annoying newlines and spaces at the edge of the text.
+    """
+    nlp = spacy.blank("xx")
+    doc = nlp.make_doc(example_text)
+
+    examples = fewshot_reader(examples_path)
+    llm_ner = make_summarization_task(examples=examples, max_n_words=20)
+
+    with pytest.warns(
+        UserWarning,
+        match=regex.escape(
+            "The provided example 'Life is a quality th...' has a summary of length 28, but `max_n_words` == 20."
+        ),
+    ):
+        prompt = list(llm_ner.generate_prompts([doc]))[0]
+
+    assert (
+        prompt.strip()
+        == f"""
+You are an expert summarization system. Your task is to accept Text as input and summarize the Text in a concise way.
+The summary must not, under any circumstances, contain more than 20 words.
+Below are some examples (only use these as a guide):
+
+Text:
+'''
+The United Nations, referred to informally as the UN, is an intergovernmental organization whose stated purposes are to maintain international peace and security, develop friendly relations among nations, achieve international cooperation, and serve as a centre for harmonizing the actions of nations. It is the world's largest international organization. The UN is headquartered on international territory in New York City, and the organization has other offices in Geneva, Nairobi, Vienna, and The Hague, where the International Court of Justice is headquartered.
+
+The UN was established after World War II with the aim of preventing future world wars, and succeeded the League of Nations, which was characterized as ineffective. On 25 April 1945, 50 nations met in San Francisco, California for a conference and started drafting the UN Charter, which was adopted on 25 June 1945. The charter took effect on 24 October 1945, when the UN began operations. The organization's objectives, as defined by its charter, include maintaining international peace and security, protecting human rights, delivering humanitarian aid, promoting sustainable development, and upholding international law. At its founding, the UN had 51 member states; as of 2023, it has 193 – almost all of the world's sovereign states.
+'''
+Summary:
+'''
+UN is an intergovernmental organization to foster international peace, security, and cooperation. Established after WW2 with 51 members, now 193.
+'''
+
+Text:
+'''
+Life is a quality that distinguishes matter that has biological processes, such as signaling and self-sustaining processes, from matter that does not, and is defined by the capacity for growth, reaction to stimuli, metabolism, energy transformation, and reproduction. Various forms of life exist, such as plants, animals, fungi, protists, archaea, and bacteria. Biology is the science that studies life.
+
+The gene is the unit of heredity, whereas the cell is the structural and functional unit of life. There are two kinds of cells, prokaryotic and eukaryotic, both of which consist of cytoplasm enclosed within a membrane and contain many biomolecules such as proteins and nucleic acids. Cells reproduce through a process of cell division, in which the parent cell divides into two or more daughter cells and passes its genes onto a new generation, sometimes producing genetic variation.
+
+Organisms, or the individual entities of life, are generally thought to be open systems that maintain homeostasis, are composed of cells, have a life cycle, undergo metabolism, can grow, adapt to their environment, respond to stimuli, reproduce and evolve over multiple generations. Other definitions sometimes include non-cellular life forms such as viruses and viroids, but they are usually excluded because they do not function on their own; rather, they exploit the biological processes of hosts.
+'''
+Summary:
+'''
+Life is a quality defined by biological processes, including reproduction, genetics, and metabolism. There are two types of cells and organisms that can grow, respond, reproduce, and evolve.
+'''
+
+Here is the Text that needs to be summarized:
+'''
+{example_text}
+'''
+Summary:
+""".strip()
+    )
+
+
+def test_external_template_actually_loads(example_text):
+    template_path = str(TEMPLATES_DIR / "summarization.jinja2")
+    template = file_reader(template_path)
+    nlp = spacy.blank("xx")
+    doc = nlp.make_doc(example_text)
+
+    llm_ner = make_summarization_task(template=template)
+    prompt = list(llm_ner.generate_prompts([doc]))[0]
+    assert (
+        prompt.strip()
+        == """
+This is a test summarization template.
+Here is the text: The atmosphere of Earth is the layer of gases, known collectively as air, retained by Earth's gravity that surrounds the planet and forms its planetary atmosphere. The atmosphere of Earth creates pressure, absorbs most meteoroids and ultraviolet solar radiation, warms the surface through heat retention (greenhouse effect), allowing life and liquid water to exist on the Earth's surface, and reduces temperature extremes between day and night (the diurnal temperature variation).
+""".strip()
+    )
+
+
+def test_ner_serde(noop_config):
+    config = Config().from_str(noop_config)
+    nlp1 = assemble_from_config(config)
+    nlp2 = assemble_from_config(config)
+    nlp2.from_bytes(nlp1.to_bytes())
+
+
+def test_ner_to_disk(noop_config, tmp_path: Path):
+    config = Config().from_str(noop_config)
+    nlp1 = assemble_from_config(config)
+    nlp2 = assemble_from_config(config)
+
+    path = tmp_path / "model"
+    nlp1.to_disk(path)
+
+    cfgs = list(path.rglob("cfg"))
+    assert len(cfgs) == 1
+
+    nlp2.from_disk(path)
diff --git a/spacy_llm/tests/tasks/test_textcat.py b/spacy_llm/tests/tasks/test_textcat.py
index c72d7027..1d93fa70 100644
--- a/spacy_llm/tests/tasks/test_textcat.py
+++ b/spacy_llm/tests/tasks/test_textcat.py
@@ -6,7 +6,6 @@
 import spacy
 import srsly
 from confection import Config
-from pydantic import ValidationError
 from spacy.training import Example
 from spacy.util import make_tempdir
 
@@ -44,10 +43,8 @@ def zeroshot_cfg_string():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -71,15 +68,13 @@ def fewshot_cfg_string():
 
     [components.llm.task.examples]
     @misc = "spacy.FewShotReader.v1"
-    path = {str(EXAMPLES_DIR / "textcat_examples.yml")}
+    path = {str(EXAMPLES_DIR / "textcat.yml")}
 
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {{}}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -105,15 +100,13 @@ def ext_template_cfg_string():
 
     [components.llm.task.template]
     @misc = "spacy.FileReader.v1"
-    path = {str((Path(__file__).parent / "templates" / "textcat_template.jinja2"))}
+    path = {str((Path(__file__).parent / "templates" / "textcat.jinja2"))}
 
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {{}}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -141,10 +134,8 @@ def zeroshot_cfg_string_v3_lds():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "spacy.REST.v1"
-    api = "OpenAI"
-    config = {}
+    [components.llm.model]
+    @llm_models = "spacy.GPT-3-5.v1"
     """
 
 
@@ -154,7 +145,7 @@ def binary():
     labels = "Recipe"
     gold_cats = ["Recipe"]
     exclusive_classes = True
-    examples_path = str(EXAMPLES_DIR / "textcat_binary_examples.yml")
+    examples_path = str(EXAMPLES_DIR / "textcat_binary.yml")
     return text, labels, gold_cats, exclusive_classes, examples_path
 
 
@@ -164,7 +155,7 @@ def multilabel_excl():
     labels = "Recipe,Feedback,Comment"
     gold_cats = ["Recipe", "Feedback", "Comment"]
     exclusive_classes = True
-    examples_path = str(EXAMPLES_DIR / "textcat_multi_excl_examples.yml")
+    examples_path = str(EXAMPLES_DIR / "textcat_multi_excl.yml")
     return text, labels, gold_cats, exclusive_classes, examples_path
 
 
@@ -174,7 +165,7 @@ def multilabel_nonexcl():
     labels = "Recipe,Feedback,Comment"
     gold_cats = ["Recipe", "Feedback", "Comment"]
     exclusive_classes = False
-    examples_path = str(EXAMPLES_DIR / "textcat_multi_nonexcl_examples.yml")
+    examples_path = str(EXAMPLES_DIR / "textcat_multi_nonexcl.yml")
     return text, labels, gold_cats, exclusive_classes, examples_path
 
 
@@ -218,7 +209,7 @@ def test_textcat_config(task, cfg_string, request):
     labels = split_labels(labels)
     task = pipe.task
     assert isinstance(task, Labeled)
-    assert task.labels == tuple(labels)
+    assert sorted(task.labels) == sorted(tuple(labels))
     assert pipe.labels == task.labels
     assert nlp.pipe_labels["llm"] == list(task.labels)
 
@@ -362,15 +353,15 @@ def test_textcat_multilabel_labels_are_correct(
     pred = list(llm_textcat.parse_responses([doc], [response]))[0]
     # Take only those that have scores
     pred_cats = [cat for cat, score in pred.cats.items() if score == 1.0]
-    assert pred_cats == expected
+    assert set(pred_cats) == set(expected)
 
 
 @pytest.mark.parametrize(
     "examples_path",
     [
-        str(EXAMPLES_DIR / "textcat_binary_examples.json"),
-        str(EXAMPLES_DIR / "textcat_binary_examples.yml"),
-        str(EXAMPLES_DIR / "textcat_binary_examples.jsonl"),
+        str(EXAMPLES_DIR / "textcat_binary.json"),
+        str(EXAMPLES_DIR / "textcat_binary.yml"),
+        str(EXAMPLES_DIR / "textcat_binary.jsonl"),
     ],
 )
 def test_jinja_template_rendering_with_examples_for_binary(examples_path, binary):
@@ -437,9 +428,9 @@ def test_jinja_template_rendering_with_examples_for_binary(examples_path, binary
 @pytest.mark.parametrize(
     "examples_path",
     [
-        str(EXAMPLES_DIR / "textcat_multi_excl_examples.json"),
-        str(EXAMPLES_DIR / "textcat_multi_excl_examples.yml"),
-        str(EXAMPLES_DIR / "textcat_multi_excl_examples.jsonl"),
+        str(EXAMPLES_DIR / "textcat_multi_excl.json"),
+        str(EXAMPLES_DIR / "textcat_multi_excl.yml"),
+        str(EXAMPLES_DIR / "textcat_multi_excl.jsonl"),
     ],
 )
 def test_jinja_template_rendering_with_examples_for_multilabel_exclusive(
@@ -462,7 +453,7 @@ def test_jinja_template_rendering_with_examples_for_multilabel_exclusive(
 You are an expert Text Classification system. Your task is to accept Text as input
 and provide a category for the text based on the predefined labels.
 
-Classify the text below to any of the following labels: Recipe, Feedback, Comment
+Classify the text below to any of the following labels: Comment, Feedback, Recipe
 
 The task is exclusive, so only choose one label from what I provided.
 Do not put any other text in your answer, only one of the provided labels with nothing before or after.
@@ -504,9 +495,9 @@ def test_jinja_template_rendering_with_examples_for_multilabel_exclusive(
 @pytest.mark.parametrize(
     "examples_path",
     [
-        str(EXAMPLES_DIR / "textcat_multi_nonexcl_examples.json"),
-        str(EXAMPLES_DIR / "textcat_multi_nonexcl_examples.yml"),
-        str(EXAMPLES_DIR / "textcat_multi_nonexcl_examples.jsonl"),
+        str(EXAMPLES_DIR / "textcat_multi_nonexcl.json"),
+        str(EXAMPLES_DIR / "textcat_multi_nonexcl.yml"),
+        str(EXAMPLES_DIR / "textcat_multi_nonexcl.jsonl"),
     ],
 )
 def test_jinja_template_rendering_with_examples_for_multilabel_nonexclusive(
@@ -529,7 +520,7 @@ def test_jinja_template_rendering_with_examples_for_multilabel_nonexclusive(
 You are an expert Text Classification system. Your task is to accept Text as input
 and provide a category for the text based on the predefined labels.
 
-Classify the text below to any of the following labels: Recipe, Feedback, Comment
+Classify the text below to any of the following labels: Comment, Feedback, Recipe
 
 The task is non-exclusive, so you can provide more than one label as long as
 they're comma-delimited. For example: Label1, Label2, Label3.
@@ -585,7 +576,7 @@ def test_example_not_following_basemodel(wrong_example, labels, exclusive_classe
         tmp_path = tmpdir / "wrong_example.yml"
         srsly.write_yaml(tmp_path, wrong_example)
 
-        with pytest.raises(ValidationError):
+        with pytest.raises(ValueError):
             make_textcat_task_v3(
                 labels=labels,
                 examples=fewshot_reader(tmp_path),
@@ -594,7 +585,7 @@ def test_example_not_following_basemodel(wrong_example, labels, exclusive_classe
 
 
 def test_external_template_actually_loads():
-    template_path = str(TEMPLATES_DIR / "textcat_template.jinja2")
+    template_path = str(TEMPLATES_DIR / "textcat.jinja2")
     template = file_reader(template_path)
     labels = "Recipe"
     nlp = spacy.blank("xx")
@@ -643,7 +634,7 @@ def test_external_template_actually_loads():
 
 @pytest.mark.parametrize("n_insults", range(len(INSULTS) + 1))
 def test_textcat_scoring(zeroshot_cfg_string, n_insults):
-    @registry.llm_backends("Dummy")
+    @registry.llm_models("Dummy")
     def factory():
         def b(prompts: Iterable[str]) -> Iterable[str]:
             for _ in prompts:
@@ -652,7 +643,7 @@ def b(prompts: Iterable[str]) -> Iterable[str]:
         return b
 
     config = Config().from_str(zeroshot_cfg_string)
-    config["components"]["llm"]["backend"] = {"@llm_backends": "Dummy"}
+    config["components"]["llm"]["model"] = {"@llm_models": "Dummy"}
     config["components"]["llm"]["task"]["labels"] = "Insult"
     nlp = assemble_from_config(config)
 
@@ -696,7 +687,7 @@ def test_jinja_template_rendering_with_label_definitions(multilabel_excl):
 You are an expert Text Classification system. Your task is to accept Text as input
 and provide a category for the text based on the predefined labels.
 
-Classify the text below to any of the following labels: Recipe, Feedback, Comment
+Classify the text below to any of the following labels: Comment, Feedback, Recipe
 
 The task is exclusive, so only choose one label from what I provided.
 Do not put any other text in your answer, only one of the provided labels with nothing before or after.
@@ -738,14 +729,18 @@ def noop_config():
     [components.llm.task.normalizer]
     @misc = "spacy.LowercaseNormalizer.v1"
 
-    [components.llm.backend]
-    @llm_backends = "test.NoOpBackend.v1"
+    [components.llm.model]
+    @llm_models = "test.NoOpModel.v1"
     """
 
 
+@pytest.mark.parametrize("n_prompt_examples", [-1, 0, 1, 2])
 @pytest.mark.parametrize("init_from_config", [True, False])
-def test_textcat_init(noop_config, init_from_config: bool):
-
+def test_textcat_init(
+    noop_config,
+    init_from_config: bool,
+    n_prompt_examples: bool,
+):
     config = Config().from_str(noop_config)
     if init_from_config:
         config["initialize"] = {"components": {"llm": {"labels": ["Test"]}}}
@@ -772,6 +767,11 @@ def test_textcat_init(noop_config, init_from_config: bool):
     else:
         target = set()
     assert set(task._label_dict.values()) == target
+    assert not task._prompt_examples
+
+    nlp.config["initialize"]["components"]["llm"] = {
+        "n_prompt_examples": n_prompt_examples
+    }
 
     nlp.initialize(lambda: examples)
 
@@ -780,10 +780,13 @@ def test_textcat_init(noop_config, init_from_config: bool):
     else:
         target = {"Insult", "Compliment"}
     assert set(task._label_dict.values()) == target
+    if n_prompt_examples >= 0:
+        assert len(task._prompt_examples) == n_prompt_examples
+    else:
+        assert len(task._prompt_examples) == len(INSULTS)
 
 
 def test_textcat_serde(noop_config, tmp_path: Path):
-
     config = Config().from_str(noop_config)
 
     nlp1 = assemble_from_config(config)
diff --git a/spacy_llm/tests/test_cache.py b/spacy_llm/tests/test_cache.py
index 1d47d633..1522c82c 100644
--- a/spacy_llm/tests/test_cache.py
+++ b/spacy_llm/tests/test_cache.py
@@ -1,20 +1,21 @@
 import copy
 import os
+import re
 import time
 from pathlib import Path
-from typing import Dict
+from typing import Dict, Iterable
 
 import pytest
 import spacy
 import srsly  # type: ignore[import]
 from spacy.language import Language
-from spacy.tokens import DocBin
+from spacy.tokens import Doc, DocBin
 
 from ..cache import BatchCache
-from .compat import has_openai_key
+from ..registry import registry
 
 _DEFAULT_CFG = {
-    "backend": {"api": "NoOp", "config": {"model": "NoOp"}},
+    "model": {"@llm_models": "spacy.NoOp.v1"},
     "task": {"@llm_tasks": "spacy.NoOp.v1"},
     "cache": {
         "batch_size": 2,
@@ -157,14 +158,10 @@ def test_path_dir_created():
         assert (tmpdir / "new_dir").exists()
 
 
-@pytest.mark.external
-@pytest.mark.skipif(has_openai_key is False, reason="OpenAI API key not available")
 def test_caching_llm_io() -> None:
     """Test availability of LLM IO after caching."""
     with spacy.util.make_tempdir() as tmpdir:
         config = copy.deepcopy(_DEFAULT_CFG)
-        config["backend"]["api"] = "OpenAI"  # type: ignore[index]
-        config["backend"]["config"] = {"model": "gpt-3.5-turbo"}  # type: ignore[index]
         config["cache"]["path"] = str(tmpdir)  # type: ignore[index]
         config["cache"]["batch_size"] = 3  # type: ignore[index]
         config["save_io"] = True
@@ -184,3 +181,54 @@ def test_caching_llm_io() -> None:
         )
         assert len(cached_docs) == 3
         assert all([doc.user_data["llm_io"]["llm"]] for doc in cached_docs)
+
+
+def test_prompt_template_handling():
+    """Tests that prompt template comparison is done properly."""
+    with spacy.util.make_tempdir() as tmpdir:
+        # Check if prompt template is written to file properly.
+        config = copy.deepcopy(_DEFAULT_CFG)
+        config["cache"]["path"] = str(tmpdir)
+        nlp = spacy.blank("en")
+        nlp.add_pipe("llm", config=config)
+        llm = nlp.get_pipe("llm")
+        docs = [nlp(text) for text in ("Test 1", "Test 2", "Test 3")]
+
+        prompt_template_filepath = tmpdir / "prompt_template.txt"
+        assert prompt_template_filepath.exists() and prompt_template_filepath.is_file()
+        with open(prompt_template_filepath, "r") as file:
+            assert hash("".join(file.readlines())) == hash(llm._task.prompt_template)
+
+        # This should fail, as the prompt template diverges from the persisted one.
+        with pytest.raises(ValueError, match="Prompt template in cache directory"):
+            llm._cache.prompt_template = llm._cache.prompt_template + " something else"
+
+        with pytest.warns(UserWarning, match="No prompt template set for Cache object"):
+            BatchCache(path=tmpdir, batch_size=3, max_batches_in_mem=4).add(docs[0])
+
+    # Check with task not providing a prompt template.
+    with spacy.util.make_tempdir() as tmpdir:
+
+        @registry.llm_tasks("NoPromptTemplate.v1")
+        class NoopTask_NoPromptTemplate:
+            def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
+                return [""] * len(list(docs))
+
+            def parse_responses(
+                self, docs: Iterable[Doc], responses: Iterable[str]
+            ) -> Iterable[Doc]:
+                return docs
+
+        # Check if prompt template is written to file properly.
+        config = copy.deepcopy(_DEFAULT_CFG)
+        config["cache"]["path"] = str(tmpdir)
+        config["task"]["@llm_tasks"] = "NoPromptTemplate.v1"
+        nlp = spacy.blank("en")
+
+        with pytest.warns(
+            UserWarning,
+            match=re.escape(
+                "The specified task does not provide its prompt template via `prompt_template()`."
+            ),
+        ):
+            nlp.add_pipe("llm", config=config)
diff --git a/spacy_llm/tests/test_combinations.py b/spacy_llm/tests/test_combinations.py
index e0111419..796cc87f 100644
--- a/spacy_llm/tests/test_combinations.py
+++ b/spacy_llm/tests/test_combinations.py
@@ -1,50 +1,41 @@
-import copy
 from typing import Any, Dict
 
 import pytest
 import spacy
-from thinc.api import get_current_ops, NumpyOps
+from thinc.api import NumpyOps, get_current_ops
 
-from spacy_llm.compat import has_minichain, has_langchain
+from spacy_llm.compat import has_langchain
 from spacy_llm.pipeline import LLMWrapper
 
-PIPE_CFG: Dict[str, Any] = {
-    "backend": {
-        "@llm_backends": None,
-        "api": "OpenAI",
-        "config": {},
-    },
-    "task": {"@llm_tasks": None},
-}
-
 
 @pytest.mark.external
-@pytest.mark.skipif(has_minichain is False, reason="MiniChain is not installed")
 @pytest.mark.skipif(has_langchain is False, reason="LangChain is not installed")
 @pytest.mark.parametrize(
-    "backend",
-    ["spacy.LangChain.v1", "spacy.MiniChain.v1", "spacy.REST.v1"],
-    ids=["langchain", "minichain", "rest"],
+    "model",
+    ["langchain.OpenAI.v1", "spacy.GPT-3-5.v1"],
+    ids=["langchain", "rest-openai"],
 )
 @pytest.mark.parametrize(
     "task",
     ["spacy.NER.v1", "spacy.NER.v2", "spacy.TextCat.v1"],
     ids=["ner.v1", "ner.v2", "textcat"],
 )
-@pytest.mark.parametrize("n_process", [1, 2])
-def test_combinations(backend: str, task: str, n_process: int):
-    """Randomly test combinations of backends and tasks."""
+@pytest.mark.parametrize("n_process", [1])  # , 2
+def test_combinations(model: str, task: str, n_process: int):
+    """Randomly test combinations of models and tasks."""
     ops = get_current_ops()
     if not isinstance(ops, NumpyOps) and n_process != 1:
         pytest.skip("Only test multiple processes on CPU")
 
-    config = copy.deepcopy(PIPE_CFG)
-    config["backend"]["@llm_backends"] = backend
-    config["backend"]["config"] = {
-        "model": "ada" if backend != "spacy.MiniChain.v1" else "gpt-3.5-turbo"
+    config: Dict[str, Any] = {
+        "model": {
+            "@llm_models": model,
+            "config": {},
+        },
+        "task": {"@llm_tasks": task},
     }
-    config["task"]["@llm_tasks"] = task
-
+    if model.startswith("langchain"):
+        config["model"]["name"] = "ada"
     # Configure task-specific settings.
     if task.startswith("spacy.NER"):
         config["task"]["labels"] = "PER,ORG,LOC"
diff --git a/usage_examples/tests/test_readme_examples.py b/usage_examples/tests/test_readme_examples.py
index 24aaa3aa..db9e044a 100644
--- a/usage_examples/tests/test_readme_examples.py
+++ b/usage_examples/tests/test_readme_examples.py
@@ -1,10 +1,10 @@
-from typing import Iterable, Callable
+from typing import Callable, Iterable
 
 import pytest
 import spacy
+from spacy import util
 from thinc.compat import has_torch_cuda_gpu
 
-from spacy import util
 from spacy_llm.registry import registry
 from spacy_llm.util import assemble
 
@@ -26,10 +26,9 @@ def test_example_1_classifier():
         @llm_tasks = "spacy.TextCat.v2"
         labels = ["COMPLIMENT", "INSULT"]
 
-        [components.llm.backend]
-        @llm_backends = "spacy.REST.v1"
-        api = "OpenAI"
-        config = {"model": "gpt-3.5-turbo", "temperature": 0.3}
+        [components.llm.model]
+        @llm_models = "spacy.GPT-3-5.v1"
+        config = {"temperature": 0.3}
         """
 
         with open(tmpdir / "cfg", "w") as text_file:
@@ -57,10 +56,10 @@ def test_example_2_ner_hf():
         @llm_tasks = "spacy.NER.v2"
         labels = ["PERSON", "ORGANISATION", "LOCATION"]
 
-        [components.llm.backend]
-        @llm_backends = "spacy.Dolly_HF.v1"
+        [components.llm.model]
+        @llm_models = "spacy.Dolly.v1"
         # For better performance, use databricks/dolly-v2-12b instead
-        model = "databricks/dolly-v2-3b"
+        name = "dolly-v2-3b"
         """
 
         with open(tmpdir / "cfg", "w") as text_file:
@@ -81,10 +80,8 @@ def test_example_3_python():
                 "@llm_tasks": "spacy.NER.v2",
                 "labels": ["PERSON", "ORGANISATION", "LOCATION"],
             },
-            "backend": {
-                "@llm_backends": "spacy.REST.v1",
-                "api": "OpenAI",
-                "config": {"model": "gpt-3.5-turbo"},
+            "model": {
+                "@llm_models": "spacy.GPT-3-5.v1",
             },
         },
     )
@@ -93,10 +90,10 @@ def test_example_3_python():
     print([(ent.text, ent.label_) for ent in doc.ents])  # noqa: T201
 
 
-def test_example_4_custom_backend():
+def test_example_4_custom_model():
     import random
 
-    @registry.llm_backends("RandomClassification.v1")
+    @registry.llm_models("RandomClassification.v1")
     def random_textcat(labels: str) -> Callable[[Iterable[str]], Iterable[str]]:
         labels = labels.split(",")
 
@@ -121,8 +118,8 @@ def _classify(prompts: Iterable[str]) -> Iterable[str]:
         @llm_tasks = "spacy.TextCat.v2"
         labels = ORDER,INFORMATION
 
-        [components.llm.backend]
-        @llm_backends = "RandomClassification.v1"
+        [components.llm.model]
+        @llm_models = "RandomClassification.v1"
         labels = ${components.llm.task.labels}
         """
 
diff --git a/usage_examples/tests/test_usage_examples.py b/usage_examples/tests/test_usage_examples.py
index 837ecc18..768803d0 100644
--- a/usage_examples/tests/test_usage_examples.py
+++ b/usage_examples/tests/test_usage_examples.py
@@ -2,16 +2,11 @@
 
 import pytest
 from thinc.compat import has_torch_cuda_gpu
+
 from spacy_llm import cache  # noqa: F401
 
-from .. import (
-    ner_dolly,
-    textcat_openai,
-    ner_langchain_openai,
-    ner_minichain_openai,
-    multitask_openai,
-    rel_openai,
-)
+from .. import multitask_openai, ner_dolly, ner_langchain_openai, rel_openai
+from .. import textcat_openai
 
 _USAGE_EXAMPLE_PATH = Path(__file__).parent.parent
 
@@ -56,14 +51,6 @@ def test_ner_langchain_openai():
     )
 
 
-@pytest.mark.external
-def test_ner_minichain_openai():
-    """Test NER LangChain OpenAI usage example."""
-    ner_minichain_openai.run_pipeline(
-        "text", _USAGE_EXAMPLE_PATH / "ner_minichain_openai" / "ner.cfg", False
-    )
-
-
 @pytest.mark.external
 @pytest.mark.parametrize("config_name", ("fewshot.cfg", "zeroshot.cfg"))
 def test_multitask_openai(config_name: str):
@@ -80,14 +67,14 @@ def test_multitask_openai(config_name: str):
 
 
 @pytest.mark.external
-@pytest.mark.parametrize("config_name", ("fewshot.cfg", "zeroshot.cfg"))
+@pytest.mark.parametrize("config_name", ("zeroshot.cfg", "fewshot.cfg"))
 def test_rel_openai(config_name: str):
     """Test REL OpenAI usage example.
     config_name (str): Name of config file to use.
     """
     path = _USAGE_EXAMPLE_PATH / "rel_openai"
     rel_openai.run_pipeline(
-        text="text",
+        text="Sara lives in Lisbon.",
         config_path=path / config_name,
         examples_path=None
         if config_name == "zeroshot.cfg"