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 -d, --database FILE Path to log database
--no-stream Do not stream output --no-stream Do not stream output
--key TEXT API key to use --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. --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. 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)= (usage-extract-fenced-code)=
### Extracting fenced code blocks ### Extracting fenced code blocks
@ -416,6 +427,8 @@ Type '!edit' to open your default editor and modify the prompt.
> !edit > !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 ## 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>`. 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("--no-stream", is_flag=True, help="Do not stream output")
@click.option("--key", help="API key to use") @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( def chat(
system, system,
model_id, model_id,
@ -920,6 +956,11 @@ def chat(
no_stream, no_stream,
key, key,
database, database,
tools,
python_tools,
tools_debug,
tools_approve,
chain_limit,
): ):
""" """
Hold an ongoing chat with a model. Hold an ongoing chat with a model.
@ -987,7 +1028,18 @@ def chat(
raise click.ClickException(render_errors(ex.errors())) raise click.ClickException(render_errors(ex.errors()))
kwargs = {} 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 should_stream = model.can_stream and not no_stream
if not should_stream: if not should_stream:
@ -1042,7 +1094,7 @@ def chat(
prompt = new_prompt prompt = new_prompt
if prompt.strip() in ("exit", "quit"): if prompt.strip() in ("exit", "quit"):
break break
response = conversation.prompt(prompt, system=system, **kwargs) response = conversation.chain(prompt, system=system, **kwargs)
# System prompt only sent for the first message: # System prompt only sent for the first message:
system = None system = None
for chunk in response: for chunk in response:

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import pytest
import sqlite_utils import sqlite_utils
import json import json
import llm import llm
import llm_echo
from llm.plugins import pm from llm.plugins import pm
from pydantic import Field from pydantic import Field
from pytest_httpx import IteratorStream 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) @pytest.fixture(autouse=True)
def register_echo_model(): def register_echo_models():
class SimpleEchoModelPlugin: class EchoModelsPlugin:
__name__ = "SimpleEchoModelPlugin" __name__ = "EchoModelsPlugin"
@llm.hookimpl @llm.hookimpl
def register_models(self, register): def register_models(self, register):
register(SimpleEchoModel()) register(SimpleEchoModel())
register(llm_echo.Echo(), llm_echo.EchoAsync())
pm.register(SimpleEchoModelPlugin(), name="undo-SimpleEchoModelPlugin") pm.register(EchoModelsPlugin(), name="undo-EchoModelsPlugin")
try: try:
yield yield
finally: finally:
pm.unregister(name="undo-SimpleEchoModelPlugin") pm.unregister(name="undo-EchoModelsPlugin")
@pytest.fixture @pytest.fixture

View file

@ -1,9 +1,11 @@
from click.testing import CliRunner from click.testing import CliRunner
import json
import llm.cli import llm.cli
from unittest.mock import ANY from unittest.mock import ANY
import pytest import pytest
import sys import sys
import sqlite_utils import sqlite_utils
import textwrap
@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") @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() assert (user_path / "logs.db").exists()
db_path = str(user_path / "logs.db") db_path = str(user_path / "logs.db")
assert sqlite_utils.Database(db_path)["responses"].count == 2 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"]) result = runner.invoke(cli.cli, ["plugins"])
assert result.exit_code == 0 assert result.exit_code == 0
expected = [ expected = [
{"name": "EchoModelsPlugin", "hooks": ["register_models"]},
{ {
"name": "MockModelsPlugin", "name": "MockModelsPlugin",
"hooks": ["register_embedding_models", "register_models"], "hooks": ["register_embedding_models", "register_models"],
}, },
{"name": "SimpleEchoModelPlugin", "hooks": ["register_models"]},
] ]
actual = json.loads(result.output) actual = json.loads(result.output)
actual.sort(key=lambda p: p["name"]) actual.sort(key=lambda p: p["name"])