mirror of
https://github.com/Hopiu/llm.git
synced 2026-03-17 05:00:25 +00:00
3551 lines
106 KiB
Python
3551 lines
106 KiB
Python
import asyncio
|
|
import click
|
|
from click_default_group import DefaultGroup
|
|
from dataclasses import asdict
|
|
import io
|
|
import json
|
|
import os
|
|
from llm import (
|
|
Attachment,
|
|
AsyncConversation,
|
|
AsyncKeyModel,
|
|
AsyncResponse,
|
|
CancelToolCall,
|
|
Collection,
|
|
Conversation,
|
|
Fragment,
|
|
Response,
|
|
Template,
|
|
Tool,
|
|
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,
|
|
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, Optional, Iterable, List, Union, Tuple, Any
|
|
import warnings
|
|
import yaml
|
|
|
|
warnings.simplefilter("ignore", ResourceWarning)
|
|
|
|
DEFAULT_TEMPLATE = "prompt: "
|
|
|
|
|
|
class FragmentNotFound(Exception):
|
|
pass
|
|
|
|
|
|
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://"):
|
|
client = httpx.Client(follow_redirects=True, max_redirects=3)
|
|
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
|
|
|
|
|
|
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,
|
|
)
|
|
@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",
|
|
"--tools",
|
|
help="Python code block defining functions to register as tools",
|
|
)
|
|
@click.option(
|
|
"tools_debug",
|
|
"--td",
|
|
"--tools-debug",
|
|
is_flag=True,
|
|
help="Show full details of tool executions",
|
|
)
|
|
@click.option(
|
|
"tools_approve",
|
|
"--ta",
|
|
"--tools-approve",
|
|
is_flag=True,
|
|
help="Manually approve every tool execution",
|
|
)
|
|
@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(
|
|
"_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,
|
|
options,
|
|
schema_input,
|
|
schema_multi,
|
|
fragments,
|
|
system_fragments,
|
|
template,
|
|
param,
|
|
no_stream,
|
|
no_log,
|
|
log,
|
|
_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)
|
|
|
|
model_aliases = get_model_aliases()
|
|
|
|
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:
|
|
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 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:
|
|
to_save["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()))
|
|
path.write_text(
|
|
yaml.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))
|
|
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
|
|
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))
|
|
|
|
# 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 = {**validated_options}
|
|
|
|
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
|
|
|
|
extra_tools = []
|
|
if python_tools:
|
|
extra_tools = _tools_from_code(python_tools)
|
|
|
|
if tools or python_tools:
|
|
prompt_method = conversation.chain
|
|
if tools_debug:
|
|
|
|
def debug_tool_call(_, tool_call, tool_result):
|
|
click.echo(
|
|
click.style(
|
|
"Tool call: {}".format(tool_call),
|
|
fg="yellow",
|
|
bold=True,
|
|
),
|
|
err=True,
|
|
)
|
|
click.echo(
|
|
click.style(
|
|
"Tool result: {}".format(tool_result),
|
|
fg="yellow",
|
|
bold=True,
|
|
),
|
|
err=True,
|
|
)
|
|
|
|
kwargs["after_call"] = debug_tool_call
|
|
if tools_approve:
|
|
|
|
def approve_tool_call(_, tool_call):
|
|
click.echo(
|
|
click.style(
|
|
"Tool call: {}".format(tool_call),
|
|
fg="yellow",
|
|
bold=True,
|
|
),
|
|
err=True,
|
|
)
|
|
if not click.confirm("Approve tool call?"):
|
|
raise CancelToolCall("User cancelled tool call")
|
|
|
|
kwargs["before_call"] = approve_tool_call
|
|
# Look up all those tools
|
|
registered_tools: dict = get_tools()
|
|
bad_tools = [tool for tool in tools if tool not in registered_tools]
|
|
if bad_tools:
|
|
raise click.ClickException(
|
|
"Tool(s) {} not found. Available tools: {}".format(
|
|
", ".join(bad_tools), ", ".join(registered_tools.keys())
|
|
)
|
|
)
|
|
kwargs["tools"] = [registered_tools[tool] for tool in tools] + extra_tools
|
|
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,
|
|
)
|
|
async for chunk in response:
|
|
print(chunk, end="")
|
|
sys.stdout.flush()
|
|
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:
|
|
for chunk in response:
|
|
print(chunk, end="")
|
|
sys.stdout.flush()
|
|
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 isinstance(response, ChainResponse):
|
|
responses = response._responses
|
|
else:
|
|
responses = [response]
|
|
|
|
for response in responses:
|
|
if isinstance(response, AsyncResponse):
|
|
response = asyncio.run(response.to_sync_response())
|
|
|
|
if usage:
|
|
# Show token usage to stderr in yellow
|
|
click.echo(
|
|
click.style(
|
|
"Token usage: {}".format(response.token_usage()),
|
|
fg="yellow",
|
|
bold=True,
|
|
),
|
|
err=True,
|
|
)
|
|
|
|
# Log to the database
|
|
if (logs_on() or log) and not no_log:
|
|
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("-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("--key", help="API key to use")
|
|
def chat(
|
|
system,
|
|
model_id,
|
|
_continue,
|
|
conversation_id,
|
|
template,
|
|
param,
|
|
options,
|
|
no_stream,
|
|
key,
|
|
database,
|
|
):
|
|
"""
|
|
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))
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
# Validate options
|
|
validated_options = {}
|
|
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.update(validated_options)
|
|
|
|
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
|
|
|
|
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")
|
|
in_multi = False
|
|
accumulated = []
|
|
end_token = "!end"
|
|
while True:
|
|
prompt = click.prompt("", prompt_suffix="> " if not in_multi else "")
|
|
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 not prompt:
|
|
continue
|
|
click.echo(prompt)
|
|
if in_multi:
|
|
if prompt.strip() == end_token:
|
|
prompt = "\n".join(accumulated)
|
|
in_multi = False
|
|
accumulated = []
|
|
else:
|
|
accumulated.append(prompt)
|
|
continue
|
|
if template_obj:
|
|
try:
|
|
template_prompt, template_system = template_obj.evaluate(prompt, params)
|
|
except Template.MissingVariables as ex:
|
|
raise click.ClickException(str(ex))
|
|
if template_system and not system:
|
|
system = template_system
|
|
if template_prompt:
|
|
new_prompt = template_prompt
|
|
if prompt:
|
|
new_prompt += "\n" + prompt
|
|
prompt = new_prompt
|
|
if prompt.strip() in ("exit", "quit"):
|
|
break
|
|
response = conversation.prompt(prompt, system=system, **kwargs)
|
|
# System prompt only sent for the first message:
|
|
system = None
|
|
for chunk in response:
|
|
print(chunk, end="")
|
|
sys.stdout.flush()
|
|
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]
|
|
):
|
|
conversation.responses.append(response_class.from_row(db, response))
|
|
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.prompt,
|
|
responses.system,
|
|
responses.prompt_json,
|
|
responses.options_json,
|
|
responses.response,
|
|
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 responses.id desc{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 responses_fts.rank desc{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,
|
|
)
|
|
@schema_option
|
|
@click.option(
|
|
"--schema-multi",
|
|
help="JSON schema used for multiple results",
|
|
)
|
|
@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,
|
|
schema_input,
|
|
schema_multi,
|
|
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
|
|
if query:
|
|
sql = LOGS_SQL_SEARCH
|
|
|
|
limit = ""
|
|
if count is not None and count > 0:
|
|
limit = " limit {}".format(count)
|
|
|
|
sql_format = {
|
|
"limit": limit,
|
|
"columns": LOGS_COLUMNS,
|
|
"extra_where": "",
|
|
}
|
|
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))
|
|
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
|
|
click.echo(output_rows_as_json(to_output, not data_array))
|
|
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
|
|
))
|
|
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"])
|
|
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["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)
|
|
)
|
|
)
|
|
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
|
|
click.echo("\n## Response\n\n{}\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"
|
|
template = load_template(name)
|
|
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=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")
|
|
def schemas_list(path, database, queries, full):
|
|
"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)
|
|
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.option("json_", "--json", is_flag=True, help="Output as JSON")
|
|
@click.option(
|
|
"python_tools",
|
|
"--tools",
|
|
help="Python code block defining functions to register as tools",
|
|
)
|
|
def tools_list(json_, python_tools):
|
|
"List available tools that have been provided by plugins"
|
|
tools = get_tools()
|
|
if python_tools:
|
|
for tool in _tools_from_code(python_tools):
|
|
tools[tool.name] = tool
|
|
if json_:
|
|
click.echo(
|
|
json.dumps(
|
|
{
|
|
name: {
|
|
"description": tool.description,
|
|
"arguments": tool.input_schema,
|
|
}
|
|
for name, tool in tools.items()
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
else:
|
|
for name, tool in tools.items():
|
|
sig = "()"
|
|
if tool.implementation:
|
|
sig = str(inspect.signature(tool.implementation))
|
|
click.echo("{}{}".format(name, sig))
|
|
if tool.description:
|
|
click.echo(textwrap.indent(tool.description, " "))
|
|
|
|
|
|
@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)
|
|
def plugins_list(all):
|
|
"List installed plugins"
|
|
click.echo(json.dumps(get_plugins(all), 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",
|
|
)
|
|
def install(packages, upgrade, editable, force_reinstall, no_cache_dir):
|
|
"""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"]
|
|
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",
|
|
)
|
|
def similar(collection, id, input, content, binary, number, plain, database):
|
|
"""
|
|
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)
|
|
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)
|
|
|
|
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()
|
|
return _parse_yaml_template(name, content)
|
|
|
|
|
|
def _tools_from_code(code: str) -> List[Tool]:
|
|
"""
|
|
Treat all Python functions in the code as tools
|
|
"""
|
|
globals = {}
|
|
tools = []
|
|
try:
|
|
exec(code, globals)
|
|
except SyntaxError as ex:
|
|
raise click.ClickException("Error in --tools definition: {}".format(ex))
|
|
# Register all callables in the locals dict:
|
|
for name, value in globals.items():
|
|
if callable(value) and not name.startswith("_"):
|
|
tools.append(Tool.function(value))
|
|
return tools
|