804027493d
Standalone !attach commands now accumulate attachments without sending an empty prompt. The next real message consumes them once, and invalid attachments report an error without terminating the interactive session or losing pending state.
4351 lines
138 KiB
Python
4351 lines
138 KiB
Python
import asyncio
|
|
import base64
|
|
import inspect
|
|
import io
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import readline
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
import textwrap
|
|
import warnings
|
|
from collections.abc import Iterable
|
|
from dataclasses import asdict
|
|
from importlib.metadata import version
|
|
from runpy import run_module
|
|
from typing import Any, cast
|
|
|
|
import click
|
|
import httpx
|
|
import pydantic
|
|
import sqlite_utils
|
|
import yaml
|
|
from click_default_group import DefaultGroup
|
|
from sqlite_utils.utils import Format, rows_from_file
|
|
|
|
from llm import (
|
|
AsyncConversation,
|
|
AsyncKeyModel,
|
|
AsyncResponse,
|
|
Attachment,
|
|
CancelToolCall,
|
|
Collection,
|
|
Conversation,
|
|
Fragment,
|
|
KeyModel,
|
|
Response,
|
|
ServerSideTool,
|
|
Template,
|
|
Tool,
|
|
Toolbox,
|
|
UnknownModelError,
|
|
encode,
|
|
get_async_model,
|
|
get_default_embedding_model,
|
|
get_default_model,
|
|
get_embedding_model,
|
|
get_embedding_model_aliases,
|
|
get_embedding_models_with_aliases,
|
|
get_fragment_loaders,
|
|
get_model,
|
|
get_model_aliases,
|
|
get_models_with_aliases,
|
|
get_plugins,
|
|
get_template_loaders,
|
|
get_tools,
|
|
remove_alias,
|
|
set_alias,
|
|
set_default_embedding_model,
|
|
set_default_model,
|
|
user_dir,
|
|
)
|
|
from llm.models import ChainResponse, _BaseChainResponse, _BaseConversation
|
|
|
|
from .logs import (
|
|
LogStore,
|
|
legacy_log_row_extras,
|
|
log_row_extras,
|
|
merged_log_rows,
|
|
)
|
|
from .migrations import migrate
|
|
from .plugins import load_plugins, pm
|
|
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,
|
|
)
|
|
|
|
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 _run_chat(
|
|
model_label,
|
|
prompt_callback,
|
|
*,
|
|
db=None,
|
|
initial_fragments=None,
|
|
initial_attachments=None,
|
|
transform_prompt=None,
|
|
after_response=None,
|
|
show_reasoning=True,
|
|
):
|
|
"""Run the terminal chat loop shared by managed and transient models."""
|
|
click.echo(f"Chatting with {model_label}")
|
|
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")
|
|
if db is not None:
|
|
click.echo(
|
|
"Type '!fragment <my_fragment> [<another_fragment> ...]' to insert one or more fragments"
|
|
)
|
|
click.echo("Type '!attach <url-or-path>' to attach a file")
|
|
|
|
argument_fragments = list(initial_fragments or [])
|
|
argument_attachments = list(initial_attachments or [])
|
|
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 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 db is not None and prompt.strip().startswith("!fragment "):
|
|
prompt, extra_fragments, fragment_attachments = process_fragments_in_chat(
|
|
db, prompt
|
|
)
|
|
fragments += extra_fragments
|
|
attachments += fragment_attachments
|
|
|
|
try:
|
|
prompt, extra_attachments = process_attachments_in_chat(prompt)
|
|
except click.ClickException as ex:
|
|
# Invalid attachments should not terminate an interactive session.
|
|
click.echo(f"Error: {ex.format_message()}", err=True)
|
|
continue
|
|
if extra_attachments and not in_multi and not prompt.strip():
|
|
# A standalone !attach command queues attachments for the next
|
|
# actual message instead of sending an empty prompt immediately.
|
|
argument_attachments.extend(extra_attachments)
|
|
noun = "Attachment" if len(extra_attachments) == 1 else "Attachments"
|
|
click.echo(f"{noun} queued for next message")
|
|
continue
|
|
attachments += extra_attachments
|
|
|
|
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 prompt.strip() in ("exit", "quit"):
|
|
break
|
|
if transform_prompt is not None:
|
|
prompt = transform_prompt(prompt)
|
|
|
|
# Command-line fragments and attachments, plus standalone !attach
|
|
# commands, remain pending until an actual message is sent.
|
|
fragments = argument_fragments + fragments
|
|
attachments = argument_attachments + attachments
|
|
argument_fragments = []
|
|
argument_attachments = []
|
|
|
|
response = prompt_callback(prompt, fragments, attachments)
|
|
display_stream_events(
|
|
response.stream_events(),
|
|
show_reasoning=show_reasoning,
|
|
)
|
|
if after_response is not None:
|
|
after_response(response)
|
|
print()
|
|
|
|
|
|
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[Fragment | Attachment]:
|
|
"""
|
|
Resolve fragment strings into a mixed of llm.Fragment() and llm.Attachment() objects.
|
|
"""
|
|
|
|
def _load_by_alias(fragment: str) -> tuple[str | None, str | None]:
|
|
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[Fragment | Attachment] = []
|
|
for fragment in fragments:
|
|
if fragment.startswith(("http://", "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) and not pathlib.Path(fragment).exists():
|
|
prefix, rest = fragment.split(":", 1)
|
|
loaders = get_fragment_loaders()
|
|
if prefix not in loaders:
|
|
raise FragmentNotFound(f"Unknown fragment prefix: {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(
|
|
f"Fragment loader {prefix} returned a disallowed attachment"
|
|
)
|
|
resolved.extend(result)
|
|
except Exception as ex: # noqa: BLE001
|
|
raise FragmentNotFound(f"Could not load fragment {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
|
|
|
|
|
|
def process_attachments_in_chat(prompt: str) -> tuple[str, list[Attachment]]:
|
|
"""
|
|
Process any !attach commands in a chat prompt and return the modified prompt plus resolved attachments.
|
|
"""
|
|
prompt_lines = []
|
|
attachments = []
|
|
for line in prompt.splitlines():
|
|
stripped_line = line.strip()
|
|
if stripped_line == "!attach":
|
|
raise click.ClickException("Usage: !attach <url-or-path>")
|
|
if stripped_line.startswith("!attach "):
|
|
attachment_value = stripped_line.removeprefix("!attach ").strip()
|
|
if not attachment_value:
|
|
raise click.ClickException("Usage: !attach <url-or-path>")
|
|
try:
|
|
attachments.append(resolve_attachment(attachment_value))
|
|
except AttachmentError as ex:
|
|
raise click.ClickException(str(ex))
|
|
else:
|
|
prompt_lines.append(line)
|
|
return "\n".join(prompt_lines), attachments
|
|
|
|
|
|
class AttachmentError(Exception):
|
|
"""Exception raised for errors in attachment resolution."""
|
|
|
|
|
|
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 _apply_template(template, prompt, params, system):
|
|
"""Apply a loaded template to a prompt and system prompt."""
|
|
try:
|
|
uses_input = "input" in template.vars()
|
|
input_ = prompt if uses_input else ""
|
|
template_prompt, template_system = template.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
|
|
return prompt, system
|
|
|
|
|
|
def _merge_template_options(template, options):
|
|
"""Add template options unless the same option was provided explicitly."""
|
|
merged_options = list(options)
|
|
specified_options = dict(merged_options)
|
|
for option_name, option_value in (template.options or {}).items():
|
|
if option_name not in specified_options:
|
|
merged_options.append((option_name, option_value))
|
|
return merged_options
|
|
|
|
|
|
def _merge_template_attachments(template, attachments, attachment_types):
|
|
"""Resolve and prepend attachments declared by a loaded template."""
|
|
if template.attachments:
|
|
attachments = [
|
|
resolve_attachment(value) for value in template.attachments
|
|
] + list(attachments)
|
|
if template.attachment_types:
|
|
attachment_types = [
|
|
resolve_attachment_with_type(item.value, item.type)
|
|
for item in template.attachment_types
|
|
] + list(attachment_types)
|
|
return attachments, attachment_types
|
|
|
|
|
|
def _merge_template_tools(template, tools, python_tools):
|
|
"""Prepend trusted tool definitions declared by a loaded template."""
|
|
if template.tools:
|
|
tools = [*template.tools, *tools]
|
|
if template.functions and template._functions_is_trusted:
|
|
python_tools = [template.functions, *python_tools]
|
|
return tools, python_tools
|
|
|
|
|
|
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
|
|
|
|
|
|
def tool_options(fn):
|
|
"""Add the shared CLI options for selecting and executing tools."""
|
|
decorators = (
|
|
click.option(
|
|
"tools",
|
|
"-T",
|
|
"--tool",
|
|
multiple=True,
|
|
help="Name of a tool to make available to the model",
|
|
),
|
|
click.option(
|
|
"python_tools",
|
|
"--functions",
|
|
multiple=True,
|
|
help="Python code block or file path defining functions to register as tools",
|
|
),
|
|
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"
|
|
),
|
|
),
|
|
)
|
|
for decorator in reversed(decorators):
|
|
fn = decorator(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",
|
|
)
|
|
@tool_options
|
|
@click.option(
|
|
"options",
|
|
"-o",
|
|
"--option",
|
|
type=(str, str),
|
|
multiple=True,
|
|
help="key/value options for the model",
|
|
)
|
|
@click.option(
|
|
"show_model_options",
|
|
"--options",
|
|
is_flag=True,
|
|
help="Show options for the selected 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", "--hide-reasoning", is_flag=True, help="Hide 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",
|
|
)
|
|
@click.option(
|
|
"json_output",
|
|
"--json",
|
|
is_flag=True,
|
|
help="Output the response as JSON, same format as llm logs --json",
|
|
)
|
|
def prompt(
|
|
prompt,
|
|
system,
|
|
model_id,
|
|
database,
|
|
queries,
|
|
attachments,
|
|
attachment_types,
|
|
tools,
|
|
python_tools,
|
|
tools_debug,
|
|
tools_approve,
|
|
chain_limit,
|
|
options,
|
|
show_model_options,
|
|
schema_input,
|
|
schema_multi,
|
|
fragments,
|
|
system_fragments,
|
|
template,
|
|
param,
|
|
no_stream,
|
|
no_log,
|
|
log,
|
|
hide_reasoning,
|
|
_continue,
|
|
conversation_id,
|
|
key,
|
|
save,
|
|
async_,
|
|
usage,
|
|
extract,
|
|
extract_last,
|
|
json_output,
|
|
):
|
|
"""
|
|
Execute a prompt
|
|
|
|
Documentation: https://llm.datasette.io/en/stable/usage.html
|
|
|
|
Examples:
|
|
|
|
\b
|
|
llm 'Capital of France?'
|
|
llm 'Capital of France?' -m gpt-5.5
|
|
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")
|
|
|
|
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 show_model_options and not (conversation_id or _continue or template):
|
|
model_id = model_id or get_default_model()
|
|
try:
|
|
if async_:
|
|
get_async_model(model_id)
|
|
else:
|
|
get_model(model_id)
|
|
except UnknownModelError as ex:
|
|
raise click.ClickException(ex)
|
|
click.echo(render_model_with_options(model_id, async_=async_))
|
|
return
|
|
|
|
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 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(f"'{model_id}' is not a known model")
|
|
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
|
|
tools, python_tools = _merge_template_tools(template_obj, tools, python_tools)
|
|
if template_obj.options:
|
|
options = _merge_template_options(template_obj, options)
|
|
if "input" in template_obj.vars():
|
|
prompt = read_prompt()
|
|
prompt, system = _apply_template(template_obj, prompt, params, system)
|
|
if model_id is None and template_obj.model:
|
|
model_id = template_obj.model
|
|
attachments, attachment_types = _merge_template_attachments(
|
|
template_obj, attachments, attachment_types
|
|
)
|
|
if extract or extract_last or json_output:
|
|
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 show_model_options:
|
|
click.echo(render_model_with_options(model_id, async_=async_))
|
|
return
|
|
|
|
if conversation is None:
|
|
# Always work through a conversation, even for a one-off prompt.
|
|
# The legacy logger invents one anyway and throws the id away;
|
|
# creating it here means both writers agree on which conversation
|
|
# (and so which thread) this response belongs to.
|
|
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 = {
|
|
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 = {}
|
|
|
|
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_kwargs = _tool_chain_kwargs(
|
|
tools, python_tools, tools_debug, tools_approve, chain_limit, model=model
|
|
)
|
|
if tool_kwargs:
|
|
prompt_method = conversation.chain
|
|
kwargs["options"] = validated_options
|
|
kwargs.update(tool_kwargs)
|
|
else:
|
|
# Merge in options for the .prompt() methods
|
|
kwargs.update(validated_options)
|
|
|
|
if hide_reasoning:
|
|
kwargs["hide_reasoning"] = True
|
|
|
|
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 hide_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
|
|
)
|
|
if not json_output:
|
|
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 hide_reasoning,
|
|
)
|
|
print()
|
|
else:
|
|
text = response.text()
|
|
if extract or extract_last:
|
|
text = extract_fenced_code_block(text, last=extract_last) or text
|
|
if not json_output:
|
|
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(
|
|
f"Token usage: {response_object.token_usage()}",
|
|
fg="yellow",
|
|
bold=True,
|
|
),
|
|
err=True,
|
|
)
|
|
|
|
# Log responses to the database
|
|
log_db = None
|
|
if (logs_on() or log) and not no_log:
|
|
log_db = db
|
|
elif json_output:
|
|
# --json needs logged rows, so use a temporary in-memory database
|
|
log_db = sqlite_utils.Database(memory=True)
|
|
migrate(log_db)
|
|
|
|
if log_db is not None:
|
|
# 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(log_db)
|
|
|
|
if json_output:
|
|
if isinstance(response, _BaseChainResponse):
|
|
response_ids = [response_.id for response_ in response._responses]
|
|
else:
|
|
response_ids = [response.id]
|
|
click.echo(logs_json_for_response_ids(log_db, response_ids))
|
|
|
|
|
|
@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", "--hide-reasoning", is_flag=True, help="Hide reasoning output")
|
|
@click.option("--key", help="API key to use")
|
|
@tool_options
|
|
def chat(
|
|
system,
|
|
model_id,
|
|
_continue,
|
|
conversation_id,
|
|
fragments,
|
|
system_fragments,
|
|
template,
|
|
param,
|
|
options,
|
|
no_stream,
|
|
hide_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
|
|
tools, python_tools = _merge_template_tools(template_obj, tools, 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(f"'{model_id}' is not a known model")
|
|
|
|
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
|
|
|
|
# Validate options
|
|
validated_options = get_model_options(model.model_id)
|
|
if options:
|
|
try:
|
|
validated_options = {
|
|
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 = {}
|
|
if validated_options:
|
|
kwargs["options"] = validated_options
|
|
|
|
kwargs.update(
|
|
_tool_chain_kwargs(
|
|
tools,
|
|
python_tools,
|
|
tools_debug,
|
|
tools_approve,
|
|
chain_limit,
|
|
model=model,
|
|
)
|
|
)
|
|
|
|
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
|
|
if hide_reasoning:
|
|
kwargs["hide_reasoning"] = True
|
|
|
|
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))
|
|
|
|
def transform_chat_prompt(prompt):
|
|
nonlocal system
|
|
if template_obj:
|
|
prompt, system = _apply_template(template_obj, prompt, params, system)
|
|
return prompt
|
|
|
|
def execute_chat_prompt(prompt, fragments, attachments):
|
|
nonlocal system, argument_system_fragments
|
|
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 = []
|
|
return response
|
|
|
|
_run_chat(
|
|
model.model_id,
|
|
execute_chat_prompt,
|
|
db=db,
|
|
initial_fragments=argument_fragments,
|
|
initial_attachments=argument_attachments,
|
|
transform_prompt=transform_chat_prompt,
|
|
after_response=lambda response: response.log_to_db(db),
|
|
show_reasoning=not hide_reasoning,
|
|
)
|
|
|
|
|
|
def load_conversation(
|
|
conversation_id: str | None,
|
|
async_=False,
|
|
database=None,
|
|
) -> _BaseConversation | None:
|
|
log_path = pathlib.Path(database) if database else logs_db_path()
|
|
db = sqlite_utils.Database(log_path)
|
|
migrate(db)
|
|
if conversation_id is None:
|
|
# Most recent conversation from either generation of tables -
|
|
# thread ids are conversation ids, so the union dedupes rows
|
|
# from the dual-write era.
|
|
matches = list(db.query("""
|
|
select id from (
|
|
select id from threads
|
|
union
|
|
select id from conversations
|
|
) 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:
|
|
# No legacy record - reconstruct the equivalent from the thread
|
|
# and its most recent turn's model.
|
|
try:
|
|
thread_row = cast(sqlite_utils.db.Table, db["threads"]).get(conversation_id)
|
|
except sqlite_utils.db.NotFoundError:
|
|
raise click.ClickException(
|
|
f"No conversation found with id={conversation_id}"
|
|
)
|
|
model_match = next(
|
|
db.query(
|
|
"select model from turns where thread_id = ? order by id desc limit 1",
|
|
[conversation_id],
|
|
),
|
|
None,
|
|
)
|
|
if model_match is None:
|
|
raise click.ClickException(
|
|
f"No conversation found with id={conversation_id}"
|
|
)
|
|
row = {
|
|
"id": conversation_id,
|
|
"name": thread_row["name"],
|
|
"model": model_match["model"],
|
|
}
|
|
# 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)
|
|
|
|
# If this conversation has a thread in the content-addressed tables,
|
|
# take the history from there. That chain is the exact message list
|
|
# that was sent and returned, so reasoning signatures and provider
|
|
# metadata survive - unlike the rebuild above, which can only work
|
|
# from the flattened legacy columns.
|
|
try:
|
|
conversation.loaded_messages = LogStore(db).thread_messages(conversation_id)
|
|
except KeyError:
|
|
pass
|
|
|
|
# Plugin and server-side tools recorded against the first turn, for
|
|
# the same reuse-on-continue behaviour the rebuilt responses provide.
|
|
# Configured instances are collapsed into a single spec string like
|
|
# Datasette({"url": "..."}) - the same format -T accepts - so the
|
|
# instance can be reconstructed with its configuration.
|
|
loaded_tools = []
|
|
seen_instance_ids = set()
|
|
supported_server_side_tool_names = {
|
|
tool_class.__name__
|
|
for tool_class in conversation.model.supported_server_side_tools
|
|
}
|
|
for tool_row in db.query(
|
|
"""
|
|
select tools.name, tools.plugin, turn_tools.instance_id,
|
|
tool_instances.name as instance_name,
|
|
tool_instances.arguments as instance_arguments
|
|
from tools
|
|
join turn_tools on turn_tools.tool_id = tools.id
|
|
left join tool_instances on tool_instances.id = turn_tools.instance_id
|
|
where turn_tools.turn_id = (
|
|
select id from turns where thread_id = ? order by id limit 1
|
|
)
|
|
""",
|
|
[conversation_id],
|
|
):
|
|
if (
|
|
tool_row["plugin"] is None
|
|
and tool_row["instance_name"] not in supported_server_side_tool_names
|
|
):
|
|
continue
|
|
if tool_row["instance_id"] is None:
|
|
loaded_tools.append(tool_row["name"])
|
|
elif tool_row["instance_id"] not in seen_instance_ids:
|
|
seen_instance_ids.add(tool_row["instance_id"])
|
|
arguments = tool_row["instance_arguments"]
|
|
if arguments and arguments != "{}":
|
|
loaded_tools.append(
|
|
"{}({})".format(tool_row["instance_name"], arguments)
|
|
)
|
|
else:
|
|
loaded_tools.append(tool_row["instance_name"])
|
|
conversation.loaded_tools = loaded_tools
|
|
|
|
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(f"No key found with name '{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(f"No log database found at {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(f"Found log database at {path}")
|
|
click.echo("Number of threads logged:\t{}".format(db["threads"].count))
|
|
click.echo("Number of turns logged:\t\t{}".format(db["turns"].count))
|
|
legacy_conversations = db["conversations"].count
|
|
legacy_responses = db["responses"].count
|
|
if legacy_conversations or legacy_responses:
|
|
click.echo(f"Number of legacy conversations:\t{legacy_conversations}")
|
|
click.echo(f"Number of legacy responses:\t{legacy_responses}")
|
|
click.echo(f"Database file size: \t\t{_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: # noqa: BLE001
|
|
raise click.ClickException(str(ex))
|
|
click.echo(f"Backed up {_human_readable_size(path.stat().st_size)} to {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()
|
|
|
|
|
|
def annotate_log_rows(db, rows, expand=False, truncate=False):
|
|
"""
|
|
Modify log rows from the merged reader in place: attach fragments
|
|
and tool information, decode (or, if truncate is on, remove) their
|
|
JSON columns and strip the reader's internal keys.
|
|
|
|
Returns a dict mapping row id to its attachments, for
|
|
log_rows_as_json and the rendered output.
|
|
"""
|
|
store = LogStore(db)
|
|
# New rows carry their extras in the row's parts; legacy rows
|
|
# batch-fetch from the legacy tables.
|
|
legacy_extras = legacy_log_row_extras(
|
|
db, [row["id"] for row in rows if row.get("_legacy")]
|
|
)
|
|
extras_by_id = {
|
|
row["id"]: (
|
|
legacy_extras[row["id"]]
|
|
if row.get("_legacy")
|
|
else log_row_extras(store, row)
|
|
)
|
|
for row in rows
|
|
}
|
|
for row in rows:
|
|
for internal in (
|
|
"_input_parts",
|
|
"_output_parts",
|
|
"_parent_message_hash",
|
|
"_input_message_hashes",
|
|
"_tip_message_hash",
|
|
"_legacy",
|
|
"_search_rank",
|
|
):
|
|
row.pop(internal, None)
|
|
extras = extras_by_id[row["id"]]
|
|
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 extras[key]
|
|
]
|
|
# 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(
|
|
{
|
|
"tools": extras["tools"],
|
|
"tool_calls": extras["tool_calls"],
|
|
"tool_results": extras["tool_results"],
|
|
}
|
|
)
|
|
return {id: extras["attachments"] for id, extras in extras_by_id.items()}
|
|
|
|
|
|
def log_rows_as_json(rows, attachments_by_id):
|
|
"Serialize annotated log rows to the JSON used by 'llm logs --json'"
|
|
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"], [])
|
|
]
|
|
return json.dumps(list(rows), indent=2)
|
|
|
|
|
|
def logs_json_for_response_ids(db, ids):
|
|
"""
|
|
Return the JSON that 'llm logs --json' would output for these response IDs,
|
|
in chronological order
|
|
"""
|
|
if not ids:
|
|
return "[]"
|
|
rows = merged_log_rows(LogStore(db), ids=list(ids))
|
|
# Newest first out of the reader, chronological out here
|
|
rows.reverse()
|
|
return log_rows_as_json(rows, annotate_log_rows(db, rows))
|
|
|
|
|
|
@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(f"No log database found at {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(f"Cannot use --short and {invalid} together")
|
|
|
|
if response and not current_conversation and not conversation_id:
|
|
current_conversation = True
|
|
|
|
if current_conversation:
|
|
try:
|
|
# Thread ids are conversation ids and both id spaces are
|
|
# ULIDs, so the most recent of either world wins.
|
|
conversation_id = next(db.query("""
|
|
select conversation_id from (
|
|
select thread_id as conversation_id, id from turns
|
|
union all
|
|
select conversation_id, 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
|
|
|
|
fragment_hashes = [fragment.id() for fragment in resolve_fragments(db, fragments)]
|
|
|
|
schema_id = make_schema_id(schema)[0] if schema else None
|
|
|
|
store = LogStore(db)
|
|
try:
|
|
rows = merged_log_rows(
|
|
store,
|
|
count=count if count and count > 0 else None,
|
|
model_id=model_id,
|
|
thread_id=conversation_id,
|
|
fragment_hashes=fragment_hashes,
|
|
tool_names=tools,
|
|
any_tools=any_tools,
|
|
schema_id=schema_id,
|
|
id_gt=id_gt,
|
|
id_gte=id_gte,
|
|
query=query,
|
|
latest=latest,
|
|
)
|
|
except sqlite3.OperationalError as ex:
|
|
if query:
|
|
# Almost certainly FTS5 syntax - unbalanced quotes, stray
|
|
# operators and the like
|
|
raise click.ClickException(
|
|
f"Invalid search query: {ex} - see the FTS5 query syntax "
|
|
"documentation at https://sqlite.org/fts5.html#full_text_query_syntax"
|
|
)
|
|
raise
|
|
|
|
# Newest first out of the query, but read chronologically - except
|
|
# for search results, which are already most-relevant first.
|
|
if not query and not data:
|
|
rows.reverse()
|
|
|
|
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)
|
|
if (
|
|
isinstance(decoded, dict)
|
|
and (data_key in decoded)
|
|
and all(isinstance(item, dict) for item in decoded[data_key])
|
|
):
|
|
new_items = list(decoded[data_key])
|
|
else:
|
|
new_items = [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
|
|
|
|
attachments_by_id = annotate_log_rows(db, rows, expand=expand, truncate=truncate)
|
|
|
|
output = None
|
|
if json_output:
|
|
# Output as JSON if requested
|
|
output = log_rows_as_json(rows, attachments_by_id)
|
|
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 and rows:
|
|
# Just output the last response
|
|
output = rows[-1]["response"]
|
|
|
|
if output is not None:
|
|
click.echo(output)
|
|
else:
|
|
# Output neatly formatted human-readable logs
|
|
def _fenced_block(value):
|
|
# Fenced code block, indented to nest inside a list item
|
|
num_backticks = 3
|
|
while "`" * num_backticks in value:
|
|
num_backticks += 1
|
|
fence = "`" * num_backticks
|
|
return textwrap.indent(f"{fence}\n{value}\n{fence}", " ")
|
|
|
|
def _inline_code(value):
|
|
num_backticks = 1
|
|
while "`" * num_backticks in value:
|
|
num_backticks += 1
|
|
delimiter = "`" * num_backticks
|
|
if value.startswith("`") or value.endswith("`"):
|
|
return f"{delimiter} {value} {delimiter}"
|
|
return f"{delimiter}{value}{delimiter}"
|
|
|
|
def _format_tool_call_arguments(arguments):
|
|
if not isinstance(arguments, dict) or not arguments:
|
|
return f" Arguments: {_inline_code(json.dumps(arguments))}"
|
|
lines = []
|
|
for key, value in arguments.items():
|
|
if isinstance(value, str):
|
|
lines.append(f" {key}:")
|
|
lines.append(_fenced_block(value))
|
|
else:
|
|
lines.append(f" {key}: {_inline_code(json.dumps(value))}")
|
|
return "\n".join(lines)
|
|
|
|
def _token_usage_markdown(input_tokens, output_tokens, token_details):
|
|
usage = token_usage_string(input_tokens, output_tokens, None)
|
|
if token_details:
|
|
details = _inline_code(json.dumps(token_details))
|
|
if usage:
|
|
return f"{usage}, {details}"
|
|
return details
|
|
return usage
|
|
|
|
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
|
|
seen_tool_hashes = set()
|
|
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(
|
|
f"- {key}: {value}" for key, value in options.items()
|
|
)
|
|
click.echo(f"\n## Options\n\n{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")
|
|
|
|
def echo_tool(tool, indent=""):
|
|
if tool["hash"] in seen_tool_hashes:
|
|
block = "- **{}**: `{}`".format(tool["name"], tool["hash"][:7])
|
|
else:
|
|
seen_tool_hashes.add(tool["hash"])
|
|
block = "- **{}**: `{}` \n{} \n Arguments: `{}`".format(
|
|
tool["name"],
|
|
tool["hash"],
|
|
textwrap.indent(
|
|
(tool["description"] or "").rstrip(), " "
|
|
),
|
|
json.dumps(tool["input_schema"].get("properties", {})),
|
|
)
|
|
click.echo(textwrap.indent(block, indent))
|
|
|
|
# Tools provided by the same configured toolbox instance
|
|
# nest beneath one instance line rather than repeating it
|
|
plain_tools = []
|
|
by_instance: dict = {}
|
|
for tool in row["tools"]:
|
|
instance = tool.get("instance")
|
|
if instance:
|
|
key = (instance["name"], instance["arguments"])
|
|
by_instance.setdefault(key, []).append(tool)
|
|
else:
|
|
plain_tools.append(tool)
|
|
for tool in plain_tools:
|
|
echo_tool(tool)
|
|
for (name, arguments), instance_tools in by_instance.items():
|
|
click.echo(
|
|
"- `{}({})`:".format(
|
|
name,
|
|
arguments if arguments and arguments != "{}" else "",
|
|
)
|
|
)
|
|
for tool in instance_tools:
|
|
echo_tool(tool, " ")
|
|
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 += f"\n - {desc}"
|
|
click.echo(
|
|
"- **{}**: `{}` \n{}{}{}".format(
|
|
tool_result["name"],
|
|
tool_result["tool_call_id"],
|
|
_fenced_block(tool_result["output"]),
|
|
(
|
|
" \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 = f"```json\n{json.dumps(parsed, indent=2)}\n```"
|
|
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(
|
|
"- **{}**: `{}` \n{}".format(
|
|
tool_call["name"],
|
|
tool_call["tool_call_id"],
|
|
_format_tool_call_arguments(tool_call["arguments"]),
|
|
)
|
|
)
|
|
click.echo("")
|
|
if response:
|
|
click.echo(f"{response}\n")
|
|
if usage:
|
|
token_usage = _token_usage_markdown(
|
|
row["input_tokens"],
|
|
row["output_tokens"],
|
|
json.loads(row["token_details"]) if row["token_details"] else None,
|
|
)
|
|
if token_usage:
|
|
click.echo(f"## Token usage\n\n{token_usage}\n")
|
|
|
|
|
|
@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",
|
|
}
|
|
|
|
|
|
def model_matches_id_or_alias(model_with_aliases, model_ids):
|
|
ids_and_aliases = set(
|
|
[model_with_aliases.model.model_id] + model_with_aliases.aliases
|
|
)
|
|
return ids_and_aliases.intersection(model_ids)
|
|
|
|
|
|
def render_model_with_aliases(
|
|
model_with_aliases,
|
|
*,
|
|
options=False,
|
|
async_=False,
|
|
models_that_have_shown_options=None,
|
|
):
|
|
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 models_that_have_shown_options is not None
|
|
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)
|
|
if models_that_have_shown_options is not None:
|
|
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 += f"\n Attachment types:\n{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(f" - {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 += f"\n key: {model.needs_key}"
|
|
if hasattr(model, "key_env_var") and model.key_env_var:
|
|
output += f"\n env_var: {model.key_env_var}"
|
|
return output
|
|
|
|
|
|
def render_model_with_options(model_id, *, async_=False):
|
|
for model_with_aliases in get_models_with_aliases():
|
|
if model_matches_id_or_alias(model_with_aliases, [model_id]):
|
|
return render_model_with_aliases(
|
|
model_with_aliases,
|
|
options=True,
|
|
async_=async_,
|
|
models_that_have_shown_options=set(),
|
|
)
|
|
raise click.ClickException(f"'{model_id}' is not a known model")
|
|
|
|
|
|
@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("json_", "--json", is_flag=True, help="Output as JSON")
|
|
@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, json_, query, model_ids):
|
|
"List available models"
|
|
models_that_have_shown_options = set()
|
|
json_models = []
|
|
for model_with_aliases in get_models_with_aliases():
|
|
if async_ and not model_with_aliases.async_model:
|
|
continue
|
|
# Only show models where every provided query string matches
|
|
if query and not all(model_with_aliases.matches(q) for q in query):
|
|
continue
|
|
if model_ids and not model_matches_id_or_alias(model_with_aliases, 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
|
|
if json_:
|
|
model = (
|
|
model_with_aliases.async_model if async_ else model_with_aliases.model
|
|
)
|
|
model_json = {
|
|
"model_id": model.model_id,
|
|
"aliases": model_with_aliases.aliases,
|
|
"can_stream": model.can_stream,
|
|
"supports_schema": model.supports_schema,
|
|
"supports_tools": model.supports_tools,
|
|
"supports_async": model_with_aliases.async_model is not None,
|
|
"attachment_types": sorted(model.attachment_types),
|
|
"server_side_tools": [
|
|
{
|
|
"name": tool_class.__name__,
|
|
"plugin": getattr(tool_class, "plugin", None),
|
|
}
|
|
for tool_class in model.supported_server_side_tools
|
|
],
|
|
}
|
|
if options:
|
|
model_json["options"] = model.Options.model_json_schema()["properties"]
|
|
json_models.append(model_json)
|
|
continue
|
|
click.echo(
|
|
render_model_with_aliases(
|
|
model_with_aliases,
|
|
options=options,
|
|
async_=async_,
|
|
models_that_have_shown_options=models_that_have_shown_options,
|
|
)
|
|
)
|
|
if json_:
|
|
click.echo(json.dumps(json_models, indent=2))
|
|
return
|
|
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(f"Unknown model: {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(
|
|
{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(f"No log database found at {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(f"%{q}%" for q in queries)
|
|
|
|
sql = f"""
|
|
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
|
|
{where_sql} group by responses.schema_id
|
|
order by recently_used
|
|
"""
|
|
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(f"No log database found at {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("model_id", "-m", "--model", help="List tools supported by this model")
|
|
@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_, model_id, python_tools):
|
|
"List available tools, optionally including tools supported by a model"
|
|
|
|
model = None
|
|
if model_id:
|
|
try:
|
|
model = get_model(model_id)
|
|
except UnknownModelError as ex:
|
|
raise click.ClickException(str(ex))
|
|
|
|
server_side_tools = []
|
|
if model is not None:
|
|
for tool_class in model.supported_server_side_tools:
|
|
try:
|
|
signature = str(inspect.signature(tool_class))
|
|
except (ValueError, TypeError):
|
|
signature = "(...)"
|
|
server_side_tools.append(
|
|
{
|
|
"name": tool_class.__name__,
|
|
"description": inspect.getdoc(tool_class),
|
|
"signature": signature,
|
|
"server_side": True,
|
|
}
|
|
)
|
|
|
|
def introspect_tools(toolbox):
|
|
# Instances report their tools(), which may be generated dynamically.
|
|
# Classes can only report tools for their introspectable methods.
|
|
if isinstance(toolbox, Toolbox):
|
|
if not toolbox._prepared:
|
|
toolbox.prepare()
|
|
toolbox._prepared = True
|
|
tool_iter = toolbox.tools()
|
|
else:
|
|
tool_iter = toolbox.method_tools()
|
|
methods = []
|
|
for tool in tool_iter:
|
|
methods.append(
|
|
{
|
|
"name": tool.name,
|
|
"description": tool.description,
|
|
"arguments": tool.input_schema,
|
|
"implementation": tool.implementation,
|
|
}
|
|
)
|
|
return methods
|
|
|
|
toolbox_specs: dict[int, str] = {}
|
|
if tool_defs:
|
|
tools = {}
|
|
gathered = _gather_tools(tool_defs, python_tools)
|
|
# _gather_tools returns --functions tools first, then one per spec
|
|
specs = [None] * (len(gathered) - len(tool_defs)) + list(tool_defs)
|
|
for spec, tool in zip(specs, gathered):
|
|
if hasattr(tool, "name"):
|
|
tools[tool.name] = tool
|
|
else:
|
|
tools[tool.__class__.__name__] = tool
|
|
if spec is not None and isinstance(tool, Toolbox):
|
|
toolbox_specs[id(tool)] = spec
|
|
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_infos = []
|
|
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_class = tool if isinstance(tool, type) else tool.__class__
|
|
# Overriding tools() or prepare() means the toolbox generates
|
|
# tools at runtime
|
|
is_dynamic = any(
|
|
getattr(toolbox_class, method) is not getattr(Toolbox, method)
|
|
for method in ("tools", "prepare", "prepare_async")
|
|
)
|
|
introspected = introspect_tools(tool)
|
|
toolbox_infos.append((name, tool, toolbox_class, is_dynamic, introspected))
|
|
output_toolboxes.append(
|
|
{
|
|
"name": name,
|
|
"dynamic": is_dynamic,
|
|
"tools": [
|
|
{
|
|
"name": tool_info["name"],
|
|
"description": tool_info["description"],
|
|
"arguments": tool_info["arguments"],
|
|
}
|
|
for tool_info in introspected
|
|
],
|
|
}
|
|
)
|
|
if json_:
|
|
output = {"tools": output_tools, "toolboxes": output_toolboxes}
|
|
if model is not None:
|
|
output["server_side_tools"] = server_side_tools
|
|
click.echo(json.dumps(output, 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,
|
|
f" (plugin: {tool.plugin})" if tool.plugin else "",
|
|
)
|
|
)
|
|
if tool.description:
|
|
click.echo(textwrap.indent(tool.description.strip(), " ") + "\n")
|
|
for name, toolbox, toolbox_class, is_dynamic, introspected in toolbox_infos:
|
|
if is_dynamic and isinstance(toolbox, type):
|
|
# A dynamic toolbox class has no tools until it is
|
|
# instantiated - show its constructor and docstring instead
|
|
try:
|
|
constructor_sig = str(inspect.signature(toolbox_class))
|
|
except (ValueError, TypeError):
|
|
constructor_sig = "(...)"
|
|
plugin = getattr(toolbox_class, "plugin", None)
|
|
click.echo(
|
|
"{}{}{}\n".format(
|
|
name,
|
|
constructor_sig,
|
|
f" (plugin: {plugin})" if plugin else "",
|
|
)
|
|
)
|
|
doc = toolbox_class.__doc__
|
|
if doc:
|
|
click.echo(textwrap.indent(inspect.cleandoc(doc), " ") + "\n")
|
|
else:
|
|
click.echo(toolbox_specs.get(id(toolbox), name) + ":\n")
|
|
for tool_info in introspected:
|
|
sig = "()"
|
|
if tool_info["implementation"]:
|
|
sig = (
|
|
str(inspect.signature(tool_info["implementation"]))
|
|
.replace("(self, ", "(")
|
|
.replace("(self)", "()")
|
|
)
|
|
click.echo(f" {tool_info['name']}{sig}\n")
|
|
if tool_info["description"]:
|
|
click.echo(
|
|
textwrap.indent(tool_info["description"].strip(), " ") + "\n"
|
|
)
|
|
if model is not None and server_side_tools:
|
|
click.echo(
|
|
f"Server-side tools for {model.model_id} "
|
|
"(executed by the provider):\n"
|
|
)
|
|
for tool_info in server_side_tools:
|
|
click.echo(f"{tool_info['name']}{tool_info['signature']}\n")
|
|
if tool_info["description"]:
|
|
click.echo(textwrap.indent(tool_info["description"], " ") + "\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 luna gpt-5.6-luna
|
|
|
|
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 luna -q gpt -q luna
|
|
"""
|
|
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 = {}
|
|
where_bits = []
|
|
if aliases:
|
|
where_bits.append("fragment_aliases.alias is not null")
|
|
for param_count, q in enumerate(queries, start=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 = f"""
|
|
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
|
|
"""
|
|
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.atomic():
|
|
fragment_id = ensure_fragment(db, resolved)
|
|
db.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)
|
|
db.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 and (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)
|
|
|
|
model_id = model or get_default_embedding_model()
|
|
try:
|
|
collection_obj = Collection(
|
|
collection, db=db, model_id=model_id, create=model_id is not None
|
|
)
|
|
except (Collection.DoesNotExist, UnknownModelError):
|
|
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(
|
|
f"Could not decode text in file {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 = f"select count(*) as c from ({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
|
|
|
|
if input_path != "-":
|
|
|
|
def rows_from_input():
|
|
with open(input_path, "rb") as fp:
|
|
yield from load_rows(fp)
|
|
|
|
rows = rows_from_input()
|
|
else:
|
|
rows = load_rows(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, bytes | str]]:
|
|
for row in rows:
|
|
values = list(row.values())
|
|
id: str = prefix + str(values[0])
|
|
content: bytes | str | None = 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 and 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(f"Unknown embedding model: {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(f"No collections table found in {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-4.1
|
|
"""
|
|
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-4.1 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-4.1
|
|
# Or for a single option
|
|
llm models options clear gpt-4.1 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 f"{size_bytes:.2f}{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(f"Invalid YAML: {ex!s}")
|
|
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://", "http://")):
|
|
response = httpx.get(name)
|
|
try:
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as ex:
|
|
raise LoadTemplateError(f"Could not load template {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(f"Unknown template prefix: {prefix}")
|
|
loader = loaders[prefix]
|
|
try:
|
|
return loader(rest)
|
|
except Exception as ex: # noqa: BLE001
|
|
raise LoadTemplateError(f"Could not load template {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(f"File not found: {code_or_path}")
|
|
namespace: dict[str, Any] = {}
|
|
tools = []
|
|
try:
|
|
exec(code_or_path, namespace) # noqa: S102
|
|
except SyntaxError as ex:
|
|
raise click.ClickException(f"Error in --functions definition: {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(
|
|
f"\nTool call: {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" {attachment!r}\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(
|
|
f" Exception: {tool_result.exception}",
|
|
fg="red",
|
|
bold=True,
|
|
),
|
|
err=True,
|
|
)
|
|
|
|
|
|
def _approve_tool_call(_, tool_call):
|
|
click.echo(
|
|
click.style(
|
|
f"Tool call: {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], model=None
|
|
) -> list[Tool | Toolbox | ServerSideTool]:
|
|
tools: list[Tool | Toolbox | ServerSideTool] = []
|
|
if python_tools:
|
|
for code_or_path in python_tools:
|
|
tools.extend(_tools_from_code(code_or_path))
|
|
registered_tools = get_tools()
|
|
server_side_tool_classes = {
|
|
tool_class.__name__: tool_class
|
|
for tool_class in (
|
|
model.supported_server_side_tools if model is not None else ()
|
|
)
|
|
}
|
|
available_tools = {**registered_tools, **server_side_tool_classes}
|
|
registered_classes = {
|
|
key: value for key, value in available_tools.items() if inspect.isclass(value)
|
|
}
|
|
bad_tools = [
|
|
tool
|
|
for tool in tool_specs
|
|
if tool.split("(", 1)[0].strip() not in available_tools
|
|
]
|
|
if bad_tools:
|
|
raise click.ClickException(
|
|
"Tool(s) {} not found. Available tools: {}".format(
|
|
", ".join(bad_tools), ", ".join(available_tools.keys())
|
|
)
|
|
)
|
|
for tool_spec in tool_specs:
|
|
if not tool_spec[0].isupper():
|
|
# It's a function
|
|
tools.append(available_tools[tool_spec])
|
|
else:
|
|
# It's a class
|
|
tools.append(instantiate_from_spec(registered_classes, tool_spec))
|
|
return tools
|
|
|
|
|
|
def _tool_chain_kwargs(
|
|
tool_specs, python_tools, tools_debug, tools_approve, chain_limit, model=None
|
|
):
|
|
"""Build Conversation.chain() keyword arguments for CLI-selected tools."""
|
|
tool_implementations = _gather_tools(tool_specs, python_tools, model=model)
|
|
if not tool_implementations:
|
|
return {}
|
|
kwargs = {
|
|
"tools": tool_implementations,
|
|
"chain_limit": chain_limit,
|
|
}
|
|
if tools_debug:
|
|
kwargs["after_call"] = _debug_tool_call
|
|
if tools_approve:
|
|
kwargs["before_call"] = _approve_tool_call
|
|
return kwargs
|
|
|
|
|
|
def _get_conversation_tools(conversation, tools):
|
|
if not conversation or tools:
|
|
return None
|
|
if 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]
|
|
elif conversation.loaded_tools:
|
|
# Conversation loaded from the message store - tool names and
|
|
# toolbox specs were read from turn_tools instead of rebuilt
|
|
# responses.
|
|
return list(conversation.loaded_tools)
|