import os
# os.environ['ANTHROPIC_LOG'] = 'debug'
Structured Output with Pydantic
= models[1] # sonnet -- haiku was quite flaky on the failure cases. model
Pydantic provides a way of extracting structured outputs from text. This is useful for integrating an LLM as a single component of a pipeline.
Much of this was inspired by Instructor.
Anthropic function calling is based on JSON Schema (with a few small tweaks). You can easily get the the JSON Schema from any Pydantic mdoel with the model_json_schema
classmethod.
class User(BaseModel):
"Create a new user"
str
username: str
password: str
email: bool = Field(..., description="Indicate if user creation is a success.")
success: str = Field(default="", description="Failure reason. This should be \"\" if `success=True`. If `success=False` you **must** give a failure reason.")
failure_reason:
User.model_json_schema()
{'description': 'Create a new user',
'properties': {'username': {'title': 'Username', 'type': 'string'},
'password': {'title': 'Password', 'type': 'string'},
'email': {'title': 'Email', 'type': 'string'},
'success': {'description': 'Indicate if user creation is a success.',
'title': 'Success',
'type': 'boolean'},
'failure_reason': {'default': '',
'description': 'Failure reason. This should be "" if `success=True`. If `success=False` you **must** give a failure reason.',
'title': 'Failure Reason',
'type': 'string'}},
'required': ['username', 'password', 'email', 'success'],
'title': 'User',
'type': 'object'}
Notable differences between JSON Schema and Anthropic’s function calling:
title -> name
properties -> input_schema: { properties }
- No title in parameters, instead key is used.
I’m not really sure why they’d break a spec for such small differences. We can create a new claude_schema
based on model_json_schema
.
BaseModel.claude_schema
BaseModel.claude_schema ()
Create tool schema for claude
Exported source
@patch(cls_method=True)
def claude_schema(cls: BaseModel):
"Create tool schema for claude"
def _filter_title(obj):
if isinstance(obj, dict): return {k:_filter_title(v) for k,v in obj.items() if k != 'title'}
elif isinstance(obj, list): return [_filter_title(item) for item in obj]
else: return obj
= cls.model_json_schema()
schema = schema.pop('title')
name try:
= schema.pop('description')
description except KeyError:
raise KeyError("Provide a docstring")
return {
"name": name,
"description": description,
"input_schema": _filter_title(schema)
}
User.claude_schema()
{'name': 'User',
'description': 'Create a new user',
'input_schema': {'properties': {'username': {'type': 'string'},
'password': {'type': 'string'},
'email': {'type': 'string'},
'success': {'description': 'Indicate if user creation is a success.',
'type': 'boolean'},
'failure_reason': {'default': '',
'description': 'Failure reason. This should be "" if `success=True`. If `success=False` you **must** give a failure reason.',
'type': 'string'}},
'required': ['username', 'password', 'email', 'success'],
'type': 'object'}}
Just using claude_schema
, we can now use Pydantic models using only our existing tools:
= Client(model)
c = "create a user for sarah adams, email sarah@gmail.com, and give them a strong password"
pr = c(pr, tools=[User.claude_schema()], tool_choice=mk_tool_choice('User'))
r = contents(r)
cts = call_func(cts, ns=[User])
mod mod
User(username='sarahadams', password='X9#mK2$pL7@qR4', email='sarah@gmail.com', success=True, failure_reason='')
And creating a new chat messages:
id, mod)]) mk_msg([mk_funcres(cts.
{'role': 'user',
'content': [{'type': 'tool_result',
'tool_use_id': 'toolu_01KUWkLVxiwnkVJPybTbod9f',
'content': "username='sarahadams' password='X9#mK2$pL7@qR4' email='sarah@gmail.com' success=True failure_reason=''"}]}
Let’s create a nicer function that wraps the Chat.__call__
. This takes an unintialized Pydantic BaseModel
and returns an initialized BaseModel
.
Client.struct
Client.struct (msgs:list, resp_model:type[pydantic.main.BaseModel], sp='', temp=0, maxtok=4096, prefill='', stream:bool=False, stop=None, tools:Optional[list]=None, tool_choice:Optional[dict]=None, metadata:MetadataParam|NotGiven=NOT_GIVEN, stop_sequences:List[str]|NotGiven=NOT_GIVEN, system:Union[ str,Iterable[TextBlockParam]]|NotGiven=NOT_GIVEN, temperature:float|NotGiven=NOT_GIVEN, top_k:int|NotGiven=NOT_GIVEN, top_p:float|NotGiven=NOT_GIVEN, extra_headers:Headers|None=None, extra_query:Query|None=None, extra_body:Body|None=None, timeout:float|httpx.Timeout|None|NotGiven=NOT_GIVEN)
Parse Claude output into the Pydantic resp_model
Type | Default | Details | |
---|---|---|---|
msgs | list | List of messages in the dialog | |
resp_model | type | Non-initialized pydantic struct | |
sp | str | The system prompt | |
temp | int | 0 | Temperature |
maxtok | int | 4096 | Maximum tokens |
prefill | str | Optional prefill to pass to Claude as start of its response | |
stream | bool | False | Stream response? |
stop | NoneType | None | Stop sequence |
tools | Optional | None | List of tools to make available to Claude |
tool_choice | Optional | None | Optionally force use of some tool |
metadata | MetadataParam | NotGiven | NOT_GIVEN | |
stop_sequences | List[str] | NotGiven | NOT_GIVEN | |
system | Union[str, Iterable[TextBlockParam]] | NotGiven | NOT_GIVEN | |
temperature | float | NotGiven | NOT_GIVEN | |
top_k | int | NotGiven | NOT_GIVEN | |
top_p | float | NotGiven | NOT_GIVEN | |
extra_headers | Headers | None | None | |
extra_query | Query | None | None | |
extra_body | Body | None | None | |
timeout | float | httpx.Timeout | None | NotGiven | NOT_GIVEN | |
Returns | BaseModel | Initialized pydantic struct |
Exported source
@patch
@delegates(Client.__call__)
def struct(self:Client,
list, # List of messages in the dialog
msgs:type[BaseModel], # Non-initialized pydantic struct
resp_model: **kwargs
-> BaseModel: # Initialized pydantic struct
) "Parse Claude output into the Pydantic `resp_model`"
"tool_choice"] = mk_tool_choice(resp_model.__name__)
kwargs["tools"] = [resp_model.claude_schema()] # no other tools needed -- model is forced by tool_choice
kwargs[= self(msgs=msgs, **kwargs)
fc = _mk_struct(contents(fc).input, resp_model)
res return res
This will always return a BaseModel
“struct”
=User) c.struct(pr, resp_model
User(username='sarahadams', password='X9#mK2$pL7@qR4', email='sarah@gmail.com', success=True, failure_reason='')
Even if we try not to:
'what is 2+2', resp_model=User) c.struct(
User(username='<UNKNOWN>', password='<UNKNOWN>', email='<UNKNOWN>', success=False, failure_reason="The query is unrelated to user creation. It's a simple arithmetic question.")
Now let’s implement this in Chat
. The most non-invasive way I can think of to do this is add a new struct
function that adds the function result to the history
Chat.struct
Chat.struct (resp_model:type[pydantic.main.BaseModel], treat_as_output=True, sp='', temp=0, maxtok=4096, prefill='', stream:bool=False, stop=None, tools:Optional[list]=None, tool_choice:Optional[dict]=None, metadata:MetadataParam|NotGiven=NOT_GIVEN, stop_sequences:List[str]|NotGiven=NOT_GIVEN, system:Union[st r,Iterable[TextBlockParam]]|NotGiven=NOT_GIVEN, temperature:float|NotGiven=NOT_GIVEN, top_k:int|NotGiven=NOT_GIVEN, top_p:float|NotGiven=NOT_GIVEN, extra_headers:Headers|None=None, extra_query:Query|None=None, extra_body:Body|None=None, timeout:float|httpx.Timeout|None|NotGiven=NOT_GIVEN)
Type | Default | Details | |
---|---|---|---|
resp_model | type | Non-initialized pydantic struct | |
treat_as_output | bool | True | Usually using a tool |
sp | str | The system prompt | |
temp | int | 0 | Temperature |
maxtok | int | 4096 | Maximum tokens |
prefill | str | Optional prefill to pass to Claude as start of its response | |
stream | bool | False | Stream response? |
stop | NoneType | None | Stop sequence |
tools | Optional | None | List of tools to make available to Claude |
tool_choice | Optional | None | Optionally force use of some tool |
metadata | MetadataParam | NotGiven | NOT_GIVEN | |
stop_sequences | List[str] | NotGiven | NOT_GIVEN | |
system | Union[str, Iterable[TextBlockParam]] | NotGiven | NOT_GIVEN | |
temperature | float | NotGiven | NOT_GIVEN | |
top_k | int | NotGiven | NOT_GIVEN | |
top_p | float | NotGiven | NOT_GIVEN | |
extra_headers | Headers | None | None | |
extra_query | Query | None | None | |
extra_body | Body | None | None | |
timeout | float | httpx.Timeout | None | NotGiven | NOT_GIVEN | |
Returns | BaseModel | Initialized pydantic struct |
Exported source
@patch
@delegates(Client.struct)
def struct(self:Chat,
type[BaseModel], # Non-initialized pydantic struct
resp_model: =True, # Usually using a tool
treat_as_output**kwargs) -> BaseModel:
self._append_pr(kwargs.pop("pr", None))
= self.c.struct(self.h, resp_model=resp_model, **kwargs)
res if treat_as_output:
= [mk_msg(repr(res), "assistant")] # alternatively: res.json()
msgs else:
= self.c.result
r = contents(r).id
tool_id = [mk_msg(r, "assistant"),
msgs repr(res))], "user")]
mk_msg([mk_funcres(tool_id, self.h += msgs
return res
= True
gen_pass def generate_password() -> dict:
"""generate a strong user password.
@returns { "success": <indicates function success>, "pass": <password> }
"""
if gen_pass:
return {"success": True, "pass": "qwerty123"}
return {"success": False, "pass": "<UNKNOWN>"}
= """You are a user generation system.
sp Refer to only the **most recent** user generation request. Do not attend to previous requests.
<instructions>
1. If creating a user with only an email, pick a relevant username.
a) If no email is given, fail user creation. Do not ask for more information.
2. You must use `generate_password` tool to create passwords. You must NOT create your own passwords.
a) if `generate_password` returns `success=False` and `pass=<UNKNOWN>` fail user creation.
3. If you are given `tool_choice=User`, refer to <user_creation> for your response. Else respond in plain english.
</instructions>
<user_creation>
if user creation is successful:
create user using the `User` tool
else: # user creation has failed
refer to <fail_user>
</user_creation>
<fail_user>
This should only be run if and only if `User` is given in `tool_choice` and user creation has failed.
1. Mark failed fields as <UNKNOWN>
2. Set `success = False`
3. Give relevant details for failure in `failure_reason`
</fail_user>
"""
= Chat(model, tools=[generate_password], sp=sp, cont_pr="use the tool specified")
chat "create a user with username jackAdam12 and email jack@email.com") chat(
Certainly! I’ll create a user with the username jackAdam12 and email jack@email.com. To do this, we need to generate a strong password using the generate_password
function. Let’s proceed with the user creation process.
- id: msg_019SpTmBh9LtM7tERkY4Rxko
- content: [{‘text’: “Certainly! I’ll create a user with the username jackAdam12 and email jack@email.com. To do this, we need to generate a strong password using the
generate_password
function. Let’s proceed with the user creation process.”, ‘type’: ‘text’}, {‘id’: ‘toolu_01LHxVZsNKUSpfkvHGa5u9BX’, ‘input’: {}, ‘name’: ‘generate_password’, ‘type’: ‘tool_use’}] - model: claude-3-5-sonnet-20240620
- role: assistant
- stop_reason: tool_use
- stop_sequence: None
- type: message
- usage: {‘input_tokens’: 681, ‘output_tokens’: 89}
chat.struct(User)
User(username='jackAdam12', password='qwerty123', email='jack@email.com', success=True, failure_reason='')
Now let’s make the gen_pass function fail:
= False
gen_pass print(generate_password())
'cool, can you create another user for sarahjones@gmail.com?') chat(
{'success': False, 'pass': '<UNKNOWN>'}
Certainly! I’ll create a user for sarahjones@gmail.com. Since only the email was provided, I’ll generate a relevant username based on the email address. Then, we’ll use the generate_password
function to create a secure password for this user.
First, let’s generate the password:
- id: msg_01S6SUDmhmrDKns5nxZRc9Ae
- content: [{‘text’: “Certainly! I’ll create a user for sarahjones@gmail.com. Since only the email was provided, I’ll generate a relevant username based on the email address. Then, we’ll use the
generate_password
function to create a secure password for this user., let’s generate the password:”, ‘type’: ‘text’}, {‘id’: ‘toolu_018LXGQEsMjhEZzrwyW2eJ5b’, ‘input’: {}, ‘name’: ‘generate_password’, ‘type’: ‘tool_use’}] - model: claude-3-5-sonnet-20240620
- role: assistant
- stop_reason: tool_use
- stop_sequence: None
- type: message
- usage: {‘input_tokens’: 1080, ‘output_tokens’: 103}
We get a user creation failure with an appropiate failure message.
chat.struct(User)
User(username='sarahjones', password='<UNKNOWN>', email='sarahjones@gmail.com', success=False, failure_reason='Unable to generate a secure password')
Finally let’s try to create a user with no email:
= True
gen_pass 'finally can you create an account for Adam?') chat(
I apologize, but I’m unable to create an account for Adam with just the name provided. To create a user account, we need at least an email address. Without an email address, I cannot proceed with the user creation process as per the instructions I’ve been given.
Here’s why I can’t create the account:
- An email address is a required piece of information for user creation.
- We don’t have enough information to generate a unique username or to associate the account with a valid email address.
- The instructions specifically state that if no email is given, we should fail user creation and not ask for more information.
If you’d like to create an account for Adam, you would need to provide at least an email address. Once you have an email address for Adam, please feel free to ask again, and I’ll be happy to assist you with creating the account.
Is there anything else I can help you with, or would you like to provide an email address for Adam to proceed with account creation?
- id: msg_01EHSXCJ4nACtApWNT89fdmy
- content: [{‘text’: “I apologize, but I’m unable to create an account for Adam with just the name provided. To create a user account, we need at least an email address. Without an email address, I cannot proceed with the user creation process as per the instructions I’ve been given.‘s why I can’t create the account:. An email address is a required piece of information for user creation.. We don’t have enough information to generate a unique username or to associate the account with a valid email address.. The instructions specifically state that if no email is given, we should fail user creation and not ask for more information.you’d like to create an account for Adam, you would need to provide at least an email address. Once you have an email address for Adam, please feel free to ask again, and I’ll be happy to assist you with creating the account.there anything else I can help you with, or would you like to provide an email address for Adam to proceed with account creation?“, ’type’: ‘text’}]
- model: claude-3-5-sonnet-20240620
- role: assistant
- stop_reason: end_turn
- stop_sequence: None
- type: message
- usage: {‘input_tokens’: 1655, ‘output_tokens’: 217}
chat.struct(User)
User(username='Adam', password='<UNKNOWN>', email='<UNKNOWN>', success=False, failure_reason='Insufficient information provided: missing email address and password')