Skip to content

Commit 62c90dd

Browse files
authored
llm prompt --schema X option and model.prompt(..., schema=) parameter (#777)
Refs #776 * Implemented new llm prompt --schema and model.prompt(schema=) * Log schema to responses.schema_id and schemas table * Include schema in llm logs Markdown output * Test for schema=pydantic_model * Initial --schema CLI documentation * Python docs for schema= * Advanced plugin docs on schemas
1 parent eda1f4f commit 62c90dd

15 files changed

+246
-23
lines changed

docs/changelog.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ There's also a new {ref}`llm.Collection <embeddings-python-collections>` class f
411411
- The output format for `llm logs` has changed. Previously it was JSON - it's now a much more readable Markdown format suitable for pasting into other documents. [#160](https://github.com/simonw/llm/issues/160)
412412
- The new `llm logs --json` option can be used to get the old JSON format.
413413
- Pass `llm logs --conversation ID` or `--cid ID` to see the full logs for a specific conversation.
414-
- You can now combine piped input and a prompt in a single command: `cat script.py | llm 'explain this code'`. This works even for models that do not support {ref}`system prompts <system-prompts>`. [#153](https://github.com/simonw/llm/issues/153)
414+
- You can now combine piped input and a prompt in a single command: `cat script.py | llm 'explain this code'`. This works even for models that do not support {ref}`system prompts <usage-system-prompts>`. [#153](https://github.com/simonw/llm/issues/153)
415415
- Additional {ref}`openai-compatible-models` can now be configured with custom HTTP headers. This enables platforms such as [openrouter.ai](https://openrouter.ai/) to be used with LLM, which can provide Claude access even without an Anthropic API key.
416416
- Keys set in `keys.json` are now used in preference to environment variables. [#158](https://github.com/simonw/llm/issues/158)
417417
- The documentation now includes a {ref}`plugin directory <plugin-directory>` listing all available plugins for LLM. [#173](https://github.com/simonw/llm/issues/173)

docs/help.md

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Options:
117117
--at, --attachment-type <TEXT TEXT>...
118118
Attachment with explicit mimetype
119119
-o, --option <TEXT TEXT>... key/value options for the model
120+
--schema TEXT JSON schema to use for output
120121
-t, --template TEXT Template to use
121122
-p, --param <TEXT TEXT>... Parameters for template
122123
--no-stream Do not stream output

docs/logging.md

+9-4
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ Example output:
157157
(logs-conversation)=
158158
### Logs for a conversation
159159

160-
To view the logs for the most recent {ref}`conversation <conversation>` you have had with a model, use `-c`:
160+
To view the logs for the most recent {ref}`conversation <usage-conversation>` you have had with a model, use `-c`:
161161

162162
```bash
163163
llm logs -c
@@ -209,7 +209,7 @@ def cleanup_sql(sql):
209209
return first_line + '(\n ' + ',\n '.join(columns) + '\n);'
210210

211211
cog.out("```sql\n")
212-
for table in ("conversations", "responses", "responses_fts", "attachments", "prompt_attachments"):
212+
for table in ("conversations", "schemas", "responses", "responses_fts", "attachments", "prompt_attachments"):
213213
schema = db[table].schema
214214
cog.out(format(cleanup_sql(schema)))
215215
cog.out("\n")
@@ -221,7 +221,11 @@ CREATE TABLE [conversations] (
221221
[name] TEXT,
222222
[model] TEXT
223223
);
224-
CREATE TABLE [responses] (
224+
CREATE TABLE [schemas] (
225+
[id] TEXT PRIMARY KEY,
226+
[content] TEXT
227+
);
228+
CREATE TABLE "responses" (
225229
[id] TEXT PRIMARY KEY,
226230
[model] TEXT,
227231
[prompt] TEXT,
@@ -235,7 +239,8 @@ CREATE TABLE [responses] (
235239
[datetime_utc] TEXT,
236240
[input_tokens] INTEGER,
237241
[output_tokens] INTEGER,
238-
[token_details] TEXT
242+
[token_details] TEXT,
243+
[schema_id] TEXT REFERENCES [schemas]([id])
239244
);
240245
CREATE VIRTUAL TABLE [responses_fts] USING FTS5 (
241246
[prompt],

docs/plugins/advanced-model-plugins.md

+15
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ def register_models(register):
9090
)
9191
```
9292

93+
(advanced-model-plugins-schemas)=
94+
95+
## Supporting schemas
96+
97+
If your model supports {ref}`structured output <python-api-schemas>` against a defined JSON schema you can implement support by first adding `supports_schema = True` to the class:
98+
99+
```python
100+
class MyModel(llm.KeyModel):
101+
...
102+
support_schema = True
103+
```
104+
And then adding code to your `.execute()` method that checks for `prompt.schema` and, if it is present, uses that to prompt the model. `prompt.schema` will always be a Python dictionary, even if the user passed in a Pydantic model class.
105+
106+
Check the [llm-gemini](https://github.com/simonw/llm-gemini) and [llm-anthropic](https://github.com/simonw/llm-anthropic) plugins for example of this pattern in action.
107+
93108
(advanced-model-plugins-attachments)=
94109

95110
## Attachments for multi-modal models

docs/python-api.md

+35
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,41 @@ if "image/jpeg" in model.attachment_types:
8383
...
8484
```
8585

86+
(python-api-schemas)=
87+
88+
### Schemas
89+
90+
As with {ref}`the CLI tool <usage-schemas>` some models support passing a JSON schema should be used for the resulting response.
91+
92+
You can pass this to the `prompt(schema=)` parameter as either a Python dictionary or a [Pydantic](https://docs.pydantic.dev/) `BaseModel` subclass:
93+
94+
```python
95+
import llm, json
96+
from pydantic import BaseModel
97+
98+
class Dog(BaseModel):
99+
name: str
100+
age: int
101+
102+
model = llm.get_model("gpt-4o-mini")
103+
response = model.prompt("Describe a nice dog", schema=Dog)
104+
dog = json.loads(response.text())
105+
print(dog)
106+
# {"name":"Buddy","age":3}
107+
```
108+
You can also pass a schema directly, like this:
109+
```python
110+
response = model.prompt("Describe a nice dog", schema={
111+
"properties": {
112+
"name": {"title": "Name", "type": "string"},
113+
"age": {"title": "Age", "type": "integer"},
114+
},
115+
"required": ["name", "age"],
116+
"title": "Dog",
117+
"type": "object",
118+
})
119+
```
120+
86121
(python-api-model-options)=
87122

88123
### Model options

docs/usage.md

+37-3
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Will run a prompt of:
3838
```
3939
<contents of myscript.py> explain this code
4040
```
41-
For models that support them, {ref}`system prompts <system-prompts>` are a better tool for this kind of prompting.
41+
For models that support them, {ref}`system prompts <usage-system-prompts>` are a better tool for this kind of prompting.
4242

4343
Some models support options. You can pass these using `-o/--option name value` - for example, to set the temperature to 1.5 run this:
4444

@@ -88,7 +88,7 @@ LLM will attempt to automatically detect the content type of the image. If this
8888
cat myfile | llm "describe this image" --at - image/jpeg
8989
```
9090

91-
(system-prompts)=
91+
(usage-system-prompts)=
9292
### System prompts
9393

9494
You can use `-s/--system '...'` to set a system prompt.
@@ -122,7 +122,41 @@ cat llm/utils.py | llm -t pytest
122122
```
123123
See {ref}`prompt templates <prompt-templates>` for more.
124124

125-
(conversation)=
125+
(usage-schemas)=
126+
### Schemas
127+
128+
Some models include the ability to return JSON that matches a provided [JSON schema](https://json-schema.org/). Models from OpenAI, Anthropic and Google Gemini all include this capability.
129+
130+
LLM has alpha functionality for specifying a schema to use for the response to a prompt.
131+
132+
Create the schema as a JSON string, then pass that to the `--schema` option. For example:
133+
134+
```bash
135+
llm --schema '{
136+
"type": "object",
137+
"properties": {
138+
"dogs": {
139+
"type": "array",
140+
"items": {
141+
"type": "object",
142+
"properties": {
143+
"name": {
144+
"type": "string"
145+
},
146+
"bio": {
147+
"type": "string"
148+
}
149+
}
150+
}
151+
}
152+
}
153+
}' -m gpt-4o-mini 'invent two dogs'
154+
```
155+
The JSON returned from the model should match that schema.
156+
157+
Be warned that different models may support different dialects of the JSON schema specification.
158+
159+
(usage-conversation)=
126160
### Continuing a conversation
127161

128162
By default, the tool will start a new conversation each time you run it.

llm/cli.py

+40-13
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,19 @@ def attachment_types_callback(ctx, param, values):
114114
return collected
115115

116116

117-
def _validate_metadata_json(ctx, param, value):
118-
if value is None:
119-
return value
120-
try:
121-
obj = json.loads(value)
122-
if not isinstance(obj, dict):
123-
raise click.BadParameter("Metadata must be a JSON object")
124-
return obj
125-
except json.JSONDecodeError:
126-
raise click.BadParameter("Metadata must be valid JSON")
117+
def json_validator(object_name):
118+
def validator(ctx, param, value):
119+
if value is None:
120+
return value
121+
try:
122+
obj = json.loads(value)
123+
if not isinstance(obj, dict):
124+
raise click.BadParameter(f"{object_name} must be a JSON object")
125+
return obj
126+
except json.JSONDecodeError:
127+
raise click.BadParameter(f"{object_name} must be valid JSON")
128+
129+
return validator
127130

128131

129132
@click.group(
@@ -184,6 +187,9 @@ def cli():
184187
multiple=True,
185188
help="key/value options for the model",
186189
)
190+
@click.option(
191+
"--schema", callback=json_validator("schema"), help="JSON schema to use for output"
192+
)
187193
@click.option("-t", "--template", help="Template to use")
188194
@click.option(
189195
"-p",
@@ -228,6 +234,7 @@ def prompt(
228234
attachments,
229235
attachment_types,
230236
options,
237+
schema,
231238
template,
232239
param,
233240
no_stream,
@@ -429,6 +436,7 @@ async def inner():
429436
prompt,
430437
attachments=resolved_attachments,
431438
system=system,
439+
schema=schema,
432440
**kwargs,
433441
)
434442
async for chunk in response:
@@ -440,6 +448,7 @@ async def inner():
440448
prompt,
441449
attachments=resolved_attachments,
442450
system=system,
451+
schema=schema,
443452
**kwargs,
444453
)
445454
text = await response.text()
@@ -456,6 +465,7 @@ async def inner():
456465
prompt,
457466
attachments=resolved_attachments,
458467
system=system,
468+
schema=schema,
459469
**kwargs,
460470
)
461471
if should_stream:
@@ -829,13 +839,15 @@ def logs_turn_off():
829839
responses.output_tokens,
830840
responses.token_details,
831841
conversations.name as conversation_name,
832-
conversations.model as conversation_model"""
842+
conversations.model as conversation_model,
843+
schemas.content as schema_json"""
833844

834845
LOGS_SQL = """
835846
select
836847
{columns}
837848
from
838849
responses
850+
left join schemas on responses.schema_id = schemas.id
839851
left join conversations on responses.conversation_id = conversations.id{extra_where}
840852
order by responses.id desc{limit}
841853
"""
@@ -844,6 +856,7 @@ def logs_turn_off():
844856
{columns}
845857
from
846858
responses
859+
left join schemas on responses.schema_id = schemas.id
847860
left join conversations on responses.conversation_id = conversations.id
848861
join responses_fts on responses_fts.rowid = responses.rowid
849862
where responses_fts match :query{extra_where}
@@ -1117,6 +1130,12 @@ def logs_list(
11171130
if row["system"] is not None:
11181131
click.echo("\n## System:\n\n{}".format(row["system"]))
11191132
current_system = row["system"]
1133+
if row["schema_json"]:
1134+
click.echo(
1135+
"\n## Schema:\n\n```json\n{}\n```".format(
1136+
json.dumps(row["schema_json"], indent=2)
1137+
)
1138+
)
11201139
attachments = attachments_by_id.get(row["id"])
11211140
if attachments:
11221141
click.echo("\n### Attachments\n")
@@ -1141,7 +1160,15 @@ def logs_list(
11411160
)
11421161
)
11431162

1144-
click.echo("\n## Response:\n\n{}\n".format(row["response"]))
1163+
# If a schema was provided and the row is valid JSON, pretty print and syntax highlight it
1164+
response = row["response"]
1165+
if row["schema_json"]:
1166+
try:
1167+
parsed = json.loads(response)
1168+
response = "```json\n{}\n```".format(json.dumps(parsed, indent=2))
1169+
except ValueError:
1170+
pass
1171+
click.echo("\n## Response:\n\n{}\n".format(response))
11451172
if usage:
11461173
token_usage = token_usage_string(
11471174
row["input_tokens"],
@@ -1510,7 +1537,7 @@ def uninstall(packages, yes):
15101537
@click.option(
15111538
"--metadata",
15121539
help="JSON object metadata to store",
1513-
callback=_validate_metadata_json,
1540+
callback=json_validator("metadata"),
15141541
)
15151542
@click.option(
15161543
"format_",

llm/default_plugins/openai_models.py

+7
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ def _attachment(attachment):
366366

367367

368368
class _Shared:
369+
supports_schema = True
370+
369371
def __init__(
370372
self,
371373
model_id,
@@ -504,6 +506,11 @@ def build_kwargs(self, prompt, stream):
504506
kwargs["max_tokens"] = self.default_max_tokens
505507
if json_object:
506508
kwargs["response_format"] = {"type": "json_object"}
509+
if prompt.schema:
510+
kwargs["response_format"] = {
511+
"type": "json_schema",
512+
"json_schema": {"name": "output", "schema": prompt.schema},
513+
}
507514
if stream:
508515
kwargs["stream_options"] = {"include_usage": True}
509516
return kwargs

llm/migrations.py

+18
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,21 @@ def m013_usage(db):
237237
db["responses"].add_column("input_tokens", int)
238238
db["responses"].add_column("output_tokens", int)
239239
db["responses"].add_column("token_details", str)
240+
241+
242+
@migration
243+
def m014_schemas(db):
244+
db["schemas"].create(
245+
{
246+
"id": str,
247+
"content": str,
248+
},
249+
pk="id",
250+
)
251+
db["responses"].add_column("schema_id", str, fk="schemas", fk_col="id")
252+
# Clean up SQL create table indentation
253+
db["responses"].transform()
254+
# These changes may have dropped the FTS configuration, fix that
255+
db["responses"].enable_fts(
256+
["prompt", "response"], create_triggers=True, replace=True
257+
)

0 commit comments

Comments
 (0)