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