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

Issue:
Synch `develop` with `main`
<!--- 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. -->
Synch `develop` with `main`.

### Corresponding documentation PR
<!--- Add the link to the corresponding documentation PR here, if applicable. -->
\-

### 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.
- [ ] 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 (i. e. `pytest` ran with `--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/spacy_llm/models/rest/palm/__init__.py b/spacy_llm/models/rest/palm/__init__.py
new file mode 100644
index 00000000..1255be2f
--- /dev/null
+++ b/spacy_llm/models/rest/palm/__init__.py
@@ -0,0 +1,4 @@
+from .model import Endpoints, PaLM
+from .registry import palm_bison
+
+__all__ = ["palm_bison", "PaLM", "Endpoints"]
diff --git a/spacy_llm/models/rest/palm/model.py b/spacy_llm/models/rest/palm/model.py
new file mode 100644
index 00000000..1e9b10b1
--- /dev/null
+++ b/spacy_llm/models/rest/palm/model.py
@@ -0,0 +1,113 @@
+import os
+import warnings
+from enum import Enum
+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 REST
+
+
+class Endpoints(str, Enum):
+    TEXT = "https://generativelanguage.googleapis.com/v1beta3/models/{model}:generateText?key={api_key}"
+    MSG = "https://generativelanguage.googleapis.com/v1beta3/models/{model}:generateMessage?key={api_key}"
+
+
+class PaLM(REST):
+    @property
+    def credentials(self) -> Dict[str, str]:
+        api_key = os.getenv("PALM_API_KEY")
+        if api_key is None:
+            warnings.warn(
+                "Could not find the API key to access the Cohere API. Ensure you have an API key "
+                "set up via https://cloud.google.com/docs/authentication/api-keys#rest, then make it available as "
+                "an environment variable 'PALM_API_KEY'."
+            )
+
+        assert api_key is not None
+        return {"api_key": api_key}
+
+    def _verify_auth(self) -> None:
+        try:
+            self(["What's 2+2?"])
+        except ValueError as err:
+            if "API key not valid" in str(err):
+                warnings.warn(
+                    "Authentication with provided API key failed. Please double-check you provided the correct "
+                    "credentials."
+                )
+            else:
+                raise err
+
+    def __call__(self, prompts: Iterable[str]) -> Iterable[str]:
+        headers = {
+            "Content-Type": "application/json",
+            "Accept": "application/json",
+        }
+        api_responses: List[str] = []
+        prompts = list(prompts)
+        url = self._endpoint.format(
+            model=self._name, api_key=self._credentials["api_key"]
+        )
+
+        def _request(json_data: Dict[str, Any]) -> Dict[str, Any]:
+            r = self.retry(
+                call_method=requests.post,
+                url=url,
+                headers=headers,
+                json={**json_data, **self._config},
+                timeout=self._max_request_time,
+            )
+            try:
+                r.raise_for_status()
+            except HTTPError as ex:
+                res_content = srsly.json_loads(r.content.decode("utf-8"))
+                # Include specific error message in exception.
+                error_message = res_content.get("error", {}).get("message", {})
+                # Catching other types of HTTPErrors (e.g., "429: too many requests")
+                raise ValueError(f"Request to PaLM API failed: {error_message}") from ex
+            response = r.json()
+
+            # PaLM returns a 'filter' key when a message was filtered due to safety concerns.
+            if "filters" in response:
+                if self._strict:
+                    raise ValueError(f"API call failed: {response}.")
+                else:
+                    assert isinstance(prompts, Sized)
+                    return {"error": [srsly.json_dumps(response)] * len(prompts)}
+            return response
+
+        # PaLM API currently doesn't accept batch prompts, so we're making
+        # a request for each iteration. This approach can be prone to rate limit
+        # errors. In practice, you can adjust _max_request_time so that the
+        # timeout is larger.
+        uses_chat = "chat" in self._name
+        responses = [
+            _request(
+                {
+                    "prompt": {"text": prompt}
+                    if not uses_chat
+                    else {"messages": [{"content": prompt}]}
+                }
+            )
+            for prompt in prompts
+        ]
+        for response in responses:
+            if "candidates" in response:
+                # Although you can set the number of candidates in PaLM to be greater than 1, we only need to return a
+                # single value. In this case, we will just return the very first output.
+                api_responses.append(
+                    response["candidates"][0].get(
+                        "content" if uses_chat else "output", srsly.json_dumps(response)
+                    )
+                )
+            else:
+                api_responses.append(srsly.json_dumps(response))
+
+        return api_responses
+
+    @classmethod
+    def get_model_names(cls) -> Tuple[str, ...]:
+        return "text-bison-001", "chat-bison-001"
diff --git a/spacy_llm/models/rest/palm/registry.py b/spacy_llm/models/rest/palm/registry.py
new file mode 100644
index 00000000..9a56576a
--- /dev/null
+++ b/spacy_llm/models/rest/palm/registry.py
@@ -0,0 +1,42 @@
+from typing import Any, Callable, Dict, Iterable
+
+from confection import SimpleFrozenDict
+
+from ....compat import Literal
+from ....registry import registry
+from .model import Endpoints, PaLM
+
+
+@registry.llm_models("spacy.PaLM.v1")
+def palm_bison(
+    config: Dict[Any, Any] = SimpleFrozenDict(temperature=0),
+    name: Literal["chat-bison-001", "text-bison-001"] = "text-bison-001",  # noqa: F821
+    strict: bool = PaLM.DEFAULT_STRICT,
+    max_tries: int = PaLM.DEFAULT_MAX_TRIES,
+    interval: float = PaLM.DEFAULT_INTERVAL,
+    max_request_time: float = PaLM.DEFAULT_MAX_REQUEST_TIME,
+) -> Callable[[Iterable[str]], Iterable[str]]:
+    """Returns Google instance for PaLM Bison model using REST to prompt API.
+    name (Literal["chat-bison-001", "text-bison-001"]): 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
+        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]]]): Cohere instance for 'command' model using REST to prompt API.
+    """
+    return PaLM(
+        name=name,
+        endpoint=Endpoints.TEXT.value
+        if name in {"text-bison-001"}
+        else Endpoints.MSG.value,
+        config=config,
+        strict=strict,
+        max_tries=max_tries,
+        interval=interval,
+        max_request_time=max_request_time,
+    )


Test patch:
diff --git a/spacy_llm/tests/compat.py b/spacy_llm/tests/compat.py
index 410ad8ff..55579480 100644
--- a/spacy_llm/tests/compat.py
+++ b/spacy_llm/tests/compat.py
@@ -14,3 +14,8 @@
     has_cohere_key = False
 else:
     has_cohere_key = True
+
+if os.getenv("PALM_API_KEY") is None:
+    has_palm_key = False
+else:
+    has_palm_key = True
diff --git a/spacy_llm/tests/models/test_palm.py b/spacy_llm/tests/models/test_palm.py
new file mode 100644
index 00000000..f4df8a51
--- /dev/null
+++ b/spacy_llm/tests/models/test_palm.py
@@ -0,0 +1,60 @@
+# mypy: ignore-errors
+import pytest
+
+from spacy_llm.models.rest.palm import palm_bison
+
+from ..compat import has_palm_key
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_palm_key is False, reason="PaLM API key not available")
+@pytest.mark.parametrize("name", ("text-bison-001", "chat-bison-001"))
+def test_palm_api_response_is_correct(name: str):
+    """Check if we're getting the response from the correct structure"""
+    model = palm_bison(name=name)
+    prompt = "The number of stars in the universe is"
+    num_prompts = 3  # arbitrary number to check multiple inputs
+    responses = list(model([prompt] * num_prompts))
+    for response in responses:
+        assert isinstance(response, str)
+    assert len(responses) == 3
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_palm_key is False, reason="PaLM API key not available")
+def test_palm_api_response_n_generations():
+    """Test how the model handles more than 1 generation of output
+
+    Users can configure PaLM to return more than one candidate for a single prompt.
+    The current model doesn't support that and the implementation only returns
+    the very first output.
+    """
+    candidate_count = 3
+    model = palm_bison(config={"candidate_count": candidate_count})
+
+    prompt = "The number of stars in the universe is"
+    num_prompts = 3
+    responses = list(model([prompt] * num_prompts))
+    assert len(responses) == 3
+    for response in responses:
+        assert isinstance(response, str)
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_palm_key is False, reason="PaLM API key not available")
+def test_palm_api_response_when_error():
+    """Ensure graceful handling of error in the PaLM model."""
+    # Incorrect config because temperature is in incorrect range [0, 5]
+    # c.f. https://developers.generativeai.google/api/rest/generativelanguage/models/generateText
+    incorrect_temperature = 1000  # must be between 0 and 1.0
+    with pytest.raises(ValueError, match="Request to PaLM API failed:"):
+        palm_bison(config={"temperature": incorrect_temperature})
+
+
+@pytest.mark.external
+@pytest.mark.skipif(has_palm_key is False, reason="PaLM API key not available")
+def test_palm_error_unsupported_model():
+    """Ensure graceful handling of error when model is not supported"""
+    incorrect_model = "x-gpt-3.5-turbo"
+    with pytest.raises(ValueError, match="Model 'x-gpt-3.5-turbo' is not supported"):
+        palm_bison(name=incorrect_model)