llm chat --tool and --functions (#1062)

* Tool support for llm chat, closes #1004
This commit is contained in:
Simon Willison 2025-05-20 21:30:27 -07:00 committed by GitHub
parent e48a5b9f11
commit bd2180df7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 152 additions and 9 deletions

View file

@ -173,6 +173,13 @@ Options:
-d, --database FILE Path to log database
--no-stream Do not stream output
--key TEXT API key to use
-T, --tool TEXT Name of a tool to make available to the model
--functions TEXT Python code block or file path defining functions
to register as tools
--td, --tools-debug Show full details of tool executions
--ta, --tools-approve Manually approve every tool execution
--cl, --chain-limit INTEGER How many chained tool responses to allow, default
5, set 0 for unlimited
--help Show this message and exit.
```

View file

@ -162,6 +162,17 @@ Approve tool call? [y/N]:
```
The `--functions` option can be passed more than once, and can also point to the filename of a `.py` file containing one or more functions.
If you have any tools that have been made available via plugins you can add them to the prompt using `--tool/-T` option. For example, using [llm-tools-simpleeval](https://github.com/simonw/llm-tools-simpleeval) like this:
```bash
llm install llm-tools-simpleeval
llm --tool simple_eval "4444 * 233423" --td
```
Run this command to see a list of available tools from plugins:
```bash
llm tools
```
(usage-extract-fenced-code)=
### Extracting fenced code blocks
@ -416,6 +427,8 @@ Type '!edit' to open your default editor and modify the prompt.
> !edit
```
`llm chat` takes the same `--tool/-T` and `--functions` options as `llm prompt`. You can use this to start a chat with the specified {ref}`tools <usage-tools>` enabled.
## Listing available models
The `llm models` command lists every model that can be used with LLM, along with their aliases. This includes models that have been installed using {ref}`plugins <plugins>`.

View file

@ -909,6 +909,42 @@ def prompt(
)
@click.option("--no-stream", is_flag=True, help="Do not stream output")
@click.option("--key", help="API key to use")
@click.option(
"tools",
"-T",
"--tool",
multiple=True,
help="Name of a tool to make available to the model",
)
@click.option(
"python_tools",
"--functions",
help="Python code block or file path defining functions to register as tools",
multiple=True,
)
@click.option(
"tools_debug",
"--td",
"--tools-debug",
is_flag=True,
help="Show full details of tool executions",
envvar="LLM_TOOLS_DEBUG",
)
@click.option(
"tools_approve",
"--ta",
"--tools-approve",
is_flag=True,
help="Manually approve every tool execution",
)
@click.option(
"chain_limit",
"--cl",
"--chain-limit",
type=int,
default=5,
help="How many chained tool responses to allow, default 5, set 0 for unlimited",
)
def chat(
system,
model_id,
@ -920,6 +956,11 @@ def chat(
no_stream,
key,
database,
tools,
python_tools,
tools_debug,
tools_approve,
chain_limit,
):
"""
Hold an ongoing chat with a model.
@ -987,7 +1028,18 @@ def chat(
raise click.ClickException(render_errors(ex.errors()))
kwargs = {}
kwargs.update(validated_options)
if validated_options:
kwargs["options"] = validated_options
tool_functions = _gather_tools(tools, python_tools)
if tool_functions:
kwargs["chain_limit"] = chain_limit
if tools_debug:
kwargs["after_call"] = _debug_tool_call
if tools_approve:
kwargs["before_call"] = _approve_tool_call
kwargs["tools"] = tool_functions
should_stream = model.can_stream and not no_stream
if not should_stream:
@ -1042,7 +1094,7 @@ def chat(
prompt = new_prompt
if prompt.strip() in ("exit", "quit"):
break
response = conversation.prompt(prompt, system=system, **kwargs)
response = conversation.chain(prompt, system=system, **kwargs)
# System prompt only sent for the first message:
system = None
for chunk in response:

View file

@ -332,7 +332,7 @@ class Conversation(_BaseConversation):
details: bool = False,
key: Optional[str] = None,
options: Optional[dict] = None,
):
) -> "ChainResponse":
self.model._validate_attachments(attachments)
return ChainResponse(
Prompt(

View file

@ -67,6 +67,7 @@ test = [
"types-click",
"types-PyYAML",
"types-setuptools",
"llm-echo==0.3a1",
]
[build-system]

View file

@ -2,6 +2,7 @@ import pytest
import sqlite_utils
import json
import llm
import llm_echo
from llm.plugins import pm
from pydantic import Field
from pytest_httpx import IteratorStream
@ -233,19 +234,20 @@ def register_embed_demo_model(embed_demo, mock_model, async_mock_model):
@pytest.fixture(autouse=True)
def register_echo_model():
class SimpleEchoModelPlugin:
__name__ = "SimpleEchoModelPlugin"
def register_echo_models():
class EchoModelsPlugin:
__name__ = "EchoModelsPlugin"
@llm.hookimpl
def register_models(self, register):
register(SimpleEchoModel())
register(llm_echo.Echo(), llm_echo.EchoAsync())
pm.register(SimpleEchoModelPlugin(), name="undo-SimpleEchoModelPlugin")
pm.register(EchoModelsPlugin(), name="undo-EchoModelsPlugin")
try:
yield
finally:
pm.unregister(name="undo-SimpleEchoModelPlugin")
pm.unregister(name="undo-EchoModelsPlugin")
@pytest.fixture

View file

@ -1,9 +1,11 @@
from click.testing import CliRunner
import json
import llm.cli
from unittest.mock import ANY
import pytest
import sys
import sqlite_utils
import textwrap
@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows")
@ -265,3 +267,69 @@ def test_llm_chat_creates_log_database(tmpdir, monkeypatch, custom_database_path
assert (user_path / "logs.db").exists()
db_path = str(user_path / "logs.db")
assert sqlite_utils.Database(db_path)["responses"].count == 2
@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows")
def test_chat_tools(logs_db):
runner = CliRunner()
functions = textwrap.dedent(
"""
def upper(text: str) -> str:
"Convert text to upper case"
return text.upper()
"""
)
result = runner.invoke(
llm.cli.cli,
["chat", "-m", "echo", "--functions", functions],
input="\n".join(
[
json.dumps(
{
"prompt": "Convert hello to uppercase",
"tool_calls": [
{"name": "upper", "arguments": {"text": "hello"}}
],
}
),
"quit",
]
),
catch_exceptions=False,
)
assert result.exit_code == 0
assert result.output == (
"Chatting with echo\n"
"Type 'exit' or 'quit' to exit\n"
"Type '!multi' to enter multiple lines, then '!end' to finish\n"
"Type '!edit' to open your default editor and modify the prompt\n"
'> {"prompt": "Convert hello to uppercase", "tool_calls": [{"name": "upper", '
'"arguments": {"text": "hello"}}]}\n'
"{\n"
' "prompt": "Convert hello to uppercase",\n'
' "system": "",\n'
' "attachments": [],\n'
' "stream": true,\n'
' "previous": []\n'
"}{\n"
' "prompt": "",\n'
' "system": "",\n'
' "attachments": [],\n'
' "stream": true,\n'
' "previous": [\n'
" {\n"
' "prompt": "{\\"prompt\\": \\"Convert hello to uppercase\\", '
'\\"tool_calls\\": [{\\"name\\": \\"upper\\", \\"arguments\\": {\\"text\\": '
'\\"hello\\"}}]}"\n'
" }\n"
" ],\n"
' "tool_results": [\n'
" {\n"
' "name": "upper",\n'
' "output": "HELLO",\n'
' "tool_call_id": null\n'
" }\n"
" ]\n"
"}\n"
"> quit\n"
)

View file

@ -297,11 +297,11 @@ def test_plugins_command():
result = runner.invoke(cli.cli, ["plugins"])
assert result.exit_code == 0
expected = [
{"name": "EchoModelsPlugin", "hooks": ["register_models"]},
{
"name": "MockModelsPlugin",
"hooks": ["register_embedding_models", "register_models"],
},
{"name": "SimpleEchoModelPlugin", "hooks": ["register_models"]},
]
actual = json.loads(result.output)
actual.sort(key=lambda p: p["name"])