项目文件夹

文件
Simon Willison d8ada58d32
Test / test (ubuntu-latest, 3.14) (push) Has been cancelled
Test / test (windows-latest, 3.10) (push) Has been cancelled
Test / test (windows-latest, 3.11) (push) Has been cancelled
Test / test (windows-latest, 3.12) (push) Has been cancelled
Test / test (windows-latest, 3.13) (push) Has been cancelled
Test / test (windows-latest, 3.14) (push) Has been cancelled
Test / test (macos-latest, 3.10) (push) Has been cancelled
Test / test (macos-latest, 3.11) (push) Has been cancelled
Test / test (macos-latest, 3.12) (push) Has been cancelled
Test / test (macos-latest, 3.13) (push) Has been cancelled
Test / test (macos-latest, 3.14) (push) Has been cancelled
Test / test (ubuntu-latest, 3.10) (push) Has been cancelled
Test / test (ubuntu-latest, 3.11) (push) Has been cancelled
Test / test (ubuntu-latest, 3.12) (push) Has been cancelled
Test / test (ubuntu-latest, 3.13) (push) Has been cancelled
Experimental prompt.display_reasoning mechanism
So you can turn off display reasoning and compatible plugins can then
opt not to request reasoning summaries from their models, even as
they do actually run reasoning.
2026-05-05 14:34:04 -07:00

4112 行
126 KiB
Python

import asyncio
import click
from click_default_group import DefaultGroup
from dataclasses import asdict
from importlib.metadata import version
import io
import json
import os
from llm import (
Attachment,
AsyncConversation,
AsyncKeyModel,
AsyncResponse,
CancelToolCall,
Collection,
Conversation,
Fragment,
Response,
Template,
Tool,
Toolbox,
UnknownModelError,
KeyModel,
encode,
get_async_model,
get_default_model,
get_default_embedding_model,
get_embedding_models_with_aliases,
get_embedding_model_aliases,
get_embedding_model,
get_plugins,
get_tools,
get_fragment_loaders,
get_template_loaders,
get_model,
get_model_aliases,
get_models_with_aliases,
user_dir,
set_alias,
set_default_model,
set_default_embedding_model,
remove_alias,
)
from llm.models import _BaseConversation, ChainResponse
from .migrations import migrate
from .plugins import pm, load_plugins
from .utils import (
ensure_fragment,
extract_fenced_code_block,
find_unused_key,
has_plugin_prefix,
instantiate_from_spec,
make_schema_id,
maybe_fenced_code,
mimetype_from_path,
mimetype_from_string,
multi_schema,
output_rows_as_json,
resolve_schema_input,
schema_dsl,
schema_summary,
token_usage_string,
truncate_string,
)
import base64
import httpx
import inspect
import pathlib
import pydantic
import re
import readline
from runpy import run_module
import shutil
import sqlite_utils
from sqlite_utils.utils import rows_from_file, Format
import sys
import textwrap
from typing import cast, Dict, Optional, Iterable, List, Union, Tuple, Type, Any
import warnings
import yaml
warnings.simplefilter("ignore", ResourceWarning)
DEFAULT_TEMPLATE = "prompt: "
class FragmentNotFound(Exception):
pass
def display_stream_events(events, *, show_reasoning=True):
"""Consume a sync iterator of StreamEvents and write them.
Text events go to stdout. Reasoning events go to stderr in dim style.
A newline is written to stderr at each reasoning→text transition so
the assistant text starts on a fresh visual line.
"""
was_reasoning = False
for event in events:
if event.type == "text":
if was_reasoning and show_reasoning:
click.echo("", err=True)
was_reasoning = False
click.echo(event.chunk, nl=False)
elif event.type == "reasoning" and show_reasoning:
was_reasoning = True
click.echo(click.style(event.chunk, dim=True), nl=False, err=True)
async def display_async_stream_events(events, *, show_reasoning=True):
"""Async counterpart of display_stream_events."""
was_reasoning = False
async for event in events:
if event.type == "text":
if was_reasoning and show_reasoning:
click.echo("", err=True)
was_reasoning = False
click.echo(event.chunk, nl=False)
elif event.type == "reasoning" and show_reasoning:
was_reasoning = True
click.echo(click.style(event.chunk, dim=True), nl=False, err=True)
def validate_fragment_alias(ctx, param, value):
if not re.match(r"^[a-zA-Z0-9_-]+$", value):
raise click.BadParameter("Fragment alias must be alphanumeric")
return value
def resolve_fragments(
db: sqlite_utils.Database, fragments: Iterable[str], allow_attachments: bool = False
) -> List[Union[Fragment, Attachment]]:
"""
Resolve fragment strings into a mixed of llm.Fragment() and llm.Attachment() objects.
"""
def _load_by_alias(fragment: str) -> Tuple[Optional[str], Optional[str]]:
rows = list(
db.query(
"""
select content, source from fragments
left join fragment_aliases on fragments.id = fragment_aliases.fragment_id
where alias = :alias or hash = :alias limit 1
""",
{"alias": fragment},
)
)
if rows:
row = rows[0]
return row["content"], row["source"]
return None, None
# The fragment strings could be URLs or paths or plugin references
resolved: List[Union[Fragment, Attachment]] = []
for fragment in fragments:
if fragment.startswith("http://") or fragment.startswith("https://"):
llm_version = version("llm")
headers = {"User-Agent": f"llm/{llm_version} (https://llm.datasette.io/)"}
client = httpx.Client(
follow_redirects=True, max_redirects=3, headers=headers
)
response = client.get(fragment)
response.raise_for_status()
resolved.append(Fragment(response.text, fragment))
elif fragment == "-":
resolved.append(Fragment(sys.stdin.read(), "-"))
elif has_plugin_prefix(fragment):
prefix, rest = fragment.split(":", 1)
loaders = get_fragment_loaders()
if prefix not in loaders:
raise FragmentNotFound("Unknown fragment prefix: {}".format(prefix))
loader = loaders[prefix]
try:
result = loader(rest)
if not isinstance(result, list):
result = [result]
if not allow_attachments and any(
isinstance(r, Attachment) for r in result
):
raise FragmentNotFound(
"Fragment loader {} returned a disallowed attachment".format(
prefix
)
)
resolved.extend(result)
except Exception as ex:
raise FragmentNotFound(
"Could not load fragment {}: {}".format(fragment, ex)
)
else:
# Try from the DB
content, source = _load_by_alias(fragment)
if content is not None:
resolved.append(Fragment(content, source))
else:
# Now try path
path = pathlib.Path(fragment)
if path.exists():
resolved.append(Fragment(path.read_text(), str(path.resolve())))
else:
raise FragmentNotFound(f"Fragment '{fragment}' not found")
return resolved
def process_fragments_in_chat(
db: sqlite_utils.Database, prompt: str
) -> tuple[str, list[Fragment], list[Attachment]]:
"""
Process any !fragment commands in a chat prompt and return the modified prompt plus resolved fragments and attachments.
"""
prompt_lines = []
fragments = []
attachments = []
for line in prompt.splitlines():
if line.startswith("!fragment "):
try:
fragment_strs = line.strip().removeprefix("!fragment ").split()
fragments_and_attachments = resolve_fragments(
db, fragments=fragment_strs, allow_attachments=True
)
fragments += [
fragment
for fragment in fragments_and_attachments
if isinstance(fragment, Fragment)
]
attachments += [
attachment
for attachment in fragments_and_attachments
if isinstance(attachment, Attachment)
]
except FragmentNotFound as ex:
raise click.ClickException(str(ex))
else:
prompt_lines.append(line)
return "\n".join(prompt_lines), fragments, attachments
class AttachmentError(Exception):
"""Exception raised for errors in attachment resolution."""
pass
def resolve_attachment(value):
"""
Resolve an attachment from a string value which could be:
- "-" for stdin
- A URL
- A file path
Returns an Attachment object.
Raises AttachmentError if the attachment cannot be resolved.
"""
if value == "-":
content = sys.stdin.buffer.read()
# Try to guess type
mimetype = mimetype_from_string(content)
if mimetype is None:
raise AttachmentError("Could not determine mimetype of stdin")
return Attachment(type=mimetype, path=None, url=None, content=content)
if "://" in value:
# Confirm URL exists and try to guess type
try:
response = httpx.head(value)
response.raise_for_status()
mimetype = response.headers.get("content-type")
except httpx.HTTPError as ex:
raise AttachmentError(str(ex))
return Attachment(type=mimetype, path=None, url=value, content=None)
# Check that the file exists
path = pathlib.Path(value)
if not path.exists():
raise AttachmentError(f"File {value} does not exist")
path = path.resolve()
# Try to guess type
mimetype = mimetype_from_path(str(path))
if mimetype is None:
raise AttachmentError(f"Could not determine mimetype of {value}")
return Attachment(type=mimetype, path=str(path), url=None, content=None)
class AttachmentType(click.ParamType):
name = "attachment"
def convert(self, value, param, ctx):
try:
return resolve_attachment(value)
except AttachmentError as e:
self.fail(str(e), param, ctx)
def resolve_attachment_with_type(value: str, mimetype: str) -> Attachment:
if "://" in value:
attachment = Attachment(mimetype, None, value, None)
elif value == "-":
content = sys.stdin.buffer.read()
attachment = Attachment(mimetype, None, None, content)
else:
# Look for file
path = pathlib.Path(value)
if not path.exists():
raise click.BadParameter(f"File {value} does not exist")
path = path.resolve()
attachment = Attachment(mimetype, str(path), None, None)
return attachment
def attachment_types_callback(ctx, param, values) -> List[Attachment]:
collected = []
for value, mimetype in values:
collected.append(resolve_attachment_with_type(value, mimetype))
return collected
def json_validator(object_name):
def validator(ctx, param, value):
if value is None:
return value
try:
obj = json.loads(value)
if not isinstance(obj, dict):
raise click.BadParameter(f"{object_name} must be a JSON object")
return obj
except json.JSONDecodeError:
raise click.BadParameter(f"{object_name} must be valid JSON")
return validator
def schema_option(fn):
click.option(
"schema_input",
"--schema",
help="JSON schema, filepath or ID",
)(fn)
return fn
@click.group(
cls=DefaultGroup,
default="prompt",
default_if_no_args=True,
context_settings={"help_option_names": ["-h", "--help"]},
)
@click.version_option()
def cli():
"""
Access Large Language Models from the command-line
Documentation: https://llm.datasette.io/
LLM can run models from many different providers. Consult the
plugin directory for a list of available models:
https://llm.datasette.io/en/stable/plugins/directory.html
To get started with OpenAI, obtain an API key from them and:
\b
$ llm keys set openai
Enter key: ...
Then execute a prompt like this:
llm 'Five outrageous names for a pet pelican'
For a full list of prompting options run:
llm prompt --help
"""
@cli.command(name="prompt")
@click.argument("prompt", required=False)
@click.option("-s", "--system", help="System prompt to use")
@click.option("model_id", "-m", "--model", help="Model to use", envvar="LLM_MODEL")
@click.option(
"-d",
"--database",
type=click.Path(readable=True, dir_okay=False),
help="Path to log database",
)
@click.option(
"queries",
"-q",
"--query",
multiple=True,
help="Use first model matching these strings",
)
@click.option(
"attachments",
"-a",
"--attachment",
type=AttachmentType(),
multiple=True,
help="Attachment path or URL or -",
)
@click.option(
"attachment_types",
"--at",
"--attachment-type",
type=(str, str),
multiple=True,
callback=attachment_types_callback,
help="\b\nAttachment with explicit mimetype,\n--at image.jpg image/jpeg",
)
@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",
)
@click.option(
"options",
"-o",
"--option",
type=(str, str),
multiple=True,
help="key/value options for the model",
)
@schema_option
@click.option(
"--schema-multi",
help="JSON schema to use for multiple results",
)
@click.option(
"fragments",
"-f",
"--fragment",
multiple=True,
help="Fragment (alias, URL, hash or file path) to add to the prompt",
)
@click.option(
"system_fragments",
"--sf",
"--system-fragment",
multiple=True,
help="Fragment to add to system prompt",
)
@click.option("-t", "--template", help="Template to use")
@click.option(
"-p",
"--param",
multiple=True,
type=(str, str),
help="Parameters for template",
)
@click.option("--no-stream", is_flag=True, help="Do not stream output")
@click.option("-n", "--no-log", is_flag=True, help="Don't log to database")
@click.option("--log", is_flag=True, help="Log prompt and response to the database")
@click.option(
"-R", "--no-reasoning", is_flag=True, help="Don't display reasoning output"
)
@click.option(
"_continue",
"-c",
"--continue",
is_flag=True,
flag_value=-1,
help="Continue the most recent conversation.",
)
@click.option(
"conversation_id",
"--cid",
"--conversation",
help="Continue the conversation with the given ID.",
)
@click.option("--key", help="API key to use")
@click.option("--save", help="Save prompt with this template name")
@click.option("async_", "--async", is_flag=True, help="Run prompt asynchronously")
@click.option("-u", "--usage", is_flag=True, help="Show token usage")
@click.option("-x", "--extract", is_flag=True, help="Extract first fenced code block")
@click.option(
"extract_last",
"--xl",
"--extract-last",
is_flag=True,
help="Extract last fenced code block",
)
def prompt(
prompt,
system,
model_id,
database,
queries,
attachments,
attachment_types,
tools,
python_tools,
tools_debug,
tools_approve,
chain_limit,
options,
schema_input,
schema_multi,
fragments,
system_fragments,
template,
param,
no_stream,
no_log,
log,
no_reasoning,
_continue,
conversation_id,
key,
save,
async_,
usage,
extract,
extract_last,
):
"""
Execute a prompt
Documentation: https://llm.datasette.io/en/stable/usage.html
Examples:
\b
llm 'Capital of France?'
llm 'Capital of France?' -m gpt-4o
llm 'Capital of France?' -s 'answer in Spanish'
Multi-modal models can be called with attachments like this:
\b
llm 'Extract text from this image' -a image.jpg
llm 'Describe' -a https://static.simonwillison.net/static/2024/pelicans.jpg
cat image | llm 'describe image' -a -
# With an explicit mimetype:
cat image | llm 'describe image' --at - image/jpeg
The -x/--extract option returns just the content of the first ``` fenced code
block, if one is present. If none are present it returns the full response.
\b
llm 'JavaScript function for reversing a string' -x
"""
if log and no_log:
raise click.ClickException("--log and --no-log are mutually exclusive")
log_path = pathlib.Path(database) if database else logs_db_path()
(log_path.parent).mkdir(parents=True, exist_ok=True)
db = sqlite_utils.Database(log_path)
migrate(db)
if queries and not model_id:
# Use -q options to find model with shortest model_id
matches = []
for model_with_aliases in get_models_with_aliases():
if all(model_with_aliases.matches(q) for q in queries):
matches.append(model_with_aliases.model.model_id)
if not matches:
raise click.ClickException(
"No model found matching queries {}".format(", ".join(queries))
)
model_id = min(matches, key=len)
if schema_multi:
schema_input = schema_multi
schema = resolve_schema_input(db, schema_input, load_template)
if schema_multi:
# Convert that schema into multiple "items" of the same schema
schema = multi_schema(schema)
def read_prompt():
nonlocal prompt, schema
# Is there extra prompt available on stdin?
stdin_prompt = None
if not sys.stdin.isatty():
stdin_prompt = sys.stdin.read()
if stdin_prompt:
bits = [stdin_prompt]
if prompt:
bits.append(prompt)
prompt = " ".join(bits)
if (
prompt is None
and not save
and sys.stdin.isatty()
and not attachments
and not attachment_types
and not schema
and not fragments
):
# Hang waiting for input to stdin (unless --save)
prompt = sys.stdin.read()
return prompt
if save:
# We are saving their prompt/system/etc to a new template
# Fields to save: prompt, system, model - and more in the future
disallowed_options = []
for option, var in (
("--template", template),
("--continue", _continue),
("--cid", conversation_id),
):
if var:
disallowed_options.append(option)
if disallowed_options:
raise click.ClickException(
"--save cannot be used with {}".format(", ".join(disallowed_options))
)
path = template_dir() / f"{save}.yaml"
to_save = {}
if model_id:
model_aliases = get_model_aliases()
try:
to_save["model"] = model_aliases[model_id].model_id
except KeyError:
raise click.ClickException("'{}' is not a known model".format(model_id))
prompt = read_prompt()
if prompt:
to_save["prompt"] = prompt
if system:
to_save["system"] = system
if param:
to_save["defaults"] = dict(param)
if extract:
to_save["extract"] = True
if extract_last:
to_save["extract_last"] = True
if schema:
to_save["schema_object"] = schema
if fragments:
to_save["fragments"] = list(fragments)
if system_fragments:
to_save["system_fragments"] = list(system_fragments)
if python_tools:
to_save["functions"] = "\n\n".join(python_tools)
if tools:
to_save["tools"] = list(tools)
if attachments:
# Only works for attachments with a path or url
to_save["attachments"] = [
(a.path or a.url) for a in attachments if (a.path or a.url)
]
if attachment_types:
to_save["attachment_types"] = [
{"type": a.type, "value": a.path or a.url}
for a in attachment_types
if (a.path or a.url)
]
if options:
# Need to validate and convert their types first
model = get_model(model_id or get_default_model())
try:
options_model = model.Options(**dict(options))
# Use model_dump(mode="json") so Enums become their .value strings
to_save["options"] = {
k: v
for k, v in options_model.model_dump(mode="json").items()
if v is not None
}
except pydantic.ValidationError as ex:
raise click.ClickException(render_errors(ex.errors()))
path.write_text(
yaml.safe_dump(
to_save,
indent=4,
default_flow_style=False,
sort_keys=False,
),
"utf-8",
)
return
if template:
params = dict(param)
# Cannot be used with system
try:
template_obj = load_template(template)
except LoadTemplateError as ex:
raise click.ClickException(str(ex))
if not (extract or extract_last):
extract = template_obj.extract
extract_last = template_obj.extract_last
# Combine with template fragments/system_fragments
if template_obj.fragments:
fragments = [*template_obj.fragments, *fragments]
if template_obj.system_fragments:
system_fragments = [*template_obj.system_fragments, *system_fragments]
if template_obj.schema_object:
schema = template_obj.schema_object
if template_obj.tools:
tools = [*template_obj.tools, *tools]
if template_obj.functions and template_obj._functions_is_trusted:
python_tools = [template_obj.functions, *python_tools]
input_ = ""
if template_obj.options:
# Make options mutable (they start as a tuple)
options = list(options)
# Load any options, provided they were not set using -o already
specified_options = dict(options)
for option_name, option_value in template_obj.options.items():
if option_name not in specified_options:
options.append((option_name, option_value))
if "input" in template_obj.vars():
input_ = read_prompt()
try:
template_prompt, template_system = template_obj.evaluate(input_, params)
if template_prompt:
# Combine with user prompt
if prompt and "input" not in template_obj.vars():
prompt = template_prompt + "\n" + prompt
else:
prompt = template_prompt
if template_system and not system:
system = template_system
except Template.MissingVariables as ex:
raise click.ClickException(str(ex))
if model_id is None and template_obj.model:
model_id = template_obj.model
# Merge in any attachments
if template_obj.attachments:
attachments = [
resolve_attachment(a) for a in template_obj.attachments
] + list(attachments)
if template_obj.attachment_types:
attachment_types = [
resolve_attachment_with_type(at.value, at.type)
for at in template_obj.attachment_types
] + list(attachment_types)
if extract or extract_last:
no_stream = True
conversation = None
if conversation_id or _continue:
# Load the conversation - loads most recent if no ID provided
try:
conversation = load_conversation(
conversation_id, async_=async_, database=database
)
except UnknownModelError as ex:
raise click.ClickException(str(ex))
if conversation_tools := _get_conversation_tools(conversation, tools):
tools = conversation_tools
# Figure out which model we are using
if model_id is None:
if conversation:
model_id = conversation.model.model_id
else:
model_id = get_default_model()
# Now resolve the model
try:
if async_:
model = get_async_model(model_id)
else:
model = get_model(model_id)
except UnknownModelError as ex:
raise click.ClickException(ex)
if conversation is None and (tools or python_tools):
conversation = model.conversation()
if conversation:
# To ensure it can see the key
conversation.model = model
# Validate options
validated_options = {}
if options:
# Validate with pydantic
try:
validated_options = dict(
(key, value)
for key, value in model.Options(**dict(options))
if value is not None
)
except pydantic.ValidationError as ex:
raise click.ClickException(render_errors(ex.errors()))
# Add on any default model options
default_options = get_model_options(model.model_id)
for key_, value in default_options.items():
if key_ not in validated_options:
validated_options[key_] = value
kwargs = {}
kwargs["display_reasoning"] = not no_reasoning
resolved_attachments = [*attachments, *attachment_types]
should_stream = model.can_stream and not no_stream
if not should_stream:
kwargs["stream"] = False
if isinstance(model, (KeyModel, AsyncKeyModel)):
kwargs["key"] = key
prompt = read_prompt()
response = None
try:
fragments_and_attachments = resolve_fragments(
db, fragments, allow_attachments=True
)
resolved_fragments = [
fragment
for fragment in fragments_and_attachments
if isinstance(fragment, Fragment)
]
resolved_attachments.extend(
attachment
for attachment in fragments_and_attachments
if isinstance(attachment, Attachment)
)
resolved_system_fragments = resolve_fragments(db, system_fragments)
except FragmentNotFound as ex:
raise click.ClickException(str(ex))
prompt_method = model.prompt
if conversation:
prompt_method = conversation.prompt
tool_implementations = _gather_tools(tools, python_tools)
if tool_implementations:
prompt_method = conversation.chain
kwargs["options"] = validated_options
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_implementations
else:
# Merge in options for the .prompt() methods
kwargs.update(validated_options)
try:
if async_:
async def inner():
if should_stream:
response = prompt_method(
prompt,
attachments=resolved_attachments,
system=system,
schema=schema,
fragments=resolved_fragments,
system_fragments=resolved_system_fragments,
**kwargs,
)
await display_async_stream_events(
response.astream_events(),
show_reasoning=not no_reasoning,
)
print("")
else:
response = prompt_method(
prompt,
fragments=resolved_fragments,
attachments=resolved_attachments,
schema=schema,
system=system,
system_fragments=resolved_system_fragments,
**kwargs,
)
text = await response.text()
if extract or extract_last:
text = (
extract_fenced_code_block(text, last=extract_last) or text
)
print(text)
return response
response = asyncio.run(inner())
else:
response = prompt_method(
prompt,
fragments=resolved_fragments,
attachments=resolved_attachments,
system=system,
schema=schema,
system_fragments=resolved_system_fragments,
**kwargs,
)
if should_stream:
display_stream_events(
response.stream_events(),
show_reasoning=not no_reasoning,
)
print("")
else:
text = response.text()
if extract or extract_last:
text = extract_fenced_code_block(text, last=extract_last) or text
print(text)
# List of exceptions that should never be raised in pytest:
except (ValueError, NotImplementedError) as ex:
raise click.ClickException(str(ex))
except Exception as ex:
# All other exceptions should raise in pytest, show to user otherwise
if getattr(sys, "_called_from_test", False) or os.environ.get(
"LLM_RAISE_ERRORS", None
):
raise
raise click.ClickException(str(ex))
if usage:
if isinstance(response, ChainResponse):
responses = response._responses
else:
responses = [response]
for response_object in responses:
# Show token usage to stderr in yellow
click.echo(
click.style(
"Token usage: {}".format(response_object.token_usage()),
fg="yellow",
bold=True,
),
err=True,
)
# Log responses to the database
if (logs_on() or log) and not no_log:
# Could be Response, AsyncResponse, ChainResponse, AsyncChainResponse
if isinstance(response, AsyncResponse):
response = asyncio.run(response.to_sync_response())
# At this point ALL forms should have a log_to_db() method that works:
response.log_to_db(db)
@cli.command()
@click.option("-s", "--system", help="System prompt to use")
@click.option("model_id", "-m", "--model", help="Model to use", envvar="LLM_MODEL")
@click.option(
"_continue",
"-c",
"--continue",
is_flag=True,
flag_value=-1,
help="Continue the most recent conversation.",
)
@click.option(
"conversation_id",
"--cid",
"--conversation",
help="Continue the conversation with the given ID.",
)
@click.option(
"fragments",
"-f",
"--fragment",
multiple=True,
help="Fragment (alias, URL, hash or file path) to add to the prompt",
)
@click.option(
"system_fragments",
"--sf",
"--system-fragment",
multiple=True,
help="Fragment to add to system prompt",
)
@click.option("-t", "--template", help="Template to use")
@click.option(
"-p",
"--param",
multiple=True,
type=(str, str),
help="Parameters for template",
)
@click.option(
"options",
"-o",
"--option",
type=(str, str),
multiple=True,
help="key/value options for the model",
)
@click.option(
"-d",
"--database",
type=click.Path(readable=True, dir_okay=False),
help="Path to log database",
)
@click.option("--no-stream", is_flag=True, help="Do not stream output")
@click.option(
"-R", "--no-reasoning", is_flag=True, help="Don't display reasoning 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,
_continue,
conversation_id,
fragments,
system_fragments,
template,
param,
options,
no_stream,
no_reasoning,
key,
database,
tools,
python_tools,
tools_debug,
tools_approve,
chain_limit,
):
"""
Hold an ongoing chat with a model.
"""
# Left and right arrow keys to move cursor:
if sys.platform != "win32":
readline.parse_and_bind("\\e[D: backward-char")
readline.parse_and_bind("\\e[C: forward-char")
else:
readline.parse_and_bind("bind -x '\\e[D: backward-char'")
readline.parse_and_bind("bind -x '\\e[C: forward-char'")
log_path = pathlib.Path(database) if database else logs_db_path()
(log_path.parent).mkdir(parents=True, exist_ok=True)
db = sqlite_utils.Database(log_path)
migrate(db)
conversation = None
if conversation_id or _continue:
# Load the conversation - loads most recent if no ID provided
try:
conversation = load_conversation(conversation_id, database=database)
except UnknownModelError as ex:
raise click.ClickException(str(ex))
if conversation_tools := _get_conversation_tools(conversation, tools):
tools = conversation_tools
template_obj = None
if template:
params = dict(param)
try:
template_obj = load_template(template)
except LoadTemplateError as ex:
raise click.ClickException(str(ex))
if model_id is None and template_obj.model:
model_id = template_obj.model
if template_obj.tools:
tools = [*template_obj.tools, *tools]
if template_obj.functions and template_obj._functions_is_trusted:
python_tools = [template_obj.functions, *python_tools]
# Figure out which model we are using
if model_id is None:
if conversation:
model_id = conversation.model.model_id
else:
model_id = get_default_model()
# Now resolve the model
try:
model = get_model(model_id)
except KeyError:
raise click.ClickException("'{}' is not a known model".format(model_id))
if conversation is None:
# Start a fresh conversation for this chat
conversation = Conversation(model=model)
else:
# Ensure it can see the API key
conversation.model = model
if tools_debug:
conversation.after_call = _debug_tool_call
if tools_approve:
conversation.before_call = _approve_tool_call
# Validate options
validated_options = get_model_options(model.model_id)
if options:
try:
validated_options = dict(
(key, value)
for key, value in model.Options(**dict(options))
if value is not None
)
except pydantic.ValidationError as ex:
raise click.ClickException(render_errors(ex.errors()))
kwargs = {}
kwargs["display_reasoning"] = not no_reasoning
if validated_options:
kwargs["options"] = validated_options
tool_functions = _gather_tools(tools, python_tools)
if tool_functions:
kwargs["chain_limit"] = chain_limit
kwargs["tools"] = tool_functions
should_stream = model.can_stream and not no_stream
if not should_stream:
kwargs["stream"] = False
if key and isinstance(model, KeyModel):
kwargs["key"] = key
try:
fragments_and_attachments = resolve_fragments(
db, fragments, allow_attachments=True
)
argument_fragments = [
fragment
for fragment in fragments_and_attachments
if isinstance(fragment, Fragment)
]
argument_attachments = [
attachment
for attachment in fragments_and_attachments
if isinstance(attachment, Attachment)
]
argument_system_fragments = resolve_fragments(db, system_fragments)
except FragmentNotFound as ex:
raise click.ClickException(str(ex))
click.echo("Chatting with {}".format(model.model_id))
click.echo("Type 'exit' or 'quit' to exit")
click.echo("Type '!multi' to enter multiple lines, then '!end' to finish")
click.echo("Type '!edit' to open your default editor and modify the prompt")
click.echo(
"Type '!fragment <my_fragment> [<another_fragment> ...]' to insert one or more fragments"
)
in_multi = False
accumulated = []
accumulated_fragments = []
accumulated_attachments = []
end_token = "!end"
while True:
prompt = click.prompt("", prompt_suffix="> " if not in_multi else "")
fragments = []
attachments = []
if argument_fragments:
fragments += argument_fragments
# fragments from --fragments will get added to the first message only
argument_fragments = []
if argument_attachments:
attachments = argument_attachments
argument_attachments = []
if prompt.strip().startswith("!multi"):
in_multi = True
bits = prompt.strip().split()
if len(bits) > 1:
end_token = "!end {}".format(" ".join(bits[1:]))
continue
if prompt.strip() == "!edit":
edited_prompt = click.edit()
if edited_prompt is None:
click.echo("Editor closed without saving.", err=True)
continue
prompt = edited_prompt.strip()
if prompt.strip().startswith("!fragment "):
prompt, fragments, attachments = process_fragments_in_chat(db, prompt)
if in_multi:
if prompt.strip() == end_token:
prompt = "\n".join(accumulated)
fragments = accumulated_fragments
attachments = accumulated_attachments
in_multi = False
accumulated = []
accumulated_fragments = []
accumulated_attachments = []
else:
if prompt:
accumulated.append(prompt)
accumulated_fragments += fragments
accumulated_attachments += attachments
continue
if template_obj:
try:
# Mirror prompt() logic: only pass input if template uses it
uses_input = "input" in template_obj.vars()
input_ = prompt if uses_input else ""
template_prompt, template_system = template_obj.evaluate(input_, params)
except Template.MissingVariables as ex:
raise click.ClickException(str(ex))
if template_system and not system:
system = template_system
if template_prompt:
if prompt and not uses_input:
prompt = f"{template_prompt}\n{prompt}"
else:
prompt = template_prompt
if prompt.strip() in ("exit", "quit"):
break
response = conversation.chain(
prompt,
fragments=fragments,
system_fragments=argument_system_fragments,
attachments=attachments,
system=system,
**kwargs,
)
# System prompt and system fragments only sent for the first message
system = None
argument_system_fragments = []
display_stream_events(
response.stream_events(),
show_reasoning=not no_reasoning,
)
response.log_to_db(db)
print("")
def load_conversation(
conversation_id: Optional[str],
async_=False,
database=None,
) -> Optional[_BaseConversation]:
log_path = pathlib.Path(database) if database else logs_db_path()
db = sqlite_utils.Database(log_path)
migrate(db)
if conversation_id is None:
# Return the most recent conversation, or None if there are none
matches = list(db["conversations"].rows_where(order_by="id desc", limit=1))
if matches:
conversation_id = matches[0]["id"]
else:
return None
try:
row = cast(sqlite_utils.db.Table, db["conversations"]).get(conversation_id)
except sqlite_utils.db.NotFoundError:
raise click.ClickException(
"No conversation found with id={}".format(conversation_id)
)
# Inflate that conversation
conversation_class = AsyncConversation if async_ else Conversation
response_class = AsyncResponse if async_ else Response
conversation = conversation_class.from_row(row)
for response in db["responses"].rows_where(
"conversation_id = ?", [conversation_id], order_by="id"
):
response_obj = response_class.from_row(db, response)
if conversation.responses:
previous_response = conversation.responses[-1]
# SQLite rows store each response's legacy current-turn inputs
# (prompt text, attachments, tool_results), not the full
# prompt.messages chain. Rebuild that chain here so follow-up
# prompts via `llm -c` satisfy the Prompt.messages invariant.
response_obj.prompt._explicit_messages = (
list(previous_response.prompt.messages)
+ list(previous_response._messages_now())
+ list(response_obj.prompt.messages)
)
conversation.responses.append(response_obj)
return conversation
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def keys():
"Manage stored API keys for different models"
@keys.command(name="list")
def keys_list():
"List names of all stored keys"
path = user_dir() / "keys.json"
if not path.exists():
click.echo("No keys found")
return
keys = json.loads(path.read_text())
for key in sorted(keys.keys()):
if key != "// Note":
click.echo(key)
@keys.command(name="path")
def keys_path_command():
"Output the path to the keys.json file"
click.echo(user_dir() / "keys.json")
@keys.command(name="get")
@click.argument("name")
def keys_get(name):
"""
Return the value of a stored key
Example usage:
\b
export OPENAI_API_KEY=$(llm keys get openai)
"""
path = user_dir() / "keys.json"
if not path.exists():
raise click.ClickException("No keys found")
keys = json.loads(path.read_text())
try:
click.echo(keys[name])
except KeyError:
raise click.ClickException("No key found with name '{}'".format(name))
@keys.command(name="set")
@click.argument("name")
@click.option("--value", prompt="Enter key", hide_input=True, help="Value to set")
def keys_set(name, value):
"""
Save a key in the keys.json file
Example usage:
\b
$ llm keys set openai
Enter key: ...
"""
default = {"// Note": "This file stores secret API credentials. Do not share!"}
path = user_dir() / "keys.json"
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text(json.dumps(default))
path.chmod(0o600)
try:
current = json.loads(path.read_text())
except json.decoder.JSONDecodeError:
current = default
current[name] = value
path.write_text(json.dumps(current, indent=2) + "\n")
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def logs():
"Tools for exploring logged prompts and responses"
@logs.command(name="path")
def logs_path():
"Output the path to the logs.db file"
click.echo(logs_db_path())
@logs.command(name="status")
def logs_status():
"Show current status of database logging"
path = logs_db_path()
if not path.exists():
click.echo("No log database found at {}".format(path))
return
if logs_on():
click.echo("Logging is ON for all prompts".format())
else:
click.echo("Logging is OFF".format())
db = sqlite_utils.Database(path)
migrate(db)
click.echo("Found log database at {}".format(path))
click.echo("Number of conversations logged:\t{}".format(db["conversations"].count))
click.echo("Number of responses logged:\t{}".format(db["responses"].count))
click.echo(
"Database file size: \t\t{}".format(_human_readable_size(path.stat().st_size))
)
@logs.command(name="backup")
@click.argument("path", type=click.Path(dir_okay=True, writable=True))
def backup(path):
"Backup your logs database to this file"
logs_path = logs_db_path()
path = pathlib.Path(path)
db = sqlite_utils.Database(logs_path)
try:
db.execute("vacuum into ?", [str(path)])
except Exception as ex:
raise click.ClickException(str(ex))
click.echo(
"Backed up {} to {}".format(_human_readable_size(path.stat().st_size), path)
)
@logs.command(name="on")
def logs_turn_on():
"Turn on logging for all prompts"
path = user_dir() / "logs-off"
if path.exists():
path.unlink()
@logs.command(name="off")
def logs_turn_off():
"Turn off logging for all prompts"
path = user_dir() / "logs-off"
path.touch()
LOGS_COLUMNS = """ responses.id,
responses.model,
responses.resolved_model,
responses.prompt,
responses.system,
responses.prompt_json,
responses.options_json,
responses.response,
responses.reasoning,
responses.response_json,
responses.conversation_id,
responses.duration_ms,
responses.datetime_utc,
responses.input_tokens,
responses.output_tokens,
responses.token_details,
conversations.name as conversation_name,
conversations.model as conversation_model,
schemas.content as schema_json"""
LOGS_SQL = """
select
{columns}
from
responses
left join schemas on responses.schema_id = schemas.id
left join conversations on responses.conversation_id = conversations.id{extra_where}
order by {order_by}{limit}
"""
LOGS_SQL_SEARCH = """
select
{columns}
from
responses
left join schemas on responses.schema_id = schemas.id
left join conversations on responses.conversation_id = conversations.id
join responses_fts on responses_fts.rowid = responses.rowid
where responses_fts match :query{extra_where}
order by {order_by}{limit}
"""
ATTACHMENTS_SQL = """
select
response_id,
attachments.id,
attachments.type,
attachments.path,
attachments.url,
length(attachments.content) as content_length
from attachments
join prompt_attachments
on attachments.id = prompt_attachments.attachment_id
where prompt_attachments.response_id in ({})
order by prompt_attachments."order"
"""
@logs.command(name="list")
@click.option(
"-n",
"--count",
type=int,
default=None,
help="Number of entries to show - defaults to 3, use 0 for all",
)
@click.option(
"-p",
"--path",
type=click.Path(readable=True, exists=True, dir_okay=False),
help="Path to log database",
hidden=True,
)
@click.option(
"-d",
"--database",
type=click.Path(readable=True, exists=True, dir_okay=False),
help="Path to log database",
)
@click.option("-m", "--model", help="Filter by model or model alias")
@click.option("-q", "--query", help="Search for logs matching this string")
@click.option(
"fragments",
"--fragment",
"-f",
help="Filter for prompts using these fragments",
multiple=True,
)
@click.option(
"tools",
"-T",
"--tool",
multiple=True,
help="Filter for prompts with results from these tools",
)
@click.option(
"any_tools",
"--tools",
is_flag=True,
help="Filter for prompts with results from any tools",
)
@schema_option
@click.option(
"--schema-multi",
help="JSON schema used for multiple results",
)
@click.option(
"-l", "--latest", is_flag=True, help="Return latest results matching search query"
)
@click.option(
"--data", is_flag=True, help="Output newline-delimited JSON data for schema"
)
@click.option("--data-array", is_flag=True, help="Output JSON array of data for schema")
@click.option("--data-key", help="Return JSON objects from array in this key")
@click.option(
"--data-ids", is_flag=True, help="Attach corresponding IDs to JSON objects"
)
@click.option("-t", "--truncate", is_flag=True, help="Truncate long strings in output")
@click.option(
"-s", "--short", is_flag=True, help="Shorter YAML output with truncated prompts"
)
@click.option("-u", "--usage", is_flag=True, help="Include token usage")
@click.option("-r", "--response", is_flag=True, help="Just output the last response")
@click.option("-x", "--extract", is_flag=True, help="Extract first fenced code block")
@click.option(
"extract_last",
"--xl",
"--extract-last",
is_flag=True,
help="Extract last fenced code block",
)
@click.option(
"current_conversation",
"-c",
"--current",
is_flag=True,
flag_value=-1,
help="Show logs from the current conversation",
)
@click.option(
"conversation_id",
"--cid",
"--conversation",
help="Show logs for this conversation ID",
)
@click.option("--id-gt", help="Return responses with ID > this")
@click.option("--id-gte", help="Return responses with ID >= this")
@click.option(
"json_output",
"--json",
is_flag=True,
help="Output logs as JSON",
)
@click.option(
"--expand",
"-e",
is_flag=True,
help="Expand fragments to show their content",
)
def logs_list(
count,
path,
database,
model,
query,
fragments,
tools,
any_tools,
schema_input,
schema_multi,
latest,
data,
data_array,
data_key,
data_ids,
truncate,
short,
usage,
response,
extract,
extract_last,
current_conversation,
conversation_id,
id_gt,
id_gte,
json_output,
expand,
):
"Show logged prompts and their responses"
if database and not path:
path = database
path = pathlib.Path(path or logs_db_path())
if not path.exists():
raise click.ClickException("No log database found at {}".format(path))
db = sqlite_utils.Database(path)
migrate(db)
if schema_multi:
schema_input = schema_multi
schema = resolve_schema_input(db, schema_input, load_template)
if schema_multi:
schema = multi_schema(schema)
if short and (json_output or response):
invalid = " or ".join(
[
flag[0]
for flag in (("--json", json_output), ("--response", response))
if flag[1]
]
)
raise click.ClickException("Cannot use --short and {} together".format(invalid))
if response and not current_conversation and not conversation_id:
current_conversation = True
if current_conversation:
try:
conversation_id = next(
db.query(
"select conversation_id from responses order by id desc limit 1"
)
)["conversation_id"]
except StopIteration:
# No conversations yet
raise click.ClickException("No conversations found")
# For --conversation set limit 0, if not explicitly set
if count is None:
if conversation_id:
count = 0
else:
count = 3
model_id = None
if model:
# Resolve alias, if any
try:
model_id = get_model(model).model_id
except UnknownModelError:
# Maybe they uninstalled a model, use the -m option as-is
model_id = model
sql = LOGS_SQL
order_by = "responses.id desc"
if query:
sql = LOGS_SQL_SEARCH
if not latest:
order_by = "responses_fts.rank desc"
limit = ""
if count is not None and count > 0:
limit = " limit {}".format(count)
sql_format = {
"limit": limit,
"columns": LOGS_COLUMNS,
"extra_where": "",
"order_by": order_by,
}
where_bits = []
sql_params = {
"model": model_id,
"query": query,
"conversation_id": conversation_id,
"id_gt": id_gt,
"id_gte": id_gte,
}
if model_id:
where_bits.append("responses.model = :model")
if conversation_id:
where_bits.append("responses.conversation_id = :conversation_id")
if id_gt:
where_bits.append("responses.id > :id_gt")
if id_gte:
where_bits.append("responses.id >= :id_gte")
if fragments:
# Resolve the fragments to their hashes
fragment_hashes = [
fragment.id() for fragment in resolve_fragments(db, fragments)
]
exists_clauses = []
for i, fragment_hash in enumerate(fragment_hashes):
exists_clause = f"""
exists (
select 1 from prompt_fragments
where prompt_fragments.response_id = responses.id
and prompt_fragments.fragment_id in (
select fragments.id from fragments
where hash = :f{i}
)
union
select 1 from system_fragments
where system_fragments.response_id = responses.id
and system_fragments.fragment_id in (
select fragments.id from fragments
where hash = :f{i}
)
)
"""
exists_clauses.append(exists_clause)
sql_params["f{}".format(i)] = fragment_hash
where_bits.append(" and ".join(exists_clauses))
if any_tools:
# Any response that involved at least one tool result
where_bits.append("""
exists (
select 1
from tool_results
where
tool_results.response_id = responses.id
)
""")
if tools:
tools_by_name = get_tools()
# Filter responses by tools (must have ALL of the named tools, including plugin)
tool_clauses = []
for i, tool_name in enumerate(tools):
try:
plugin_name = tools_by_name[tool_name].plugin
except KeyError:
raise click.ClickException(f"Unknown tool: {tool_name}")
tool_clauses.append(f"""
exists (
select 1
from tool_results
join tools on tools.id = tool_results.tool_id
where tool_results.response_id = responses.id
and tools.name = :tool{i}
and tools.plugin = :plugin{i}
)
""")
sql_params[f"tool{i}"] = tool_name
sql_params[f"plugin{i}"] = plugin_name
# AND means “must have all” — use OR instead if you want “any of”
where_bits.append(" and ".join(tool_clauses))
schema_id = None
if schema:
schema_id = make_schema_id(schema)[0]
where_bits.append("responses.schema_id = :schema_id")
sql_params["schema_id"] = schema_id
if where_bits:
where_ = " and " if query else " where "
sql_format["extra_where"] = where_ + " and ".join(where_bits)
final_sql = sql.format(**sql_format)
rows = list(db.query(final_sql, sql_params))
# Reverse the order - we do this because we 'order by id desc limit 3' to get the
# 3 most recent results, but we still want to display them in chronological order
# ... except for searches where we don't do this
if not query and not data:
rows.reverse()
# Fetch any attachments
ids = [row["id"] for row in rows]
attachments = list(db.query(ATTACHMENTS_SQL.format(",".join("?" * len(ids))), ids))
attachments_by_id = {}
for attachment in attachments:
attachments_by_id.setdefault(attachment["response_id"], []).append(attachment)
FRAGMENTS_SQL = """
select
{table}.response_id,
fragments.hash,
fragments.id as fragment_id,
fragments.content,
(
select json_group_array(fragment_aliases.alias)
from fragment_aliases
where fragment_aliases.fragment_id = fragments.id
) as aliases
from {table}
join fragments on {table}.fragment_id = fragments.id
where {table}.response_id in ({placeholders})
order by {table}."order"
"""
# Fetch any prompt or system prompt fragments
prompt_fragments_by_id = {}
system_fragments_by_id = {}
for table, dictionary in (
("prompt_fragments", prompt_fragments_by_id),
("system_fragments", system_fragments_by_id),
):
for fragment in db.query(
FRAGMENTS_SQL.format(placeholders=",".join("?" * len(ids)), table=table),
ids,
):
dictionary.setdefault(fragment["response_id"], []).append(fragment)
if data or data_array or data_key or data_ids:
# Special case for --data to output valid JSON
to_output = []
for row in rows:
response = row["response"] or ""
try:
decoded = json.loads(response)
new_items = []
if (
isinstance(decoded, dict)
and (data_key in decoded)
and all(isinstance(item, dict) for item in decoded[data_key])
):
for item in decoded[data_key]:
new_items.append(item)
else:
new_items.append(decoded)
if data_ids:
for item in new_items:
item[find_unused_key(item, "response_id")] = row["id"]
item[find_unused_key(item, "conversation_id")] = row["id"]
to_output.extend(new_items)
except ValueError:
pass
for line in output_rows_as_json(to_output, nl=not data_array, compact=True):
click.echo(line)
return
# Tool usage information
TOOLS_SQL = """
SELECT responses.id,
-- Tools related to this response
COALESCE(
(SELECT json_group_array(json_object(
'id', t.id,
'hash', t.hash,
'name', t.name,
'description', t.description,
'input_schema', json(t.input_schema)
))
FROM tools t
JOIN tool_responses tr ON t.id = tr.tool_id
WHERE tr.response_id = responses.id
),
'[]'
) AS tools,
-- Tool calls for this response
COALESCE(
(SELECT json_group_array(json_object(
'id', tc.id,
'tool_id', tc.tool_id,
'name', tc.name,
'arguments', json(tc.arguments),
'tool_call_id', tc.tool_call_id
))
FROM tool_calls tc
WHERE tc.response_id = responses.id
),
'[]'
) AS tool_calls,
-- Tool results for this response
COALESCE(
(SELECT json_group_array(json_object(
'id', tr.id,
'tool_id', tr.tool_id,
'name', tr.name,
'output', tr.output,
'tool_call_id', tr.tool_call_id,
'exception', tr.exception,
'attachments', COALESCE(
(SELECT json_group_array(json_object(
'id', a.id,
'type', a.type,
'path', a.path,
'url', a.url,
'content', a.content
))
FROM tool_results_attachments tra
JOIN attachments a ON tra.attachment_id = a.id
WHERE tra.tool_result_id = tr.id
),
'[]'
)
))
FROM tool_results tr
WHERE tr.response_id = responses.id
),
'[]'
) AS tool_results
FROM responses
where id in ({placeholders})
"""
tool_info_by_id = {
row["id"]: {
"tools": json.loads(row["tools"]),
"tool_calls": json.loads(row["tool_calls"]),
"tool_results": json.loads(row["tool_results"]),
}
for row in db.query(
TOOLS_SQL.format(placeholders=",".join("?" * len(ids))), ids
)
}
for row in rows:
if truncate:
row["prompt"] = truncate_string(row["prompt"] or "")
row["response"] = truncate_string(row["response"] or "")
# Add prompt and system fragments
for key in ("prompt_fragments", "system_fragments"):
row[key] = [
{
"hash": fragment["hash"],
"content": (
fragment["content"]
if expand
else truncate_string(fragment["content"])
),
"aliases": json.loads(fragment["aliases"]),
}
for fragment in (
prompt_fragments_by_id.get(row["id"], [])
if key == "prompt_fragments"
else system_fragments_by_id.get(row["id"], [])
)
]
# Either decode or remove all JSON keys
keys = list(row.keys())
for key in keys:
if key.endswith("_json") and row[key] is not None:
if truncate:
del row[key]
else:
row[key] = json.loads(row[key])
row.update(tool_info_by_id[row["id"]])
output = None
if json_output:
# Output as JSON if requested
for row in rows:
row["attachments"] = [
{k: v for k, v in attachment.items() if k != "response_id"}
for attachment in attachments_by_id.get(row["id"], [])
]
output = json.dumps(list(rows), indent=2)
elif extract or extract_last:
# Extract and return first code block
for row in rows:
output = extract_fenced_code_block(row["response"], last=extract_last)
if output is not None:
break
elif response:
# Just output the last response
if rows:
output = rows[-1]["response"]
if output is not None:
click.echo(output)
else:
# Output neatly formatted human-readable logs
def _display_fragments(fragments, title):
if not fragments:
return
if not expand:
content = "\n".join(
["- {}".format(fragment["hash"]) for fragment in fragments]
)
else:
# <details><summary> for each one
bits = []
for fragment in fragments:
bits.append(
"<details><summary>{}</summary>\n{}\n</details>".format(
fragment["hash"], maybe_fenced_code(fragment["content"])
)
)
content = "\n".join(bits)
click.echo(f"\n### {title}\n\n{content}")
current_system = None
should_show_conversation = True
for row in rows:
if short:
system = truncate_string(
row["system"] or "", 120, normalize_whitespace=True
)
prompt = truncate_string(
row["prompt"] or "", 120, normalize_whitespace=True, keep_end=True
)
cid = row["conversation_id"]
attachments = attachments_by_id.get(row["id"])
obj = {
"model": row["model"],
"datetime": row["datetime_utc"].split(".")[0],
"conversation": cid,
}
if row["tool_calls"]:
obj["tool_calls"] = [
"{}({})".format(
tool_call["name"], json.dumps(tool_call["arguments"])
)
for tool_call in row["tool_calls"]
]
if row["tool_results"]:
obj["tool_results"] = [
"{}: {}".format(
tool_result["name"], truncate_string(tool_result["output"])
)
for tool_result in row["tool_results"]
]
if system:
obj["system"] = system
if prompt:
obj["prompt"] = prompt
if attachments:
items = []
for attachment in attachments:
details = {"type": attachment["type"]}
if attachment.get("path"):
details["path"] = attachment["path"]
if attachment.get("url"):
details["url"] = attachment["url"]
items.append(details)
obj["attachments"] = items
for key in ("prompt_fragments", "system_fragments"):
obj[key] = [fragment["hash"] for fragment in row[key]]
if usage and (row["input_tokens"] or row["output_tokens"]):
usage_details = {
"input": row["input_tokens"],
"output": row["output_tokens"],
}
if row["token_details"]:
usage_details["details"] = json.loads(row["token_details"])
obj["usage"] = usage_details
click.echo(yaml.dump([obj], sort_keys=False).strip())
continue
# Not short, output Markdown
click.echo(
"# {}{}\n{}".format(
row["datetime_utc"].split(".")[0],
(
" conversation: {} id: {}".format(
row["conversation_id"], row["id"]
)
if should_show_conversation
else ""
),
(
(
"\nModel: **{}**{}\n".format(
row["model"],
(
" (resolved: **{}**)".format(row["resolved_model"])
if row["resolved_model"]
else ""
),
)
)
if should_show_conversation
else ""
),
)
)
# In conversation log mode only show it for the first one
if conversation_id:
should_show_conversation = False
click.echo("## Prompt\n\n{}".format(row["prompt"] or "-- none --"))
_display_fragments(row["prompt_fragments"], "Prompt fragments")
if row["options_json"]:
options = row["options_json"]
if isinstance(options, str):
options = json.loads(options)
if options:
options_text = "\n".join(
"- {}: {}".format(key, value) for key, value in options.items()
)
click.echo("\n## Options\n\n{}".format(options_text))
if row["system"] != current_system:
if row["system"] is not None:
click.echo("\n## System\n\n{}".format(row["system"]))
current_system = row["system"]
_display_fragments(row["system_fragments"], "System fragments")
if row["schema_json"]:
click.echo(
"\n## Schema\n\n```json\n{}\n```".format(
json.dumps(row["schema_json"], indent=2)
)
)
# Show tool calls and results
if row["tools"]:
click.echo("\n### Tools\n")
for tool in row["tools"]:
click.echo(
"- **{}**: `{}`<br>\n {}<br>\n Arguments: {}".format(
tool["name"],
tool["hash"],
tool["description"],
json.dumps(tool["input_schema"]["properties"]),
)
)
if row["tool_results"]:
click.echo("\n### Tool results\n")
for tool_result in row["tool_results"]:
attachments = ""
for attachment in tool_result["attachments"]:
desc = ""
if attachment.get("type"):
desc += attachment["type"] + ": "
if attachment.get("path"):
desc += attachment["path"]
elif attachment.get("url"):
desc += attachment["url"]
elif attachment.get("content"):
desc += f"<{attachment['content_length']:,} bytes>"
attachments += "\n - {}".format(desc)
click.echo(
"- **{}**: `{}`<br>\n{}{}{}".format(
tool_result["name"],
tool_result["tool_call_id"],
textwrap.indent(tool_result["output"], " "),
(
"<br>\n **Error**: {}\n".format(
tool_result["exception"]
)
if tool_result["exception"]
else ""
),
attachments,
)
)
attachments = attachments_by_id.get(row["id"])
if attachments:
click.echo("\n### Attachments\n")
for i, attachment in enumerate(attachments, 1):
if attachment["path"]:
path = attachment["path"]
click.echo(
"{}. **{}**: `{}`".format(i, attachment["type"], path)
)
elif attachment["url"]:
click.echo(
"{}. **{}**: {}".format(
i, attachment["type"], attachment["url"]
)
)
elif attachment["content_length"]:
click.echo(
"{}. **{}**: `<{} bytes>`".format(
i,
attachment["type"],
f"{attachment['content_length']:,}",
)
)
# If a schema was provided and the row is valid JSON, pretty print and syntax highlight it
response = row["response"]
if row["schema_json"]:
try:
parsed = json.loads(response)
response = "```json\n{}\n```".format(json.dumps(parsed, indent=2))
except ValueError:
pass
if row.get("reasoning"):
click.echo("\n## Reasoning\n\n{}".format(row["reasoning"].rstrip()))
click.echo("\n## Response\n")
if row["tool_calls"]:
click.echo("### Tool calls\n")
for tool_call in row["tool_calls"]:
click.echo(
"- **{}**: `{}`<br>\n Arguments: {}".format(
tool_call["name"],
tool_call["tool_call_id"],
json.dumps(tool_call["arguments"]),
)
)
click.echo("")
if response:
click.echo("{}\n".format(response))
if usage:
token_usage = token_usage_string(
row["input_tokens"],
row["output_tokens"],
json.loads(row["token_details"]) if row["token_details"] else None,
)
if token_usage:
click.echo("## Token usage\n\n{}\n".format(token_usage))
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def models():
"Manage available models"
_type_lookup = {
"number": "float",
"integer": "int",
"string": "str",
"object": "dict",
}
@models.command(name="list")
@click.option(
"--options", is_flag=True, help="Show options for each model, if available"
)
@click.option("async_", "--async", is_flag=True, help="List async models")
@click.option("--schemas", is_flag=True, help="List models that support schemas")
@click.option("--tools", is_flag=True, help="List models that support tools")
@click.option(
"-q",
"--query",
multiple=True,
help="Search for models matching these strings",
)
@click.option("model_ids", "-m", "--model", help="Specific model IDs", multiple=True)
def models_list(options, async_, schemas, tools, query, model_ids):
"List available models"
models_that_have_shown_options = set()
for model_with_aliases in get_models_with_aliases():
if async_ and not model_with_aliases.async_model:
continue
if query:
# Only show models where every provided query string matches
if not all(model_with_aliases.matches(q) for q in query):
continue
if model_ids:
ids_and_aliases = set(
[model_with_aliases.model.model_id] + model_with_aliases.aliases
)
if not ids_and_aliases.intersection(model_ids):
continue
if schemas and not model_with_aliases.model.supports_schema:
continue
if tools and not model_with_aliases.model.supports_tools:
continue
extra_info = []
if model_with_aliases.aliases:
extra_info.append(
"aliases: {}".format(", ".join(model_with_aliases.aliases))
)
model = (
model_with_aliases.model if not async_ else model_with_aliases.async_model
)
output = str(model)
if extra_info:
output += " ({})".format(", ".join(extra_info))
if options and model.Options.model_json_schema()["properties"]:
output += "\n Options:"
for name, field in model.Options.model_json_schema()["properties"].items():
any_of = field.get("anyOf")
if any_of is None:
any_of = [{"type": field.get("type", "str")}]
types = ", ".join(
[
_type_lookup.get(item.get("type"), item.get("type", "str"))
for item in any_of
if item.get("type") != "null"
]
)
bits = ["\n ", name, ": ", types]
description = field.get("description", "")
if description and (
model.__class__ not in models_that_have_shown_options
):
wrapped = textwrap.wrap(description, 70)
bits.append("\n ")
bits.extend("\n ".join(wrapped))
output += "".join(bits)
models_that_have_shown_options.add(model.__class__)
if options and model.attachment_types:
attachment_types = ", ".join(sorted(model.attachment_types))
wrapper = textwrap.TextWrapper(
width=min(max(shutil.get_terminal_size().columns, 30), 70),
initial_indent=" ",
subsequent_indent=" ",
)
output += "\n Attachment types:\n{}".format(wrapper.fill(attachment_types))
features = (
[]
+ (["streaming"] if model.can_stream else [])
+ (["schemas"] if model.supports_schema else [])
+ (["tools"] if model.supports_tools else [])
+ (["async"] if model_with_aliases.async_model else [])
)
if options and features:
output += "\n Features:\n{}".format(
"\n".join(" - {}".format(feature) for feature in features)
)
if options and hasattr(model, "needs_key") and model.needs_key:
output += "\n Keys:"
if hasattr(model, "needs_key") and model.needs_key:
output += "\n key: {}".format(model.needs_key)
if hasattr(model, "key_env_var") and model.key_env_var:
output += "\n env_var: {}".format(model.key_env_var)
click.echo(output)
if not query and not options and not schemas and not model_ids:
click.echo(f"Default: {get_default_model()}")
@models.command(name="default")
@click.argument("model", required=False)
def models_default(model):
"Show or set the default model"
if not model:
click.echo(get_default_model())
return
# Validate it is a known model
try:
model = get_model(model)
set_default_model(model.model_id)
except KeyError:
raise click.ClickException("Unknown model: {}".format(model))
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def templates():
"Manage stored prompt templates"
@templates.command(name="list")
def templates_list():
"List available prompt templates"
path = template_dir()
pairs = []
for file in path.glob("*.yaml"):
name = file.stem
try:
template = load_template(name)
except LoadTemplateError:
# Skip invalid templates
continue
text = []
if template.system:
text.append(f"system: {template.system}")
if template.prompt:
text.append(f" prompt: {template.prompt}")
else:
text = [template.prompt if template.prompt else ""]
pairs.append((name, "".join(text).replace("\n", " ")))
try:
max_name_len = max(len(p[0]) for p in pairs)
except ValueError:
return
else:
fmt = "{name:<" + str(max_name_len) + "} : {prompt}"
for name, prompt in sorted(pairs):
text = fmt.format(name=name, prompt=prompt)
click.echo(display_truncated(text))
@templates.command(name="show")
@click.argument("name")
def templates_show(name):
"Show the specified prompt template"
try:
template = load_template(name)
except LoadTemplateError:
raise click.ClickException(f"Template '{name}' not found or invalid")
click.echo(
yaml.dump(
dict((k, v) for k, v in template.model_dump().items() if v is not None),
indent=4,
default_flow_style=False,
)
)
@templates.command(name="edit")
@click.argument("name")
def templates_edit(name):
"Edit the specified prompt template using the default $EDITOR"
# First ensure it exists
path = template_dir() / f"{name}.yaml"
if not path.exists():
path.write_text(DEFAULT_TEMPLATE, "utf-8")
click.edit(filename=str(path))
# Validate that template
load_template(name)
@templates.command(name="path")
def templates_path():
"Output the path to the templates directory"
click.echo(template_dir())
@templates.command(name="loaders")
def templates_loaders():
"Show template loaders registered by plugins"
found = False
for prefix, loader in get_template_loaders().items():
found = True
docs = "Undocumented"
if loader.__doc__:
docs = textwrap.dedent(loader.__doc__).strip()
click.echo(f"{prefix}:")
click.echo(textwrap.indent(docs, " "))
if not found:
click.echo("No template loaders found")
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def schemas():
"Manage stored schemas"
@schemas.command(name="list")
@click.option(
"-p",
"--path",
type=click.Path(readable=True, exists=True, dir_okay=False),
help="Path to log database",
hidden=True,
)
@click.option(
"-d",
"--database",
type=click.Path(readable=True, exists=True, dir_okay=False),
help="Path to log database",
)
@click.option(
"queries",
"-q",
"--query",
multiple=True,
help="Search for schemas matching this string",
)
@click.option("--full", is_flag=True, help="Output full schema contents")
@click.option("json_", "--json", is_flag=True, help="Output as JSON")
@click.option("nl", "--nl", is_flag=True, help="Output as newline-delimited JSON")
def schemas_list(path, database, queries, full, json_, nl):
"List stored schemas"
if database and not path:
path = database
path = pathlib.Path(path or logs_db_path())
if not path.exists():
raise click.ClickException("No log database found at {}".format(path))
db = sqlite_utils.Database(path)
migrate(db)
params = []
where_sql = ""
if queries:
where_bits = ["schemas.content like ?" for _ in queries]
where_sql += " where {}".format(" and ".join(where_bits))
params.extend("%{}%".format(q) for q in queries)
sql = """
select
schemas.id,
schemas.content,
max(responses.datetime_utc) as recently_used,
count(*) as times_used
from schemas
join responses
on responses.schema_id = schemas.id
{} group by responses.schema_id
order by recently_used
""".format(where_sql)
rows = db.query(sql, params)
if json_ or nl:
for line in output_rows_as_json(rows, json_cols={"content"}, nl=nl):
click.echo(line)
return
for row in rows:
click.echo("- id: {}".format(row["id"]))
if full:
click.echo(
" schema: |\n{}".format(
textwrap.indent(
json.dumps(json.loads(row["content"]), indent=2), " "
)
)
)
else:
click.echo(
" summary: |\n {}".format(
schema_summary(json.loads(row["content"]))
)
)
click.echo(
" usage: |\n {} time{}, most recently {}".format(
row["times_used"],
"s" if row["times_used"] != 1 else "",
row["recently_used"],
)
)
@schemas.command(name="show")
@click.argument("schema_id")
@click.option(
"-p",
"--path",
type=click.Path(readable=True, exists=True, dir_okay=False),
help="Path to log database",
hidden=True,
)
@click.option(
"-d",
"--database",
type=click.Path(readable=True, exists=True, dir_okay=False),
help="Path to log database",
)
def schemas_show(schema_id, path, database):
"Show a stored schema"
if database and not path:
path = database
path = pathlib.Path(path or logs_db_path())
if not path.exists():
raise click.ClickException("No log database found at {}".format(path))
db = sqlite_utils.Database(path)
migrate(db)
try:
row = db["schemas"].get(schema_id)
except sqlite_utils.db.NotFoundError:
raise click.ClickException("Invalid schema ID")
click.echo(json.dumps(json.loads(row["content"]), indent=2))
@schemas.command(name="dsl")
@click.argument("input")
@click.option("--multi", is_flag=True, help="Wrap in an array")
def schemas_dsl_debug(input, multi):
"""
Convert LLM's schema DSL to a JSON schema
\b
llm schema dsl 'name, age int, bio: their bio'
"""
schema = schema_dsl(input, multi)
click.echo(json.dumps(schema, indent=2))
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def tools():
"Manage tools that can be made available to LLMs"
@tools.command(name="list")
@click.argument("tool_defs", nargs=-1)
@click.option("json_", "--json", is_flag=True, help="Output as JSON")
@click.option(
"python_tools",
"--functions",
help="Python code block or file path defining functions to register as tools",
multiple=True,
)
def tools_list(tool_defs, json_, python_tools):
"List available tools that have been provided by plugins"
def introspect_tools(toolbox_class):
methods = []
for tool in toolbox_class.method_tools():
methods.append(
{
"name": tool.name,
"description": tool.description,
"arguments": tool.input_schema,
"implementation": tool.implementation,
}
)
return methods
if tool_defs:
tools = {}
for tool in _gather_tools(tool_defs, python_tools):
if hasattr(tool, "name"):
tools[tool.name] = tool
else:
tools[tool.__class__.__name__] = tool
else:
tools = get_tools()
if python_tools:
for code_or_path in python_tools:
for tool in _tools_from_code(code_or_path):
tools[tool.name] = tool
output_tools = []
output_toolboxes = []
tool_objects = []
toolbox_objects = []
for name, tool in sorted(tools.items()):
if isinstance(tool, Tool):
tool_objects.append(tool)
output_tools.append(
{
"name": name,
"description": tool.description,
"arguments": tool.input_schema,
"plugin": tool.plugin,
}
)
else:
toolbox_objects.append(tool)
output_toolboxes.append(
{
"name": name,
"tools": [
{
"name": tool["name"],
"description": tool["description"],
"arguments": tool["arguments"],
}
for tool in introspect_tools(tool)
],
}
)
if json_:
click.echo(
json.dumps(
{"tools": output_tools, "toolboxes": output_toolboxes},
indent=2,
)
)
else:
for tool in tool_objects:
sig = "()"
if tool.implementation:
sig = str(inspect.signature(tool.implementation))
click.echo(
"{}{}{}\n".format(
tool.name,
sig,
" (plugin: {})".format(tool.plugin) if tool.plugin else "",
)
)
if tool.description:
click.echo(textwrap.indent(tool.description.strip(), " ") + "\n")
for toolbox in toolbox_objects:
click.echo(toolbox.name + ":\n")
for tool in toolbox.method_tools():
sig = (
str(inspect.signature(tool.implementation))
.replace("(self, ", "(")
.replace("(self)", "()")
)
click.echo(
" {}{}\n".format(
tool.name,
sig,
)
)
if tool.description:
click.echo(textwrap.indent(tool.description.strip(), " ") + "\n")
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def aliases():
"Manage model aliases"
@aliases.command(name="list")
@click.option("json_", "--json", is_flag=True, help="Output as JSON")
def aliases_list(json_):
"List current aliases"
to_output = []
for alias, model in get_model_aliases().items():
if alias != model.model_id:
to_output.append((alias, model.model_id, ""))
for alias, embedding_model in get_embedding_model_aliases().items():
if alias != embedding_model.model_id:
to_output.append((alias, embedding_model.model_id, "embedding"))
if json_:
click.echo(
json.dumps({key: value for key, value, type_ in to_output}, indent=4)
)
return
max_alias_length = max(len(a) for a, _, _ in to_output)
fmt = "{alias:<" + str(max_alias_length) + "} : {model_id}{type_}"
for alias, model_id, type_ in to_output:
click.echo(
fmt.format(
alias=alias, model_id=model_id, type_=f" ({type_})" if type_ else ""
)
)
@aliases.command(name="set")
@click.argument("alias")
@click.argument("model_id", required=False)
@click.option(
"-q",
"--query",
multiple=True,
help="Set alias for model matching these strings",
)
def aliases_set(alias, model_id, query):
"""
Set an alias for a model
Example usage:
\b
llm aliases set mini gpt-4o-mini
Alternatively you can omit the model ID and specify one or more -q options.
The first model matching all of those query strings will be used.
\b
llm aliases set mini -q 4o -q mini
"""
if not model_id:
if not query:
raise click.ClickException(
"You must provide a model_id or at least one -q option"
)
# Search for the first model matching all query strings
found = None
for model_with_aliases in get_models_with_aliases():
if all(model_with_aliases.matches(q) for q in query):
found = model_with_aliases
break
if not found:
raise click.ClickException(
"No model found matching query: " + ", ".join(query)
)
model_id = found.model.model_id
set_alias(alias, model_id)
click.echo(
f"Alias '{alias}' set to model '{model_id}'",
err=True,
)
else:
set_alias(alias, model_id)
@aliases.command(name="remove")
@click.argument("alias")
def aliases_remove(alias):
"""
Remove an alias
Example usage:
\b
$ llm aliases remove turbo
"""
try:
remove_alias(alias)
except KeyError as ex:
raise click.ClickException(ex.args[0])
@aliases.command(name="path")
def aliases_path():
"Output the path to the aliases.json file"
click.echo(user_dir() / "aliases.json")
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def fragments():
"""
Manage fragments that are stored in the database
Fragments are reusable snippets of text that are shared across multiple prompts.
"""
@fragments.command(name="list")
@click.option(
"queries",
"-q",
"--query",
multiple=True,
help="Search for fragments matching these strings",
)
@click.option("--aliases", is_flag=True, help="Show only fragments with aliases")
@click.option("json_", "--json", is_flag=True, help="Output as JSON")
def fragments_list(queries, aliases, json_):
"List current fragments"
db = sqlite_utils.Database(logs_db_path())
migrate(db)
params = {}
param_count = 0
where_bits = []
if aliases:
where_bits.append("fragment_aliases.alias is not null")
for q in queries:
param_count += 1
p = f"p{param_count}"
params[p] = q
where_bits.append(f"""
(fragments.hash = :{p} or fragment_aliases.alias = :{p}
or fragments.source like '%' || :{p} || '%'
or fragments.content like '%' || :{p} || '%')
""")
where = "\n and\n ".join(where_bits)
if where:
where = " where " + where
sql = """
select
fragments.hash,
json_group_array(fragment_aliases.alias) filter (
where
fragment_aliases.alias is not null
) as aliases,
fragments.datetime_utc,
fragments.source,
fragments.content
from
fragments
left join
fragment_aliases on fragment_aliases.fragment_id = fragments.id
{where}
group by
fragments.id, fragments.hash, fragments.content, fragments.datetime_utc, fragments.source
order by fragments.datetime_utc
""".format(where=where)
results = list(db.query(sql, params))
for result in results:
result["aliases"] = json.loads(result["aliases"])
if json_:
click.echo(json.dumps(results, indent=4))
else:
yaml.add_representer(
str,
lambda dumper, data: dumper.represent_scalar(
"tag:yaml.org,2002:str", data, style="|" if "\n" in data else None
),
)
for result in results:
result["content"] = truncate_string(result["content"])
click.echo(yaml.dump([result], sort_keys=False, width=sys.maxsize).strip())
@fragments.command(name="set")
@click.argument("alias", callback=validate_fragment_alias)
@click.argument("fragment")
def fragments_set(alias, fragment):
"""
Set an alias for a fragment
Accepts an alias and a file path, URL, hash or '-' for stdin
Example usage:
\b
llm fragments set mydocs ./docs.md
"""
db = sqlite_utils.Database(logs_db_path())
migrate(db)
try:
resolved = resolve_fragments(db, [fragment])[0]
except FragmentNotFound as ex:
raise click.ClickException(str(ex))
migrate(db)
alias_sql = """
insert into fragment_aliases (alias, fragment_id)
values (:alias, :fragment_id)
on conflict(alias) do update set
fragment_id = excluded.fragment_id;
"""
with db.conn:
fragment_id = ensure_fragment(db, resolved)
db.conn.execute(alias_sql, {"alias": alias, "fragment_id": fragment_id})
@fragments.command(name="show")
@click.argument("alias_or_hash")
def fragments_show(alias_or_hash):
"""
Display the fragment stored under an alias or hash
\b
llm fragments show mydocs
"""
db = sqlite_utils.Database(logs_db_path())
migrate(db)
try:
resolved = resolve_fragments(db, [alias_or_hash])[0]
except FragmentNotFound as ex:
raise click.ClickException(str(ex))
click.echo(resolved)
@fragments.command(name="remove")
@click.argument("alias", callback=validate_fragment_alias)
def fragments_remove(alias):
"""
Remove a fragment alias
Example usage:
\b
llm fragments remove docs
"""
db = sqlite_utils.Database(logs_db_path())
migrate(db)
with db.conn:
db.conn.execute(
"delete from fragment_aliases where alias = :alias", {"alias": alias}
)
@fragments.command(name="loaders")
def fragments_loaders():
"""Show fragment loaders registered by plugins"""
from llm import get_fragment_loaders
found = False
for prefix, loader in get_fragment_loaders().items():
if found:
# Extra newline on all after the first
click.echo("")
found = True
docs = "Undocumented"
if loader.__doc__:
docs = textwrap.dedent(loader.__doc__).strip()
click.echo(f"{prefix}:")
click.echo(textwrap.indent(docs, " "))
if not found:
click.echo("No fragment loaders found")
@cli.command(name="plugins")
@click.option("--all", help="Include built-in default plugins", is_flag=True)
@click.option(
"hooks", "--hook", help="Filter for plugins that implement this hook", multiple=True
)
def plugins_list(all, hooks):
"List installed plugins"
plugins = get_plugins(all)
hooks = set(hooks)
if hooks:
plugins = [plugin for plugin in plugins if hooks.intersection(plugin["hooks"])]
click.echo(json.dumps(plugins, indent=2))
def display_truncated(text):
console_width = shutil.get_terminal_size()[0]
if len(text) > console_width:
return text[: console_width - 3] + "..."
else:
return text
@cli.command()
@click.argument("packages", nargs=-1, required=False)
@click.option(
"-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version"
)
@click.option(
"-e",
"--editable",
help="Install a project in editable mode from this path",
)
@click.option(
"--force-reinstall",
is_flag=True,
help="Reinstall all packages even if they are already up-to-date",
)
@click.option(
"--no-cache-dir",
is_flag=True,
help="Disable the cache",
)
@click.option(
"--pre",
is_flag=True,
help="Include pre-release and development versions",
)
def install(packages, upgrade, editable, force_reinstall, no_cache_dir, pre):
"""Install packages from PyPI into the same environment as LLM"""
args = ["pip", "install"]
if upgrade:
args += ["--upgrade"]
if editable:
args += ["--editable", editable]
if force_reinstall:
args += ["--force-reinstall"]
if no_cache_dir:
args += ["--no-cache-dir"]
if pre:
args += ["--pre"]
args += list(packages)
sys.argv = args
run_module("pip", run_name="__main__")
@cli.command()
@click.argument("packages", nargs=-1, required=True)
@click.option("-y", "--yes", is_flag=True, help="Don't ask for confirmation")
def uninstall(packages, yes):
"""Uninstall Python packages from the LLM environment"""
sys.argv = ["pip", "uninstall"] + list(packages) + (["-y"] if yes else [])
run_module("pip", run_name="__main__")
@cli.command()
@click.argument("collection", required=False)
@click.argument("id", required=False)
@click.option(
"-i",
"--input",
type=click.Path(exists=True, readable=True, allow_dash=True),
help="File to embed",
)
@click.option(
"-m", "--model", help="Embedding model to use", envvar="LLM_EMBEDDING_MODEL"
)
@click.option("--store", is_flag=True, help="Store the text itself in the database")
@click.option(
"-d",
"--database",
type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),
envvar="LLM_EMBEDDINGS_DB",
)
@click.option(
"-c",
"--content",
help="Content to embed",
)
@click.option("--binary", is_flag=True, help="Treat input as binary data")
@click.option(
"--metadata",
help="JSON object metadata to store",
callback=json_validator("metadata"),
)
@click.option(
"format_",
"-f",
"--format",
type=click.Choice(["json", "blob", "base64", "hex"]),
help="Output format",
)
def embed(
collection, id, input, model, store, database, content, binary, metadata, format_
):
"""Embed text and store or return the result"""
if collection and not id:
raise click.ClickException("Must provide both collection and id")
if store and not collection:
raise click.ClickException("Must provide collection when using --store")
# Lazy load this because we do not need it for -c or -i versions
def get_db():
if database:
return sqlite_utils.Database(database)
else:
return sqlite_utils.Database(user_dir() / "embeddings.db")
collection_obj = None
model_obj = None
if collection:
db = get_db()
if Collection.exists(db, collection):
# Load existing collection and use its model
collection_obj = Collection(collection, db)
model_obj = collection_obj.model()
else:
# We will create a new one, but that means model is required
if not model:
model = get_default_embedding_model()
if model is None:
raise click.ClickException(
"You need to specify an embedding model (no default model is set)"
)
collection_obj = Collection(collection, db=db, model_id=model)
model_obj = collection_obj.model()
if model_obj is None:
if model is None:
model = get_default_embedding_model()
try:
model_obj = get_embedding_model(model)
except UnknownModelError:
raise click.ClickException(
"You need to specify an embedding model (no default model is set)"
)
show_output = True
if collection and (format_ is None):
show_output = False
# Resolve input text
if not content:
if not input or input == "-":
# Read from stdin
input_source = sys.stdin.buffer if binary else sys.stdin
content = input_source.read()
else:
mode = "rb" if binary else "r"
with open(input, mode) as f:
content = f.read()
if not content:
raise click.ClickException("No content provided")
if collection_obj:
embedding = collection_obj.embed(id, content, metadata=metadata, store=store)
else:
embedding = model_obj.embed(content)
if show_output:
if format_ == "json" or format_ is None:
click.echo(json.dumps(embedding))
elif format_ == "blob":
click.echo(encode(embedding))
elif format_ == "base64":
click.echo(base64.b64encode(encode(embedding)).decode("ascii"))
elif format_ == "hex":
click.echo(encode(embedding).hex())
@cli.command()
@click.argument("collection")
@click.argument(
"input_path",
type=click.Path(exists=True, dir_okay=False, allow_dash=True, readable=True),
required=False,
)
@click.option(
"--format",
type=click.Choice(["json", "csv", "tsv", "nl"]),
help="Format of input file - defaults to auto-detect",
)
@click.option(
"--files",
type=(click.Path(file_okay=False, dir_okay=True, allow_dash=False), str),
multiple=True,
help="Embed files in this directory - specify directory and glob pattern",
)
@click.option(
"encodings",
"--encoding",
help="Encodings to try when reading --files",
multiple=True,
)
@click.option("--binary", is_flag=True, help="Treat --files as binary data")
@click.option("--sql", help="Read input using this SQL query")
@click.option(
"--attach",
type=(str, click.Path(file_okay=True, dir_okay=False, allow_dash=False)),
multiple=True,
help="Additional databases to attach - specify alias and file path",
)
@click.option(
"--batch-size", type=int, help="Batch size to use when running embeddings"
)
@click.option("--prefix", help="Prefix to add to the IDs", default="")
@click.option(
"-m", "--model", help="Embedding model to use", envvar="LLM_EMBEDDING_MODEL"
)
@click.option(
"--prepend",
help="Prepend this string to all content before embedding",
)
@click.option("--store", is_flag=True, help="Store the text itself in the database")
@click.option(
"-d",
"--database",
type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),
envvar="LLM_EMBEDDINGS_DB",
)
def embed_multi(
collection,
input_path,
format,
files,
encodings,
binary,
sql,
attach,
batch_size,
prefix,
model,
prepend,
store,
database,
):
"""
Store embeddings for multiple strings at once in the specified collection.
Input data can come from one of three sources:
\b
1. A CSV, TSV, JSON or JSONL file:
- CSV/TSV: First column is ID, remaining columns concatenated as content
- JSON: Array of objects with "id" field and content fields
- JSONL: Newline-delimited JSON objects
\b
Examples:
llm embed-multi docs input.csv
cat data.json | llm embed-multi docs -
llm embed-multi docs input.json --format json
\b
2. A SQL query against a SQLite database:
- First column returned is used as ID
- Other columns concatenated to form content
\b
Examples:
llm embed-multi docs --sql "SELECT id, title, body FROM posts"
llm embed-multi docs --attach blog blog.db --sql "SELECT id, content FROM blog.posts"
\b
3. Files in directories matching glob patterns:
- Each file becomes one embedding
- Relative file paths become IDs
\b
Examples:
llm embed-multi docs --files docs '**/*.md'
llm embed-multi images --files photos '*.jpg' --binary
llm embed-multi texts --files texts '*.txt' --encoding utf-8 --encoding latin-1
"""
if binary and not files:
raise click.UsageError("--binary must be used with --files")
if binary and encodings:
raise click.UsageError("--binary cannot be used with --encoding")
if not input_path and not sql and not files:
raise click.UsageError("Either --sql or input path or --files is required")
if files:
if input_path or sql or format:
raise click.UsageError(
"Cannot use --files with --sql, input path or --format"
)
if database:
db = sqlite_utils.Database(database)
else:
db = sqlite_utils.Database(user_dir() / "embeddings.db")
for alias, attach_path in attach:
db.attach(alias, attach_path)
try:
collection_obj = Collection(
collection, db=db, model_id=model or get_default_embedding_model()
)
except ValueError:
raise click.ClickException(
"You need to specify an embedding model (no default model is set)"
)
expected_length = None
if files:
encodings = encodings or ("utf-8", "latin-1")
def count_files():
i = 0
for directory, pattern in files:
for path in pathlib.Path(directory).glob(pattern):
i += 1
return i
def iterate_files():
for directory, pattern in files:
p = pathlib.Path(directory)
if not p.exists() or not p.is_dir():
# fixes issue/274 - raise error if directory does not exist
raise click.UsageError(f"Invalid directory: {directory}")
for path in pathlib.Path(directory).glob(pattern):
if path.is_dir():
continue # fixed issue/280 - skip directories
relative = path.relative_to(directory)
content = None
if binary:
content = path.read_bytes()
else:
for encoding in encodings:
try:
content = path.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
if content is None:
# Log to stderr
click.echo(
"Could not decode text in file {}".format(path),
err=True,
)
else:
yield {"id": str(relative), "content": content}
expected_length = count_files()
rows = iterate_files()
elif sql:
rows = db.query(sql)
count_sql = "select count(*) as c from ({})".format(sql)
expected_length = next(db.query(count_sql))["c"]
else:
def load_rows(fp):
return rows_from_file(fp, Format[format.upper()] if format else None)[0]
try:
if input_path != "-":
# Read the file twice - first time is to get a count
expected_length = 0
with open(input_path, "rb") as fp:
for _ in load_rows(fp):
expected_length += 1
rows = load_rows(
open(input_path, "rb")
if input_path != "-"
else io.BufferedReader(sys.stdin.buffer)
)
except json.JSONDecodeError as ex:
raise click.ClickException(str(ex))
with click.progressbar(
rows, label="Embedding", show_percent=True, length=expected_length
) as rows:
def tuples() -> Iterable[Tuple[str, Union[bytes, str]]]:
for row in rows:
values = list(row.values())
id: str = prefix + str(values[0])
content: Optional[Union[bytes, str]] = None
if binary:
content = cast(bytes, values[1])
else:
content = " ".join(v or "" for v in values[1:])
if prepend and isinstance(content, str):
content = prepend + content
yield id, content or ""
embed_kwargs = {"store": store}
if batch_size:
embed_kwargs["batch_size"] = batch_size
collection_obj.embed_multi(tuples(), **embed_kwargs)
@cli.command()
@click.argument("collection")
@click.argument("id", required=False)
@click.option(
"-i",
"--input",
type=click.Path(exists=True, readable=True, allow_dash=True),
help="File to embed for comparison",
)
@click.option("-c", "--content", help="Content to embed for comparison")
@click.option("--binary", is_flag=True, help="Treat input as binary data")
@click.option(
"-n", "--number", type=int, default=10, help="Number of results to return"
)
@click.option("-p", "--plain", is_flag=True, help="Output in plain text format")
@click.option(
"-d",
"--database",
type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),
envvar="LLM_EMBEDDINGS_DB",
)
@click.option("--prefix", help="Just IDs with this prefix", default="")
def similar(collection, id, input, content, binary, number, plain, database, prefix):
"""
Return top N similar IDs from a collection using cosine similarity.
Example usage:
\b
llm similar my-collection -c "I like cats"
Or to find content similar to a specific stored ID:
\b
llm similar my-collection 1234
"""
if not id and not content and not input:
raise click.ClickException("Must provide content or an ID for the comparison")
if database:
db = sqlite_utils.Database(database)
else:
db = sqlite_utils.Database(user_dir() / "embeddings.db")
if not db["embeddings"].exists():
raise click.ClickException("No embeddings table found in database")
try:
collection_obj = Collection(collection, db, create=False)
except Collection.DoesNotExist:
raise click.ClickException("Collection does not exist")
if id:
try:
results = collection_obj.similar_by_id(id, number, prefix=prefix)
except Collection.DoesNotExist:
raise click.ClickException("ID not found in collection")
else:
# Resolve input text
if not content:
if not input or input == "-":
# Read from stdin
input_source = sys.stdin.buffer if binary else sys.stdin
content = input_source.read()
else:
mode = "rb" if binary else "r"
with open(input, mode) as f:
content = f.read()
if not content:
raise click.ClickException("No content provided")
results = collection_obj.similar(content, number, prefix=prefix)
for result in results:
if plain:
click.echo(f"{result.id} ({result.score})\n")
if result.content:
click.echo(textwrap.indent(result.content, " "))
if result.metadata:
click.echo(textwrap.indent(json.dumps(result.metadata), " "))
click.echo("")
else:
click.echo(json.dumps(asdict(result)))
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def embed_models():
"Manage available embedding models"
@embed_models.command(name="list")
@click.option(
"-q",
"--query",
multiple=True,
help="Search for embedding models matching these strings",
)
def embed_models_list(query):
"List available embedding models"
output = []
for model_with_aliases in get_embedding_models_with_aliases():
if query:
if not all(model_with_aliases.matches(q) for q in query):
continue
s = str(model_with_aliases.model)
if model_with_aliases.aliases:
s += " (aliases: {})".format(", ".join(model_with_aliases.aliases))
output.append(s)
click.echo("\n".join(output))
@embed_models.command(name="default")
@click.argument("model", required=False)
@click.option(
"--remove-default", is_flag=True, help="Reset to specifying no default model"
)
def embed_models_default(model, remove_default):
"Show or set the default embedding model"
if not model and not remove_default:
default = get_default_embedding_model()
if default is None:
click.echo("<No default embedding model set>", err=True)
else:
click.echo(default)
return
# Validate it is a known model
try:
if remove_default:
set_default_embedding_model(None)
else:
model = get_embedding_model(model)
set_default_embedding_model(model.model_id)
except KeyError:
raise click.ClickException("Unknown embedding model: {}".format(model))
@cli.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def collections():
"View and manage collections of embeddings"
@collections.command(name="path")
def collections_path():
"Output the path to the embeddings database"
click.echo(user_dir() / "embeddings.db")
@collections.command(name="list")
@click.option(
"-d",
"--database",
type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),
envvar="LLM_EMBEDDINGS_DB",
help="Path to embeddings database",
)
@click.option("json_", "--json", is_flag=True, help="Output as JSON")
def embed_db_collections(database, json_):
"View a list of collections"
database = database or (user_dir() / "embeddings.db")
db = sqlite_utils.Database(str(database))
if not db["collections"].exists():
raise click.ClickException("No collections table found in {}".format(database))
rows = db.query("""
select
collections.name,
collections.model,
count(embeddings.id) as num_embeddings
from
collections left join embeddings
on collections.id = embeddings.collection_id
group by
collections.name, collections.model
""")
if json_:
click.echo(json.dumps(list(rows), indent=4))
else:
for row in rows:
click.echo("{}: {}".format(row["name"], row["model"]))
click.echo(
" {} embedding{}".format(
row["num_embeddings"], "s" if row["num_embeddings"] != 1 else ""
)
)
@collections.command(name="delete")
@click.argument("collection")
@click.option(
"-d",
"--database",
type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),
envvar="LLM_EMBEDDINGS_DB",
help="Path to embeddings database",
)
def collections_delete(collection, database):
"""
Delete the specified collection
Example usage:
\b
llm collections delete my-collection
"""
database = database or (user_dir() / "embeddings.db")
db = sqlite_utils.Database(str(database))
try:
collection_obj = Collection(collection, db, create=False)
except Collection.DoesNotExist:
raise click.ClickException("Collection does not exist")
collection_obj.delete()
@models.group(
cls=DefaultGroup,
default="list",
default_if_no_args=True,
)
def options():
"Manage default options for models"
@options.command(name="list")
def options_list():
"""
List default options for all models
Example usage:
\b
llm models options list
"""
options = get_all_model_options()
if not options:
click.echo("No default options set for any models.", err=True)
return
for model_id, model_options in options.items():
click.echo(f"{model_id}:")
for key, value in model_options.items():
click.echo(f" {key}: {value}")
@options.command(name="show")
@click.argument("model")
def options_show(model):
"""
List default options set for a specific model
Example usage:
\b
llm models options show gpt-4o
"""
import llm
try:
# Resolve alias to model ID
model_obj = llm.get_model(model)
model_id = model_obj.model_id
except llm.UnknownModelError:
# Use as-is if not found
model_id = model
options = get_model_options(model_id)
if not options:
click.echo(f"No default options set for model '{model_id}'.", err=True)
return
for key, value in options.items():
click.echo(f"{key}: {value}")
@options.command(name="set")
@click.argument("model")
@click.argument("key")
@click.argument("value")
def options_set(model, key, value):
"""
Set a default option for a model
Example usage:
\b
llm models options set gpt-4o temperature 0.5
"""
import llm
try:
# Resolve alias to model ID
model_obj = llm.get_model(model)
model_id = model_obj.model_id
# Validate option against model schema
try:
# Create a test Options object to validate
test_options = {key: value}
model_obj.Options(**test_options)
except pydantic.ValidationError as ex:
raise click.ClickException(render_errors(ex.errors()))
except llm.UnknownModelError:
# Use as-is if not found
model_id = model
set_model_option(model_id, key, value)
click.echo(f"Set default option {key}={value} for model {model_id}", err=True)
@options.command(name="clear")
@click.argument("model")
@click.argument("key", required=False)
def options_clear(model, key):
"""
Clear default option(s) for a model
Example usage:
\b
llm models options clear gpt-4o
# Or for a single option
llm models options clear gpt-4o temperature
"""
import llm
try:
# Resolve alias to model ID
model_obj = llm.get_model(model)
model_id = model_obj.model_id
except llm.UnknownModelError:
# Use as-is if not found
model_id = model
cleared_keys = []
if not key:
cleared_keys = list(get_model_options(model_id).keys())
for key_ in cleared_keys:
clear_model_option(model_id, key_)
else:
cleared_keys.append(key)
clear_model_option(model_id, key)
if cleared_keys:
if len(cleared_keys) == 1:
click.echo(f"Cleared option '{cleared_keys[0]}' for model {model_id}")
else:
click.echo(
f"Cleared {', '.join(cleared_keys)} options for model {model_id}"
)
def template_dir():
path = user_dir() / "templates"
path.mkdir(parents=True, exist_ok=True)
return path
def logs_db_path():
return user_dir() / "logs.db"
def get_history(chat_id):
if chat_id is None:
return None, []
log_path = logs_db_path()
db = sqlite_utils.Database(log_path)
migrate(db)
if chat_id == -1:
# Return the most recent chat
last_row = list(db["logs"].rows_where(order_by="-id", limit=1))
if last_row:
chat_id = last_row[0].get("chat_id") or last_row[0].get("id")
else: # Database is empty
return None, []
rows = db["logs"].rows_where(
"id = ? or chat_id = ?", [chat_id, chat_id], order_by="id"
)
return chat_id, rows
def render_errors(errors):
output = []
for error in errors:
output.append(", ".join(error["loc"]))
output.append(" " + error["msg"])
return "\n".join(output)
load_plugins()
pm.hook.register_commands(cli=cli)
def _human_readable_size(size_bytes):
if size_bytes == 0:
return "0B"
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
i = 0
while size_bytes >= 1024 and i < len(size_name) - 1:
size_bytes /= 1024.0
i += 1
return "{:.2f}{}".format(size_bytes, size_name[i])
def logs_on():
return not (user_dir() / "logs-off").exists()
def get_all_model_options() -> dict:
"""
Get all default options for all models
"""
path = user_dir() / "model_options.json"
if not path.exists():
return {}
try:
options = json.loads(path.read_text())
except json.JSONDecodeError:
return {}
return options
def get_model_options(model_id: str) -> dict:
"""
Get default options for a specific model
Args:
model_id: Return options for model with this ID
Returns:
A dictionary of model options
"""
path = user_dir() / "model_options.json"
if not path.exists():
return {}
try:
options = json.loads(path.read_text())
except json.JSONDecodeError:
return {}
return options.get(model_id, {})
def set_model_option(model_id: str, key: str, value: Any) -> None:
"""
Set a default option for a model.
Args:
model_id: The model ID
key: The option key
value: The option value
"""
path = user_dir() / "model_options.json"
if path.exists():
try:
options = json.loads(path.read_text())
except json.JSONDecodeError:
options = {}
else:
options = {}
# Ensure the model has an entry
if model_id not in options:
options[model_id] = {}
# Set the option
options[model_id][key] = value
# Save the options
path.write_text(json.dumps(options, indent=2))
def clear_model_option(model_id: str, key: str) -> None:
"""
Clear a model option
Args:
model_id: The model ID
key: Key to clear
"""
path = user_dir() / "model_options.json"
if not path.exists():
return
try:
options = json.loads(path.read_text())
except json.JSONDecodeError:
return
if model_id not in options:
return
if key in options[model_id]:
del options[model_id][key]
if not options[model_id]:
del options[model_id]
path.write_text(json.dumps(options, indent=2))
class LoadTemplateError(ValueError):
pass
def _parse_yaml_template(name, content):
try:
loaded = yaml.safe_load(content)
except yaml.YAMLError as ex:
raise LoadTemplateError("Invalid YAML: {}".format(str(ex)))
if isinstance(loaded, str):
return Template(name=name, prompt=loaded)
loaded["name"] = name
try:
return Template(**loaded)
except pydantic.ValidationError as ex:
msg = "A validation error occurred:\n"
msg += render_errors(ex.errors())
raise LoadTemplateError(msg)
def load_template(name: str) -> Template:
"Load template, or raise LoadTemplateError(msg)"
if name.startswith("https://") or name.startswith("http://"):
response = httpx.get(name)
try:
response.raise_for_status()
except httpx.HTTPStatusError as ex:
raise LoadTemplateError("Could not load template {}: {}".format(name, ex))
return _parse_yaml_template(name, response.text)
potential_path = pathlib.Path(name)
if has_plugin_prefix(name) and not potential_path.exists():
prefix, rest = name.split(":", 1)
loaders = get_template_loaders()
if prefix not in loaders:
raise LoadTemplateError("Unknown template prefix: {}".format(prefix))
loader = loaders[prefix]
try:
return loader(rest)
except Exception as ex:
raise LoadTemplateError("Could not load template {}: {}".format(name, ex))
# Try local file
if potential_path.exists():
path = potential_path
else:
# Look for template in template_dir()
path = template_dir() / f"{name}.yaml"
if not path.exists():
raise LoadTemplateError(f"Invalid template: {name}")
content = path.read_text()
template_obj = _parse_yaml_template(name, content)
# We trust functions here because they came from the filesystem
template_obj._functions_is_trusted = True
return template_obj
def _tools_from_code(code_or_path: str) -> List[Tool]:
"""
Treat all Python functions in the code as tools
"""
if "\n" not in code_or_path and code_or_path.endswith(".py"):
try:
code_or_path = pathlib.Path(code_or_path).read_text()
except FileNotFoundError:
raise click.ClickException("File not found: {}".format(code_or_path))
namespace: Dict[str, Any] = {}
tools = []
try:
exec(code_or_path, namespace)
except SyntaxError as ex:
raise click.ClickException("Error in --functions definition: {}".format(ex))
# Register all callables in the locals dict:
for name, value in namespace.items():
if callable(value) and not name.startswith("_"):
tools.append(Tool.function(value))
return tools
def _debug_tool_call(_, tool_call, tool_result):
click.echo(
click.style(
"\nTool call: {}({})".format(tool_call.name, tool_call.arguments),
fg="yellow",
bold=True,
),
err=True,
)
output = ""
attachments = ""
if tool_result.attachments:
attachments += "\nAttachments:\n"
for attachment in tool_result.attachments:
attachments += f" {repr(attachment)}\n"
try:
output = json.dumps(json.loads(tool_result.output), indent=2)
except ValueError:
output = tool_result.output
output += attachments
click.echo(
click.style(
textwrap.indent(output, " ") + ("\n" if not tool_result.exception else ""),
fg="green",
bold=True,
),
err=True,
)
if tool_result.exception:
click.echo(
click.style(
" Exception: {}".format(tool_result.exception),
fg="red",
bold=True,
),
err=True,
)
def _approve_tool_call(_, tool_call):
click.echo(
click.style(
"Tool call: {}({})".format(tool_call.name, tool_call.arguments),
fg="yellow",
bold=True,
),
err=True,
)
if not click.confirm("Approve tool call?"):
raise CancelToolCall("User cancelled tool call")
def _gather_tools(
tool_specs: List[str], python_tools: List[str]
) -> List[Union[Tool, Type[Toolbox]]]:
tools: List[Union[Tool, Type[Toolbox]]] = []
if python_tools:
for code_or_path in python_tools:
tools.extend(_tools_from_code(code_or_path))
registered_tools = get_tools()
registered_classes = dict(
(key, value)
for key, value in registered_tools.items()
if inspect.isclass(value)
)
bad_tools = [
tool for tool in tool_specs if tool.split("(")[0] not in registered_tools
]
if bad_tools:
raise click.ClickException(
"Tool(s) {} not found. Available tools: {}".format(
", ".join(bad_tools), ", ".join(registered_tools.keys())
)
)
for tool_spec in tool_specs:
if not tool_spec[0].isupper():
# It's a function
tools.append(registered_tools[tool_spec])
else:
# It's a class
tools.append(instantiate_from_spec(registered_classes, tool_spec))
return tools
def _get_conversation_tools(conversation, tools):
if conversation and not tools and conversation.responses:
# Copy plugin tools from first response in conversation
initial_tools = conversation.responses[0].prompt.tools
if initial_tools:
# Only tools from plugins:
return [tool.name for tool in initial_tools if tool.plugin]