Skip to content

Commit

Permalink
feat: support anthropic (non-streaming only) (#512)
Browse files Browse the repository at this point in the history
Co-authored-by: Jason Liu <[email protected]>
  • Loading branch information
shreya-51 and jxnl authored Mar 20, 2024
1 parent c5c19fa commit 8da87c4
Show file tree
Hide file tree
Showing 14 changed files with 889 additions and 195 deletions.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,44 @@ print(model.model_dump_json(indent=2))
"""
```

## Using Anthropic Models

Install dependencies with

```shell
poetry install -E anthropic
```

Usage:

```python
import instructor
from anthropic import Anthropic

class User(BaseModel):
name: str
age: int

create = instructor.patch(create=anthropic.Anthropic().messages.create, mode=instructor.Mode.ANTHROPIC_TOOLS)

resp = create(
model="claude-3-opus-20240229",
max_tokens=1024,
max_retries=0,
messages=[
{
"role": "user",
"content": "Extract Jason is 25 years old.",
}
],
response_model=User,
)

assert isinstance(resp, User)
assert resp.name == "Jason"
assert resp.age == 25
```

## [Evals](https://github.com/jxnl/instructor/tree/main/tests/openai/evals)

We invite you to contribute to evals in `pytest` as a way to monitor the quality of the OpenAI models and the `instructor` library. To get started check out the [jxnl/instructor/tests/evals](https://github.com/jxnl/instructor/tree/main/tests/openai/evals) and contribute your own evals in the form of pytest tests. These evals will be run once a week and the results will be posted.
Expand Down
66 changes: 66 additions & 0 deletions docs/blog/posts/anthropic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
draft: False
date: 2024-03-20
authors:
- jxnl
---

# Announcing Anthropic Support

A special shoutout to [Shreya](https://twitter.com/shreyaw_) for her contributions to the anthropic support. As of now, all features are operational with the exception of streaming support.

For those eager to experiment, simply patch the client with `ANTHROPIC_TOOLS`, which will enable you to leverage the `anthropic` client for making requests.

```
pip install instructor[anthropic]
```

```python
from pydantic import BaseModel
from typing import List
import anthropic
import instructor

# Patching the Anthropics client with the instructor for enhanced capabilities
anthropic_client = instructor.patch(
create=anthropic.Anthropic().messages.create,
mode=instructor.Mode.ANTHROPIC_TOOLS
)

class Properties(BaseModel):
name: str
value: str

class User(BaseModel):
name: str
age: int
properties: List[Properties]

user_response = anthropic_client(
model="claude-3-haiku-20240307",
max_tokens=1024,
max_retries=0,
messages=[
{
"role": "user",
"content": "Create a user for a model with a name, age, and properties.",
}
],
response_model=User,
) # type: ignore

print(user_response.model_dump_json(indent=2))
"""
{
"name": "John",
"age": 25,
"properties": [
{
"key": "favorite_color",
"value": "blue"
}
]
}
```
We're encountering challenges with deeply nested types and eagerly invite the community to test, provide feedback, and suggest necessary improvements as we enhance the anthropic client's support.
36 changes: 36 additions & 0 deletions examples/anthropic/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import List
import anthropic
import instructor

# Patching the Anthropics client with the instructor for enhanced capabilities
anthropic_client = instructor.patch(
create=anthropic.Anthropic().messages.create, mode=instructor.Mode.ANTHROPIC_TOOLS
)


class Properties(BaseModel):
key: str
value: str


class User(BaseModel):
name: str
age: int
properties: List[Properties]


user_response = anthropic_client(
model="claude-3-haiku-20240307",
max_tokens=1024,
max_retries=0,
messages=[
{
"role": "user",
"content": "Create a user for a model with a name, age, and properties.",
}
],
response_model=User,
) # type: ignore

print(user_response.model_dump_json(indent=2))
124 changes: 124 additions & 0 deletions instructor/anthropic_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import re
import xmltodict
from pydantic import BaseModel
import xml.etree.ElementTree as ET
from typing import Type, Any, Dict, TypeVar

T = TypeVar("T", bound=BaseModel)


def json_to_xml(model: Type[BaseModel]) -> str:
"""Takes a Pydantic model and returns XML format for Anthropic function calling."""
model_dict = model.model_json_schema()

root = ET.Element("tool_description")
tool_name = ET.SubElement(root, "tool_name")
tool_name.text = model_dict.get("title", "Unknown")
description = ET.SubElement(root, "description")
description.text = (
"This is the function that must be used to construct the response."
)
parameters = ET.SubElement(root, "parameters")
references = model_dict.get("$defs", {})
list_type_found = _add_params(parameters, model_dict, references)

if list_type_found: # Need to append to system prompt for List type handling
return (
ET.tostring(root, encoding="unicode")
+ "\nFor any List[] types, include multiple <$PARAMETER_NAME>$PARAMETER_VALUE</$PARAMETER_NAME> tags for each item in the list. XML tags should only contain the name of the parameter."
)
else:
return ET.tostring(root, encoding="unicode")


def _add_params(
root: ET.Element, model_dict: Dict[str, Any], references: Dict[str, Any]
) -> bool: # Return value indiciates if we ever came across a param with type List
# TODO: handling of nested params with the same name
properties = model_dict.get("properties", {})
list_found = False

for field_name, details in properties.items():
parameter = ET.SubElement(root, "parameter")
name = ET.SubElement(parameter, "name")
name.text = field_name
type_element = ET.SubElement(parameter, "type")

# Get type
if "anyOf" in details: # Case where there can be multiple types
# supports:
# case 1: List type (example json: {'anyOf': [{'items': {'$ref': '#/$defs/PartialUser'}, 'type': 'array'}, {'type': 'null'}], 'default': None, 'title': 'Users'})
# case 2: nested model (example json: {'anyOf': [{'$ref': '#/$defs/PartialDate'}, {'type': 'null'}], 'default': {}})
field_type = " or ".join(
[
d["type"]
if "type" in d
else (d["$ref"] if "$ref" in d else "unknown")
for d in details["anyOf"]
]
)
else:
field_type = details.get(
"type", "unknown"
) # Might be better to fail here if there is no type since pydantic models require types

# Adjust type if array
if "array" in field_type or "List" in field_type:
type_element.text = f"List[{details['title']}]"
list_found = True
else:
type_element.text = field_type

param_description = ET.SubElement(parameter, "description")
param_description.text = details.get("description", "")

if (
isinstance(details, dict) and "$ref" in details
): # Checking if there are nested params
nested_params = ET.SubElement(parameter, "parameters")
list_found |= _add_params(
nested_params,
_resolve_reference(references, details["$ref"]),
references,
)
elif field_type == "array": # Handling for List[] type
nested_params = ET.SubElement(parameter, "parameters")
list_found |= _add_params(
nested_params,
_resolve_reference(references, details["items"]["$ref"]),
references,
)
elif "array" in field_type: # Handling for optional List[] type
nested_params = ET.SubElement(parameter, "parameters")
list_found |= _add_params(
nested_params,
_resolve_reference(
references, details["anyOf"][0]["items"]["$ref"]
), # CHANGE
references,
)

return list_found


def _resolve_reference(references: Dict[str, Any], reference: str) -> Dict[str, Any]:
parts = reference.split("/")[2:] # Remove "#" and "$defs"
for part in parts:
references = references[part]
return references


def extract_xml(content: str) -> str: # Currently assumes 1 function call only
"""Extracts XML content in Anthropic's schema from a string."""
pattern = r"<function_calls>.*?</function_calls>"
matches = re.findall(pattern, content, re.DOTALL)
return "".join(matches)


def xml_to_model(model: Type[T], xml_string: str) -> T:
"""Converts XML in Anthropic's schema to an instance of the provided class."""
parsed_xml = xmltodict.parse(xml_string)
model_dict = parsed_xml["function_calls"]["invoke"]["parameters"]
return model(
**model_dict
) # This sometimes fails if Anthropic's response hallucinates from the schema
1 change: 0 additions & 1 deletion instructor/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,3 @@ def docs(query: str = typer.Argument(None, help="Search the documentation")) ->
typer.launch(f"https://jxnl.github.io/instructor/?q={query}")
else:
typer.launch("https://jxnl.github.io/instructor")

19 changes: 19 additions & 0 deletions instructor/function_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from instructor.mode import Mode
from instructor.utils import extract_json_from_codeblock
import logging
import importlib

from .anthropic_utils import json_to_xml, extract_xml, xml_to_model

T = TypeVar("T")

Expand Down Expand Up @@ -57,6 +60,11 @@ def openai_schema(cls) -> Dict[str, Any]:
"parameters": parameters,
}

@classmethod
@property
def anthropic_schema(cls) -> str:
return json_to_xml(cls)

@classmethod
def from_response(
cls,
Expand All @@ -77,6 +85,17 @@ def from_response(
Returns:
cls (OpenAISchema): An instance of the class
"""
if mode == Mode.ANTHROPIC_TOOLS:
try:
assert isinstance(
completion,
importlib.import_module("anthropic.types.message").Message,
)
except ImportError as err:
raise ImportError("Please 'pip install anthropic' package to proceed.") from err
assert hasattr(completion, "content")
return xml_to_model(cls, extract_xml(completion.content[0].text)) # type:ignore

assert hasattr(completion, "choices")

if completion.choices[0].finish_reason == "length":
Expand Down
1 change: 1 addition & 0 deletions instructor/mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Mode(enum.Enum):
JSON = "json_mode"
MD_JSON = "markdown_json_mode"
JSON_SCHEMA = "json_schema_mode"
ANTHROPIC_TOOLS = "anthropic_tools"

def __new__(cls, value: str) -> "Mode":
member = object.__new__(cls)
Expand Down
23 changes: 23 additions & 0 deletions instructor/process_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,29 @@ def handle_response_model(
# if it is, system append the schema to the end
else:
new_kwargs["messages"][0]["content"] += f"\n\n{message}"
elif mode == Mode.ANTHROPIC_TOOLS:
tool_descriptions = response_model.anthropic_schema
system_prompt = dedent(
f"""
In this environment you have access to a set of tools you can use to answer the user's question.
You may call them like this:
<function_calls>
<invoke>
<tool_name>$TOOL_NAME</tool_name>
<parameters>
<$PARAMETER_NAME>$PARAMETER_VALUE</$PARAMETER_NAME>
...
</parameters>
</invoke>
</function_calls>
Here are the tools available:\n{tool_descriptions}
"""
)
if "system" in new_kwargs:
new_kwargs["system"] = f"{system_prompt}\n{new_kwargs['system']}"
else:
new_kwargs["system"] = system_prompt
else:
raise ValueError(f"Invalid patch mode: {mode}")

Expand Down
9 changes: 8 additions & 1 deletion instructor/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,15 @@


def reask_messages(response: ChatCompletion, mode: Mode, exception: Exception):
yield dump_message(response.choices[0].message)
if mode == Mode.ANTHROPIC_TOOLS:
# TODO: we need to include the original response
yield {
"role": "user",
"content": f"Validation Error found:\n{exception}\nRecall the function correctly, fix the errors",
}
return

yield dump_message(response.choices[0].message)
# TODO: Give users more control on configuration
if mode == Mode.TOOLS:
for tool_call in response.choices[0].message.tool_calls: # type: ignore
Expand Down
Loading

0 comments on commit 8da87c4

Please sign in to comment.